feat(KB-621): add AI title summarization feature

- Add core AI summarization service with model selection hierarchy
- Modify task store to support title generation callback pattern
- Add POST /api/ai/summarize-title endpoint with rate limiting
- Wire up summarization in dashboard task creation flow
- Add AI Summarization settings UI section in dashboard
- Add comprehensive tests for summarization service and API
- Document auto-summarization settings in AGENTS.md
This commit is contained in:
gsxdsm
2026-03-31 18:59:02 -07:00
parent bc0cb27e61
commit e65e00537c
12 changed files with 1240 additions and 15 deletions

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
summarizeTitle,
checkRateLimit,
getRateLimitResetTime,
validateDescription,
SUMMARIZE_SYSTEM_PROMPT,
MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH,
MAX_REQUESTS_PER_HOUR,
ValidationError,
RateLimitError,
AiServiceError,
__resetSummarizeState,
} from "./ai-summarize.js";
describe("ai-summarize", () => {
beforeEach(() => {
__resetSummarizeState();
});
// ── Constants ──────────────────────────────────────────────────────────────
describe("constants", () => {
it("should have correct system prompt", () => {
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters");
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization");
});
it("should have correct length limits", () => {
expect(MIN_DESCRIPTION_LENGTH).toBe(141);
expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
expect(MAX_TITLE_LENGTH).toBe(60);
});
it("should have correct rate limit", () => {
expect(MAX_REQUESTS_PER_HOUR).toBe(10);
});
});
// ── Validation ─────────────────────────────────────────────────────────────
describe("validateDescription", () => {
it("should accept valid description length", () => {
const desc = "a".repeat(200);
expect(validateDescription(desc)).toBe(desc);
});
it("should throw for null description", () => {
expect(() => validateDescription(null)).toThrow(ValidationError);
expect(() => validateDescription(null)).toThrow("description is required");
});
it("should throw for undefined description", () => {
expect(() => validateDescription(undefined)).toThrow(ValidationError);
});
it("should throw for non-string description", () => {
expect(() => validateDescription(123)).toThrow(ValidationError);
expect(() => validateDescription(123)).toThrow("description must be a string");
});
it("should throw for description too short", () => {
const desc = "a".repeat(100);
expect(() => validateDescription(desc)).toThrow(ValidationError);
expect(() => validateDescription(desc)).toThrow("at least 141 characters");
});
it("should throw for description too long", () => {
const desc = "a".repeat(2001);
expect(() => validateDescription(desc)).toThrow(ValidationError);
expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters");
});
it("should accept description at minimum boundary", () => {
const desc = "a".repeat(141);
expect(validateDescription(desc)).toBe(desc);
});
it("should accept description at maximum boundary", () => {
const desc = "a".repeat(2000);
expect(validateDescription(desc)).toBe(desc);
});
});
// ── Rate Limiting ──────────────────────────────────────────────────────────
describe("checkRateLimit", () => {
it("should allow first request from IP", () => {
expect(checkRateLimit("192.168.1.1")).toBe(true);
});
it("should track request count", () => {
const ip = "192.168.1.1";
for (let i = 0; i < 5; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
expect(checkRateLimit(ip)).toBe(true); // 6th request
});
it("should block after max requests", () => {
const ip = "192.168.1.1";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
expect(checkRateLimit(ip)).toBe(false); // 11th request should be blocked
});
it("should track different IPs separately", () => {
const ip1 = "192.168.1.1";
const ip2 = "192.168.1.2";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip1)).toBe(true);
}
expect(checkRateLimit(ip1)).toBe(false);
// Different IP should still be allowed
expect(checkRateLimit(ip2)).toBe(true);
});
});
describe("getRateLimitResetTime", () => {
it("should return null for unknown IP", () => {
expect(getRateLimitResetTime("unknown")).toBeNull();
});
it("should return reset time after requests", () => {
const ip = "192.168.1.1";
checkRateLimit(ip);
const resetTime = getRateLimitResetTime(ip);
expect(resetTime).toBeInstanceOf(Date);
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
});
});
// ── summarizeTitle ─────────────────────────────────────────────────────────
describe("summarizeTitle", () => {
it("should return null for descriptions <= 140 characters", async () => {
const result = await summarizeTitle("Short description", "/tmp");
expect(result).toBeNull();
});
it("should throw AiServiceError when engine not available", async () => {
// In test environment, the dynamic import fails, so createKbAgent is undefined
const longDesc = "a".repeat(200);
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow(AiServiceError);
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow("AI engine not available");
});
it("should accept optional provider and modelId", async () => {
// Since engine isn't available in tests, this will throw
const longDesc = "a".repeat(200);
await expect(
summarizeTitle(longDesc, "/tmp", "anthropic", "claude-sonnet-4-5")
).rejects.toThrow(AiServiceError);
});
});
// ── Error Classes ───────────────────────────────────────────────────────────
describe("error classes", () => {
it("ValidationError should have correct name", () => {
const err = new ValidationError("test");
expect(err.name).toBe("ValidationError");
expect(err.message).toBe("test");
});
it("RateLimitError should have correct name and resetTime", () => {
const resetTime = new Date();
const err = new RateLimitError("rate limited", resetTime);
expect(err.name).toBe("RateLimitError");
expect(err.message).toBe("rate limited");
expect(err.resetTime).toBe(resetTime);
});
it("RateLimitError should allow null resetTime", () => {
const err = new RateLimitError("rate limited");
expect(err.name).toBe("RateLimitError");
expect(err.resetTime).toBeNull();
});
it("AiServiceError should have correct name", () => {
const err = new AiServiceError("ai failed");
expect(err.name).toBe("AiServiceError");
expect(err.message).toBe("ai failed");
});
});
// ── State Reset ───────────────────────────────────────────────────────────
describe("__resetSummarizeState", () => {
it("should clear all rate limit entries", () => {
const ip = "192.168.1.1";
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
expect(getRateLimitResetTime(ip)).not.toBeNull();
__resetSummarizeState();
expect(getRateLimitResetTime(ip)).toBeNull();
});
});
});

View File

@@ -0,0 +1,328 @@
/**
* AI Title Summarization Service
*
* Provides AI-powered title generation from task descriptions.
* Automatically generates concise titles (≤60 characters) from descriptions
* longer than 140 characters.
*
* Features:
* - Rate limiting per IP (10 requests per hour)
* - Dynamic import of @fusion/engine for AI agent creation
* - Text length validation (141-2000 characters)
*/
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
type AgentResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgent: any;
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine() {
if (!createKbAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
createKbAgent = engine.createKbAgent;
} catch {
// Allow failure in test environments - agent functionality will be stubbed
createKbAgent = undefined;
}
}
}
// Initialize on module load (will be awaited in actual usage)
const engineReady = initEngine();
// ── Constants ───────────────────────────────────────────────────────────────
/** System prompt for title summarization */
export const SUMMARIZE_SYSTEM_PROMPT = `You are a title summarization assistant for a task management system.
Your job is to create a concise title (max 60 characters) that summarizes the given task description.
## Guidelines
- Create a clear, descriptive title that captures the essence of what the task is about
- Return only the title text, no quotes, no markdown, no explanations
- The title should be actionable and professional
- Maximum 60 characters — be concise but informative
- Focus on the main goal or deliverable of the task`;
/** Maximum description length in characters */
export const MAX_DESCRIPTION_LENGTH = 2000;
/** Minimum description length for summarization in characters */
export const MIN_DESCRIPTION_LENGTH = 141;
/** Maximum title length in characters */
export const MAX_TITLE_LENGTH = 60;
/** Rate limit: max requests per IP per hour */
export const MAX_REQUESTS_PER_HOUR = 10;
/** Rate limit window in milliseconds (1 hour) */
export const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
/** Cleanup interval in milliseconds (5 minutes) */
export const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// ── Rate Limiting ─────────────────────────────────────────────────────────
interface RateLimitEntry {
count: number;
firstRequestAt: Date;
}
/** Rate limiting state indexed by IP */
const rateLimits = new Map<string, RateLimitEntry>();
/**
* Check if IP can make a summarization request.
* Returns true if allowed, false if rate limited.
*/
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);
if (!entry) {
// First request from this IP
rateLimits.set(ip, {
count: 1,
firstRequestAt: new Date(),
});
return true;
}
// Check if window has expired
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
// Reset window
rateLimits.set(ip, {
count: 1,
firstRequestAt: new Date(),
});
return true;
}
// Within window - check limit
if (entry.count >= MAX_REQUESTS_PER_HOUR) {
return false;
}
// Increment count
entry.count++;
return true;
}
/**
* Get rate limit reset time for an IP.
* Returns null if no rate limit entry exists.
*/
export function getRateLimitResetTime(ip: string): Date | null {
const entry = rateLimits.get(ip);
if (!entry) return null;
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
}
/**
* Remove expired rate limit entries.
* Runs periodically via setInterval.
*/
function cleanupExpiredRateLimits(): void {
const now = Date.now();
let cleanedRateLimits = 0;
for (const [ip, entry] of rateLimits) {
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
rateLimits.delete(ip);
cleanedRateLimits++;
}
}
if (cleanedRateLimits > 0) {
console.log(`[ai-summarize] Cleanup: removed ${cleanedRateLimits} rate limit entries`);
}
}
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
// Handle graceful shutdown
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
});
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export class RateLimitError extends Error {
resetTime: Date | null;
constructor(message: string, resetTime: Date | null = null) {
super(message);
this.name = "RateLimitError";
this.resetTime = resetTime;
}
}
export class AiServiceError extends Error {
constructor(message: string) {
super(message);
this.name = "AiServiceError";
}
}
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Validate description for summarization.
* Throws appropriate errors for invalid input.
*/
export function validateDescription(description: unknown): string {
// Validate description exists
if (description === undefined || description === null) {
throw new ValidationError("description is required");
}
// Validate description is a string
if (typeof description !== "string") {
throw new ValidationError("description must be a string");
}
// Validate description length
if (description.length < MIN_DESCRIPTION_LENGTH) {
throw new ValidationError(
`description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`
);
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
throw new ValidationError(
`description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`
);
}
return description;
}
// ── AI Integration ───────────────────────────────────────────────────────────
/**
* Summarize a task description into a concise title using AI.
* @param description - The task description to summarize (must be 141-2000 chars)
* @param rootDir - Project root directory for AI agent context
* @param provider - Optional AI model provider (e.g., "anthropic")
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
* @returns The generated title (guaranteed ≤60 characters), or null if validation fails
*/
export async function summarizeTitle(
description: string,
rootDir: string,
provider?: string,
modelId?: string
): Promise<string | null> {
// Validate description length first
if (description.length <= 140) {
return null; // Too short for summarization
}
// Ensure engine is loaded before using createKbAgent
await engineReady;
if (!createKbAgent) {
throw new AiServiceError("AI engine not available");
}
const agentOptions: {
cwd: string;
systemPrompt: string;
tools: "readonly";
defaultProvider?: string;
defaultModelId?: string;
} = {
cwd: rootDir,
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
tools: "readonly",
};
// Add model selection if both provider and modelId are provided
if (provider && modelId) {
agentOptions.defaultProvider = provider;
agentOptions.defaultModelId = modelId;
}
const agentResult = await createKbAgent(agentOptions);
if (!agentResult?.session) {
throw new AiServiceError("Failed to initialize AI agent");
}
try {
// Send the description to the agent
await agentResult.session.prompt(description);
// Get the response text from the agent's state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agentResult.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let title = "";
if (lastMessage?.content) {
// Handle both string and array content types
if (typeof lastMessage.content === "string") {
title = lastMessage.content.trim();
} else if (Array.isArray(lastMessage.content)) {
// Extract text from content blocks
title = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("")
.trim();
}
}
if (!title) {
throw new AiServiceError("AI returned empty response");
}
// Truncate to max title length if needed
if (title.length > MAX_TITLE_LENGTH) {
title = title.slice(0, MAX_TITLE_LENGTH).trim();
}
return title;
} catch (err) {
if (err instanceof AiServiceError) {
throw err;
}
throw new AiServiceError(
err instanceof Error ? err.message : "AI processing failed"
);
} finally {
// Ensure session is disposed even on error
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
// ── Test Helpers ───────────────────────────────────────────────────────────
/**
* Reset all summarization state. Used for testing only.
*/
export function __resetSummarizeState(): void {
rateLimits.clear();
}

View File

@@ -52,6 +52,24 @@ export type {
ImportResult,
} from "./settings-export.js";
// ── AI Summarization ─────────────────────────────────────────────────────
export {
summarizeTitle,
checkRateLimit,
getRateLimitResetTime,
validateDescription,
SUMMARIZE_SYSTEM_PROMPT,
MAX_DESCRIPTION_LENGTH,
MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH,
MAX_REQUESTS_PER_HOUR,
ValidationError,
RateLimitError,
AiServiceError,
__resetSummarizeState,
} from "./ai-summarize.js";
// ── Mission Hierarchy Types ────────────────────────────────────────────
export {

View File

@@ -3733,4 +3733,162 @@ describe("TaskStore", () => {
expect(task.enabledWorkflowSteps).toBeUndefined();
});
});
// ── Title Summarization Tests ────────────────────────────────────────────
describe("createTask with title summarization", () => {
it("should use generated title when onSummarize returns a title", async () => {
const longDescription = "a".repeat(200);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Generated Title");
const task = await store.createTask(
{ description: longDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("AI Generated Title");
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
});
it("should not call onSummarize when title is already provided", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ title: "User Title", description: "a".repeat(200) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("User Title");
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when description is too short", async () => {
const shortDescription = "a".repeat(100);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: shortDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when autoSummarizeTitles is false", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(200) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: false } }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should not call onSummarize when no settings provided", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(200) },
{ onSummarize: mockOnSummarize }
);
expect(task.title).toBeUndefined();
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should call onSummarize when summarize input flag is true", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: "a".repeat(200), summarize: true },
{ onSummarize: mockOnSummarize }
);
expect(task.title).toBe("AI Title");
expect(mockOnSummarize).toHaveBeenCalled();
});
it("should handle onSummarize returning null", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue(null);
const task = await store.createTask(
{ description: "a".repeat(200) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
});
it("should handle onSummarize throwing error gracefully", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
const task = await store.createTask(
{ description: "a".repeat(200) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBeUndefined();
expect(task.id).toMatch(/^KB-\d+$/); // Task still created
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Title summarization failed"),
expect.stringContaining("AI service failed")
);
consoleSpy.mockRestore();
});
it("should trigger summarization at exactly 141 characters", async () => {
const boundaryDescription = "a".repeat(141);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: boundaryDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(mockOnSummarize).toHaveBeenCalled();
});
it("should not trigger summarization at exactly 140 characters", async () => {
const boundaryDescription = "a".repeat(140);
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ description: boundaryDescription },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should prioritize explicit title over summarize flag", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
const task = await store.createTask(
{ title: "User Title", description: "a".repeat(200), summarize: true },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("User Title");
expect(mockOnSummarize).not.toHaveBeenCalled();
});
it("should include generated title in PROMPT.md heading", async () => {
const mockOnSummarize = vi.fn().mockResolvedValue("Generated Task Title");
const task = await store.createTask(
{ description: "a".repeat(200) },
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
);
expect(task.title).toBe("Generated Task Title");
const detail = await store.getTask(task.id);
expect(detail.prompt).toMatch(/^# KB-\d+: Generated Task Title\n/);
});
});
});

View File

@@ -582,7 +582,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return join(this.tasksDir, id);
}
async createTask(input: TaskCreateInput): Promise<Task> {
async createTask(
input: TaskCreateInput,
options?: {
onSummarize?: (description: string) => Promise<string | null>;
settings?: { autoSummarizeTitles?: boolean };
}
): Promise<Task> {
if (!input.description?.trim()) {
throw new Error("Description is required and cannot be empty");
}
@@ -592,10 +598,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (input.dependencies?.includes(id)) {
throw new Error(`Task ${id} cannot depend on itself`);
}
// Determine if we should try to summarize the title
let title = input.title?.trim() || undefined;
const shouldSummarize =
!title && // Only if no title provided
input.description.length > 140 && // Only if description is long enough
(input.summarize === true || // Explicit request
options?.settings?.autoSummarizeTitles === true); // Auto-enabled
if (shouldSummarize && options?.onSummarize) {
try {
const generatedTitle = await options.onSummarize(input.description);
if (generatedTitle) {
title = generatedTitle;
}
} catch (err) {
// Log warning but don't block task creation
console.warn(`[TaskStore] Title summarization failed for task ${id}:`, err instanceof Error ? err.message : err);
}
}
const now = new Date().toISOString();
const task: Task = {
id,
title: input.title?.trim() || undefined,
title,
description: input.description,
column: input.column || "triage",
dependencies: input.dependencies || [],

View File

@@ -466,6 +466,8 @@ export interface TaskCreateInput {
validatorModelId?: string;
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
thinkingLevel?: ThinkingLevel;
/** When true, trigger AI title summarization if description is long and no title provided */
summarize?: boolean;
}
// ── Settings Scope Types ────────────────────────────────────────────────
@@ -641,6 +643,18 @@ export interface ProjectSettings {
autoBackupRetention?: number;
/** Directory for backup files, relative to project root. Default: ".kb/backups". */
autoBackupDir?: string;
/** When true, tasks created without titles but with descriptions longer than 140
* characters will automatically receive an AI-generated title (max 60 chars).
* Default: false. */
autoSummarizeTitles?: boolean;
/** AI model provider for title summarization (when autoSummarizeTitles is enabled).
* Must be set together with `titleSummarizerModelId`. Falls back to planningProvider,
* then defaultProvider if not specified. */
titleSummarizerProvider?: string;
/** AI model ID for title summarization (when autoSummarizeTitles is enabled).
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
* then defaultModelId if not specified. */
titleSummarizerModelId?: string;
}
/**
@@ -701,6 +715,9 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7,
autoBackupDir: ".kb/backups",
autoSummarizeTitles: false,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
};
/**
@@ -758,6 +775,9 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"autoBackupSchedule",
"autoBackupRetention",
"autoBackupDir",
"autoSummarizeTitles",
"titleSummarizerProvider",
"titleSummarizerModelId",
] as const;
export interface BoardConfig {

View File

@@ -22,6 +22,7 @@ import {
saveWorkspaceFileContent,
startPlanningStreaming,
fetchTasks,
summarizeTitle,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
@@ -1678,3 +1679,124 @@ describe("REFINE_ERROR_MESSAGES", () => {
});
});
// --- Summarize Title Tests ---
describe("summarizeTitle", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns title on successful response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
});
global.fetch = mockFetch;
const result = await summarizeTitle("a".repeat(200));
expect(result).toBe("Generated Title");
expect(mockFetch).toHaveBeenCalledWith(
"/api/ai/summarize-title",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "a".repeat(200), provider: undefined, modelId: undefined }),
})
);
});
it("sends provider and modelId when provided", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
});
global.fetch = mockFetch;
await summarizeTitle("a".repeat(200), "anthropic", "claude-sonnet-4-5");
expect(mockFetch).toHaveBeenCalledWith(
"/api/ai/summarize-title",
expect.objectContaining({
body: JSON.stringify({ description: "a".repeat(200), provider: "anthropic", modelId: "claude-sonnet-4-5" }),
})
);
});
it("throws descriptive error on 400 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Description too short" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("short")).rejects.toThrow("Invalid request: Description too short");
});
it("throws descriptive error on 429 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Rate limit exceeded" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Rate limit exceeded: Rate limit exceeded");
});
it("throws descriptive error on 503 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "AI service unavailable" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("AI service temporarily unavailable: AI service unavailable");
});
it("throws generic error on other failure responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Internal server error" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Internal server error");
});
it("throws error for non-JSON responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html" }),
text: vi.fn().mockResolvedValue("<html>Not JSON</html>"),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned non-JSON response");
});
it("throws error when response has no title", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({})),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned empty title");
});
});

View File

@@ -1667,3 +1667,58 @@ export function importSettings(
}),
});
}
// --- AI Summarization API ---
/** Response from title summarization endpoint */
export interface SummarizeTitleResponse {
title: string;
}
/** Summarize a task description into a concise title using AI.
* @param description - The task description to summarize (must be 141-2000 chars)
* @param provider - Optional AI model provider (e.g., "anthropic")
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
* @returns The generated title (guaranteed ≤60 characters)
* @throws Error with descriptive message for 400/429/503 errors
*/
export async function summarizeTitle(
description: string,
provider?: string,
modelId?: string
): Promise<string> {
const res = await fetch("/api/ai/summarize-title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description, provider, modelId }),
});
const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text();
const isJson = contentType.includes("application/json");
if (!isJson) {
throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`);
}
const data = JSON.parse(bodyText) as { title?: string; error?: string };
if (!res.ok) {
const errorMessage = data.error || "Request failed";
if (res.status === 400) {
throw new Error(`Invalid request: ${errorMessage}`);
} else if (res.status === 429) {
throw new Error(`Rate limit exceeded: ${errorMessage}`);
} else if (res.status === 503) {
throw new Error(`AI service temporarily unavailable: ${errorMessage}`);
} else {
throw new Error(errorMessage);
}
}
if (!data.title) {
throw new Error("API returned empty title");
}
return data.title;
}

View File

@@ -37,6 +37,7 @@ const SETTINGS_SECTIONS = [
{ id: "general", label: "General", scope: "project" as const },
{ id: "model", label: "Model", scope: "global" as const },
{ id: "model-presets", label: "Model Presets", scope: "project" as const },
{ id: "ai-summarization", label: "AI Summarization", scope: "project" as const },
{ id: "appearance", label: "Appearance", scope: "global" as const },
{ id: "scheduling", label: "Scheduling", scope: "project" as const },
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
@@ -825,6 +826,112 @@ export function SettingsModal({
</>
);
}
case "ai-summarization":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">AI Summarization</h4>
<div className="form-group">
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
<input
id="autoSummarizeTitles"
type="checkbox"
checked={form.autoSummarizeTitles || false}
onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
/>
Auto-summarize long descriptions as titles
</label>
<small>
When enabled, tasks created without a title but with descriptions over 140 characters
will automatically get an AI-generated title (max 60 characters).
</small>
</div>
{(form.autoSummarizeTitles || false) && (
<>
<div className="form-group">
<label>Title summarization model</label>
{modelsLoading ? (
<small>Loading available models...</small>
) : availableModels.length === 0 ? (
<small>No models available. Configure authentication first.</small>
) : (
<CustomModelDropdown
id="titleSummarizerModel"
label="Title summarization model"
models={availableModels}
value={
form.titleSummarizerProvider && form.titleSummarizerModelId
? `${form.titleSummarizerProvider}/${form.titleSummarizerModelId}`
: ""
}
onChange={(val) => {
if (!val) {
setForm((f) => ({
...f,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
}));
return;
}
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
titleSummarizerProvider: val.slice(0, slashIdx),
titleSummarizerModelId: val.slice(slashIdx + 1),
}));
}}
placeholder="Use fallback model"
/>
)}
<small>
{form.titleSummarizerProvider && form.titleSummarizerModelId
? "Using explicitly configured model"
: form.planningProvider && form.planningModelId
? "(using planning model)"
: form.defaultProvider && form.defaultModelId
? "(using default model)"
: "(using automatic model selection)"}
</small>
</div>
<div className="form-group">
<div className="modal-actions" style={{ justifyContent: "flex-start" }}>
<button
type="button"
className="btn btn-sm"
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: f.planningProvider,
titleSummarizerModelId: f.planningModelId,
}))
}
disabled={!form.planningProvider || !form.planningModelId}
>
Use planning model
</button>
<button
type="button"
className="btn btn-sm"
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: f.defaultProvider,
titleSummarizerModelId: f.defaultModelId,
}))
}
disabled={!form.defaultProvider || !form.defaultModelId}
>
Use default model
</button>
</div>
</div>
</>
)}
</>
);
case "appearance":
return (
<>

View File

@@ -1428,19 +1428,54 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
const task = await store.createTask({
title,
description,
column,
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
modelProvider: executorModel.provider,
modelId: executorModel.modelId,
validatorModelProvider: validatorModel.provider,
validatorModelId: validatorModel.modelId,
});
// Check for summarize flag in request
const summarize = req.body.summarize === true;
// Get settings for auto-summarization
const settings = await store.getSettings();
// Create onSummarize callback if summarization is enabled
const onSummarize = (summarize || settings.autoSummarizeTitles)
? async (desc: string): Promise<string | null> => {
try {
const { summarizeTitle } = await import("@fusion/core");
// Resolve model selection hierarchy for summarization
const resolvedProvider =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
return await summarizeTitle(desc, store.getRootDir(), resolvedProvider, resolvedModelId);
} catch {
// Return null on error so task creation continues without title
return null;
}
}
: undefined;
const task = await store.createTask(
{
title,
description,
column,
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
modelProvider: executorModel.provider,
modelId: executorModel.modelId,
validatorModelProvider: validatorModel.provider,
validatorModelId: validatorModel.modelId,
summarize,
},
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
);
res.status(201).json(task);
} catch (err: any) {
const status = err.message?.includes("must be a string") ? 400 : 500;
@@ -4915,6 +4950,108 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/ai/summarize-title
* AI-powered title generation from task descriptions.
* Body: { description: string, provider?: string, modelId?: string }
* Returns: { title: string }
*
* Generates a concise title (≤60 characters) from descriptions longer than 140 characters.
* Rate limited: 10 requests per hour per IP
*/
router.post("/ai/summarize-title", async (req, res) => {
try {
const { description, provider, modelId } = req.body;
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = store.getRootDir();
const {
checkRateLimit,
getRateLimitResetTime,
summarizeTitle,
validateDescription,
MIN_DESCRIPTION_LENGTH,
MAX_DESCRIPTION_LENGTH,
RateLimitError,
ValidationError,
AiServiceError,
} = await import("@fusion/core");
// Debug logging
if (process.env.KB_DEBUG_AI) {
console.log(`[ai-summarize] Request from ${ip}, description length: ${description?.length || 0}`);
}
// Check rate limit first
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
res.status(429).json({
error: `Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`,
});
return;
}
// Validate request body
try {
validateDescription(description);
} catch (err: any) {
if (err?.name === "ValidationError") {
res.status(400).json({ error: err.message });
return;
}
throw err;
}
// Resolve model selection hierarchy:
// 1. Request body provider+modelId
// 2. Settings titleSummarizerProvider + titleSummarizerModelId
// 3. Settings planningProvider + planningModelId
// 4. Settings defaultProvider + defaultModelId
// 5. Automatic model resolution (no explicit model)
const settings = await store.getSettings();
const resolvedProvider =
(provider && modelId ? provider : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(provider && modelId ? modelId : undefined) ||
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
if (process.env.KB_DEBUG_AI) {
console.log(`[ai-summarize] Resolved model: ${resolvedProvider || "auto"}/${resolvedModelId || "auto"}`);
}
// Process summarization
const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId);
if (!title) {
res.status(400).json({
error: `Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`,
});
return;
}
res.json({ title });
} catch (err: any) {
// Check error by name since error classes are from dynamic import
if (err?.name === "RateLimitError") {
res.status(429).json({ error: err.message });
} else if (err?.name === "AiServiceError") {
res.status(503).json({ error: err.message || "AI service temporarily unavailable" });
} else if (err?.name === "ValidationError") {
res.status(400).json({ error: err.message });
} else {
console.error("[ai-summarize] Unexpected error:", err);
res.status(500).json({ error: err?.message || "Failed to generate title" });
}
}
});
/**
* GET /api/usage
* Fetch AI provider subscription usage (Claude, Codex, Gemini).