diff --git a/AGENTS.md b/AGENTS.md index 3272a45c..e2481373 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ A change is done only when: ## Current Stats (v0.9.24) - 55 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 141 REST endpoints +- 142 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 9 skills - 60+ iii functions diff --git a/README.md b/README.md index aaf4d959..47d26d22 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ LLM extraction also writes classification tags such as `type:waiting_for_user`, ## Under the hood -Under the hood, the current prototype still exposes the full implementation surface: **55 MCP tools** (8 visible by default — 55 tools, 6 resources, 3 prompts over MCP) and a local REST API serving **141 endpoints on port** 3111. These counts track the implementation that is mid-rename to AI Todo. +Under the hood, the current prototype still exposes the full implementation surface: **55 MCP tools** (8 visible by default — 55 tools, 6 resources, 3 prompts over MCP) and a local REST API serving **142 endpoints on port** 3111. These counts track the implementation that is mid-rename to AI Todo. ## Documentation diff --git a/src/config.ts b/src/config.ts index ac94a421..20730043 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,18 +22,15 @@ export const DEFAULT_LANGEXTRACT_MODEL = "deepseek/deepseek-v4-flash"; export const DEFAULT_LANGEXTRACT_PROVIDER = "openai"; export const DEFAULT_LANGEXTRACT_BASE_URL = "https://api.novita.ai/openai/v1"; export const DEFAULT_TODO_EXTRACT_TIMEOUT_MS = 120_000; +export const DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS = 12; // STEP-11: extraction scope. sinceDays = only sessions whose endedAt/startedAt // falls within the last N days are eligible (the primary scope control). Max // interactions = per session, keep at most M most-recent interaction records // (a "turn": one user message through everything before the next). maxSessions -// stays a backend safety cap (not surfaced as a setting). +// has no default cap; REST callers can still pass an explicit positive cap. export const DEFAULT_TODO_EXTRACT_SINCE_DAYS = 7; export const DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS = 10; -// Interactive safety cap on how many sessions one extraction pass touches. The -// day window is the primary control, but with the LLM extractor each session is -// a serial sidecar call (up to the per-call timeout), so a single "organize" -// click must stay bounded. REST callers can still pass a larger maxSessions. -export const DEFAULT_TODO_EXTRACT_MAX_SESSIONS = 8; +export const DEFAULT_TODO_EXTRACT_MAX_SESSIONS = Number.POSITIVE_INFINITY; const LEGACY_LANGEXTRACT_MODELS = new Set(["pa/gpt-5.5"]); export const WRITABLE_TODO_EXTRACT_KEYS = new Set([ "AGENTMEMORY_TODO_EXTRACTOR", @@ -46,6 +43,7 @@ export const WRITABLE_TODO_EXTRACT_KEYS = new Set([ "AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS", "AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS", "AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION", + "AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS", ]); let warnPremiumModelShown = false; @@ -96,6 +94,8 @@ export function getTodoExtractorUserConfig(): Record { LANGEXTRACT_THINKING_DEPTH: env["LANGEXTRACT_THINKING_DEPTH"] || "medium", AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS: env["AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS"] || String(DEFAULT_TODO_EXTRACT_TIMEOUT_MS), + AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS: + env["AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS"] || String(DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS), AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS: env["AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS"] || String(DEFAULT_TODO_EXTRACT_SINCE_DAYS), AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION: diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index 3983bfd6..88bcb305 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -8,6 +8,7 @@ import { KV, fingerprintId, generateId, nearDuplicateTitle } from "../state/sche import type { Action, CompressedObservation, ReviewQueueItem, ScanCheckpoint, Session } from "../types.js"; import { DEFAULT_LANGEXTRACT_BASE_URL, + DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS, DEFAULT_TODO_EXTRACT_TIMEOUT_MS, DEFAULT_TODO_EXTRACT_SINCE_DAYS, DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS, @@ -59,6 +60,7 @@ type TodoExtractOptions = { // then the config defaults when omitted. sinceDays?: number; maxInteractionsPerSession?: number; + maxLlmSessions?: number; project?: string; force?: boolean; scanSources?: boolean; @@ -81,14 +83,78 @@ const SIDE_CAR_ENV_KEYS = [ "LANGEXTRACT_MAX_CHAR_BUFFER", ]; +export type TodoExtractErrorCode = + | "llm_unavailable" + | "provider_timeout" + | "config_error" + | "provider_error" + | "extract_failed"; + +export type TodoExtractJobStatus = "idle" | "running" | "done" | "error"; + +export type TodoExtractResult = { + success: true; + jobId?: string; + status?: TodoExtractJobStatus; + startedAt?: string; + finishedAt?: string; + engine: "langextract" | "rules" | "mixed"; + scannedSessions: number; + processedSessions: number; + skippedUnchangedSessions: number; + scannedObservations: number; + directCreated: number; + reviewCreated: number; + hiddenHistory: number; + discarded: number; + cleanedActions: number; + cleanedReviews: number; + completedActions: number; + completedReviews: number; + recheckMarked: number; + llmSessionBudget: number; + llmSessionsAttempted: number; + llmSessionsSkipped: number; + llmFallback?: boolean; + fallbackReason?: string; + errorCode?: TodoExtractErrorCode; + cleanupPreview?: { actions: unknown[]; reviews: unknown[] }; + sourceScan?: { imported: number; skipped: number; errors: number }; +}; + +export type TodoExtractJob = { + success: boolean; + jobId: string; + status: TodoExtractJobStatus; + startedAt: string; + finishedAt?: string; + message?: string; + errorCode?: TodoExtractErrorCode; + errorMessage?: string; + result?: TodoExtractResult; + inFlight?: boolean; +}; + +let activeTodoExtractJob: Promise | null = null; +let currentTodoExtractJob: TodoExtractJob | null = null; + function envNumber(key: string, fallback: number): number { const parsed = Number(getEnvVar(key)); return Number.isFinite(parsed) ? parsed : fallback; } +function classifyExtractError(message: string | undefined): TodoExtractErrorCode { + const text = String(message || "").toLowerCase(); + if (/(timed out|timeout|abort)/.test(text)) return "provider_timeout"; + if (/(api key|apikey|unauthorized|forbidden|401|403|required)/.test(text)) return "config_error"; + if (/(rate limit|429|quota|provider|openai|novita|langextract)/.test(text)) return "provider_error"; + return "extract_failed"; +} + function clampPositiveInt(value: unknown, fallback: number, max: number): number { const parsed = typeof value === "number" ? value : parseInt(String(value ?? ""), 10); if (!Number.isFinite(parsed) || parsed < 1) return fallback; + if (!Number.isFinite(max)) return Math.floor(parsed); return Math.min(max, Math.floor(parsed)); } @@ -1134,12 +1200,13 @@ async function extractForSession( session: Session, observations: CompressedObservation[], mode: string, -): Promise<{ todos: ExtractedTodo[]; engine: "langextract" | "rules"; fallbackReason?: string }> { + opts: { allowLlm?: boolean } = {}, +): Promise<{ todos: ExtractedTodo[]; engine: "langextract" | "rules"; fallbackReason?: string; errorCode?: TodoExtractErrorCode }> { const { ruleObservations, llmObservations } = prefilterTodoObservations(session, observations); const blocks = llmObservations.map(blockFor).filter((block) => block.text); const bucket = timeBucketFor(session); let fallbackReason = ""; - if (mode !== "rules" && blocks.length > 0) { + if (mode !== "rules" && opts.allowLlm !== false && blocks.length > 0) { try { const todos = await runLangExtractSidecar({ sessionId: session.id, @@ -1160,31 +1227,14 @@ async function extractForSession( todos: candidates.map((candidate) => candidateToTodo(candidate, session, bucket)).filter((todo): todo is ExtractedTodo => !!todo), engine: "rules", ...(fallbackReason ? { fallbackReason } : {}), + ...(fallbackReason ? { errorCode: classifyExtractError(fallbackReason) } : {}), }; } export async function generateTodosFromSessions( kv: Pick, data: TodoExtractOptions = {}, -): Promise<{ - success: true; - engine: "langextract" | "rules" | "mixed"; - scannedSessions: number; - scannedObservations: number; - directCreated: number; - reviewCreated: number; - hiddenHistory: number; - discarded: number; - cleanedActions: number; - cleanedReviews: number; - completedActions: number; - completedReviews: number; - cleanupPreview?: { actions: CleanupPreviewItem[]; reviews: CleanupPreviewItem[] }; - recheckMarked: number; - sourceScan?: { imported: number; skipped: number; errors: number }; - llmFallback?: boolean; - fallbackReason?: string; -}> { +): Promise { let sourceScan: { imported: number; skipped: number; errors: number } | undefined; if (data.scanSources !== false) { const scan = await scanCodexSource(kv as StateKV).catch(() => null); @@ -1204,6 +1254,11 @@ export async function generateTodosFromSessions( DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS, 500, ); + const maxLlmSessions = clampPositiveInt( + data.maxLlmSessions ?? getEnvVar("AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS"), + DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS, + 100, + ); const sinceCutoffMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000; const mode = (getEnvVar("AGENTMEMORY_TODO_EXTRACTOR") || "auto").toLowerCase(); const directThreshold = envNumber("AGENTMEMORY_TODO_DIRECT_CONFIDENCE", 0.82); @@ -1233,8 +1288,8 @@ export async function generateTodosFromSessions( const sessions = allSessions .filter((session) => !data.project || session.project === data.project || session.cwd === data.project) // STEP-11: day-window is the primary scope control. Sessions with no/invalid - // timestamp are kept (never silently drop work); maxSessions is the cap that - // still bounds a day with a flood of sessions. + // timestamp are kept (never silently drop work); maxSessions only applies + // when a caller explicitly passes a positive cap. .filter((session) => { const raw = sessionSortTime(session); if (!raw) return true; @@ -1248,14 +1303,22 @@ export async function generateTodosFromSessions( let reviewCreated = 0; let hiddenHistory = 0; let discarded = 0; + let processedSessions = 0; + let skippedUnchangedSessions = 0; + let llmSessionsAttempted = 0; + let llmSessionsSkipped = 0; const engines = new Set<"langextract" | "rules">(); const fallbackReasons = new Set(); + const errorCodes = new Set(); const now = new Date().toISOString(); const recheckMarked = await markChangedGeneratedActions(kv, new Map(allSessions.map((session) => [session.id, session])), now); for (const session of sessions) { const key = checkpointKey(session); - if (!data.force && processed[session.id] === key) continue; + if (!data.force && processed[session.id] === key) { + skippedUnchangedSessions++; + continue; + } const sortedObservations = (await kv.list(KV.observations(session.id)).catch(() => [])) .sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); // STEP-11: keep only the most recent N interaction records, then apply the @@ -1266,9 +1329,14 @@ export async function generateTodosFromSessions( scannedObservations += ruleObservations.length; const evidenceObservations = mode === "rules" ? ruleObservations : llmObservations.length ? llmObservations : ruleObservations; const blockMap = new Map(evidenceObservations.map((obs) => [obs.id, blockFor(obs)])); - const { todos, engine, fallbackReason } = await extractForSession(session, ruleObservations, mode); + const wantsLlm = mode !== "rules" && llmObservations.length > 0 && timeBucketFor(session) !== "history"; + const allowLlm = !wantsLlm || llmSessionsAttempted < maxLlmSessions; + if (wantsLlm && allowLlm) llmSessionsAttempted++; + if (wantsLlm && !allowLlm) llmSessionsSkipped++; + const { todos, engine, fallbackReason, errorCode } = await extractForSession(session, ruleObservations, mode, { allowLlm }); engines.add(engine); if (fallbackReason) fallbackReasons.add(fallbackReason.slice(0, 240)); + if (errorCode) errorCodes.add(errorCode); for (const rawTodo of todos) { const todo = todoForStorage(rawTodo); if (!todo || !validateTodoEvidence(todo, blockMap)) { @@ -1318,6 +1386,7 @@ export async function generateTodosFromSessions( seenTitles.push(titleKey); } processed[session.id] = key; + processedSessions++; } await kv.set(KV.scanCheckpoints, checkpointId, { @@ -1330,6 +1399,8 @@ export async function generateTodosFromSessions( success: true, engine: engines.size > 1 ? "mixed" : Array.from(engines)[0] || "rules", scannedSessions: sessions.length, + processedSessions, + skippedUnchangedSessions, scannedObservations, directCreated, reviewCreated, @@ -1340,17 +1411,122 @@ export async function generateTodosFromSessions( completedActions: cleanup.completedActions, completedReviews: cleanup.completedReviews, recheckMarked, + llmSessionBudget: maxLlmSessions, + llmSessionsAttempted, + llmSessionsSkipped, ...(data.cleanup === "dry-run" ? { cleanupPreview: cleanup.preview } : {}), ...(sourceScan ? { sourceScan } : {}), ...(fallbackReasons.size ? { llmFallback: true } : {}), ...(fallbackReasons.size ? { fallbackReason: Array.from(fallbackReasons)[0] } : {}), + ...(errorCodes.size ? { errorCode: Array.from(errorCodes)[0] } : {}), }; } +export function getTodoExtractJobStatus(): TodoExtractJob { + if (!currentTodoExtractJob) { + return { + success: true, + jobId: "", + status: "idle", + startedAt: "", + }; + } + return { ...currentTodoExtractJob }; +} + +export async function startTodoExtractJob( + kv: Pick, + data: TodoExtractOptions = {}, +): Promise { + if (activeTodoExtractJob && currentTodoExtractJob?.status === "running") { + return { ...currentTodoExtractJob, success: true, inFlight: true }; + } + + const jobId = generateId("todo_extract"); + const startedAt = new Date().toISOString(); + currentTodoExtractJob = { + success: true, + jobId, + status: "running", + startedAt, + message: "running", + inFlight: true, + }; + + activeTodoExtractJob = generateTodosFromSessions(kv, data) + .then((result) => { + const finishedAt = new Date().toISOString(); + const next: TodoExtractResult = { + ...result, + jobId, + status: "done", + startedAt, + finishedAt, + }; + currentTodoExtractJob = { + success: true, + jobId, + status: "done", + startedAt, + finishedAt, + result: next, + }; + return next; + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err || "todo extraction failed"); + const errorCode = classifyExtractError(message); + currentTodoExtractJob = { + success: false, + jobId, + status: "error", + startedAt, + finishedAt: new Date().toISOString(), + errorCode, + errorMessage: message, + }; + throw err; + }) + .finally(() => { + activeTodoExtractJob = null; + }); + + return { ...currentTodoExtractJob, inFlight: false }; +} + +function isDuplicateTodoExtractJob(job: TodoExtractJob): boolean { + return job.inFlight === true; +} + +export async function runTodoExtractJob( + kv: Pick, + data: TodoExtractOptions = {}, +): Promise { + const job = await startTodoExtractJob(kv, data); + if (isDuplicateTodoExtractJob(job) && job.status === "running") { + return job; + } + if (!activeTodoExtractJob) return getTodoExtractJobStatus(); + + try { + const result = await activeTodoExtractJob; + return { + ...result, + success: true, + jobId: result.jobId || job.jobId, + status: "done", + startedAt: result.startedAt || job.startedAt, + finishedAt: result.finishedAt, + result, + } as TodoExtractJob; + } catch { + return getTodoExtractJobStatus(); + } +} + export function registerTodoExtractFunctions(sdk: ISdk, kv: StateKV): void { - sdk.registerFunction("mem::todo-extract-generate", async (data: TodoExtractOptions = {}) => - generateTodosFromSessions(kv, data), - ); + sdk.registerFunction("mem::todo-extract-generate", async (data: TodoExtractOptions = {}) => runTodoExtractJob(kv, data)); + sdk.registerFunction("mem::todo-extract-status", async () => getTodoExtractJobStatus()); sdk.registerFunction("mem::todo-update", async (data: TodoUpdateOptions = {}) => updateChangedTodoCards(kv, data), ); diff --git a/src/index.ts b/src/index.ts index 91fd8852..3aa47834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -531,7 +531,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 141 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 142 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7fe3cb84..3f0ec849 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -522,7 +522,7 @@ export function registerApiTriggers( success: true, envPath: getUserEnvPath(), config: getTodoExtractorUserConfig(), - restartRequired: true, + restartRequired: false, }, }; }, @@ -1510,11 +1510,16 @@ export function registerApiTriggers( if (maxInteractionsPerSession === null) { return { status_code: 400, body: { error: "maxInteractionsPerSession must be a positive integer" } }; } + const maxLlmSessions = parseOptionalPositiveInt(body.maxLlmSessions); + if (maxLlmSessions === null) { + return { status_code: 400, body: { error: "maxLlmSessions must be a positive integer" } }; + } const payload: Record = {}; if (maxSessions !== undefined) payload.maxSessions = maxSessions; if (maxObservationsPerSession !== undefined) payload.maxObservationsPerSession = maxObservationsPerSession; if (sinceDays !== undefined) payload.sinceDays = sinceDays; if (maxInteractionsPerSession !== undefined) payload.maxInteractionsPerSession = maxInteractionsPerSession; + if (maxLlmSessions !== undefined) payload.maxLlmSessions = maxLlmSessions; const project = asNonEmptyString(body.project); if (project) payload.project = project; if (body.force === true) payload.force = true; @@ -1528,6 +1533,19 @@ export function registerApiTriggers( function_id: "api::todo-extract-generate", config: { api_path: "/agentmemory/todo-extract/generate", http_method: "POST" }, }); + sdk.registerFunction("api::todo-extract-status", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::todo-extract-status", payload: {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::todo-extract-status", + config: { api_path: "/agentmemory/todo-extract/status", http_method: "GET" }, + }); sdk.registerFunction("api::todo-update", async (req: ApiRequest): Promise => { diff --git a/src/viewer/index.html b/src/viewer/index.html index ee768b00..44460812 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -4163,6 +4163,10 @@

AI Todo

'act.extract.rules': 'LLM unavailable', 'act.extract.error': 'Organize failed', 'act.extract.failedExisting': 'Extraction failed; showing existing todos', + 'act.extract.timeout': 'Provider timed out; showing existing todos', + 'act.extract.configError': 'LLM config needs attention; showing existing todos', + 'act.extract.providerError': 'LLM provider unavailable; showing existing todos', + 'act.extract.runningExisting': 'Still organizing from a previous request...', 'act.extract.loading': 'Loading todos...', 'act.extract.starting': 'Organizing recent sessions...', 'act.extract.background': 'Latest todos are shown; still organizing...', @@ -4192,10 +4196,11 @@

AI Todo

'act.empty.title': 'No todos yet', 'act.empty.lead': 'This is where todos, blocked items, and completed work extracted from your sessions will appear.', 'settings.title': 'Settings', - 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', + 'settings.subtitle': 'Local configuration is written to the user config file and applies to the next organize run.', 'settings.close': 'Close', 'settings.language': 'UI language', 'settings.extractor': 'LLM extraction config', + 'settings.maxLlmSessions': 'Max LLM sessions per organize run', 'settings.sinceDays': 'Look-back window (days): only sessions from the last N days', 'settings.maxInteractions': 'Max interaction records per session (one user request → agent reply)', 'settings.apiKeyKeep': 'Enter a new API key to replace it, or leave blank to keep the current key', @@ -4203,7 +4208,7 @@

AI Todo

'settings.apiKeyLabel': 'API key:', 'settings.save': 'Save config', 'settings.saving': 'Saving...', - 'settings.savedRestart': 'Config saved. Restart the service to apply it.', + 'settings.savedRestart': 'Config saved. It applies to the next organize run.', 'settings.saveFailed': 'Config save failed', 'act.status.updateFailed': 'Todo status update failed', 'obs.type.file_read': 'Read file', @@ -4365,6 +4370,10 @@

AI Todo

'act.extract.rules': '未走大模型', 'act.extract.error': '整理失败', 'act.extract.failedExisting': '抽取失败,已显示现有待办', + 'act.extract.timeout': '上游超时,已显示现有待办', + 'act.extract.configError': '大模型配置需要检查,已显示现有待办', + 'act.extract.providerError': '大模型服务不可用,已显示现有待办', + 'act.extract.runningExisting': '上一次整理仍在进行...', 'act.extract.loading': '正在整理待办...', 'act.extract.starting': '正在从最近会话整理待办...', 'act.extract.background': '已显示最新待办,后台仍在整理...', @@ -4394,10 +4403,11 @@

AI Todo

'act.empty.title': '还没有待办', 'act.empty.lead': '这里会放从会话里整理出的待办、卡住事项和已完成事项。', 'settings.title': '设置', - 'settings.subtitle': '本机配置会写入用户配置文件,重启服务后生效。', + 'settings.subtitle': '本机配置会写入用户配置文件,下次整理时生效。', 'settings.close': '关闭', 'settings.language': '界面语言', 'settings.extractor': '大模型抽取配置', + 'settings.maxLlmSessions': '每次整理最多调用大模型的会话数', 'settings.sinceDays': '回溯天数:只抽取最近 N 天内的会话', 'settings.maxInteractions': '每会话最多交互记录数(一次用户派发→Agent 回复为一条)', 'settings.apiKeyKeep': '输入新 API key 覆盖,留空保持不变', @@ -4405,7 +4415,7 @@

AI Todo

'settings.apiKeyLabel': 'API key:', 'settings.save': '保存配置', 'settings.saving': '保存中...', - 'settings.savedRestart': '配置已保存,重启后生效。', + 'settings.savedRestart': '配置已保存,下次整理时生效。', 'settings.saveFailed': '配置保存失败', 'act.status.updateFailed': '待办状态更新失败', 'obs.type.file_read': '读取文件', @@ -4614,7 +4624,7 @@

AI Todo

audit: { loaded: false, entries: [], opFilter: '' }, activity: { loaded: false, observations: [], sessions: [], typeFilter: '', loadingPhase: '', warnings: [] }, lessons: { loaded: false, items: [], search: '', skillSearch: '', skillRootFilter: 'all', mode: 'explicit', projects: [] }, - actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, stale: false, config: null, configSaving: false, configDraft: {} }, + actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, extractJob: null, stale: false, config: null, configSaving: false, configDraft: {} }, inbox: { loaded: false, items: [], awaitingItems: [], answeredItems: [], dismissedItems: [], replyingId: null, pendingById: {}, briefingExpanded: false, answeredExpanded: false }, crystals: { loaded: false, items: [], search: '', lessonMap: {} }, profile: { loaded: false, projects: [], selectedProject: '', data: null }, @@ -4624,7 +4634,6 @@

AI Todo

settings: { open: false }, ws: null }; - function esc(s) { if (!s) return ''; var d = document.createElement('div'); @@ -8720,6 +8729,7 @@

AI Todo

if (state.settings.open) { loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); } + apiGet('todo-extract/status').then(syncTodoExtractJob).catch(function() {}); if (opts.generate === true) startTodoExtraction(opts.force === true); } @@ -8746,6 +8756,43 @@

AI Todo

return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; } + function todoExtractionErrorMessage(result) { + var code = result && (result.errorCode || (result.result && result.result.errorCode)); + if (code === 'provider_timeout') return t('act.extract.timeout'); + if (code === 'config_error') return t('act.extract.configError'); + if (code === 'provider_error' || code === 'llm_unavailable') return t('act.extract.providerError'); + return t('act.extract.failedExisting'); + } + + function todoExtractionResultFromJob(job) { + if (!job) return null; + return job.result || (job.success === true && job.engine ? job : null); + } + + function syncTodoExtractJob(job) { + if (!job || !job.status || job.status === 'idle') return job; + state.actions.extractJob = job; + if (job.status === 'running') { + state.actions.extractInFlight = true; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.runningExisting'); + if (state.activeTab === 'actions') renderActions(); + return job; + } + state.actions.extractInFlight = false; + if (job.status === 'done') { + var result = todoExtractionResultFromJob(job); + state.actions.extractStatus = 'done'; + state.actions.extractFallback = !todoExtractionUsedLlm(result); + state.actions.extractMessage = todoExtractionSummary(result); + } else if (job.status === 'error') { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = todoExtractionErrorMessage(job); + } + if (state.activeTab === 'actions') renderActions(); + return job; + } + function refreshActionListsAfterExtract() { return Promise.all([ apiGet('actions'), @@ -8817,6 +8864,7 @@

AI Todo

html += ''; html += ''; html += ''; + html += '
' + esc(t('settings.maxLlmSessions')) + '
'; html += '
' + esc(t('settings.sinceDays')) + '
'; html += '
' + esc(t('settings.maxInteractions')) + '
'; html += '
'; @@ -8841,6 +8889,7 @@

AI Todo

'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS', 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' @@ -8861,6 +8910,7 @@

AI Todo

'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS', 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' @@ -8937,13 +8987,21 @@

AI Todo

// settings would never take effect on this primary extraction path. apiPost('todo-extract/generate', { force: force === true - }).then(function(result) { + }).then(function(job) { + var result = todoExtractionResultFromJob(job); + if (job && job.status === 'running') { + state.actions.extractJob = job; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.runningExisting'); + return refreshActionListsAfterExtract(); + } var delta = todoExtractionDelta(result); if (!result || result.success !== true) { state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.extract.failedExisting'); + state.actions.extractMessage = todoExtractionErrorMessage(job || result); return null; } + state.actions.extractJob = job; state.actions.extractStatus = 'done'; state.actions.extractFallback = !todoExtractionUsedLlm(result); state.actions.extractMessage = todoExtractionSummary(result); @@ -8957,7 +9015,9 @@

AI Todo

state.actions.extractMessage = t('act.extract.failedExisting'); }).then(function() { clearTimeout(softRefreshTimer); - state.actions.extractInFlight = false; + if (!state.actions.extractJob || state.actions.extractJob.status !== 'running') { + state.actions.extractInFlight = false; + } if (state.activeTab === 'actions' && !actionsScrolledAway()) { renderActions(); } else if (state.activeTab !== 'actions') { diff --git a/src/viewer/parts/app/05-i18n.js b/src/viewer/parts/app/05-i18n.js index b38934f9..c960732f 100644 --- a/src/viewer/parts/app/05-i18n.js +++ b/src/viewer/parts/app/05-i18n.js @@ -63,6 +63,10 @@ 'act.extract.rules': 'LLM unavailable', 'act.extract.error': 'Organize failed', 'act.extract.failedExisting': 'Extraction failed; showing existing todos', + 'act.extract.timeout': 'Provider timed out; showing existing todos', + 'act.extract.configError': 'LLM config needs attention; showing existing todos', + 'act.extract.providerError': 'LLM provider unavailable; showing existing todos', + 'act.extract.runningExisting': 'Still organizing from a previous request...', 'act.extract.loading': 'Loading todos...', 'act.extract.starting': 'Organizing recent sessions...', 'act.extract.background': 'Latest todos are shown; still organizing...', @@ -92,10 +96,11 @@ 'act.empty.title': 'No todos yet', 'act.empty.lead': 'This is where todos, blocked items, and completed work extracted from your sessions will appear.', 'settings.title': 'Settings', - 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', + 'settings.subtitle': 'Local configuration is written to the user config file and applies to the next organize run.', 'settings.close': 'Close', 'settings.language': 'UI language', 'settings.extractor': 'LLM extraction config', + 'settings.maxLlmSessions': 'Max LLM sessions per organize run', 'settings.sinceDays': 'Look-back window (days): only sessions from the last N days', 'settings.maxInteractions': 'Max interaction records per session (one user request → agent reply)', 'settings.apiKeyKeep': 'Enter a new API key to replace it, or leave blank to keep the current key', @@ -103,7 +108,7 @@ 'settings.apiKeyLabel': 'API key:', 'settings.save': 'Save config', 'settings.saving': 'Saving...', - 'settings.savedRestart': 'Config saved. Restart the service to apply it.', + 'settings.savedRestart': 'Config saved. It applies to the next organize run.', 'settings.saveFailed': 'Config save failed', 'act.status.updateFailed': 'Todo status update failed', 'obs.type.file_read': 'Read file', @@ -265,6 +270,10 @@ 'act.extract.rules': '未走大模型', 'act.extract.error': '整理失败', 'act.extract.failedExisting': '抽取失败,已显示现有待办', + 'act.extract.timeout': '上游超时,已显示现有待办', + 'act.extract.configError': '大模型配置需要检查,已显示现有待办', + 'act.extract.providerError': '大模型服务不可用,已显示现有待办', + 'act.extract.runningExisting': '上一次整理仍在进行...', 'act.extract.loading': '正在整理待办...', 'act.extract.starting': '正在从最近会话整理待办...', 'act.extract.background': '已显示最新待办,后台仍在整理...', @@ -294,10 +303,11 @@ 'act.empty.title': '还没有待办', 'act.empty.lead': '这里会放从会话里整理出的待办、卡住事项和已完成事项。', 'settings.title': '设置', - 'settings.subtitle': '本机配置会写入用户配置文件,重启服务后生效。', + 'settings.subtitle': '本机配置会写入用户配置文件,下次整理时生效。', 'settings.close': '关闭', 'settings.language': '界面语言', 'settings.extractor': '大模型抽取配置', + 'settings.maxLlmSessions': '每次整理最多调用大模型的会话数', 'settings.sinceDays': '回溯天数:只抽取最近 N 天内的会话', 'settings.maxInteractions': '每会话最多交互记录数(一次用户派发→Agent 回复为一条)', 'settings.apiKeyKeep': '输入新 API key 覆盖,留空保持不变', @@ -305,7 +315,7 @@ 'settings.apiKeyLabel': 'API key:', 'settings.save': '保存配置', 'settings.saving': '保存中...', - 'settings.savedRestart': '配置已保存,重启后生效。', + 'settings.savedRestart': '配置已保存,下次整理时生效。', 'settings.saveFailed': '配置保存失败', 'act.status.updateFailed': '待办状态更新失败', 'obs.type.file_read': '读取文件', diff --git a/src/viewer/parts/app/08-state-expert.js b/src/viewer/parts/app/08-state-expert.js index 8c9cbefa..3a2ef572 100644 --- a/src/viewer/parts/app/08-state-expert.js +++ b/src/viewer/parts/app/08-state-expert.js @@ -72,7 +72,7 @@ audit: { loaded: false, entries: [], opFilter: '' }, activity: { loaded: false, observations: [], sessions: [], typeFilter: '', loadingPhase: '', warnings: [] }, lessons: { loaded: false, items: [], search: '', skillSearch: '', skillRootFilter: 'all', mode: 'explicit', projects: [] }, - actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, stale: false, config: null, configSaving: false, configDraft: {} }, + actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, extractJob: null, stale: false, config: null, configSaving: false, configDraft: {} }, inbox: { loaded: false, items: [], awaitingItems: [], answeredItems: [], dismissedItems: [], replyingId: null, pendingById: {}, briefingExpanded: false, answeredExpanded: false }, crystals: { loaded: false, items: [], search: '', lessonMap: {} }, profile: { loaded: false, projects: [], selectedProject: '', data: null }, @@ -82,4 +82,3 @@ settings: { open: false }, ws: null }; - diff --git a/src/viewer/parts/app/60-actions-todo.js b/src/viewer/parts/app/60-actions-todo.js index a687104f..7c3fbc0f 100644 --- a/src/viewer/parts/app/60-actions-todo.js +++ b/src/viewer/parts/app/60-actions-todo.js @@ -30,6 +30,7 @@ if (state.settings.open) { loadTodoExtractorConfig().then(renderSettingsPanel).catch(function() {}); } + apiGet('todo-extract/status').then(syncTodoExtractJob).catch(function() {}); if (opts.generate === true) startTodoExtraction(opts.force === true); } @@ -56,6 +57,43 @@ return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; } + function todoExtractionErrorMessage(result) { + var code = result && (result.errorCode || (result.result && result.result.errorCode)); + if (code === 'provider_timeout') return t('act.extract.timeout'); + if (code === 'config_error') return t('act.extract.configError'); + if (code === 'provider_error' || code === 'llm_unavailable') return t('act.extract.providerError'); + return t('act.extract.failedExisting'); + } + + function todoExtractionResultFromJob(job) { + if (!job) return null; + return job.result || (job.success === true && job.engine ? job : null); + } + + function syncTodoExtractJob(job) { + if (!job || !job.status || job.status === 'idle') return job; + state.actions.extractJob = job; + if (job.status === 'running') { + state.actions.extractInFlight = true; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.runningExisting'); + if (state.activeTab === 'actions') renderActions(); + return job; + } + state.actions.extractInFlight = false; + if (job.status === 'done') { + var result = todoExtractionResultFromJob(job); + state.actions.extractStatus = 'done'; + state.actions.extractFallback = !todoExtractionUsedLlm(result); + state.actions.extractMessage = todoExtractionSummary(result); + } else if (job.status === 'error') { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = todoExtractionErrorMessage(job); + } + if (state.activeTab === 'actions') renderActions(); + return job; + } + function refreshActionListsAfterExtract() { return Promise.all([ apiGet('actions'), @@ -127,6 +165,7 @@ html += ''; html += ''; html += ''; + html += '
' + esc(t('settings.maxLlmSessions')) + '
'; html += '
' + esc(t('settings.sinceDays')) + '
'; html += '
' + esc(t('settings.maxInteractions')) + '
'; html += '
'; @@ -151,6 +190,7 @@ 'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS', 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' @@ -171,6 +211,7 @@ 'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS', 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' @@ -247,13 +288,21 @@ // settings would never take effect on this primary extraction path. apiPost('todo-extract/generate', { force: force === true - }).then(function(result) { + }).then(function(job) { + var result = todoExtractionResultFromJob(job); + if (job && job.status === 'running') { + state.actions.extractJob = job; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.runningExisting'); + return refreshActionListsAfterExtract(); + } var delta = todoExtractionDelta(result); if (!result || result.success !== true) { state.actions.extractStatus = 'error'; - state.actions.extractMessage = t('act.extract.failedExisting'); + state.actions.extractMessage = todoExtractionErrorMessage(job || result); return null; } + state.actions.extractJob = job; state.actions.extractStatus = 'done'; state.actions.extractFallback = !todoExtractionUsedLlm(result); state.actions.extractMessage = todoExtractionSummary(result); @@ -267,7 +316,9 @@ state.actions.extractMessage = t('act.extract.failedExisting'); }).then(function() { clearTimeout(softRefreshTimer); - state.actions.extractInFlight = false; + if (!state.actions.extractJob || state.actions.extractJob.status !== 'running') { + state.actions.extractInFlight = false; + } if (state.activeTab === 'actions' && !actionsScrolledAway()) { renderActions(); } else if (state.activeTab !== 'actions') { diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 7084046c..c4ba5463 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { renderViewerDocument } from "./document.js"; import type { Action, CompressedObservation, Memory, ReviewQueueItem, Session } from "../types.js"; import { KV, fingerprintId } from "../state/schema.js"; -import { generateTodosFromSessions, updateChangedTodoCards } from "../functions/todo-extract.js"; +import { getTodoExtractJobStatus, runTodoExtractJob, updateChangedTodoCards } from "../functions/todo-extract.js"; import { getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv, WRITABLE_TODO_EXTRACT_KEYS } from "../config.js"; // Self-host the viewer favicon at /favicon.svg instead of an inline @@ -1382,11 +1382,16 @@ export function startViewerServer( if (method === "POST" && pathname === "/agentmemory/todo-extract/generate") { const raw = await readBody(req); const body = raw ? JSON.parse(raw) as Record : {}; - const result = await generateTodosFromSessions(kv as ViewerKv, body); + const result = await runTodoExtractJob(kv as ViewerKv, body); json(res, 200, result, req); return; } + if (method === "GET" && pathname === "/agentmemory/todo-extract/status") { + json(res, 200, getTodoExtractJobStatus(), req); + return; + } + if (method === "POST" && pathname === "/agentmemory/todo/update") { const raw = await readBody(req); const body = raw ? JSON.parse(raw) as Record : {}; @@ -1419,7 +1424,7 @@ export function startViewerServer( success: true, envPath: getUserEnvPath(), config: getTodoExtractorUserConfig(), - restartRequired: true, + restartRequired: false, }, req); return; } diff --git a/test/review-action.test.ts b/test/review-action.test.ts index 296e8a28..66198f7e 100644 --- a/test/review-action.test.ts +++ b/test/review-action.test.ts @@ -103,6 +103,7 @@ describe("review action candidates", () => { const response = await sdk.trigger("api::todo-extract-generate", req({ maxSessions: 3, maxObservationsPerSession: 20, + maxLlmSessions: 4, project: "agentmemory-lab", force: true, cleanup: "dry-run", @@ -113,6 +114,7 @@ describe("review action candidates", () => { success: true, maxSessions: 3, maxObservationsPerSession: 20, + maxLlmSessions: 4, project: "agentmemory-lab", force: true, cleanup: "dry-run", @@ -124,7 +126,7 @@ describe("review action candidates", () => { const oldKey = process.env.LANGEXTRACT_API_KEY; process.env.LANGEXTRACT_MODEL = "deepseek/deepseek-v4-flash"; process.env.LANGEXTRACT_API_KEY = "secret"; - const response = await sdk.trigger("api::todo-extractor-config", req()) as { status_code: number; body: { success: boolean; config: Record; envPath: string } }; + const response = await sdk.trigger("api::todo-extractor-config", req()) as { status_code: number; body: { success: boolean; config: Record; envPath: string; restartRequired: boolean } }; if (oldModel === undefined) delete process.env.LANGEXTRACT_MODEL; else process.env.LANGEXTRACT_MODEL = oldModel; if (oldKey === undefined) delete process.env.LANGEXTRACT_API_KEY; @@ -136,6 +138,22 @@ describe("review action candidates", () => { expect(response.body.config.LANGEXTRACT_MODEL).toBe("deepseek/deepseek-v4-flash"); expect(response.body.config.LANGEXTRACT_API_KEY).toBeUndefined(); expect(response.body.config.LANGEXTRACT_API_KEY_CONFIGURED).toBe(true); + expect(response.body.restartRequired).toBe(false); + }); + + it("exposes todo extraction job status through the API", async () => { + sdk.registerFunction("mem::todo-extract-status", async () => ({ + success: true, + jobId: "job-1", + status: "running", + startedAt: "2026-06-24T03:00:00Z", + inFlight: true, + })); + + const response = await sdk.trigger("api::todo-extract-status", req()) as { status_code: number; body: Record }; + + expect(response.status_code).toBe(200); + expect(response.body).toMatchObject({ success: true, jobId: "job-1", status: "running", inFlight: true }); }); it("rejects invalid todo extraction limits through the API", async () => { diff --git a/test/session-highlights-api.test.ts b/test/session-highlights-api.test.ts index 6417a87b..bb11fabc 100644 --- a/test/session-highlights-api.test.ts +++ b/test/session-highlights-api.test.ts @@ -30,9 +30,9 @@ describe("session highlights REST API wiring", () => { const agents = readText("AGENTS.md"); const index = readText("src/index.ts"); - expect(endpointCount).toBe(141); - expect(readme).toContain("141 endpoints on port"); - expect(agents).toContain("141 REST endpoints"); - expect(index).toContain("REST API: 141 endpoints"); + expect(endpointCount).toBe(142); + expect(readme).toContain("142 endpoints on port"); + expect(agents).toContain("142 REST endpoints"); + expect(index).toContain("REST API: 142 endpoints"); }); }); diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 68856745..393d900e 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; vi.mock("../src/config.js", () => ({ DEFAULT_LANGEXTRACT_BASE_URL: "https://api.novita.ai/openai/v1", DEFAULT_TODO_EXTRACT_TIMEOUT_MS: 120_000, + DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS: 12, DEFAULT_TODO_EXTRACT_SINCE_DAYS: 7, DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS: 10, - DEFAULT_TODO_EXTRACT_MAX_SESSIONS: 8, + DEFAULT_TODO_EXTRACT_MAX_SESSIONS: Number.POSITIVE_INFINITY, getEnvVar: (key: string) => { const values: Record = { AGENTMEMORY_TODO_EXTRACTOR: "rules", @@ -21,7 +22,7 @@ vi.mock("../src/config.js", () => ({ normalizeTodoExtractorProvider: (value?: string) => (value || "openai").toLowerCase(), })); -import { cleanPollutedTodoCards, updateChangedTodoCards, cleanTodoTitle, generateTodosFromSessions, validateTodoEvidence, runLangExtractSidecar, type ExtractedTodo } from "../src/functions/todo-extract.js"; +import { cleanPollutedTodoCards, updateChangedTodoCards, cleanTodoTitle, generateTodosFromSessions, getTodoExtractJobStatus, runTodoExtractJob, startTodoExtractJob, validateTodoEvidence, runLangExtractSidecar, type ExtractedTodo } from "../src/functions/todo-extract.js"; import type { Action, CompressedObservation, ReviewQueueItem, Session } from "../src/types.js"; import { KV } from "../src/state/schema.js"; import { mockKV } from "./helpers/mocks.js"; @@ -864,6 +865,9 @@ describe("todo extraction scope — sinceDays + interaction window (STEP-11)", ( afterEach(() => { delete process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS; delete process.env.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION; + delete process.env.AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS; + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + delete process.env.LANGEXTRACT_PYTHON; }); // Two completed sessions, both inside the 14d "recent" bucket (so neither is @@ -912,6 +916,38 @@ describe("todo extraction scope — sinceDays + interaction window (STEP-11)", ( expect((await kv.list(KV.actions))[0].metadata?.todoExtraction).toMatchObject({ sourceSessionId: "ses_recent" }); }); + it("does not cap eligible sessions by default after applying the sinceDays window", async () => { + const narratives = [ + "后续需要修复登录接口的超时问题。", + "后续需要更新数据库驱动到 v5。", + "后续需要补充新用户的上手文档。", + "后续需要确认头像资源加载。", + "后续需要修复设置面板保存问题。", + "后续需要补充会话列表分页。", + "后续需要校验证据引用。", + "后续需要调整更新按钮文案。", + "后续需要补充端口占用提示。", + ]; + for (let i = 0; i < narratives.length; i++) { + const id = `ses_window_${i + 1}`; + const at = daysAgo(1 + i / 100); + await kv.set(KV.sessions, id, session({ + id, status: "completed", startedAt: at, endedAt: at, observationCount: 1, + })); + await kv.set(KV.observations(id), `o_window_${i + 1}`, obs({ + id: `o_window_${i + 1}`, sessionId: id, timestamp: at, narrative: narratives[i], + })); + } + + const result = await generateTodosFromSessions(kv as never, { + force: true, scanSources: false, cleanup: "none", sinceDays: 7, + }); + + expect(result.scannedSessions).toBe(9); + const checkpoint = await kv.get<{ cursor: string }>(KV.scanCheckpoints, "todo-extract:all"); + expect(Object.keys(JSON.parse(checkpoint?.cursor || "{}"))).toHaveLength(9); + }); + // A user message in synthetic-compression form: type "conversation", title // === raw hookType "prompt_submit" (the interaction-boundary signal). function promptObs(id: string, narrative: string, timestamp: string): CompressedObservation { @@ -940,6 +976,100 @@ describe("todo extraction scope — sinceDays + interaction window (STEP-11)", ( expect((await kv.list(KV.actions))[0].sourceObservationIds).toEqual(["t3"]); }); + it("defaults to the most recent 10 interaction records per session", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; + await kv.set(KV.sessions, "ses_turns", session({ id: "ses_turns", status: "active", observationCount: 12 })); + const narratives = [ + "后续需要修复登录接口超时。", + "后续需要更新数据库驱动到 v5。", + "后续需要创建导出报告入口。", + "后续需要移除废弃配置开关。", + "后续需要实现离线缓存同步。", + "后续需要补充安装故障文档。", + "后续需要处理浏览器插件认证失败。", + "后续需要调整设置保存流程。", + "后续需要验证头像 PNG 加载。", + "后续需要重试 CI 发布流程。", + "后续需要排查工作台空白页。", + "后续需要整理权限错误提示。", + ]; + for (let i = 0; i < narratives.length; i++) { + await kv.set(KV.observations("ses_turns"), `t${i + 1}`, promptObs(`t${i + 1}`, narratives[i], daysAgo(12 - i))); + } + + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + + expect(result.scannedObservations).toBe(10); + const sourceIds = (await kv.list(KV.actions)).flatMap((action) => action.sourceObservationIds || []); + expect(sourceIds).not.toContain("t1"); + expect(sourceIds).not.toContain("t2"); + const checkpoint = await kv.get<{ cursor: string }>(KV.scanCheckpoints, "todo-extract:all"); + expect(JSON.parse(checkpoint?.cursor || "{}")).toHaveProperty("ses_turns"); + }); + + it("bounds LLM sidecar calls while still scanning the full session window", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + process.env.AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS = "1"; + process.env.LANGEXTRACT_PYTHON = "definitely-missing-python"; + for (let i = 0; i < 3; i++) { + const id = `ses_llm_budget_${i + 1}`; + const at = daysAgo(i + 1); + await kv.set(KV.sessions, id, session({ id, status: "active", startedAt: at, endedAt: at, observationCount: 1 })); + await kv.set(KV.observations(id), `o_llm_budget_${i + 1}`, obs({ + id: `o_llm_budget_${i + 1}`, + sessionId: id, + timestamp: at, + narrative: `后续需要修复第 ${i + 1} 个抽取预算测试问题。`, + })); + } + + const result = await generateTodosFromSessions(kv as never, { + force: true, scanSources: false, cleanup: "none", sinceDays: 30, + }); + + expect(result.scannedSessions).toBe(3); + expect(result.processedSessions).toBe(3); + expect(result.llmSessionBudget).toBe(1); + expect(result.llmSessionsAttempted).toBe(1); + expect(result.llmSessionsSkipped).toBe(2); + expect(result.llmFallback).toBe(true); + expect(result.errorCode).toBeDefined(); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + delete process.env.AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS; + delete process.env.LANGEXTRACT_PYTHON; + }); + + it("exposes a single running extraction job instead of starting duplicate work", async () => { + await kv.set(KV.sessions, "ses_job", session({ id: "ses_job", status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_job"), "o_job", obs({ + id: "o_job", sessionId: "ses_job", narrative: "后续需要验证抽取任务单飞。", + })); + + const first = await startTodoExtractJob(kv as never, { force: true, scanSources: false, cleanup: "none" }); + const second = await startTodoExtractJob(kv as never, { force: true, scanSources: false, cleanup: "none" }); + + expect(first.status).toBe("running"); + expect(second.status).toBe("running"); + expect(second.inFlight).toBe(true); + expect(second.jobId).toBe(first.jobId); + await vi.waitFor(() => expect(getTodoExtractJobStatus().status).toBe("done")); + }); + + it("waits for the owner extraction request but lets duplicate requests observe the running job", async () => { + await kv.set(KV.sessions, "ses_run_job", session({ id: "ses_run_job", status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_run_job"), "o_run_job", obs({ + id: "o_run_job", sessionId: "ses_run_job", narrative: "后续需要验证抽取请求等待最终结果。", + })); + + const owner = runTodoExtractJob(kv as never, { force: true, scanSources: false, cleanup: "none" }); + const duplicate = await runTodoExtractJob(kv as never, { force: true, scanSources: false, cleanup: "none" }); + const completed = await owner; + + expect(duplicate).toMatchObject({ status: "running", inFlight: true }); + expect(completed.status).toBe("done"); + expect(completed.result).toMatchObject({ success: true, processedSessions: 1 }); + }); + it("treats a session with no user-message boundary as a single interaction", async () => { process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; process.env.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION = "1"; diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 45da7b22..e499c5ae 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -1075,10 +1075,10 @@ describe("viewer session rendering", () => { target.getAttribute = (name: string) => name === "data-action" ? "save-todo-config" : null; target.closest = (selector: string) => selector === "[data-action]" ? target : null; dispatchDocumentClick(target); - await waitFor(() => sandbox.state.actions.extractMessage === "Config saved. Restart the service to apply it."); + await waitFor(() => sandbox.state.actions.extractMessage === "Config saved. It applies to the next organize run."); expect(posts[0]).toMatchObject({ LANGEXTRACT_MODEL: "deepseek/deepseek-v4-flash", AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS: "120000", LANGEXTRACT_API_KEY: "secret" }); - expect(sandbox.state.actions.extractMessage).toBe("Config saved. Restart the service to apply it."); + expect(sandbox.state.actions.extractMessage).toBe("Config saved. It applies to the next organize run."); }); it("keeps unsaved todo extractor config while the settings panel rerenders", () => { @@ -1212,6 +1212,29 @@ describe("viewer session rendering", () => { expect(sandbox.state.actions.items[0].title).toBe("整理首版功能文档"); }); + it("restores running todo extraction state after the actions view reloads", async () => { + const { sandbox } = loadViewerSandbox(); + sandbox.fetch = async (input: unknown) => { + const url = String(input); + if (url.includes("todo-extract/status")) { + return { ok: true, json: async () => ({ success: true, jobId: "job-1", status: "running", startedAt: "2026-06-24T03:00:00Z", inFlight: true }) }; + } + if (url.includes("review?status=pending")) return { ok: true, json: async () => ({ items: [] }) }; + if (url.includes("frontier")) return { ok: true, json: async () => ({ frontier: [] }) }; + if (url.includes("actions")) return { ok: true, json: async () => ({ actions: [] }) }; + if (url.includes("inbox")) return { ok: true, json: async () => ({ items: [] }) }; + return { ok: true, json: async () => ({}) }; + }; + + sandbox.state.activeTab = "actions"; + await sandbox.loadActions(); + await waitFor(() => sandbox.state.actions.extractInFlight === true); + + expect(sandbox.state.actions.extractInFlight).toBe(true); + expect(sandbox.state.actions.extractStatus).toBe("running"); + expect(sandbox.state.actions.extractMessage).toBe("Still organizing from a previous request..."); + }); + it("renders the action classification metrics without a false waiting section when inbox is empty", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions";