diff --git a/src/config.ts b/src/config.ts index 20730043..ac94a421 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,15 +22,18 @@ 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 -// has no default cap; REST callers can still pass an explicit positive cap. +// stays a backend safety cap (not surfaced as a setting). export const DEFAULT_TODO_EXTRACT_SINCE_DAYS = 7; export const DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS = 10; -export const DEFAULT_TODO_EXTRACT_MAX_SESSIONS = Number.POSITIVE_INFINITY; +// 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; const LEGACY_LANGEXTRACT_MODELS = new Set(["pa/gpt-5.5"]); export const WRITABLE_TODO_EXTRACT_KEYS = new Set([ "AGENTMEMORY_TODO_EXTRACTOR", @@ -43,7 +46,6 @@ 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; @@ -94,8 +96,6 @@ 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/action-candidates.ts b/src/functions/action-candidates.ts index 85f2cdeb..f0815577 100644 --- a/src/functions/action-candidates.ts +++ b/src/functions/action-candidates.ts @@ -23,10 +23,13 @@ export interface ActionCandidateOptions { type BrowserTurn = { role?: string; text?: string }; const READ_ONLY_TYPES = new Set(["file_read", "search", "web_fetch"]); -const ACTION_VERB_PATTERN = "(修复|补充|实现|调整|验证|排查|定位|跟进|提交|创建|更新|移除|处理|合并|重试|重新(?:运行|跑))"; +const ACTION_VERB_PATTERN = "(修复|修正|补充|实现|调整|验证|排查|定位|跟进|整理|生成|上传|提交|创建|更新|移除|删除|处理|审查|合并|推送|构建|重试|重新(?:运行|跑))"; const ENGLISH_ACTION_VERB = "(fix|add|update|create|remove|validate|retry|rerun|re-run|submit|handle|implement|investigate|debug|resolve)"; const ENGLISH_FOLLOW_UP_PATTERN = new RegExp(`\\b(follow up|follow-up|need to|must)\\b.{0,80}\\b${ENGLISH_ACTION_VERB}\\b`, "i"); const ACTION_DESCRIPTION_MAX_LENGTH = 180; +const PROCESS_CHECK_PHRASES = /(?:做最后一次状态确认|最后一次状态确认|启动后做健康检查|做健康检查|健康检查已完成|确认工作区干净|确认当前分支|确认 PR 链接|服务可用|重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i; +const PROCESS_CHECK_REWRITEABLE = /(?:重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i; +const DURABLE_DELIVERABLE_TERMS = new RegExp(ACTION_VERB_PATTERN + `|\\b${ENGLISH_ACTION_VERB}\\b`, "i"); function normalizeText(value: string | undefined): string { return String(value || "").replace(/\s+/g, " ").trim(); @@ -140,10 +143,18 @@ function cleanTitle(text: string): string { return (boundary > 0 ? head.slice(0, boundary) : head).join("").replace(/[,,;;::\s]+$/u, ""); } +function stripActionPrefix(text: string): string { + return normalizeText(text) + .replace(/^(todo|fixme)\s*[::-]\s*/i, "") + .replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, "") + .replace(/^(请|需要|必须)\s*/u, "") + .replace(/^(follow up|follow-up|fix)\s*[::-]?\s*/i, ""); +} + function titleFromText(text: string, fallback: string): string { const followUpMatch = text.match(new RegExp(`(?:下一步|后续)\\s*(?:请|需要|必须)?\\s*(${ACTION_VERB_PATTERN}[^。!?\\n]*)`, "u"))?.[1]; const failureRepairMatch = text.match(new RegExp(`(?:验证未通过|验证失败|测试未通过|测试失败|command failed|exit code [1-9]\\d*|exited with code [1-9]\\d*)[,,。\\s]*(?:请|需要|必须)?\\s*(${ACTION_VERB_PATTERN}[^。!?\\n]*)`, "iu"))?.[1]; - const explicit = text.match(/\b(?:TODO|FIXME)\b\s*[::-]\s*([^。!?\n]+)/i)?.[1] || + const explicit = text.match(/(?:^|[\s((])(?:TODO|FIXME)\b\s*[::-]\s*([^。!?\n]+)/i)?.[1] || text.match(/待办\s*[::-]\s*([^。!?\n]+)/u)?.[1] || followUpMatch || failureRepairMatch || @@ -171,10 +182,31 @@ function isStatusReport(text: string): boolean { return false; } +function isProcessCheckText(text: string): boolean { + return PROCESS_CHECK_PHRASES.test(normalizeText(text)); +} + +function isRewriteableProcessCheck(text: string): boolean { + return PROCESS_CHECK_REWRITEABLE.test(normalizeText(text)); +} + +function isPureProcessCheck(text: string): boolean { + const normalized = normalizeText(text); + if (!normalized || !isProcessCheckText(normalized)) return false; + if (isRewriteableProcessCheck(normalized)) return false; + return !DURABLE_DELIVERABLE_TERMS.test(stripActionPrefix(normalized).replace(PROCESS_CHECK_PHRASES, "")); +} + +function confidenceFor(reason: ActionCandidate["reason"], description: string): number { + if (isRewriteableProcessCheck(description)) return 0.56; + return reason === "follow_up" ? 0.62 : 0.72; +} + function sentenceReason(text: string): ActionCandidate["reason"] | null { if (isToolTrace(text) || isStatusReport(text)) return null; + if (isPureProcessCheck(text)) return null; if (/不应生成待办|不要生成待办|无需生成待办|不是待办|not an action|not a todo/i.test(text)) return null; - if (/\b(?:TODO|FIXME)\b\s*[::-]\s*\S/i.test(text) || /待办\s*[::-]\s*\S/u.test(text)) return "todo"; + if (/(?:^|[\s((])(?:TODO|FIXME)\b\s*[::-]\s*\S/i.test(text) || /待办\s*[::-]\s*\S/u.test(text)) return "todo"; if (hasExplicitFollowUpAction(text)) return "follow_up"; if (/\bblocked\b|未完成|被阻塞|阻塞/u.test(text)) return "blocked"; if (hasExplicitFailureRepair(text)) return "validation_failed"; @@ -301,7 +333,7 @@ export function extractActionCandidatesFromObservations( source: "observation", sourceObservationIds: [obs.id], tags: tagsFor(reason), - confidence: reason === "follow_up" ? 0.62 : 0.72, + confidence: confidenceFor(reason, description), reason, }); } @@ -331,7 +363,7 @@ export function extractActionCandidatesFromTurns( source: "browser-review", sourceObservationIds: [], tags: tagsFor(reason), - confidence: reason === "follow_up" ? 0.62 : 0.72, + confidence: confidenceFor(reason, description), reason, }); } diff --git a/src/functions/todo-extract-langextract.py b/src/functions/todo-extract-langextract.py index cfe812b3..46d3d7a4 100644 --- a/src/functions/todo-extract-langextract.py +++ b/src/functions/todo-extract-langextract.py @@ -21,23 +21,47 @@ PROMPT = textwrap.dedent( """\ - Extract actionable todos from AI agent session text. + Extract mature, user-facing todo cards from AI agent session text. + When refreshAction metadata is provided, use it as advisory context about the + existing card. Prefer a clearer title/description suggested by cleanup + metadata only when the nearby source quote supports it. Extract only UNRESOLVED, actionable items: explicit next actions, follow-ups, failed validations, blocked work, and in-progress work that still needs doing. DO NOT extract completed work, results, status reports, confirmations, or narration of what was already done (e.g. "…已通过", "…都能显示", "确认…完成"). - Ignore background summaries, generic facts, read-only tool traces, and - anything without a source quote. + Ignore background summaries, generic facts, read-only tool traces, transient + agent process narration, and anything without a source quote. Use exact source text as extraction_text. Put fields in attributes: title, description, confidence, timeBucket, typeBucket, dedupeKey. - title must be a CRISP, SPECIFIC action summary — a concrete verb + the - specific object (+ the concrete target/outcome when it adds signal), so each - card's title alone makes clear what THIS todo is. Keep it short, but do not - drop the object or constraint just to hit a character target. DO NOT pad - with vague filler such as + The generated card must read like a mature todo app item, not a transcript + snippet. title must be a CRISP, SCANNABLE user-facing action summary — a + concrete verb + the semantic object (+ the concrete target/outcome when it + adds signal), so the title alone makes clear what THIS todo is. Preserve the + source language unless the source itself mixes languages. Keep it short, but + do not drop the user-relevant object or constraint just to hit a character + target. + Precise technical identifiers must be preserved, but they should usually go + in description rather than the title when they are long: branch names, + commit hashes, file paths, URLs, session ids, package names, and raw repos. + Prefer title "推送当前工作分支到远程仓库" with description + "分支:codex/todo-cleanup-flash-model。" over title + "推送 codex/todo-cleanup-flash-model 分支到远程仓库". + A good title may contain tightly related steps when they serve one outcome, + e.g. "修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染". + description must be one concise sentence about the remaining user-relevant + work; do not start with "I will / 我会 / 现在 / 接下来". + DO NOT pad with vague filler such as "全面了解 / 了解现状 / 梳理现状 / 获取信息 / 进行 / 处理" — name the actual thing (the repo, the file, the bug, the command's purpose), not the process of "understanding" it. Prefer "克隆 AI-Todo 仓库" over "克隆仓库并全面了解其状况". + Do NOT turn agent workflow/status chores into todos unless a durable user + outcome remains. Examples to skip: "做最后一次状态确认", "启动后做健康检查", + "确认工作区干净", "服务可用", "健康检查已完成", "重启后再测一次" when it is just + suggested procedure or stale troubleshooting, and "我会/接下来/现在确认…". + Negative example: source text "我会做最后一次状态确认,确保工作区干净、当前分支和 PR 链接明确。服务可用,健康检查已完成。" + should produce no todo. + If text might be useful but is ambiguous or process-like, set confidence + below the direct-create threshold (0.55-0.81) so it goes to human review. dedupeKey must be a short STABLE slug of the core action+object (e.g. clone-aitodo-repo, read-project-config), the SAME for two todos that are the same task regardless of wording, so reworded duplicates collapse. @@ -45,6 +69,9 @@ shell flags, logs, or truncated trace fragments as title. If the only source text is a tool log, command payload, path, or JSON object, do not extract a todo. + Never extract dangling/truncated titles such as + "准备推送分支 codex/todo-cleanup-flash-model 到"; if context cannot repair + them into a complete, supported action, produce no todo. Never extract tool-call echo lines (starting with ⏺ or containing Bash(/Shell(), service-status reports (e.g. "服务可用", "Viewer:"/"Health:" URL lists), or git-ref fragments — these are not todos. @@ -119,6 +146,11 @@ def main() -> int: print(json.dumps({"todos": []}, ensure_ascii=False)) return 0 + refresh_action = payload.get("refreshAction") + if isinstance(refresh_action, dict): + refresh_json = json.dumps(refresh_action, ensure_ascii=False, sort_keys=True) + text = f"[refreshAction]\n{refresh_json}\n\n{text}" + examples = [ lx.data.ExampleData( text="[obs:obs_1]\n后续需要修复 CI 失败,并重新跑测试。", @@ -171,6 +203,74 @@ def main() -> int: ) ], ), + lx.data.ExampleData( + text="[obs:obs_4]\n下一步需要修复深色模式按钮对比度,避免主操作在暗色背景下不可读。", + extractions=[ + lx.data.Extraction( + extraction_class="todo", + extraction_text="修复深色模式按钮对比度,避免主操作在暗色背景下不可读", + attributes={ + "title": "修复深色模式按钮对比度", + "description": "修复深色模式按钮对比度,避免主操作在暗色背景下不可读。", + "confidence": 0.9, + "timeBucket": "current", + "typeBucket": "follow_up", + "dedupeKey": "fix-dark-mode-button-contrast", + }, + ) + ], + ), + lx.data.ExampleData( + text="[obs:obs_5]\n下一步需要推送 codex/todo-cleanup-flash-model 分支到远程仓库。", + extractions=[ + lx.data.Extraction( + extraction_class="todo", + extraction_text="推送 codex/todo-cleanup-flash-model 分支到远程仓库", + attributes={ + "title": "推送当前工作分支到远程仓库", + "description": "推送当前工作分支到远程仓库。分支:codex/todo-cleanup-flash-model。", + "confidence": 0.88, + "timeBucket": "current", + "typeBucket": "to_start", + "dedupeKey": "push-current-branch", + }, + ) + ], + ), + lx.data.ExampleData( + text="[obs:obs_7]\n下一步需要修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染。", + extractions=[ + lx.data.Extraction( + extraction_class="todo", + extraction_text="修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染", + attributes={ + "title": "修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染", + "description": "修正目录显示文字,去掉重复编号,并更新页码缓存后重渲染。", + "confidence": 0.9, + "timeBucket": "current", + "typeBucket": "follow_up", + "dedupeKey": "fix-toc-numbering-rerender", + }, + ) + ], + ), + lx.data.ExampleData( + text="[obs:obs_6]\n建议处理顺序:2. 重启 Codex desktop app 后再测一次。", + extractions=[ + lx.data.Extraction( + extraction_class="todo", + extraction_text="重启 Codex desktop app 后再测一次", + attributes={ + "title": "验证重启后的 Codex desktop app", + "description": "重启 Codex desktop app 后再验证问题是否仍存在。", + "confidence": 0.65, + "timeBucket": "recent", + "typeBucket": "follow_up", + "dedupeKey": "verify-codex-desktop-after-restart", + }, + ) + ], + ), ] model_id = model_id_from_env() result = lx.extract( @@ -243,7 +343,15 @@ class DummyLx: assert params["use_schema_constraints"] is False params = extract_kwargs(DummyLx, "custom/openai-compatible-model", DummyConfig) assert params["use_schema_constraints"] is False - assert "CRISP, SPECIFIC" in PROMPT + assert "CRISP, SCANNABLE" in PROMPT + assert "codex/todo-cleanup-flash-model" in PROMPT + assert "推送当前工作分支到远程仓库" in PROMPT + assert "修正目录显示文字" in PROMPT + assert "mature todo app item" in PROMPT + assert "做最后一次状态确认" in PROMPT + assert "Negative example" in PROMPT + assert "refreshAction metadata" in PROMPT + assert "0.55-0.81" in PROMPT assert "dedupeKey must be a short STABLE slug" in PROMPT assert "克隆 AI-Todo 仓库" in PROMPT print("ok") diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index 88bcb305..c8fcd2eb 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -8,7 +8,6 @@ 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, @@ -42,6 +41,22 @@ export interface ExtractedTodo { dedupeKey: string; } +type TodoQualityReason = + | "ok" + | "incomplete-title" + | "process-or-status" + | "polluted" + | "completed" + | "low-actionability"; + +type TodoQuality = { + confidence: number; + reason: TodoQualityReason; + warnings: string[]; + titleCompacted?: boolean; + originalTitle?: string; +}; + type ObservationBlock = { sourceObservationId: string; timestamp: string; @@ -60,13 +75,24 @@ type TodoExtractOptions = { // then the config defaults when omitted. sinceDays?: number; maxInteractionsPerSession?: number; - maxLlmSessions?: number; project?: string; force?: boolean; scanSources?: boolean; cleanup?: "none" | "dry-run" | "apply"; }; +type TodoRefreshActionOptions = { + actionId?: string; +}; + +type LangExtractRunner = typeof runLangExtractSidecar; + +type ExtractForSessionOptions = { + runLangExtractSidecar?: LangExtractRunner; + refreshAction?: Record; + forceLlmContext?: boolean; +}; + const TIME_BUCKETS = new Set(["current", "recent", "history"]); const TYPE_BUCKETS = new Set(["pending", "to_start", "follow_up", "in_progress", "done", "processing"]); const SIDE_CAR = "todo-extract-langextract.py"; @@ -82,79 +108,18 @@ const SIDE_CAR_ENV_KEYS = [ "LANGEXTRACT_MAX_WORKERS", "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; +const TECH_IDENTIFIER_PATTERN = /(?:\b[a-z][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*(?:\/[a-z0-9][a-z0-9_.-]*)*\b|\/(?:Users|tmp|var|private|Volumes)\/\S+|https?:\/\/\S+|\b[0-9a-f]{7,40}\b)/i; +const DANGLE_TITLE_PATTERN = /(?:到|为|把|对|向|在|从|将|with|to|for|from|into|onto|via|using)$/i; +const BRANCH_IDENTIFIER_PATTERN = /\b[a-z][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*(?:\/[a-z0-9][a-z0-9_.-]*)*\b/i; 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)); } @@ -259,6 +224,100 @@ function firstTitleSentence(value: string): string { return trimToTitleBoundary(short || compact, 42); } +function looksIncompleteTitle(value: string): boolean { + const text = normalizeText(value).replace(/[。!?!?,,;;::\s]+$/u, "").trim(); + if (!text) return true; + if (looksTruncated(text)) return true; + if (DANGLE_TITLE_PATTERN.test(text)) return true; + if (/^(?:准备|开始|继续|接下来|现在我会)\s*[^\n。!?]{0,80}(?:到|为|把|对|向|在|从|将)$/u.test(text)) return true; + return false; +} + +function compactTitleTechnicalIdentifiers(title: string, description: string, quote = ""): { title: string; compacted: boolean } { + const text = normalizeText(title); + const context = normalizeText(`${title} ${description} ${quote}`); + if (!TECH_IDENTIFIER_PATTERN.test(text)) return { title: text, compacted: false }; + if (/(?:推送|提交|push)/i.test(context) && /(?:\borigin\b|远程|remote|仓库|repo)/i.test(context)) { + const target = /\borigin\b/i.test(context) ? "origin" : I18NChineseTitle(context) ? "远程仓库" : "the remote repository"; + const zhTitle = target === "origin" ? `推送当前工作分支到 ${target}` : `推送当前工作分支到${target}`; + return { title: I18NChineseTitle(context) ? zhTitle : `Push the current branch to ${target}`, compacted: true }; + } + const compacted = text + .replace(BRANCH_IDENTIFIER_PATTERN, I18NChineseTitle(context) ? "当前工作分支" : "current branch") + .replace(/https?:\/\/\S+/ig, I18NChineseTitle(context) ? "相关链接" : "the link") + .replace(/\/(?:Users|tmp|var|private|Volumes)\/\S+/ig, I18NChineseTitle(context) ? "相关文件" : "the file") + .replace(/\b[0-9a-f]{7,40}\b/ig, I18NChineseTitle(context) ? "相关提交" : "the commit") + .replace(/\s+/g, " ") + .trim(); + return { title: compacted || text, compacted: compacted !== text }; +} + +function I18NChineseTitle(value: string): boolean { + return /[\u4e00-\u9fff]/u.test(value); +} + +function assessTodoQuality(todo: ExtractedTodo): TodoQuality { + const title = normalizeText(todo.title); + const description = normalizeText(todo.description); + const evidence = normalizeText(todo.evidence?.quote); + const warnings: string[] = []; + if (looksIncompleteTitle(title)) return { confidence: 0, reason: "incomplete-title", warnings: ["title is incomplete or truncated"] }; + if (isCompletedTodoText(title) || isCompletedTodoText(description) || isCompletedTodoText(evidence)) { + return { confidence: 0, reason: "completed", warnings: ["looks completed"] }; + } + if (isPollutedTodoText(title) || isPollutedTodoText(description) || isPollutedTodoText(evidence)) { + return { confidence: 0, reason: "polluted", warnings: ["looks like log or tool output"] }; + } + if (isPureProcessCheck(`${title} ${description} ${evidence}`) || isPureStatusReport(`${title} ${description}`)) { + return { confidence: 0, reason: "process-or-status", warnings: ["looks like process/status narration"] }; + } + let confidence = 0.9; + if (!hasTodoActionTrigger(`${title} ${description}`)) { + confidence = Math.min(confidence, 0.58); + warnings.push("weak action verb"); + } + if (TECH_IDENTIFIER_PATTERN.test(title)) { + confidence = Math.min(confidence, 0.7); + warnings.push("title contains long technical identifier"); + } + if (Array.from(title).length > 56) { + confidence = Math.min(confidence, 0.72); + warnings.push("title is too long to scan"); + } + return { confidence, reason: confidence >= 0.55 ? "ok" : "low-actionability", warnings }; +} + +function todoQualityMetadata(quality: TodoQuality): Record { + return { + confidence: quality.confidence, + reason: quality.reason, + warnings: quality.warnings, + ...(quality.titleCompacted ? { titleCompacted: true } : {}), + ...(quality.originalTitle ? { originalTitle: quality.originalTitle } : {}), + }; +} + +function effectiveTodoConfidence(todo: ExtractedTodo): number { + const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; + return Math.min(todo.confidence, quality?.confidence ?? 1); +} + +function firstInvalidTodoReason(todos: ExtractedTodo[], blockMap: Map>): string { + for (const rawTodo of todos) { + const todo = todoForStorage(rawTodo); + if (!todo) { + const title = normalizeText(rawTodo.title || rawTodo.description || rawTodo.evidence?.quote); + if (looksIncompleteTitle(title)) return "incomplete-title"; + if (isCompletedTodoText(title)) return "completed-or-history"; + if (isPollutedTodoText(title)) return "polluted"; + return "low-quality"; + } + if (!validateTodoEvidence(todo, blockMap)) return "evidence-invalid"; + if (todo.timeBucket === "history" || todo.typeBucket === "done") return "completed-or-history"; + } + return "no-valid-todo"; +} + function looksLikeBadTitle(value: string): boolean { const text = normalizeText(value); const lower = text.toLowerCase(); @@ -281,10 +340,32 @@ function looksLikeBadTitle(value: string): boolean { return lower === "untitled todo" || lower === "untitled candidate"; } +const PROCESS_CHECK_PHRASES = /(?:做最后一次状态确认|最后一次状态确认|启动后做健康检查|做健康检查|健康检查已完成|确认工作区干净|确认当前分支|确认 PR 链接|服务可用|重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i; +const PROCESS_CHECK_REWRITEABLE = /(?:重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i; +const DURABLE_DELIVERABLE_TERMS = /(?:修复|修正|补充|实现|调整|验证|排查|定位|跟进|整理|生成|上传|创建|更新|移除|删除|审查|合并|推送|提交|构建|\b(?:fix|add|update|create|remove|validate|retry|rerun|re-run|follow up|follow-up|investigate|debug|resolve|implement)\b)/i; + +function isProcessCheckText(value: string | undefined): boolean { + const text = normalizeText(value); + return !!text && PROCESS_CHECK_PHRASES.test(text); +} + +function isRewriteableProcessCheck(value: string | undefined): boolean { + const text = normalizeText(value); + return !!text && PROCESS_CHECK_REWRITEABLE.test(text); +} + +function isPureProcessCheck(value: string | undefined): boolean { + const text = normalizeText(value); + if (!text || !isProcessCheckText(text)) return false; + if (isRewriteableProcessCheck(text)) return false; + const remainder = stripTitleNoise(text).replace(PROCESS_CHECK_PHRASES, ""); + return !DURABLE_DELIVERABLE_TERMS.test(remainder); +} + // An action being requested — used to exempt status/completed-narration text // from the pollution filter, so a real repair that mentions a status phrase // ("修复服务可用性回归", "排查…失败") is not silently dropped. -const TODO_ACTION_TRIGGER = /(?:修复|补充|实现|调整|验证|排查|定位|跟进|整理|生成|上传|创建|更新|移除|删除|处理|审查|合并|推送|提交|构建|设计|重试|重新(?:运行|跑)|需要|必须|未完成|阻塞|TODO|FIXME|\b(?:fix|add|update|create|remove|validate|retry|rerun|re-run|follow up|follow-up|need to|must|blocked|blocking|investigate|debug|resolve|handle|implement)\b)/i; +const TODO_ACTION_TRIGGER = /(?:修复|修正|补充|实现|调整|验证|排查|定位|跟进|整理|生成|上传|创建|更新|移除|删除|处理|审查|合并|推送|提交|构建|设计|重试|重新(?:运行|跑)|需要|必须|未完成|阻塞|TODO|FIXME|\b(?:fix|add|update|create|remove|validate|retry|rerun|re-run|follow up|follow-up|need to|must|blocked|blocking|investigate|debug|resolve|handle|implement)\b)/i; const AGENT_PROGRESS_PREFIX = /^(?:我会|我将|我要|现在我会|接下来|先|继续|等待|查看|读取|检查|确认|核对|梳理|记录|准备|进行|定位当前|开始)\b/u; const PROGRESS_NOUNS = /(?:仓库现状|远程元数据|关键入口|GitHub 状态|依赖安装|安装完成|空闲端口|健康检查|本地可运行性验证|静态梳理|运行验证|截图|console|服务可用|页面已经能返回|工作区状态|同名目录|PR\/issue|PR、issue|CI 配置)/i; const DONE_NARRATION = /(?:已(?:经)?|成功|顺利|全绿|pass(?:ed)?|merged|pushed|resolved|done|completed|works now|no action needed|完成|通过|可用|生效|能返回|能显示|已合并|已推送|已更新|已修复|无需处理)/i; @@ -316,6 +397,7 @@ function isPollutedTodoText(value: string | undefined): boolean { if (/^⏺/.test(text) || /\b(?:bash|shell|exec)\(/i.test(text)) return true; if (/^[a-z][a-z0-9_-]*-[0-9a-f]{6,}`?$/i.test(text)) return true; if (/\b(?:Viewer|Health)\b\s*[::]\s*(?:\[|https?:\/\/)/i.test(text)) return true; + if (isPureProcessCheck(text)) return true; if (AGENT_PROGRESS_PREFIX.test(text) && PROGRESS_NOUNS.test(text) && !hasTodoActionTrigger(text)) return true; // Status-report and completed-work narration are pollution ONLY when no action // is being requested. The action-verb exception keeps genuine repairs like @@ -348,18 +430,20 @@ function isUsefulTodoText(value: string | undefined): boolean { const text = normalizeText(value); if (!text || isPollutedTodoText(text) || isCompletedTodoText(text)) return false; if (AGENT_PROGRESS_PREFIX.test(text) && !hasTodoActionTrigger(text)) return false; - return hasTodoActionTrigger(text) || /\b(?:TODO|FIXME|follow up|follow-up)\b/i.test(text); + return hasTodoActionTrigger(text) || /(?:^|[\s((])(?:TODO|FIXME)\b|(?:follow up|follow-up)\b/i.test(text); } export function cleanTodoTitle(title: string, description = "", quote = ""): string | null { + if (isPureProcessCheck(`${title} ${description} ${quote}`)) return null; for (const raw of [title, description, quote]) { const candidate = firstTitleSentence(raw); + if (isPureProcessCheck(candidate)) continue; if (candidate && !looksLikeBadTitle(candidate) && !looksTruncated(candidate)) return candidate; } return null; } -function todoForStorage(todo: ExtractedTodo): ExtractedTodo | null { +function todoForStorage(todo: ExtractedTodo): (ExtractedTodo & { quality: TodoQuality }) | null { // STEP-08 Layer 2: never store completed work as a todo — the surface is // for UNRESOLVED pain points. (Enum keeps accepting "done"; we filter at emit.) if (todo.typeBucket === "done") return null; @@ -367,16 +451,29 @@ function todoForStorage(todo: ExtractedTodo): ExtractedTodo | null { if (!title) return null; const description = normalizeText(todo.description || todo.evidence?.quote).slice(0, 1000); if (!description) return null; + const compacted = compactTitleTechnicalIdentifiers(title, description, todo.evidence?.quote); + const finalTitle = compacted.title; + const quality = assessTodoQuality({ ...todo, title: finalTitle, description }); if ( - isCompletedTodoText(title) || isCompletedTodoText(description) || isCompletedTodoText(todo.evidence?.quote) || - isPollutedTodoText(title) || isPollutedTodoText(description) || isPollutedTodoText(todo.evidence?.quote) + quality.reason !== "ok" || + isCompletedTodoText(finalTitle) || isCompletedTodoText(description) || isCompletedTodoText(todo.evidence?.quote) || + isPollutedTodoText(finalTitle) || isPollutedTodoText(description) || isPollutedTodoText(todo.evidence?.quote) ) return null; const rawDedupe = normalizeText(todo.dedupeKey); const dedupeKey = rawDedupe && !looksLikeBadTitle(rawDedupe) ? normalizedKey(rawDedupe) - : normalizedKey(`${title}:${description}`); - return { ...todo, title, description, dedupeKey }; + : normalizedKey(`${finalTitle}:${description}`); + return { + ...todo, + title: finalTitle, + description, + dedupeKey, + quality: { + ...quality, + ...(compacted.compacted ? { titleCompacted: true, originalTitle: title } : {}), + }, + }; } function sessionSortTime(session: Session): string { @@ -418,6 +515,42 @@ function takeRecentInteractions( return observations.slice(cutoff); } +function interactionRanges(observations: CompressedObservation[]): Array<{ start: number; end: number }> { + if (!observations.length) return []; + const starts: number[] = []; + for (let i = 0; i < observations.length; i++) { + if (observationStartsInteraction(observations[i])) starts.push(i); + } + if (!starts.length || starts[0] !== 0) starts.unshift(0); + return starts.map((start, index) => ({ + start, + end: starts[index + 1] ?? observations.length, + })); +} + +function nearbyObservationContext( + observations: CompressedObservation[], + sourceObservationId: string | undefined, + maxObservations = 12, +): { observations: CompressedObservation[]; foundSource: boolean } { + const sorted = [...observations].sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); + if (!sorted.length) return { observations: [], foundSource: false }; + const sourceIndex = sourceObservationId ? sorted.findIndex((obs) => obs.id === sourceObservationId) : -1; + if (sourceIndex < 0) { + return { observations: takeRecentInteractions(sorted, 2).slice(-maxObservations), foundSource: false }; + } + const ranges = interactionRanges(sorted); + const rangeIndex = ranges.findIndex((range) => sourceIndex >= range.start && sourceIndex < range.end); + if (rangeIndex < 0) return { observations: sorted.slice(Math.max(0, sourceIndex - 5), sourceIndex + 7), foundSource: true }; + const startRange = Math.max(0, rangeIndex - 2); + const endRange = Math.min(ranges.length - 1, rangeIndex + 2); + const picked = sorted.slice(ranges[startRange].start, ranges[endRange].end); + if (picked.length <= maxObservations) return { observations: picked, foundSource: true }; + const pickedSourceIndex = picked.findIndex((obs) => obs.id === sourceObservationId); + const start = Math.max(0, Math.min(pickedSourceIndex - Math.floor(maxObservations / 2), picked.length - maxObservations)); + return { observations: picked.slice(start, start + maxObservations), foundSource: true }; +} + function timeBucketFor(session: Session, now = Date.now()): TimeBucket { if (session.status === "active") return "current"; const raw = session.endedAt || session.startedAt; @@ -632,6 +765,7 @@ function parseCheckpoint(cursor: string | undefined): Record { } function makeAction(todo: ExtractedTodo, session: Session, now: string): Action { + const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; const changed = sessionChangedSinceExtraction(todo, session); const tags = ["todo-extracted", `time:${todo.timeBucket}`, `type:${todo.typeBucket}`, ...(changed ? ["todo-recheck"] : [])]; return { @@ -647,11 +781,15 @@ function makeAction(todo: ExtractedTodo, session: Session, now: string): Action tags, sourceObservationIds: [todo.evidence.sourceObservationId], sourceMemoryIds: [], - metadata: { todoExtraction: withSourceCheckpoint(todo, session) }, + metadata: { + todoExtraction: withSourceCheckpoint(todo, session), + ...(quality ? { todoQuality: todoQualityMetadata(quality) } : {}), + }, }; } function makeReview(todo: ExtractedTodo, session: Session, now: string): ReviewQueueItem { + const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; const tags = ["todo-extracted", `time:${todo.timeBucket}`, `type:${todo.typeBucket}`]; return { id: generateId("review"), @@ -676,6 +814,7 @@ function makeReview(todo: ExtractedTodo, session: Session, now: string): ReviewQ // card can also be picked up by updateChangedTodoCards when its source // session later changes (otherwise the review-update path is dead). todoExtraction: withSourceCheckpoint(todo, session), + ...(quality ? { todoQuality: todoQualityMetadata(quality) } : {}), }, }; } @@ -688,6 +827,32 @@ function actionLooksGenerated(action: Action): boolean { !!action.metadata?.todoExtraction; } +function replaceActionFromTodo(action: Action, fresh: Action, engine: "langextract" | "rules", now: string): Action { + return { + ...action, + title: fresh.title, + description: fresh.description, + status: fresh.status, + priority: fresh.priority, + updatedAt: now, + project: fresh.project, + tags: fresh.tags, + sourceObservationIds: fresh.sourceObservationIds, + sourceMemoryIds: fresh.sourceMemoryIds, + metadata: { + ...(action.metadata || {}), + todoExtraction: fresh.metadata?.todoExtraction, + ...(fresh.metadata?.todoQuality ? { todoQuality: fresh.metadata.todoQuality } : {}), + refresh: { + refreshedAt: now, + engine, + reason: "replaced", + previousTitle: action.title, + }, + }, + }; +} + function sessionChangedSinceExtraction(todo: ExtractedTodo, session: Session): boolean { const stored = (todo as unknown as { sourceCheckpoint?: string }).sourceCheckpoint; return !!stored && stored !== checkpointKey(session); @@ -868,10 +1033,22 @@ const VAGUE_TITLE_TERMS = [ "进行", "处理", ]; +const PROCESS_TITLE_TERMS = [ + "重启 Codex desktop app 后再测", + "重启后再测", + "最后一次状态确认", + "做健康检查", + "健康检查", + "确认工作区", +]; function titleQualityHint(title: string): string { - const hits = VAGUE_TITLE_TERMS.filter((term) => title.includes(term)); - return hits.length ? `Title contains vague filler terms: ${hits.join(", ")}.` : ""; + const hints: string[] = []; + const vagueHits = VAGUE_TITLE_TERMS.filter((term) => title.includes(term)); + if (vagueHits.length) hints.push(`Title contains vague filler terms: ${vagueHits.join(", ")}.`); + const processHits = PROCESS_TITLE_TERMS.filter((term) => title.includes(term)); + if (processHits.length) hints.push(`Title looks like agent process or status-check narration: ${processHits.join(", ")}.`); + return hints.join(" "); } async function runCleanupSidecar( @@ -1200,22 +1377,28 @@ async function extractForSession( session: Session, observations: CompressedObservation[], mode: string, - opts: { allowLlm?: boolean } = {}, -): Promise<{ todos: ExtractedTodo[]; engine: "langextract" | "rules"; fallbackReason?: string; errorCode?: TodoExtractErrorCode }> { + options: ExtractForSessionOptions = {}, +): Promise<{ todos: ExtractedTodo[]; engine: "langextract" | "rules"; fallbackReason?: string }> { const { ruleObservations, llmObservations } = prefilterTodoObservations(session, observations); - const blocks = llmObservations.map(blockFor).filter((block) => block.text); + const llmSourceObservations = options.forceLlmContext && !llmObservations.length + ? observations.slice(0, MAX_LLM_OBSERVATIONS_PER_SESSION) + : llmObservations; + const blocks = llmSourceObservations.map(blockFor).filter((block) => block.text); const bucket = timeBucketFor(session); let fallbackReason = ""; - if (mode !== "rules" && opts.allowLlm !== false && blocks.length > 0) { + if (mode !== "rules" && blocks.length > 0) { try { - const todos = await runLangExtractSidecar({ + const run = options.runLangExtractSidecar || runLangExtractSidecar; + const input: Record = { sessionId: session.id, project: session.project, cwd: session.cwd, startedAt: session.startedAt, endedAt: session.endedAt, blocks, - }); + }; + if (options.refreshAction) input.refreshAction = options.refreshAction; + const todos = await run(input); return { todos: todos.map((todo) => safeTodo(todo, session)), engine: "langextract" }; } catch (err) { fallbackReason = err instanceof Error ? err.message : String(err || "langextract failed"); @@ -1227,14 +1410,255 @@ 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) } : {}), + }; +} + +function slimRecord(value: unknown, allowed: string[]): Record | undefined { + if (!value || typeof value !== "object") return undefined; + const input = value as Record; + const out: Record = {}; + for (const key of allowed) { + const raw = input[key]; + if (typeof raw === "string") out[key] = raw.slice(0, 500); + else if (typeof raw === "number" || typeof raw === "boolean") out[key] = raw; + } + return Object.keys(out).length ? out : undefined; +} + +function refreshActionPromptContext(action: Action): Record { + const metadata = action.metadata || {}; + return { + id: action.id, + title: action.title, + description: action.description, + status: action.status, + tags: action.tags, + sourceObservationIds: action.sourceObservationIds, + cleanup: slimRecord(metadata.cleanup, [ + "decision", + "title", + "description", + "reason", + "previousTitle", + "previousDescription", + "previousStatus", + ]), + todoExtraction: slimRecord(metadata.todoExtraction, [ + "title", + "description", + "confidence", + "timeBucket", + "typeBucket", + "dedupeKey", + ]), + }; +} + +function todoFromExistingActionEvidence(action: Action, session: Session): ExtractedTodo | null { + const extraction = action.metadata?.todoExtraction as Record | undefined; + const evidence = extraction?.evidence as Record | undefined; + const quote = normalizeText(typeof evidence?.quote === "string" ? evidence.quote : action.description); + const sourceObservationId = + normalizeText(typeof evidence?.sourceObservationId === "string" ? evidence.sourceObservationId : "") || + action.sourceObservationIds.find((id) => typeof id === "string" && id.length > 0) || + ""; + if (!quote || !sourceObservationId) return null; + if (!hasTodoActionTrigger(`${action.title} ${action.description} ${quote}`)) return null; + if (looksIncompleteTitle(quote) && !/(?:\borigin\b|远程仓库|remote repository)/i.test(`${action.description} ${quote}`)) return null; + const typeBucket = TYPE_BUCKETS.has(extraction?.typeBucket) ? extraction!.typeBucket as TypeBucket : action.status === "active" ? "in_progress" : "pending"; + return safeTodo({ + title: action.title, + description: quote, + confidence: 0.86, + timeBucket: TIME_BUCKETS.has(extraction?.timeBucket) ? extraction!.timeBucket as TimeBucket : timeBucketFor(session), + typeBucket, + sourceSessionId: session.id, + evidence: { + sourceObservationId, + quote, + }, + dedupeKey: normalizedKey(`${action.title}:${quote}`), + }, session); +} + +export async function refreshTodoAction( + kv: Pick, + data: TodoRefreshActionOptions = {}, + deps: { runLangExtractSidecar?: LangExtractRunner } = {}, +): Promise<{ + success: boolean; + action?: Action; + review?: ReviewQueueItem; + keptOld: boolean; + reason: string; + error?: string; + engine?: "langextract" | "rules"; + scannedObservations: number; + fallbackReason?: string; +}> { + const actionId = normalizeText(data.actionId); + if (!actionId) { + return { success: false, keptOld: true, reason: "missing-action-id", error: "actionId is required", scannedObservations: 0 }; + } + const action = await kv.get(KV.actions, actionId).catch(() => null); + if (!action) { + return { success: false, keptOld: true, reason: "action-not-found", error: "action not found", scannedObservations: 0 }; + } + if (!actionLooksGenerated(action)) { + return { success: false, keptOld: true, reason: "not-generated", error: "action is not a generated todo card", scannedObservations: 0 }; + } + const extraction = action.metadata?.todoExtraction as Record | undefined; + const evidence = extraction?.evidence as Record | undefined; + const sourceSessionId = typeof extraction?.sourceSessionId === "string" ? extraction.sourceSessionId : ""; + if (!sourceSessionId) { + return { success: false, keptOld: true, reason: "missing-source-session", error: "source session missing", scannedObservations: 0 }; + } + const session = await kv.get(KV.sessions, sourceSessionId).catch(() => null); + if (!session) { + return { success: false, keptOld: true, reason: "source-session-not-found", error: "source session not found", scannedObservations: 0 }; + } + + const sourceObservationId = + (typeof evidence?.sourceObservationId === "string" && evidence.sourceObservationId) || + action.sourceObservationIds.find((id) => typeof id === "string" && id.length > 0) || + ""; + const allObservations = await kv.list(KV.observations(session.id)).catch(() => []); + const context = nearbyObservationContext(allObservations, sourceObservationId, 12); + const mode = (getEnvVar("AGENTMEMORY_TODO_EXTRACTOR") || "auto").toLowerCase(); + const directThreshold = envNumber("AGENTMEMORY_TODO_DIRECT_CONFIDENCE", 0.82); + const { ruleObservations, llmObservations } = prefilterTodoObservations(session, context.observations); + const evidenceObservations = mode === "rules" ? ruleObservations : llmObservations.length ? llmObservations : context.observations; + const blockMap = new Map([...context.observations, ...evidenceObservations].map((obs) => [obs.id, blockFor(obs)])); + const scannedObservations = ruleObservations.length; + const [actions, reviews] = await Promise.all([ + kv.list(KV.actions).catch(() => []), + kv.list(KV.reviewQueue).catch(() => []), + ]); + const otherActions = actions.filter((item) => item.id !== action.id); + const existing = existingDedupeKeys(otherActions, reviews); + const seenTitles = existingActiveTitles(otherActions, reviews); + + if (looksIncompleteTitle(action.title)) { + const fallbackTodo = todoFromExistingActionEvidence(action, session); + const todo = fallbackTodo ? todoForStorage(fallbackTodo) : null; + if (todo && validateTodoEvidence(todo, blockMap) && todo.timeBucket !== "history" && todo.typeBucket !== "done") { + const dedupeKey = todo.dedupeKey || normalizedKey(`${todo.title}:${todo.description}`); + const titleKey = normalizedKey(todo.title); + if (dedupeKey && !existing.has(dedupeKey) && !existing.has(titleKey) && !isNearDuplicateTitle(titleKey, seenTitles)) { + const now = new Date().toISOString(); + const fresh = makeAction({ ...todo, dedupeKey }, session, now); + const replacement = replaceActionFromTodo(action, fresh, "rules", now); + await kv.set(KV.actions, action.id, replacement); + return { + success: true, + action: replacement, + keptOld: false, + reason: "replaced-from-existing-evidence", + engine: "rules", + scannedObservations, + }; + } + } + } + + const { todos, engine, fallbackReason } = await extractForSession(session, context.observations, mode, { + runLangExtractSidecar: deps.runLangExtractSidecar, + refreshAction: refreshActionPromptContext(action), + forceLlmContext: true, + }); + if (mode !== "rules" && fallbackReason) { + return { + success: false, + keptOld: true, + reason: "llm-refresh-failed", + error: "LLM refresh failed", + engine, + scannedObservations, + fallbackReason, + }; + } + + const candidates: ExtractedTodo[] = []; + let usedExistingEvidenceFallback = false; + const rawTodos = [...todos]; + if (!rawTodos.length) { + const fallbackTodo = todoFromExistingActionEvidence(action, session); + if (fallbackTodo) { + rawTodos.push(fallbackTodo); + usedExistingEvidenceFallback = true; + } + } + for (const rawTodo of rawTodos) { + const todo = todoForStorage(rawTodo); + if (!todo || !validateTodoEvidence(todo, blockMap)) continue; + if (todo.timeBucket === "history" || todo.typeBucket === "done") continue; + const dedupeKey = todo.dedupeKey || normalizedKey(`${todo.title}:${todo.description}`); + const titleKey = normalizedKey(todo.title); + if (!dedupeKey || existing.has(dedupeKey) || existing.has(titleKey) || isNearDuplicateTitle(titleKey, seenTitles)) continue; + candidates.push({ ...todo, dedupeKey }); + } + candidates.sort((a, b) => effectiveTodoConfidence(b) - effectiveTodoConfidence(a)); + const todo = candidates[0]; + if (!todo) { + return { + success: true, + keptOld: true, + reason: firstInvalidTodoReason(rawTodos, blockMap), + engine, + scannedObservations, + ...(fallbackReason ? { fallbackReason } : {}), + }; + } + + const now = new Date().toISOString(); + const effectiveConfidence = effectiveTodoConfidence(todo); + if (effectiveConfidence >= directThreshold) { + const fresh = makeAction(todo, session, now); + const replacement = replaceActionFromTodo(action, fresh, engine, now); + await kv.set(KV.actions, action.id, replacement); + return { + success: true, + action: replacement, + keptOld: false, + reason: usedExistingEvidenceFallback ? "replaced-from-existing-evidence" : "replaced", + engine: usedExistingEvidenceFallback ? "rules" : engine, + scannedObservations, + ...(fallbackReason ? { fallbackReason } : {}), + }; + } + + return { + success: true, + keptOld: true, + reason: "low-confidence", + engine, + scannedObservations, + ...(fallbackReason ? { fallbackReason } : {}), }; } export async function generateTodosFromSessions( kv: Pick, data: TodoExtractOptions = {}, -): Promise { +): 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; +}> { let sourceScan: { imported: number; skipped: number; errors: number } | undefined; if (data.scanSources !== false) { const scan = await scanCodexSource(kv as StateKV).catch(() => null); @@ -1254,15 +1678,9 @@ 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); - const reviewThreshold = envNumber("AGENTMEMORY_TODO_REVIEW_CONFIDENCE", 0.55); const cleanupMode = data.cleanup === "apply" ? "apply" : data.cleanup === "dry-run" ? "dry-run" : null; const [actions, reviews, allSessions] = await Promise.all([ kv.list(KV.actions).catch(() => []), @@ -1288,8 +1706,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 only applies - // when a caller explicitly passes a positive cap. + // timestamp are kept (never silently drop work); maxSessions is the cap that + // still bounds a day with a flood of sessions. .filter((session) => { const raw = sessionSortTime(session); if (!raw) return true; @@ -1303,22 +1721,14 @@ 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) { - skippedUnchangedSessions++; - continue; - } + if (!data.force && processed[session.id] === key) 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 @@ -1329,14 +1739,9 @@ 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 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 }); + const { todos, engine, fallbackReason } = await extractForSession(session, ruleObservations, mode); 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)) { @@ -1367,13 +1772,10 @@ export async function generateTodosFromSessions( hiddenHistory++; continue; } - if (todo.confidence >= directThreshold) { + const effectiveConfidence = effectiveTodoConfidence(todo); + if (effectiveConfidence >= directThreshold) { await kv.set(KV.actions, fingerprintId("act", `todo:${dedupeKey}`), makeAction({ ...todo, dedupeKey }, session, now)); directCreated++; - } else if (todo.confidence >= reviewThreshold) { - const review = makeReview({ ...todo, dedupeKey }, session, now); - await kv.set(KV.reviewQueue, review.id, review); - reviewCreated++; } else { // Not persisted — seed nothing, so a discarded low-confidence todo can // never suppress a later genuine one (mirrors action-candidates). @@ -1386,7 +1788,6 @@ export async function generateTodosFromSessions( seenTitles.push(titleKey); } processed[session.id] = key; - processedSessions++; } await kv.set(KV.scanCheckpoints, checkpointId, { @@ -1399,8 +1800,6 @@ export async function generateTodosFromSessions( success: true, engine: engines.size > 1 ? "mixed" : Array.from(engines)[0] || "rules", scannedSessions: sessions.length, - processedSessions, - skippedUnchangedSessions, scannedObservations, directCreated, reviewCreated, @@ -1411,122 +1810,20 @@ 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 = {}) => runTodoExtractJob(kv, data)); - sdk.registerFunction("mem::todo-extract-status", async () => getTodoExtractJobStatus()); + sdk.registerFunction("mem::todo-extract-generate", async (data: TodoExtractOptions = {}) => + generateTodosFromSessions(kv, data), + ); + sdk.registerFunction("mem::todo-refresh-action", async (data: TodoRefreshActionOptions = {}) => + refreshTodoAction(kv, data), + ); sdk.registerFunction("mem::todo-update", async (data: TodoUpdateOptions = {}) => updateChangedTodoCards(kv, data), ); diff --git a/src/functions/todo-update-llm.py b/src/functions/todo-update-llm.py index 69806fc2..4da49cd3 100644 --- a/src/functions/todo-update-llm.py +++ b/src/functions/todo-update-llm.py @@ -31,7 +31,8 @@ job is to UPDATE each card using only the card, its evidence, its sessionDelta when present, and other cards in this same batch. Keep the list trustworthy — every surviving card must be a REAL, SPECIFIC, STILL-OPEN action with a clear -title. +mature todo-card title. The title should read like something a developer can +execute from a task list, not like an AI agent progress log. For EACH input card, output exactly one decision: - KEEP — still a genuine, still-open action and the new activity does not @@ -59,12 +60,26 @@ scope, steps, or detail. 4. KEEP bar: a specific subject + a verb/action + a discernible outcome (e.g. "Fix the N+1 query in the dashboard loader"). Reject pure observations, - logs, status lines, and vibes. -5. Title quality bar: use concrete verb + specific object (+ target/outcome when - it adds signal). Remove filler such as "全面了解", "了解现状", "梳理现状", + logs, status lines, one-off status checks, and vibes. +5. Title quality bar: use concrete verb + semantic object (+ target/outcome when + it adds signal), but keep the title scannable. Precise technical identifiers + must be preserved in `newDescription` when needed, not allowed to dominate + `newTitle`. Move long branch names, commit hashes, file paths, URLs, session + ids, package names, and raw repos out of the title. Prefer + newTitle "推送当前工作分支到远程仓库" + newDescription + "分支:codex/todo-cleanup-flash-model。" over + "推送 codex/todo-cleanup-flash-model 分支到远程仓库". + Remove filler such as "全面了解", "了解现状", "梳理现状", "获取信息", "进行", "处理". Prefer "克隆 AI-Todo 仓库" over "克隆仓库并全面了解其状况". -6. If a card has `titleQualityHint`, actively consider REWRITE even if it is + A good title may contain tightly related steps when they serve one outcome, + e.g. "修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染". +6. Agent process/status-check titles are not good todo titles. If still useful, + REWRITE them into the durable user outcome; otherwise DROP. Examples: + "重启 Codex desktop app 后再测一次" -> "验证重启后的 Codex 桌面端"; + "做最后一次状态确认" / "启动后做健康检查" / "确认工作区干净" -> DROP unless + evidence clearly shows a still-open deliverable. +7. If a card has `titleQualityHint`, actively consider REWRITE even if it is still open. If that card is also the best MERGE target, output REWRITE for the target card and MERGE the duplicates into that same id. @@ -77,6 +92,9 @@ - sessionDelta shows the change was committed/merged/tests passed -> DONE - "⏺ Bash(npm test)" / "Viewer: http://localhost:3114" / "abc1234 (HEAD)" -> DROP - "克隆仓库并全面了解其状况" with AI-Todo evidence -> REWRITE to "克隆 AI-Todo 仓库" +- "推送 codex/todo-cleanup-flash-model 分支到远程仓库" -> REWRITE to "推送当前工作分支到远程仓库" and keep the branch in description +- "重启 Codex desktop app 后再测一次" with live issue evidence -> REWRITE to "验证重启后的 Codex 桌面端" +- "做最后一次状态确认" / "启动后做健康检查" as agent procedure -> DROP - a real task whose wording is vague or stale given the new activity -> REWRITE - two cards describing the same fix -> MERGE duplicates + KEEP/REWRITE canonical - a still-open task the new activity does not touch -> KEEP @@ -194,6 +212,12 @@ def main() -> int: assert _out[2]["mergeIntoId"] == "b" assert "KEEP" in SYSTEM_PROMPT and "MERGE" in SYSTEM_PROMPT assert "Title quality bar" in SYSTEM_PROMPT and "克隆 AI-Todo 仓库" in SYSTEM_PROMPT + assert "推送当前工作分支到远程仓库" in SYSTEM_PROMPT + assert "codex/todo-cleanup-flash-model" in SYSTEM_PROMPT + assert "修正目录显示文字" in SYSTEM_PROMPT + assert "mature todo-card title" in SYSTEM_PROMPT + assert "重启 Codex desktop app 后再测一次" in SYSTEM_PROMPT + assert "做最后一次状态确认" in SYSTEM_PROMPT print("ok") raise SystemExit(0) raise SystemExit(main()) diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 3f0ec849..5f683d6a 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: false, + restartRequired: true, }, }; }, @@ -1510,16 +1510,11 @@ 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; @@ -1533,18 +1528,24 @@ 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", + + sdk.registerFunction("api::todo-refresh-action", 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: {} }); + const body = (req.body ?? {}) as Record; + const actionId = asNonEmptyString(body.actionId); + if (!actionId) return { status_code: 400, body: { error: "actionId is required" } }; + const result = await sdk.trigger({ function_id: "mem::todo-refresh-action", payload: { actionId } }) as Record; + if (result.success === false && result.reason === "action-not-found") return { status_code: 404, body: result }; + if (result.success === false) return { status_code: 400, body: result }; 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" }, + function_id: "api::todo-refresh-action", + config: { api_path: "/agentmemory/todo/action-refresh", http_method: "POST" }, }); sdk.registerFunction("api::todo-update", diff --git a/src/viewer/index.html b/src/viewer/index.html index 44460812..5f82501d 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1762,10 +1762,9 @@ stroke: currentColor; } .action-overview { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; } .action-overview-card, .action-group, @@ -1775,10 +1774,10 @@ background: #ffffff; } .action-overview-card { - padding: 12px 14px; + padding: 7px 10px; } button.action-overview-card { - width: 100%; + min-width: 78px; text-align: left; cursor: pointer; font: inherit; @@ -1801,13 +1800,54 @@ font-weight: 650; } .action-overview-value { - margin-top: 4px; + margin-top: 2px; color: var(--ink); font-family: var(--font-display); - font-size: 26px; + font-size: 18px; line-height: 1; font-weight: 300; } + .attention-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: -2px 0 12px; + } + .attention-chip { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 112px; + min-height: 32px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + color: var(--ink-muted); + font: inherit; + font-size: 12px; + cursor: pointer; + } + .attention-chip:hover { + border-color: var(--accent); + background: var(--bg-alt); + } + .attention-chip.active { + border-color: var(--ink); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--ink); + } + .attention-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .attention-chip-value { + flex: 0 0 auto; + color: var(--ink); + font-weight: 650; + } .action-group { padding: 14px; margin-bottom: 12px; @@ -1826,6 +1866,39 @@ font-weight: 650; } .done-today-section { opacity: 0.92; } + .action-folded-section { + background: rgba(255,255,255,0.78); + } + .action-folded-head { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin: 0; + padding: 0; + border: none; + background: none; + text-align: left; + cursor: pointer; + font: inherit; + } + .action-folded-head:hover .action-group-title { color: var(--accent, #2563eb); } + .action-folded-lead { + margin-top: 4px; + color: var(--ink-faint); + font-size: 12px; + line-height: 1.35; + } + .action-folded-meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + .action-folded-section .action-card-list { + margin-top: 12px; + } .done-today-head { width: 100%; background: none; @@ -2056,6 +2129,12 @@ opacity: 1; pointer-events: auto; } + .action-recheck-note { + color: var(--accent); + font-size: 11px; + font-weight: 650; + white-space: nowrap; + } .btn-ghost-sm { appearance: none; border: none; @@ -2069,6 +2148,29 @@ border-radius: 6px; } .btn-ghost-sm:hover { color: var(--ink); background: var(--bg-warm, rgba(0,0,0,0.05)); } + .action-archive-link { + color: color-mix(in srgb, var(--ink-faint) 82%, transparent); + } + .action-refresh-link { + color: var(--ink-muted); + } + .btn-primary-sm { + appearance: none; + border: 1px solid var(--ink); + background: var(--ink); + color: #ffffff; + padding: 4px 12px; + font-size: 12px; + line-height: 1.3; + cursor: pointer; + font-family: inherit; + border-radius: 7px; + transition: background .12s ease, border-color .12s ease; + } + .btn-primary-sm:hover { + border-color: var(--accent); + background: var(--accent); + } .btn-outline-sm { appearance: none; background: none; @@ -2116,7 +2218,7 @@ white-space: pre-wrap; } @media (max-width: 760px) { - .action-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .attention-chip { flex: 1 1 calc(50% - 8px); min-width: 0; } .action-item-card.action-candidate-card, .action-item-card.action-approved-card { grid-template-columns: 5px minmax(0, 1fr); @@ -2211,7 +2313,6 @@ flex-direction: column; } } - /* Feature flag banner system — compact collapsed by default */ .flag-banners { padding: 0 12px 10px 12px; @@ -4151,7 +4252,19 @@

AI Todo

'act.attn.next': 'Next', 'act.attn.needsWork': 'Needs work', 'act.attn.noteworthy': 'Noteworthy', 'act.prio.high': 'Important', 'act.prio.normal': 'Normal', 'act.prio.low': 'Low', 'act.untitled': 'Untitled', 'act.untitledCandidate': 'Untitled candidate', - 'act.metric.waiting': 'Awaiting reply', 'act.metric.review': 'To confirm', 'act.metric.followUp': 'To follow up', 'act.metric.active': 'In progress', 'act.metric.done': 'Done', + 'act.metric.todo': 'Todo', 'act.metric.attention': 'Todo', 'act.metric.waiting': 'Todo', 'act.metric.review': 'Todo', 'act.metric.followUp': 'Todo', 'act.metric.active': 'Todo', 'act.metric.done': 'Done', + 'act.attention.reply': 'Reply', 'act.attention.confirm': 'Confirm', 'act.attention.followUp': 'Follow up', + 'act.section.awaiting': 'Needs your reply', 'act.section.review': 'Needs confirmation', 'act.section.followUp': 'Needs follow-up', + 'act.section.earlier': 'Earlier open items', + 'act.section.earlierLead': 'Open work from the last 3-10 days is folded by default.', + 'act.section.older': 'Older backlog', + 'act.section.olderLead': 'These items are 10+ days old and may be stale.', + 'act.section.expand': 'Expand', + 'act.section.collapse': 'Collapse', + 'act.focus.label': 'Focus', + 'act.focus.current': 'current', + 'act.focus.olderHidden': 'older hidden', + 'act.recheck': 'Source updated', 'act.searchPlaceholder': 'Search todos...', 'act.nToConfirm': 'to confirm', 'act.nConfirmed': 'confirmed', 'act.refresh': 'Refresh', 'act.viewOriginal': 'View original', 'act.confirm': 'Confirm', 'act.ignore': 'Ignore', @@ -4163,10 +4276,6 @@

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...', @@ -4186,9 +4295,21 @@

AI Todo

'act.cleanup.error': 'Update failed', 'act.cleanup.failed': 'Update failed; cards unchanged', 'act.cleanup.clean': 'All cards are up to date', + 'act.cleanup.none': 'No cards need updating', 'act.cleanup.llmUnavailable': 'LLM unavailable — no changes', 'act.cleanup.confirm': 'Apply these updates?', 'act.cleanup.summary': 'update {rewritten} · done {completed} · drop {dropped} · merge {merged}', + 'act.cardRefresh.run': 'Update', + 'act.cardRefresh.running': 'Updating...', + 'act.cardRefresh.done': 'Updated from source', + 'act.cardRefresh.review': 'Sent to confirm', + 'act.cardRefresh.kept': 'No better card found', + 'act.cardRefresh.kept.incompleteTitle': 'Title is incomplete', + 'act.cardRefresh.kept.evidenceInvalid': 'Source evidence did not match', + 'act.cardRefresh.kept.lowQuality': 'Candidate was too vague', + 'act.cardRefresh.kept.completedOrHistory': 'Looks completed or stale', + 'act.cardRefresh.kept.polluted': 'Looks like a log, not a todo', + 'act.cardRefresh.error': 'Update failed', 'act.status.complete': 'Complete', 'act.status.archive': 'Archive', 'act.status.delete': 'Delete', @@ -4196,11 +4317,10 @@

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 applies to the next organize run.', + 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', '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', @@ -4208,7 +4328,7 @@

AI Todo

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

AI Todo

'act.attn.next': '下一步', 'act.attn.needsWork': '需要处理', 'act.attn.noteworthy': '值得关注', 'act.prio.high': '重要', 'act.prio.normal': '普通', 'act.prio.low': '不急', 'act.untitled': '未命名待办', 'act.untitledCandidate': '未命名待办候选', - 'act.metric.waiting': '待回应', 'act.metric.review': '待确认', 'act.metric.followUp': '待跟进', 'act.metric.active': '进行中', 'act.metric.done': '已完成', + 'act.metric.todo': 'Todo', 'act.metric.attention': 'Todo', 'act.metric.waiting': 'Todo', 'act.metric.review': 'Todo', 'act.metric.followUp': 'Todo', 'act.metric.active': 'Todo', 'act.metric.done': 'Done', + 'act.attention.reply': '回应', 'act.attention.confirm': '确认', 'act.attention.followUp': '跟进', + 'act.section.awaiting': '需要你回应', 'act.section.review': '需要确认', 'act.section.followUp': '需要跟进', + 'act.section.earlier': '稍早开放事项', + 'act.section.earlierLead': '3-10 天内的开放事项默认折叠,按需展开核对。', + 'act.section.older': '陈旧积压', + 'act.section.olderLead': '这些事项已超过 10 天,可能已经过期。', + 'act.section.expand': '展开', + 'act.section.collapse': '收起', + 'act.focus.label': '聚焦', + 'act.focus.current': '当前', + 'act.focus.olderHidden': '条较早已折叠', + 'act.recheck': '来源会话已更新', 'act.searchPlaceholder': '搜索待办...', 'act.nToConfirm': '条待确认', 'act.nConfirmed': '件已确认', 'act.refresh': '刷新', 'act.viewOriginal': '查看原文', 'act.confirm': '确认', 'act.ignore': '忽略', @@ -4370,10 +4502,6 @@

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': '已显示最新待办,后台仍在整理...', @@ -4393,9 +4521,21 @@

AI Todo

'act.cleanup.error': '更新失败', 'act.cleanup.failed': '更新失败,卡片未改动', 'act.cleanup.clean': '卡片已是最新', + 'act.cleanup.none': '没有需要更新的卡片', 'act.cleanup.llmUnavailable': '大模型不可用 — 未改动', 'act.cleanup.confirm': '应用这些更新?', 'act.cleanup.summary': '更新 {rewritten} · 完成 {completed} · 丢弃 {dropped} · 合并 {merged}', + 'act.cardRefresh.run': '更新', + 'act.cardRefresh.running': '更新中...', + 'act.cardRefresh.done': '已根据来源更新', + 'act.cardRefresh.review': '已转待确认', + 'act.cardRefresh.kept': '未找到更好的卡片', + 'act.cardRefresh.kept.incompleteTitle': '标题不完整,已保留旧卡', + 'act.cardRefresh.kept.evidenceInvalid': '来源证据未匹配,已保留旧卡', + 'act.cardRefresh.kept.lowQuality': '候选过于模糊,已保留旧卡', + 'act.cardRefresh.kept.completedOrHistory': '候选像已完成或过期事项', + 'act.cardRefresh.kept.polluted': '候选像日志,不是待办', + 'act.cardRefresh.error': '更新失败', 'act.status.complete': '完成', 'act.status.archive': '归档', 'act.status.delete': '删除', @@ -4403,11 +4543,10 @@

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 覆盖,留空保持不变', @@ -4415,7 +4554,7 @@

AI Todo

'settings.apiKeyLabel': 'API key:', 'settings.save': '保存配置', 'settings.saving': '保存中...', - 'settings.savedRestart': '配置已保存,下次整理时生效。', + 'settings.savedRestart': '配置已保存,重启后生效。', 'settings.saveFailed': '配置保存失败', 'act.status.updateFailed': '待办状态更新失败', 'obs.type.file_read': '读取文件', @@ -4624,7 +4763,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, extractJob: null, stale: false, config: null, configSaving: false, configDraft: {} }, + actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, earlierOpenExpanded: false, olderBacklogExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, cardRefreshInFlight: {}, cardRefreshNotice: '', 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 }, @@ -5894,14 +6033,13 @@

AI Todo

apiGet('health'), apiGet('sessions'), apiGet('actions'), - apiGet('review?status=pending&kind=action&limit=200'), apiGet('inbox?status=awaiting&limit=50') ]); state.dashboard.health = baseResults[0]; state.dashboard.sessions = ((baseResults[1] && baseResults[1].sessions) || []).filter(function(s) { return !isDemoSession(s); }); state.dashboard.actions = ((baseResults[2] && baseResults[2].actions) || []).filter(isActionRenderable); - state.dashboard.actionReviews = ((baseResults[3] && baseResults[3].items) || []).filter(isActionReviewRenderable); - state.dashboard.inboxAwaiting = (baseResults[4] && baseResults[4].items) || []; + state.dashboard.actionReviews = []; + state.dashboard.inboxAwaiting = (baseResults[3] && baseResults[3].items) || []; if (showDebug) { var debugResults = await Promise.all([ apiGet('memories?latest=true&limit=500'), @@ -6034,9 +6172,13 @@

AI Todo

var cb = h.circuitBreaker || null; var workers = snap.workers || []; var actions = (d.actions || []).filter(isActionRenderable); - var actionReviews = (d.actionReviews || []).filter(isActionReviewRenderable); - var awaitingReplies = (d.inboxAwaiting || []).filter(function(i) { return i && i.kind === 'question'; }); - var followUps = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked'; }); + var openTodoCount = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; }).length; + var doneTodoCount = actions.filter(function(a) { return a.status === 'done'; }).length; + function todoSummary(openCount, doneCount) { + return I18N_LANG === 'zh' + ? openCount + ' 个 Todo · ' + doneCount + ' 个 Done' + : openCount + ' open · ' + doneCount + ' done'; + } var html = ''; @@ -6053,10 +6195,7 @@

AI Todo

html += '
'; var latestSessionTime = d.sessions.length ? shortDateTime(sessionRecordTime(d.sessions.slice().sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); })[0])) : t('dash.noRecord'); html += '
' + t('dash.stat.sessions') + '
' + d.sessions.length + '
' + t('dash.stat.recent') + ' ' + esc(latestSessionTime) + '
'; - html += '
' + t('dash.stat.todos') + '
' + actions.length + '
'; - html += '
' + t('act.metric.waiting') + '
' + awaitingReplies.length + '
'; - html += '
' + t('act.metric.review') + '
' + actionReviews.length + '
'; - html += '
' + t('act.metric.followUp') + '
' + followUps.length + '
'; + html += '
' + t('dash.stat.todos') + '
' + openTodoCount + '
'; var lessonCount = (d.lessons || []).length; if (showDebug) { html += '
' + t('dash.stat.memories') + '
' + d.memories.length + '
' + t('dash.stat.latestVersion') + '
'; @@ -6367,7 +6506,6 @@

AI Todo

graphSim.raf = requestAnimationFrame(runSimulation); } } - async function loadGraph() { var el = document.getElementById('view-graph'); el.innerHTML = '
检查关系图数据中...
'; @@ -8704,32 +8842,27 @@

AI Todo

var results = await Promise.all([ apiGet('actions'), apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200'), apiGet('inbox?status=awaiting&limit=50'), apiGet('inbox?status=answered&limit=50'), apiGet('inbox?status=dismissed&limit=50') ]); var explicitActions = (results[0] && results[0].actions) || []; var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; - var reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); state.actions.items = explicitActions; - state.actions.reviewItems = reviewItems; + state.actions.reviewItems = []; state.actions.frontier = frontier; state.actions.loaded = true; state.actions.stale = false; - state.inbox.awaitingItems = (results[3] && results[3].items) || []; - state.inbox.answeredItems = (results[4] && results[4].items) || []; - state.inbox.dismissedItems = (results[5] && results[5].items) || []; + state.inbox.awaitingItems = (results[2] && results[2].items) || []; + state.inbox.answeredItems = (results[3] && results[3].items) || []; + state.inbox.dismissedItems = (results[4] && results[4].items) || []; state.inbox.items = state.inbox.awaitingItems; state.inbox.loaded = true; renderActions(); 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); } @@ -8756,54 +8889,14 @@

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'), - apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200') + apiGet('frontier') ]).then(function(results) { state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; - state.actions.reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); + state.actions.reviewItems = []; return null; }); } @@ -8864,7 +8957,6 @@

AI Todo

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

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' @@ -8910,7 +9001,6 @@

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' @@ -8956,6 +9046,47 @@

AI Todo

}); } + function cardRefreshKeptNotice(reason) { + var key = { + 'incomplete-title': 'act.cardRefresh.kept.incompleteTitle', + 'evidence-invalid': 'act.cardRefresh.kept.evidenceInvalid', + 'low-quality': 'act.cardRefresh.kept.lowQuality', + 'low-confidence': 'act.cardRefresh.kept.lowQuality', + 'completed-or-history': 'act.cardRefresh.kept.completedOrHistory', + 'polluted': 'act.cardRefresh.kept.polluted' + }[String(reason || '')]; + return key ? t(key) : t('act.cardRefresh.kept'); + } + + function refreshActionCard(actionId) { + if (!actionId) return; + state.actions.cardRefreshInFlight = state.actions.cardRefreshInFlight || {}; + if (state.actions.cardRefreshInFlight[actionId]) return; + state.actions.cardRefreshInFlight[actionId] = true; + state.actions.cardRefreshNotice = ''; + renderActions(); + apiPost('todo/action-refresh', { actionId: actionId }).then(function(res) { + if (!res || res.success === false) { + state.actions.cardRefreshNotice = t('act.cardRefresh.error'); + return null; + } + if (res.action && res.action.id) { + state.actions.items = (state.actions.items || []).map(function(a) { + return a.id === res.action.id ? res.action : a; + }); + state.actions.cardRefreshNotice = t('act.cardRefresh.done'); + return null; + } + state.actions.cardRefreshNotice = cardRefreshKeptNotice(res.reason); + return null; + }).catch(function() { + state.actions.cardRefreshNotice = t('act.cardRefresh.error'); + }).then(function() { + delete state.actions.cardRefreshInFlight[actionId]; + renderActions(); + }); + } + function startTodoExtraction(force) { if (state.actions.extractInFlight) return; state.actions.extractInFlight = true; @@ -8987,21 +9118,13 @@

AI Todo

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

AI Todo

state.actions.extractMessage = t('act.extract.failedExisting'); }).then(function() { clearTimeout(softRefreshTimer); - if (!state.actions.extractJob || state.actions.extractJob.status !== 'running') { - state.actions.extractInFlight = false; - } + state.actions.extractInFlight = false; if (state.activeTab === 'actions' && !actionsScrolledAway()) { renderActions(); } else if (state.activeTab !== 'actions') { @@ -9079,8 +9200,8 @@

AI Todo

return; } // Nothing changed at all. - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = t('act.cleanup.clean'); + state.actions.cleanupStatus = 'idle'; + state.actions.cleanupMessage = t('act.cleanup.none'); if (state.activeTab === 'actions') renderActions(); return; } @@ -9325,7 +9446,7 @@

AI Todo

if (!questions.length) return ''; var html = '
'; html += '
'; - html += '
待回应 (' + questions.length + ')
'; + html += '
' + t('act.section.awaiting') + ' (' + questions.length + ')
'; html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; html += '
Agent 在等你回
'; html += '
'; @@ -9386,57 +9507,6 @@

AI Todo

html += '
'; return html; } - function renderAwaitingReplySection() { - var search = (state.actions.search || '').toLowerCase(); - var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); - var questions = items.filter(function(i) { return i && i.kind === 'question'; }); - var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); - - // 搜索时无命中:整区不渲染,避免空壳占位干扰搜索结果。 - if (search && !items.length) return ''; - - var html = '
'; - html += '
'; - html += '
待回应'; - if (questions.length) html += ' (' + questions.length + ')'; - html += '
'; - html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; - html += '
'; - if (questions.length) html += 'Agent 在等你回'; - html += '
'; - - if (!items.length) { - html += '
'; - html += '
暂无待回应
'; - html += '
Agent 在会话中抛给你、在等你回的问题会出现在这里。目前没有待回应的条目。
'; - html += '
'; - html += '
'; - return html; - } - - if (questions.length) { - html += '
'; - questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); - html += '
'; - } - if (briefings.length) { - // briefing 知悉即可、优先级低,默认折叠以缩短首屏、让 question 不被压下去。 - // 搜索命中时强制展开(否则命中的 briefing 藏在折叠里看不到)。 - var bExpanded = !!state.inbox.briefingExpanded || !!search; - html += ''; - if (bExpanded) { - html += '
'; - briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); - html += '
'; - } - } - html += ''; - return html; - } - // STEP-C3 收件箱动作。每个动作改后端状态后,从 state.inbox.items 本地剔除该项 + // 重渲染(乐观更新),避免全量 loadActions 抖动;失败用 flashHint 提示。 function removeInboxItemLocal(id) { @@ -9547,7 +9617,7 @@

AI Todo

} } - // STEP-C4「已完成」折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, + // STEP-C4 Done 折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, // 默认折叠(§3.2)。不新增抽取器、不动后端,纯前端筛 state.actions.items。 function isUpdatedToday(ts) { if (!ts) return false; @@ -9564,7 +9634,7 @@

AI Todo

var expanded = !!state.actions.doneExpanded; var html = '
'; html += ''; if (expanded && typeof cardRenderer === 'function') { @@ -9581,36 +9651,29 @@

AI Todo

function renderActions() { var el = document.getElementById('view-actions'); var items = (state.actions.items || []).filter(isActionRenderable).slice(); - var reviewItems = (state.actions.reviewItems || []).filter(isActionReviewRenderable); + state.actions.reviewItems = []; var search = state.actions.search.toLowerCase(); var statusFilter = state.actions.statusFilter === 'all' ? '' : state.actions.statusFilter; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(statusFilter) >= 0) statusFilter = 'todo'; + var defaultView = !statusFilter && !search; + var FOCUS_DAYS = 3; + var STALE_DAYS = 10; + var todoFilterActive = statusFilter === 'todo'; var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); if (search) { items = items.filter(function(a) { return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; }); - reviewItems = reviewItems.filter(function(item) { - return ((item.title || '') + ' ' + (item.content || '') + ' ' + (reviewProject(item) || '') + ' ' + reviewTags(item).join(' ')).toLowerCase().indexOf(search) >= 0; - }); } var metricItems = items.slice(); - var metricReviewItems = reviewItems.slice(); - if (statusFilter && statusFilter !== 'review' && statusFilter !== 'awaiting') { - // STEP-13: the "待跟进 / Follow-up" metric card counts pending + blocked - // and filters with data-status="pending", so the pending filter must show - // blocked too — otherwise its count and its results disagree. + if (statusFilter) { items = items.filter(function(a) { - return statusFilter === 'pending' ? (a.status === 'pending' || a.status === 'blocked') : a.status === statusFilter; + return statusFilter === 'todo' + ? (a.status === 'pending' || a.status === 'blocked' || a.status === 'active') + : a.status === statusFilter; }); } - if (statusFilter === 'review' || statusFilter === 'awaiting') { - items = []; - } - var showReviewItems = statusFilter === 'review'; - var showAwaitingItems = !statusFilter || statusFilter === 'awaiting'; - var showBriefingItems = !statusFilter; - function actionAttentionKey(a, isFrontier) { if (a.status === 'done' || a.status === 'cancelled') return ''; if (isFrontier) return 'next'; @@ -9621,6 +9684,7 @@

AI Todo

function actionDescriptionText(text) { var s = String(text || '').trim(); if (!s) return ''; + s = todoDisplayText(s); if (I18N_LANG !== 'zh') return s; var map = [ [/^Execute launch promotion for GitHub, Xiaohongshu, V2EX, Reddit, X, and other target communities\..*$/i, '继续推进 GitHub、小红书、V2EX、Reddit 和 X 等渠道的发布推广。'], @@ -9638,6 +9702,11 @@

AI Todo

} return s; } + function todoDisplayText(text) { + return String(text || '') + .replace(/Needs attention|Needs your reply|Needs confirmation|Needs follow-up|In progress|Follow up|To confirm|Reply queue|Reply/gi, 'Todo') + .replace(/需处理|需要你回应|需要确认|需要跟进|进行中|待跟进|待确认|待回应|待回复队列|回应/g, 'Todo'); + } function actionSourceText(a) { var parts = []; if (a.project) parts.push(projectDisplayName(a.project)); @@ -9660,6 +9729,7 @@

AI Todo

} function actionTitleText(text) { var s = String(text || t('act.untitled')).trim(); + s = todoDisplayText(s); if (I18N_LANG !== 'zh') return s; var map = [ [/^Create README 30s demo GIF and backup MP4 for taught-master-applications-skill$/i, '制作留学申请 Skill 的 README 演示视频'], @@ -9719,50 +9789,64 @@

AI Todo

.replace(/\s+/g, ' ') .trim(); } - function candidatePreviewText(item) { - var title = compactActionTitle(item && item.title); - var content = String((item && item.content) || '').trim(); - if (!content) return ''; - var isStructured = isMarkdownPlanText(content); - var cleaned = isStructured ? stripMarkdownPlanText(content) : content; - if (isStructured) { - var sentence = (cleaned.match(/(?:TODO|FIXME|下一步|后续|待办|修复|补充|实现|调整|验证|提交|创建|更新|移除|处理)[^。!?\n]{4,80}[。!?]?/u) || [])[0]; - cleaned = sentence || ''; - } - cleaned = cleaned - .replace(/^(TODO|FIXME)\s*[::-]\s*/i, '') - .replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, '') - .replace(/^(请|需要|必须)\s*/u, '') - .replace(/\s+/g, ' ') - .trim(); - if (!cleaned || cleaned === title || cleaned.indexOf(title) === 0) return ''; - return truncate(actionDescriptionText(cleaned), 96); - } - function actionMetricCards(counts, reviewCount, waitingCount) { + function actionMetricCards(counts) { + var todoCount = counts.pending + counts.blocked + counts.active; var metrics = [ - { label: t('act.metric.waiting'), value: waitingCount, filter: 'awaiting', primary: waitingCount > 0 }, - { label: t('act.metric.review'), value: reviewCount, filter: 'review', primary: waitingCount === 0 && reviewCount > 0 }, - { label: t('act.metric.followUp'), value: counts.pending + counts.blocked, filter: 'pending', primary: waitingCount === 0 && reviewCount === 0 && (counts.pending + counts.blocked) > 0 }, - { label: t('act.metric.active'), value: counts.active, filter: 'active', primary: false }, + { label: t('act.metric.todo'), value: todoCount, filter: 'todo', primary: todoCount > 0 }, { label: t('act.metric.done'), value: counts.done, filter: 'done', primary: false } ]; var html = '
'; metrics.forEach(function(m) { - var active = (state.actions.statusFilter || '') === m.filter; + var current = state.actions.statusFilter || ''; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(current) >= 0) current = 'todo'; + var active = current === m.filter; html += ''; }); html += '
'; return html; } + function actionNeedsRecheck(a) { + var tags = Array.isArray(a && a.tags) ? a.tags : []; + return tags.indexOf('todo-recheck') >= 0; + } + function normalizeActionTimestamp(value) { + var raw = String(value || '').trim(); + if (!raw) return ''; + var m = raw.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/); + return m ? m[0] : raw; + } + function actionTimestamp(a) { + var extraction = (a && a.metadata && a.metadata.todoExtraction) || {}; + if (actionNeedsRecheck(a) && extraction.latestSourceCheckpoint) { + return normalizeActionTimestamp(extraction.latestSourceCheckpoint); + } + return normalizeActionTimestamp(extraction.sourceCheckpoint || (a && (a.createdAt || a.updatedAt)) || ''); + } + function actionAgeDays(a) { + var ts = actionTimestamp(a); + if (!ts) return 0; + var ms = new Date(ts).getTime(); + if (!isFinite(ms)) return 0; + return Math.max(0, (Date.now() - ms) / 86400000); + } + function isOpenAction(a) { + return a && (a.status === 'active' || a.status === 'blocked' || a.status === 'pending'); + } + function splitDefaultOpenItems(list) { + var split = { focus: [], earlier: [], older: [] }; + (list || []).forEach(function(a) { + if (!isOpenAction(a)) return; + var age = actionAgeDays(a); + if (age <= FOCUS_DAYS || (actionNeedsRecheck(a) && age <= STALE_DAYS)) split.focus.push(a); + else if (age > STALE_DAYS) split.older.push(a); + else split.earlier.push(a); + }); + return split; + } function renderActionFilters() { - // STEP-13: the status-filter chip row duplicated the metric overview - // cards below (actionMetricCards) — same state.actions.statusFilter, same - // values (pending/review/active/done). Keep the metric cards (they carry - // counts) as the single status filter; this row keeps only search + the - // action buttons. var html = '
'; html += ''; - html += '' + metricReviewItems.length + ' ' + t('act.nToConfirm') + ' · ' + metricItems.length + ' ' + t('act.nConfirmed') + ''; + html += actionMetricCards(statusCounts); var extractTitle = state.actions.extractMessage || t('act.extract.title'); var extractLabel = t('act.extract.run'); if (state.actions.extractInFlight) extractLabel = t('act.extract.running'); @@ -9778,30 +9862,6 @@

AI Todo

html += '
'; return html; } - function renderActionCandidateCard(item) { - var priority = reviewActionPriority(item); - var project = reviewProject(item); - var title = compactActionTitle(item.title || t('act.untitledCandidate')); - var preview = candidatePreviewText(item); - var raw = String(item.content || '').trim(); - var showOriginal = raw && isMarkdownPlanText(raw); - var html = '
'; - html += '
'; - html += '
'; - html += '
' + t('filter.review') + '' + esc(priorityLabel(priority)) + '
'; - html += '
' + esc(title) + '
'; - if (preview) html += '
' + esc(preview) + '
'; - html += '
'; - if (project) html += '' + esc(projectDisplayName(project)) + ''; - html += '
'; - if (showOriginal) { - html += '
' + t('act.viewOriginal') + '
' + esc(truncate(raw, 1200)) + '
'; - } - html += '
'; - html += '
'; - html += '
'; - return html; - } function renderActionCard(a, isFrontier) { var html = '
'; html += '
'; @@ -9809,51 +9869,79 @@

AI Todo

html += '
' + esc(compactActionTitle(a.title)) + '
'; var actionDesc = actionDescriptionText(a.description); if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; + if (actionNeedsRecheck(a)) html += '
' + esc(t('act.recheck')) + '
'; // STEP-16 calm card: source + relative time are hidden at rest and fade in // on hover; priority shows on the rail, status via the group. No badges / // status icon / classification tags. var metaParts = []; var sourceText = actionSourceText(a); + var sourceTs = actionTimestamp(a); if (sourceText) metaParts.push(t('act.from') + ' ' + esc(sourceText)); - if (a.updatedAt) metaParts.push(esc(relativeTime(a.updatedAt))); - if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; + if (sourceTs) metaParts.push(esc(relativeTime(sourceTs))); + if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; html += '
'; html += '
'; html += '
'; var jumpObsId = Array.isArray(a.sourceObservationIds) ? a.sourceObservationIds.find(function(id) { return typeof id === 'string' && id.length > 0; }) : ''; - if (jumpObsId) html += ''; - if (a.status !== 'cancelled') html += ''; + var refreshing = !!(state.actions.cardRefreshInFlight && state.actions.cardRefreshInFlight[a.id]); + if (jumpObsId) html += ''; + html += ''; + if (a.status !== 'cancelled') html += ''; html += '
'; - if (a.status !== 'done') html += ''; + if (a.status !== 'done') html += ''; html += '
'; html += ''; return html; } - var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; - metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); - var searchForInbox = (state.actions.search || '').toLowerCase(); - var waitingCount = filterInboxItems(sortInboxItems(inboxAwaiting()), searchForInbox).filter(function(i) { return i && i.kind === 'question'; }).length; - var awaitingQuestionsHtml = showAwaitingItems ? renderAwaitingQuestionSection() : ''; - var awaitingBriefingsHtml = showBriefingItems ? renderAwaitingBriefingSection() : ''; - var inboxArchiveHtml = showBriefingItems ? renderInboxArchiveSection() : ''; - - var html = renderActionFilters(); - html += actionMetricCards(statusCounts, metricReviewItems.length, waitingCount); - html += awaitingQuestionsHtml; - - if (showReviewItems && reviewItems.length) { - html += '
'; - html += '
' + t('filter.review') + '
' + reviewItems.length + ' ' + t('act.itemsUnit') + '
'; + function renderActionGroup(status, group) { + if (!group.length) return ''; + var html = '
'; + var title = status === 'todo' ? t('act.metric.todo') : (status === 'done' ? t('act.metric.done') : statusLabel(status)); + html += '
' + esc(title) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; html += '
'; - reviewItems.forEach(function(item) { - html += renderActionCandidateCard(item); + group.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); }); html += '
'; + return html; + } + function renderTodoGroup(group) { + var cards = Array.isArray(group) ? group : []; + var total = cards.length; + if (!total) return ''; + var html = '
'; + html += '
' + esc(t('act.metric.todo')) + '
' + total + ' ' + t('act.itemsUnit') + '
'; + html += '
'; + cards.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); + }); + html += '
'; + return html; + } + function renderFoldedOpenSection(group, title, lead, stateKey, actionName) { + if (!group.length) return ''; + var expanded = !!state.actions[stateKey]; + var html = '
'; + html += ''; + if (expanded) { + html += '
'; + group.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); + }); + html += '
'; + } + html += '
'; + return html; } + var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; + metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); - html += awaitingBriefingsHtml; + var html = renderActionFilters(); - if (items.length === 0 && (!showReviewItems || reviewItems.length === 0) && !awaitingQuestionsHtml && !awaitingBriefingsHtml && !inboxArchiveHtml) { + if (items.length === 0) { html += '
' + '
' + '
' + t('act.empty.title') + '
' + @@ -9864,32 +9952,26 @@

AI Todo

var order = { active: 1, blocked: 2, pending: 3, done: 4, cancelled: 5 }; return (order[a.status] || 9) - (order[b.status] || 9) || (Number(b.priority) || 0) - (Number(a.priority) || 0); }); - // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部「已完成」 - // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了「已完成」筛选 chip + // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部 Done + // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了 Done 筛选 chip // (statusFilter==='done')时则照常全列,不走折叠区。 // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 - var defaultView = !statusFilter; - var inlineStatuses = defaultView - ? ['active','blocked','pending'] - : ['active','blocked','pending','done','cancelled']; - inlineStatuses.forEach(function(status) { - var group = items.filter(function(a) { return a.status === status; }); - if (!group.length) return; - html += '
'; - html += '
' + esc(statusLabel(status)) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; - html += '
'; - group.forEach(function(a) { - var isFrontier = frontierIds.has(a.id); - html += renderActionCard(a, isFrontier); - }); - html += '
'; - }); if (defaultView) { + var defaultSplit = splitDefaultOpenItems(items); + html += renderTodoGroup(defaultSplit.focus); + html += renderFoldedOpenSection(defaultSplit.earlier, t('act.section.earlier'), t('act.section.earlierLead'), 'earlierOpenExpanded', 'toggle-earlier-open'); + html += renderFoldedOpenSection(defaultSplit.older, t('act.section.older'), t('act.section.olderLead'), 'olderBacklogExpanded', 'toggle-older-backlog'); html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); + } else { + if (statusFilter !== 'done') { + html += renderTodoGroup(items.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; })); + } + if (!todoFilterActive) { + html += renderActionGroup('done', items.filter(function(a) { return a.status === 'done'; })); + } } } - html += inboxArchiveHtml; var __focus = captureSearchFocus(['actions-search']); el.innerHTML = html; @@ -10656,6 +10738,10 @@

AI Todo

updateActionStatus(target.getAttribute('data-action-id') || '', target.getAttribute('data-status') || ''); return; } + if (action === 'refresh-action-card') { + refreshActionCard(target.getAttribute('data-action-id') || ''); + return; + } if (action === 'refresh-sessions') { state.sessions.loaded = false; loadSessions({ showLoading: true, reason: 'manual' }); @@ -10697,6 +10783,16 @@

AI Todo

renderActions(); return; } + if (action === 'toggle-earlier-open') { + state.actions.earlierOpenExpanded = !state.actions.earlierOpenExpanded; + renderActions(); + return; + } + if (action === 'toggle-older-backlog') { + state.actions.olderBacklogExpanded = !state.actions.olderBacklogExpanded; + renderActions(); + return; + } if (action === 'toggle-briefings') { state.inbox.briefingExpanded = !state.inbox.briefingExpanded; renderActions(); @@ -10790,6 +10886,7 @@

AI Todo

} if (action === 'filter-actions-status') { var nextFilter = target.getAttribute('data-status') || ''; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(nextFilter) >= 0) nextFilter = 'todo'; // STEP-13: clicking the already-active filter clears it — with the chip // row gone, this is how the metric cards restore the "show all" view. state.actions.statusFilter = (state.actions.statusFilter || '') === nextFilter ? '' : nextFilter; @@ -10931,7 +11028,6 @@

AI Todo

state.actions.configDraft = state.actions.configDraft || {}; state.actions.configDraft[String(target.id).slice('todo-config-'.length)] = String(target.value || ''); }); - async function loadReplay() { var el = document.getElementById('view-replay'); el.innerHTML = '
加载会话列表中…
'; diff --git a/src/viewer/parts/app/05-i18n.js b/src/viewer/parts/app/05-i18n.js index c960732f..dfcdf520 100644 --- a/src/viewer/parts/app/05-i18n.js +++ b/src/viewer/parts/app/05-i18n.js @@ -51,7 +51,19 @@ 'act.attn.next': 'Next', 'act.attn.needsWork': 'Needs work', 'act.attn.noteworthy': 'Noteworthy', 'act.prio.high': 'Important', 'act.prio.normal': 'Normal', 'act.prio.low': 'Low', 'act.untitled': 'Untitled', 'act.untitledCandidate': 'Untitled candidate', - 'act.metric.waiting': 'Awaiting reply', 'act.metric.review': 'To confirm', 'act.metric.followUp': 'To follow up', 'act.metric.active': 'In progress', 'act.metric.done': 'Done', + 'act.metric.todo': 'Todo', 'act.metric.attention': 'Todo', 'act.metric.waiting': 'Todo', 'act.metric.review': 'Todo', 'act.metric.followUp': 'Todo', 'act.metric.active': 'Todo', 'act.metric.done': 'Done', + 'act.attention.reply': 'Reply', 'act.attention.confirm': 'Confirm', 'act.attention.followUp': 'Follow up', + 'act.section.awaiting': 'Needs your reply', 'act.section.review': 'Needs confirmation', 'act.section.followUp': 'Needs follow-up', + 'act.section.earlier': 'Earlier open items', + 'act.section.earlierLead': 'Open work from the last 3-10 days is folded by default.', + 'act.section.older': 'Older backlog', + 'act.section.olderLead': 'These items are 10+ days old and may be stale.', + 'act.section.expand': 'Expand', + 'act.section.collapse': 'Collapse', + 'act.focus.label': 'Focus', + 'act.focus.current': 'current', + 'act.focus.olderHidden': 'older hidden', + 'act.recheck': 'Source updated', 'act.searchPlaceholder': 'Search todos...', 'act.nToConfirm': 'to confirm', 'act.nConfirmed': 'confirmed', 'act.refresh': 'Refresh', 'act.viewOriginal': 'View original', 'act.confirm': 'Confirm', 'act.ignore': 'Ignore', @@ -63,10 +75,6 @@ '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...', @@ -86,9 +94,21 @@ 'act.cleanup.error': 'Update failed', 'act.cleanup.failed': 'Update failed; cards unchanged', 'act.cleanup.clean': 'All cards are up to date', + 'act.cleanup.none': 'No cards need updating', 'act.cleanup.llmUnavailable': 'LLM unavailable — no changes', 'act.cleanup.confirm': 'Apply these updates?', 'act.cleanup.summary': 'update {rewritten} · done {completed} · drop {dropped} · merge {merged}', + 'act.cardRefresh.run': 'Update', + 'act.cardRefresh.running': 'Updating...', + 'act.cardRefresh.done': 'Updated from source', + 'act.cardRefresh.review': 'Sent to confirm', + 'act.cardRefresh.kept': 'No better card found', + 'act.cardRefresh.kept.incompleteTitle': 'Title is incomplete', + 'act.cardRefresh.kept.evidenceInvalid': 'Source evidence did not match', + 'act.cardRefresh.kept.lowQuality': 'Candidate was too vague', + 'act.cardRefresh.kept.completedOrHistory': 'Looks completed or stale', + 'act.cardRefresh.kept.polluted': 'Looks like a log, not a todo', + 'act.cardRefresh.error': 'Update failed', 'act.status.complete': 'Complete', 'act.status.archive': 'Archive', 'act.status.delete': 'Delete', @@ -96,11 +116,10 @@ '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 applies to the next organize run.', + 'settings.subtitle': 'Local configuration is written to the user config file and takes effect after restarting the service.', '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', @@ -108,7 +127,7 @@ 'settings.apiKeyLabel': 'API key:', 'settings.save': 'Save config', 'settings.saving': 'Saving...', - 'settings.savedRestart': 'Config saved. It applies to the next organize run.', + 'settings.savedRestart': 'Config saved. Restart the service to apply it.', 'settings.saveFailed': 'Config save failed', 'act.status.updateFailed': 'Todo status update failed', 'obs.type.file_read': 'Read file', @@ -258,7 +277,19 @@ 'act.attn.next': '下一步', 'act.attn.needsWork': '需要处理', 'act.attn.noteworthy': '值得关注', 'act.prio.high': '重要', 'act.prio.normal': '普通', 'act.prio.low': '不急', 'act.untitled': '未命名待办', 'act.untitledCandidate': '未命名待办候选', - 'act.metric.waiting': '待回应', 'act.metric.review': '待确认', 'act.metric.followUp': '待跟进', 'act.metric.active': '进行中', 'act.metric.done': '已完成', + 'act.metric.todo': 'Todo', 'act.metric.attention': 'Todo', 'act.metric.waiting': 'Todo', 'act.metric.review': 'Todo', 'act.metric.followUp': 'Todo', 'act.metric.active': 'Todo', 'act.metric.done': 'Done', + 'act.attention.reply': '回应', 'act.attention.confirm': '确认', 'act.attention.followUp': '跟进', + 'act.section.awaiting': '需要你回应', 'act.section.review': '需要确认', 'act.section.followUp': '需要跟进', + 'act.section.earlier': '稍早开放事项', + 'act.section.earlierLead': '3-10 天内的开放事项默认折叠,按需展开核对。', + 'act.section.older': '陈旧积压', + 'act.section.olderLead': '这些事项已超过 10 天,可能已经过期。', + 'act.section.expand': '展开', + 'act.section.collapse': '收起', + 'act.focus.label': '聚焦', + 'act.focus.current': '当前', + 'act.focus.olderHidden': '条较早已折叠', + 'act.recheck': '来源会话已更新', 'act.searchPlaceholder': '搜索待办...', 'act.nToConfirm': '条待确认', 'act.nConfirmed': '件已确认', 'act.refresh': '刷新', 'act.viewOriginal': '查看原文', 'act.confirm': '确认', 'act.ignore': '忽略', @@ -270,10 +301,6 @@ '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': '已显示最新待办,后台仍在整理...', @@ -293,9 +320,21 @@ 'act.cleanup.error': '更新失败', 'act.cleanup.failed': '更新失败,卡片未改动', 'act.cleanup.clean': '卡片已是最新', + 'act.cleanup.none': '没有需要更新的卡片', 'act.cleanup.llmUnavailable': '大模型不可用 — 未改动', 'act.cleanup.confirm': '应用这些更新?', 'act.cleanup.summary': '更新 {rewritten} · 完成 {completed} · 丢弃 {dropped} · 合并 {merged}', + 'act.cardRefresh.run': '更新', + 'act.cardRefresh.running': '更新中...', + 'act.cardRefresh.done': '已根据来源更新', + 'act.cardRefresh.review': '已转待确认', + 'act.cardRefresh.kept': '未找到更好的卡片', + 'act.cardRefresh.kept.incompleteTitle': '标题不完整,已保留旧卡', + 'act.cardRefresh.kept.evidenceInvalid': '来源证据未匹配,已保留旧卡', + 'act.cardRefresh.kept.lowQuality': '候选过于模糊,已保留旧卡', + 'act.cardRefresh.kept.completedOrHistory': '候选像已完成或过期事项', + 'act.cardRefresh.kept.polluted': '候选像日志,不是待办', + 'act.cardRefresh.error': '更新失败', 'act.status.complete': '完成', 'act.status.archive': '归档', 'act.status.delete': '删除', @@ -303,11 +342,10 @@ '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 覆盖,留空保持不变', @@ -315,7 +353,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 3a2ef572..4e4dd4b4 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, extractJob: null, stale: false, config: null, configSaving: false, configDraft: {} }, + actions: { loaded: false, items: [], reviewItems: [], frontier: [], statusFilter: '', search: '', doneExpanded: false, earlierOpenExpanded: false, olderBacklogExpanded: false, extractStatus: '', extractMessage: '', extractInFlight: false, cardRefreshInFlight: {}, cardRefreshNotice: '', 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 }, diff --git a/src/viewer/parts/app/52-dashboard.js b/src/viewer/parts/app/52-dashboard.js index dfb10cbc..b9636e1d 100644 --- a/src/viewer/parts/app/52-dashboard.js +++ b/src/viewer/parts/app/52-dashboard.js @@ -7,14 +7,13 @@ apiGet('health'), apiGet('sessions'), apiGet('actions'), - apiGet('review?status=pending&kind=action&limit=200'), apiGet('inbox?status=awaiting&limit=50') ]); state.dashboard.health = baseResults[0]; state.dashboard.sessions = ((baseResults[1] && baseResults[1].sessions) || []).filter(function(s) { return !isDemoSession(s); }); state.dashboard.actions = ((baseResults[2] && baseResults[2].actions) || []).filter(isActionRenderable); - state.dashboard.actionReviews = ((baseResults[3] && baseResults[3].items) || []).filter(isActionReviewRenderable); - state.dashboard.inboxAwaiting = (baseResults[4] && baseResults[4].items) || []; + state.dashboard.actionReviews = []; + state.dashboard.inboxAwaiting = (baseResults[3] && baseResults[3].items) || []; if (showDebug) { var debugResults = await Promise.all([ apiGet('memories?latest=true&limit=500'), @@ -147,9 +146,13 @@ var cb = h.circuitBreaker || null; var workers = snap.workers || []; var actions = (d.actions || []).filter(isActionRenderable); - var actionReviews = (d.actionReviews || []).filter(isActionReviewRenderable); - var awaitingReplies = (d.inboxAwaiting || []).filter(function(i) { return i && i.kind === 'question'; }); - var followUps = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked'; }); + var openTodoCount = actions.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; }).length; + var doneTodoCount = actions.filter(function(a) { return a.status === 'done'; }).length; + function todoSummary(openCount, doneCount) { + return I18N_LANG === 'zh' + ? openCount + ' 个 Todo · ' + doneCount + ' 个 Done' + : openCount + ' open · ' + doneCount + ' done'; + } var html = ''; @@ -166,10 +169,7 @@ html += '
'; var latestSessionTime = d.sessions.length ? shortDateTime(sessionRecordTime(d.sessions.slice().sort(function(a, b) { return (sessionRecordTime(b) || '').localeCompare(sessionRecordTime(a) || ''); })[0])) : t('dash.noRecord'); html += '
' + t('dash.stat.sessions') + '
' + d.sessions.length + '
' + t('dash.stat.recent') + ' ' + esc(latestSessionTime) + '
'; - html += '
' + t('dash.stat.todos') + '
' + actions.length + '
'; - html += '
' + t('act.metric.waiting') + '
' + awaitingReplies.length + '
'; - html += '
' + t('act.metric.review') + '
' + actionReviews.length + '
'; - html += '
' + t('act.metric.followUp') + '
' + followUps.length + '
'; + html += '
' + t('dash.stat.todos') + '
' + openTodoCount + '
'; var lessonCount = (d.lessons || []).length; if (showDebug) { html += '
' + t('dash.stat.memories') + '
' + d.memories.length + '
' + t('dash.stat.latestVersion') + '
'; @@ -480,4 +480,3 @@ graphSim.raf = requestAnimationFrame(runSimulation); } } - diff --git a/src/viewer/parts/app/60-actions-todo.js b/src/viewer/parts/app/60-actions-todo.js index 7c3fbc0f..5a44c46a 100644 --- a/src/viewer/parts/app/60-actions-todo.js +++ b/src/viewer/parts/app/60-actions-todo.js @@ -5,32 +5,27 @@ var results = await Promise.all([ apiGet('actions'), apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200'), apiGet('inbox?status=awaiting&limit=50'), apiGet('inbox?status=answered&limit=50'), apiGet('inbox?status=dismissed&limit=50') ]); var explicitActions = (results[0] && results[0].actions) || []; var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; - var reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); state.actions.items = explicitActions; - state.actions.reviewItems = reviewItems; + state.actions.reviewItems = []; state.actions.frontier = frontier; state.actions.loaded = true; state.actions.stale = false; - state.inbox.awaitingItems = (results[3] && results[3].items) || []; - state.inbox.answeredItems = (results[4] && results[4].items) || []; - state.inbox.dismissedItems = (results[5] && results[5].items) || []; + state.inbox.awaitingItems = (results[2] && results[2].items) || []; + state.inbox.answeredItems = (results[3] && results[3].items) || []; + state.inbox.dismissedItems = (results[4] && results[4].items) || []; state.inbox.items = state.inbox.awaitingItems; state.inbox.loaded = true; renderActions(); 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); } @@ -57,54 +52,14 @@ 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'), - apiGet('frontier'), - apiGet('review?status=pending&kind=action&limit=200') + apiGet('frontier') ]).then(function(results) { state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; - state.actions.reviewItems = ((results[2] && results[2].items) || []).filter(function(item) { - return item && item.status === 'pending' && item.kind === 'action' && isActionReviewRenderable(item); - }); + state.actions.reviewItems = []; return null; }); } @@ -165,7 +120,6 @@ html += ''; html += ''; html += ''; - html += '
' + esc(t('settings.maxLlmSessions')) + '
'; html += '
' + esc(t('settings.sinceDays')) + '
'; html += '
' + esc(t('settings.maxInteractions')) + '
'; html += '
'; @@ -190,7 +144,6 @@ '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' @@ -211,7 +164,6 @@ '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' @@ -257,6 +209,47 @@ }); } + function cardRefreshKeptNotice(reason) { + var key = { + 'incomplete-title': 'act.cardRefresh.kept.incompleteTitle', + 'evidence-invalid': 'act.cardRefresh.kept.evidenceInvalid', + 'low-quality': 'act.cardRefresh.kept.lowQuality', + 'low-confidence': 'act.cardRefresh.kept.lowQuality', + 'completed-or-history': 'act.cardRefresh.kept.completedOrHistory', + 'polluted': 'act.cardRefresh.kept.polluted' + }[String(reason || '')]; + return key ? t(key) : t('act.cardRefresh.kept'); + } + + function refreshActionCard(actionId) { + if (!actionId) return; + state.actions.cardRefreshInFlight = state.actions.cardRefreshInFlight || {}; + if (state.actions.cardRefreshInFlight[actionId]) return; + state.actions.cardRefreshInFlight[actionId] = true; + state.actions.cardRefreshNotice = ''; + renderActions(); + apiPost('todo/action-refresh', { actionId: actionId }).then(function(res) { + if (!res || res.success === false) { + state.actions.cardRefreshNotice = t('act.cardRefresh.error'); + return null; + } + if (res.action && res.action.id) { + state.actions.items = (state.actions.items || []).map(function(a) { + return a.id === res.action.id ? res.action : a; + }); + state.actions.cardRefreshNotice = t('act.cardRefresh.done'); + return null; + } + state.actions.cardRefreshNotice = cardRefreshKeptNotice(res.reason); + return null; + }).catch(function() { + state.actions.cardRefreshNotice = t('act.cardRefresh.error'); + }).then(function() { + delete state.actions.cardRefreshInFlight[actionId]; + renderActions(); + }); + } + function startTodoExtraction(force) { if (state.actions.extractInFlight) return; state.actions.extractInFlight = true; @@ -288,21 +281,13 @@ // settings would never take effect on this primary extraction path. apiPost('todo-extract/generate', { force: force === true - }).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(); - } + }).then(function(result) { var delta = todoExtractionDelta(result); if (!result || result.success !== true) { state.actions.extractStatus = 'error'; - state.actions.extractMessage = todoExtractionErrorMessage(job || result); + state.actions.extractMessage = t('act.extract.failedExisting'); return null; } - state.actions.extractJob = job; state.actions.extractStatus = 'done'; state.actions.extractFallback = !todoExtractionUsedLlm(result); state.actions.extractMessage = todoExtractionSummary(result); @@ -316,9 +301,7 @@ state.actions.extractMessage = t('act.extract.failedExisting'); }).then(function() { clearTimeout(softRefreshTimer); - if (!state.actions.extractJob || state.actions.extractJob.status !== 'running') { - state.actions.extractInFlight = false; - } + state.actions.extractInFlight = false; if (state.activeTab === 'actions' && !actionsScrolledAway()) { renderActions(); } else if (state.activeTab !== 'actions') { @@ -380,8 +363,8 @@ return; } // Nothing changed at all. - state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = t('act.cleanup.clean'); + state.actions.cleanupStatus = 'idle'; + state.actions.cleanupMessage = t('act.cleanup.none'); if (state.activeTab === 'actions') renderActions(); return; } @@ -626,7 +609,7 @@ if (!questions.length) return ''; var html = '
'; html += '
'; - html += '
待回应 (' + questions.length + ')
'; + html += '
' + t('act.section.awaiting') + ' (' + questions.length + ')
'; html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; html += '
Agent 在等你回
'; html += '
'; @@ -687,57 +670,6 @@ html += '
'; return html; } - function renderAwaitingReplySection() { - var search = (state.actions.search || '').toLowerCase(); - var items = filterInboxItems(sortInboxItems(inboxAwaiting()), search); - var questions = items.filter(function(i) { return i && i.kind === 'question'; }); - var briefings = items.filter(function(i) { return i && i.kind === 'briefing'; }); - - // 搜索时无命中:整区不渲染,避免空壳占位干扰搜索结果。 - if (search && !items.length) return ''; - - var html = '
'; - html += '
'; - html += '
待回应'; - if (questions.length) html += ' (' + questions.length + ')'; - html += '
'; - html += '
Agent 运行中抛给你的、时间敏感的问题会汇集到这里。
'; - html += '
'; - if (questions.length) html += 'Agent 在等你回'; - html += '
'; - - if (!items.length) { - html += '
'; - html += '
暂无待回应
'; - html += '
Agent 在会话中抛给你、在等你回的问题会出现在这里。目前没有待回应的条目。
'; - html += '
'; - html += '
'; - return html; - } - - if (questions.length) { - html += '
'; - questions.forEach(function(it) { html += renderInboxCard(it, 'question'); }); - html += '
'; - } - if (briefings.length) { - // briefing 知悉即可、优先级低,默认折叠以缩短首屏、让 question 不被压下去。 - // 搜索命中时强制展开(否则命中的 briefing 藏在折叠里看不到)。 - var bExpanded = !!state.inbox.briefingExpanded || !!search; - html += ''; - if (bExpanded) { - html += '
'; - briefings.forEach(function(it) { html += renderInboxCard(it, 'briefing'); }); - html += '
'; - } - } - html += '
'; - return html; - } - // STEP-C3 收件箱动作。每个动作改后端状态后,从 state.inbox.items 本地剔除该项 + // 重渲染(乐观更新),避免全量 loadActions 抖动;失败用 flashHint 提示。 function removeInboxItemLocal(id) { @@ -848,7 +780,7 @@ } } - // STEP-C4「已完成」折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, + // STEP-C4 Done 折叠区:只读现有 action.status==='done' 且当天 updatedAt 的项, // 默认折叠(§3.2)。不新增抽取器、不动后端,纯前端筛 state.actions.items。 function isUpdatedToday(ts) { if (!ts) return false; @@ -865,7 +797,7 @@ var expanded = !!state.actions.doneExpanded; var html = '
'; html += ''; if (expanded && typeof cardRenderer === 'function') { @@ -882,36 +814,29 @@ function renderActions() { var el = document.getElementById('view-actions'); var items = (state.actions.items || []).filter(isActionRenderable).slice(); - var reviewItems = (state.actions.reviewItems || []).filter(isActionReviewRenderable); + state.actions.reviewItems = []; var search = state.actions.search.toLowerCase(); var statusFilter = state.actions.statusFilter === 'all' ? '' : state.actions.statusFilter; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(statusFilter) >= 0) statusFilter = 'todo'; + var defaultView = !statusFilter && !search; + var FOCUS_DAYS = 3; + var STALE_DAYS = 10; + var todoFilterActive = statusFilter === 'todo'; var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); if (search) { items = items.filter(function(a) { return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; }); - reviewItems = reviewItems.filter(function(item) { - return ((item.title || '') + ' ' + (item.content || '') + ' ' + (reviewProject(item) || '') + ' ' + reviewTags(item).join(' ')).toLowerCase().indexOf(search) >= 0; - }); } var metricItems = items.slice(); - var metricReviewItems = reviewItems.slice(); - if (statusFilter && statusFilter !== 'review' && statusFilter !== 'awaiting') { - // STEP-13: the "待跟进 / Follow-up" metric card counts pending + blocked - // and filters with data-status="pending", so the pending filter must show - // blocked too — otherwise its count and its results disagree. + if (statusFilter) { items = items.filter(function(a) { - return statusFilter === 'pending' ? (a.status === 'pending' || a.status === 'blocked') : a.status === statusFilter; + return statusFilter === 'todo' + ? (a.status === 'pending' || a.status === 'blocked' || a.status === 'active') + : a.status === statusFilter; }); } - if (statusFilter === 'review' || statusFilter === 'awaiting') { - items = []; - } - var showReviewItems = statusFilter === 'review'; - var showAwaitingItems = !statusFilter || statusFilter === 'awaiting'; - var showBriefingItems = !statusFilter; - function actionAttentionKey(a, isFrontier) { if (a.status === 'done' || a.status === 'cancelled') return ''; if (isFrontier) return 'next'; @@ -922,6 +847,7 @@ function actionDescriptionText(text) { var s = String(text || '').trim(); if (!s) return ''; + s = todoDisplayText(s); if (I18N_LANG !== 'zh') return s; var map = [ [/^Execute launch promotion for GitHub, Xiaohongshu, V2EX, Reddit, X, and other target communities\..*$/i, '继续推进 GitHub、小红书、V2EX、Reddit 和 X 等渠道的发布推广。'], @@ -939,6 +865,11 @@ } return s; } + function todoDisplayText(text) { + return String(text || '') + .replace(/Needs attention|Needs your reply|Needs confirmation|Needs follow-up|In progress|Follow up|To confirm|Reply queue|Reply/gi, 'Todo') + .replace(/需处理|需要你回应|需要确认|需要跟进|进行中|待跟进|待确认|待回应|待回复队列|回应/g, 'Todo'); + } function actionSourceText(a) { var parts = []; if (a.project) parts.push(projectDisplayName(a.project)); @@ -961,6 +892,7 @@ } function actionTitleText(text) { var s = String(text || t('act.untitled')).trim(); + s = todoDisplayText(s); if (I18N_LANG !== 'zh') return s; var map = [ [/^Create README 30s demo GIF and backup MP4 for taught-master-applications-skill$/i, '制作留学申请 Skill 的 README 演示视频'], @@ -1020,50 +952,64 @@ .replace(/\s+/g, ' ') .trim(); } - function candidatePreviewText(item) { - var title = compactActionTitle(item && item.title); - var content = String((item && item.content) || '').trim(); - if (!content) return ''; - var isStructured = isMarkdownPlanText(content); - var cleaned = isStructured ? stripMarkdownPlanText(content) : content; - if (isStructured) { - var sentence = (cleaned.match(/(?:TODO|FIXME|下一步|后续|待办|修复|补充|实现|调整|验证|提交|创建|更新|移除|处理)[^。!?\n]{4,80}[。!?]?/u) || [])[0]; - cleaned = sentence || ''; - } - cleaned = cleaned - .replace(/^(TODO|FIXME)\s*[::-]\s*/i, '') - .replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, '') - .replace(/^(请|需要|必须)\s*/u, '') - .replace(/\s+/g, ' ') - .trim(); - if (!cleaned || cleaned === title || cleaned.indexOf(title) === 0) return ''; - return truncate(actionDescriptionText(cleaned), 96); - } - function actionMetricCards(counts, reviewCount, waitingCount) { + function actionMetricCards(counts) { + var todoCount = counts.pending + counts.blocked + counts.active; var metrics = [ - { label: t('act.metric.waiting'), value: waitingCount, filter: 'awaiting', primary: waitingCount > 0 }, - { label: t('act.metric.review'), value: reviewCount, filter: 'review', primary: waitingCount === 0 && reviewCount > 0 }, - { label: t('act.metric.followUp'), value: counts.pending + counts.blocked, filter: 'pending', primary: waitingCount === 0 && reviewCount === 0 && (counts.pending + counts.blocked) > 0 }, - { label: t('act.metric.active'), value: counts.active, filter: 'active', primary: false }, + { label: t('act.metric.todo'), value: todoCount, filter: 'todo', primary: todoCount > 0 }, { label: t('act.metric.done'), value: counts.done, filter: 'done', primary: false } ]; var html = '
'; metrics.forEach(function(m) { - var active = (state.actions.statusFilter || '') === m.filter; + var current = state.actions.statusFilter || ''; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(current) >= 0) current = 'todo'; + var active = current === m.filter; html += ''; }); html += '
'; return html; } + function actionNeedsRecheck(a) { + var tags = Array.isArray(a && a.tags) ? a.tags : []; + return tags.indexOf('todo-recheck') >= 0; + } + function normalizeActionTimestamp(value) { + var raw = String(value || '').trim(); + if (!raw) return ''; + var m = raw.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/); + return m ? m[0] : raw; + } + function actionTimestamp(a) { + var extraction = (a && a.metadata && a.metadata.todoExtraction) || {}; + if (actionNeedsRecheck(a) && extraction.latestSourceCheckpoint) { + return normalizeActionTimestamp(extraction.latestSourceCheckpoint); + } + return normalizeActionTimestamp(extraction.sourceCheckpoint || (a && (a.createdAt || a.updatedAt)) || ''); + } + function actionAgeDays(a) { + var ts = actionTimestamp(a); + if (!ts) return 0; + var ms = new Date(ts).getTime(); + if (!isFinite(ms)) return 0; + return Math.max(0, (Date.now() - ms) / 86400000); + } + function isOpenAction(a) { + return a && (a.status === 'active' || a.status === 'blocked' || a.status === 'pending'); + } + function splitDefaultOpenItems(list) { + var split = { focus: [], earlier: [], older: [] }; + (list || []).forEach(function(a) { + if (!isOpenAction(a)) return; + var age = actionAgeDays(a); + if (age <= FOCUS_DAYS || (actionNeedsRecheck(a) && age <= STALE_DAYS)) split.focus.push(a); + else if (age > STALE_DAYS) split.older.push(a); + else split.earlier.push(a); + }); + return split; + } function renderActionFilters() { - // STEP-13: the status-filter chip row duplicated the metric overview - // cards below (actionMetricCards) — same state.actions.statusFilter, same - // values (pending/review/active/done). Keep the metric cards (they carry - // counts) as the single status filter; this row keeps only search + the - // action buttons. var html = '
'; html += ''; - html += '' + metricReviewItems.length + ' ' + t('act.nToConfirm') + ' · ' + metricItems.length + ' ' + t('act.nConfirmed') + ''; + html += actionMetricCards(statusCounts); var extractTitle = state.actions.extractMessage || t('act.extract.title'); var extractLabel = t('act.extract.run'); if (state.actions.extractInFlight) extractLabel = t('act.extract.running'); @@ -1079,30 +1025,6 @@ html += '
'; return html; } - function renderActionCandidateCard(item) { - var priority = reviewActionPriority(item); - var project = reviewProject(item); - var title = compactActionTitle(item.title || t('act.untitledCandidate')); - var preview = candidatePreviewText(item); - var raw = String(item.content || '').trim(); - var showOriginal = raw && isMarkdownPlanText(raw); - var html = '
'; - html += '
'; - html += '
'; - html += '
' + t('filter.review') + '' + esc(priorityLabel(priority)) + '
'; - html += '
' + esc(title) + '
'; - if (preview) html += '
' + esc(preview) + '
'; - html += '
'; - if (project) html += '' + esc(projectDisplayName(project)) + ''; - html += '
'; - if (showOriginal) { - html += '
' + t('act.viewOriginal') + '
' + esc(truncate(raw, 1200)) + '
'; - } - html += '
'; - html += '
'; - html += '
'; - return html; - } function renderActionCard(a, isFrontier) { var html = '
'; html += '
'; @@ -1110,51 +1032,79 @@ html += '
' + esc(compactActionTitle(a.title)) + '
'; var actionDesc = actionDescriptionText(a.description); if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; + if (actionNeedsRecheck(a)) html += '
' + esc(t('act.recheck')) + '
'; // STEP-16 calm card: source + relative time are hidden at rest and fade in // on hover; priority shows on the rail, status via the group. No badges / // status icon / classification tags. var metaParts = []; var sourceText = actionSourceText(a); + var sourceTs = actionTimestamp(a); if (sourceText) metaParts.push(t('act.from') + ' ' + esc(sourceText)); - if (a.updatedAt) metaParts.push(esc(relativeTime(a.updatedAt))); - if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; + if (sourceTs) metaParts.push(esc(relativeTime(sourceTs))); + if (metaParts.length) html += '
' + metaParts.join(' · ') + '
'; html += '
'; html += '
'; html += '
'; var jumpObsId = Array.isArray(a.sourceObservationIds) ? a.sourceObservationIds.find(function(id) { return typeof id === 'string' && id.length > 0; }) : ''; - if (jumpObsId) html += ''; - if (a.status !== 'cancelled') html += ''; + var refreshing = !!(state.actions.cardRefreshInFlight && state.actions.cardRefreshInFlight[a.id]); + if (jumpObsId) html += ''; + html += ''; + if (a.status !== 'cancelled') html += ''; html += '
'; - if (a.status !== 'done') html += ''; + if (a.status !== 'done') html += ''; html += '
'; html += ''; return html; } - var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; - metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); - var searchForInbox = (state.actions.search || '').toLowerCase(); - var waitingCount = filterInboxItems(sortInboxItems(inboxAwaiting()), searchForInbox).filter(function(i) { return i && i.kind === 'question'; }).length; - var awaitingQuestionsHtml = showAwaitingItems ? renderAwaitingQuestionSection() : ''; - var awaitingBriefingsHtml = showBriefingItems ? renderAwaitingBriefingSection() : ''; - var inboxArchiveHtml = showBriefingItems ? renderInboxArchiveSection() : ''; - - var html = renderActionFilters(); - html += actionMetricCards(statusCounts, metricReviewItems.length, waitingCount); - html += awaitingQuestionsHtml; - - if (showReviewItems && reviewItems.length) { - html += '
'; - html += '
' + t('filter.review') + '
' + reviewItems.length + ' ' + t('act.itemsUnit') + '
'; + function renderActionGroup(status, group) { + if (!group.length) return ''; + var html = '
'; + var title = status === 'todo' ? t('act.metric.todo') : (status === 'done' ? t('act.metric.done') : statusLabel(status)); + html += '
' + esc(title) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; html += '
'; - reviewItems.forEach(function(item) { - html += renderActionCandidateCard(item); + group.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); }); html += '
'; + return html; + } + function renderTodoGroup(group) { + var cards = Array.isArray(group) ? group : []; + var total = cards.length; + if (!total) return ''; + var html = '
'; + html += '
' + esc(t('act.metric.todo')) + '
' + total + ' ' + t('act.itemsUnit') + '
'; + html += '
'; + cards.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); + }); + html += '
'; + return html; + } + function renderFoldedOpenSection(group, title, lead, stateKey, actionName) { + if (!group.length) return ''; + var expanded = !!state.actions[stateKey]; + var html = '
'; + html += ''; + if (expanded) { + html += '
'; + group.forEach(function(a) { + html += renderActionCard(a, frontierIds.has(a.id)); + }); + html += '
'; + } + html += '
'; + return html; } + var statusCounts = { active: 0, blocked: 0, pending: 0, done: 0, cancelled: 0 }; + metricItems.forEach(function(a) { if (statusCounts[a.status] !== undefined) statusCounts[a.status] += 1; }); - html += awaitingBriefingsHtml; + var html = renderActionFilters(); - if (items.length === 0 && (!showReviewItems || reviewItems.length === 0) && !awaitingQuestionsHtml && !awaitingBriefingsHtml && !inboxArchiveHtml) { + if (items.length === 0) { html += '
' + '
' + '
' + t('act.empty.title') + '
' + @@ -1165,32 +1115,26 @@ var order = { active: 1, blocked: 2, pending: 3, done: 4, cancelled: 5 }; return (order[a.status] || 9) - (order[b.status] || 9) || (Number(b.priority) || 0) - (Number(a.priority) || 0); }); - // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部「已完成」 - // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了「已完成」筛选 chip + // STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部 Done + // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了 Done 筛选 chip // (statusFilter==='done')时则照常全列,不走折叠区。 // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 - var defaultView = !statusFilter; - var inlineStatuses = defaultView - ? ['active','blocked','pending'] - : ['active','blocked','pending','done','cancelled']; - inlineStatuses.forEach(function(status) { - var group = items.filter(function(a) { return a.status === status; }); - if (!group.length) return; - html += '
'; - html += '
' + esc(statusLabel(status)) + '
' + group.length + ' ' + t('act.itemsUnit') + '
'; - html += '
'; - group.forEach(function(a) { - var isFrontier = frontierIds.has(a.id); - html += renderActionCard(a, isFrontier); - }); - html += '
'; - }); if (defaultView) { + var defaultSplit = splitDefaultOpenItems(items); + html += renderTodoGroup(defaultSplit.focus); + html += renderFoldedOpenSection(defaultSplit.earlier, t('act.section.earlier'), t('act.section.earlierLead'), 'earlierOpenExpanded', 'toggle-earlier-open'); + html += renderFoldedOpenSection(defaultSplit.older, t('act.section.older'), t('act.section.olderLead'), 'olderBacklogExpanded', 'toggle-older-backlog'); html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); + } else { + if (statusFilter !== 'done') { + html += renderTodoGroup(items.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; })); + } + if (!todoFilterActive) { + html += renderActionGroup('done', items.filter(function(a) { return a.status === 'done'; })); + } } } - html += inboxArchiveHtml; var __focus = captureSearchFocus(['actions-search']); el.innerHTML = html; diff --git a/src/viewer/parts/app/64-ws-flags-events.js b/src/viewer/parts/app/64-ws-flags-events.js index 4f1f8408..b9c18563 100644 --- a/src/viewer/parts/app/64-ws-flags-events.js +++ b/src/viewer/parts/app/64-ws-flags-events.js @@ -421,6 +421,10 @@ updateActionStatus(target.getAttribute('data-action-id') || '', target.getAttribute('data-status') || ''); return; } + if (action === 'refresh-action-card') { + refreshActionCard(target.getAttribute('data-action-id') || ''); + return; + } if (action === 'refresh-sessions') { state.sessions.loaded = false; loadSessions({ showLoading: true, reason: 'manual' }); @@ -462,6 +466,16 @@ renderActions(); return; } + if (action === 'toggle-earlier-open') { + state.actions.earlierOpenExpanded = !state.actions.earlierOpenExpanded; + renderActions(); + return; + } + if (action === 'toggle-older-backlog') { + state.actions.olderBacklogExpanded = !state.actions.olderBacklogExpanded; + renderActions(); + return; + } if (action === 'toggle-briefings') { state.inbox.briefingExpanded = !state.inbox.briefingExpanded; renderActions(); @@ -555,6 +569,7 @@ } if (action === 'filter-actions-status') { var nextFilter = target.getAttribute('data-status') || ''; + if (['attention', 'awaiting', 'review', 'pending', 'blocked', 'active'].indexOf(nextFilter) >= 0) nextFilter = 'todo'; // STEP-13: clicking the already-active filter clears it — with the chip // row gone, this is how the metric cards restore the "show all" view. state.actions.statusFilter = (state.actions.statusFilter || '') === nextFilter ? '' : nextFilter; @@ -696,4 +711,3 @@ state.actions.configDraft = state.actions.configDraft || {}; state.actions.configDraft[String(target.id).slice('todo-config-'.length)] = String(target.value || ''); }); - diff --git a/src/viewer/parts/style/50-todo-cards.css b/src/viewer/parts/style/50-todo-cards.css index df6caff5..6efaeb73 100644 --- a/src/viewer/parts/style/50-todo-cards.css +++ b/src/viewer/parts/style/50-todo-cards.css @@ -1,8 +1,7 @@ .action-overview { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; } .action-overview-card, .action-group, @@ -12,10 +11,10 @@ background: #ffffff; } .action-overview-card { - padding: 12px 14px; + padding: 7px 10px; } button.action-overview-card { - width: 100%; + min-width: 78px; text-align: left; cursor: pointer; font: inherit; @@ -38,13 +37,54 @@ font-weight: 650; } .action-overview-value { - margin-top: 4px; + margin-top: 2px; color: var(--ink); font-family: var(--font-display); - font-size: 26px; + font-size: 18px; line-height: 1; font-weight: 300; } + .attention-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: -2px 0 12px; + } + .attention-chip { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 112px; + min-height: 32px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + color: var(--ink-muted); + font: inherit; + font-size: 12px; + cursor: pointer; + } + .attention-chip:hover { + border-color: var(--accent); + background: var(--bg-alt); + } + .attention-chip.active { + border-color: var(--ink); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--ink); + } + .attention-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .attention-chip-value { + flex: 0 0 auto; + color: var(--ink); + font-weight: 650; + } .action-group { padding: 14px; margin-bottom: 12px; @@ -63,6 +103,39 @@ font-weight: 650; } .done-today-section { opacity: 0.92; } + .action-folded-section { + background: rgba(255,255,255,0.78); + } + .action-folded-head { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin: 0; + padding: 0; + border: none; + background: none; + text-align: left; + cursor: pointer; + font: inherit; + } + .action-folded-head:hover .action-group-title { color: var(--accent, #2563eb); } + .action-folded-lead { + margin-top: 4px; + color: var(--ink-faint); + font-size: 12px; + line-height: 1.35; + } + .action-folded-meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + .action-folded-section .action-card-list { + margin-top: 12px; + } .done-today-head { width: 100%; background: none; @@ -293,6 +366,12 @@ opacity: 1; pointer-events: auto; } + .action-recheck-note { + color: var(--accent); + font-size: 11px; + font-weight: 650; + white-space: nowrap; + } .btn-ghost-sm { appearance: none; border: none; @@ -306,6 +385,29 @@ border-radius: 6px; } .btn-ghost-sm:hover { color: var(--ink); background: var(--bg-warm, rgba(0,0,0,0.05)); } + .action-archive-link { + color: color-mix(in srgb, var(--ink-faint) 82%, transparent); + } + .action-refresh-link { + color: var(--ink-muted); + } + .btn-primary-sm { + appearance: none; + border: 1px solid var(--ink); + background: var(--ink); + color: #ffffff; + padding: 4px 12px; + font-size: 12px; + line-height: 1.3; + cursor: pointer; + font-family: inherit; + border-radius: 7px; + transition: background .12s ease, border-color .12s ease; + } + .btn-primary-sm:hover { + border-color: var(--accent); + background: var(--accent); + } .btn-outline-sm { appearance: none; background: none; @@ -353,7 +455,7 @@ white-space: pre-wrap; } @media (max-width: 760px) { - .action-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .attention-chip { flex: 1 1 calc(50% - 8px); min-width: 0; } .action-item-card.action-candidate-card, .action-item-card.action-approved-card { grid-template-columns: 5px minmax(0, 1fr); @@ -448,4 +550,3 @@ flex-direction: column; } } - diff --git a/src/viewer/server.ts b/src/viewer/server.ts index c4ba5463..75263128 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 { getTodoExtractJobStatus, runTodoExtractJob, updateChangedTodoCards } from "../functions/todo-extract.js"; +import { generateTodosFromSessions, refreshTodoAction, 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,13 +1382,29 @@ 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 runTodoExtractJob(kv as ViewerKv, body); + const result = await generateTodosFromSessions(kv as ViewerKv, body); json(res, 200, result, req); return; } - if (method === "GET" && pathname === "/agentmemory/todo-extract/status") { - json(res, 200, getTodoExtractJobStatus(), req); + if (method === "POST" && pathname === "/agentmemory/todo/action-refresh") { + const raw = await readBody(req); + const body = raw ? JSON.parse(raw) as Record : {}; + const actionId = asText(body.actionId); + if (!actionId) { + json(res, 400, { error: "actionId is required" }, req); + return; + } + const result = await refreshTodoAction(kv as ViewerKv, { actionId }); + if (!result.success && result.reason === "action-not-found") { + json(res, 404, result, req); + return; + } + if (!result.success) { + json(res, 400, result, req); + return; + } + json(res, 200, result, req); return; } @@ -1424,7 +1440,7 @@ export function startViewerServer( success: true, envPath: getUserEnvPath(), config: getTodoExtractorUserConfig(), - restartRequired: false, + restartRequired: true, }, req); return; } diff --git a/test/review-action.test.ts b/test/review-action.test.ts index 66198f7e..1ed5972c 100644 --- a/test/review-action.test.ts +++ b/test/review-action.test.ts @@ -41,6 +41,7 @@ describe("review action candidates", () => { registerActionsFunction(sdk as never, kv as never); registerActionCandidateFunctions(sdk as never, kv as never); sdk.registerFunction("mem::todo-extract-generate", async (payload) => ({ success: true, ...payload })); + sdk.registerFunction("mem::todo-refresh-action", async (payload) => ({ success: true, keptOld: false, reason: "replaced", scannedObservations: 1, ...payload })); sdk.registerFunction("api::session::start", async () => ({ success: true })); sdk.registerFunction("mem::observe", async () => ({ success: true })); sdk.registerFunction("mem::remember", async () => ({ success: true, memory: { id: "mem_1" } })); @@ -103,7 +104,6 @@ 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", @@ -114,19 +114,52 @@ describe("review action candidates", () => { success: true, maxSessions: 3, maxObservationsPerSession: 20, - maxLlmSessions: 4, project: "agentmemory-lab", force: true, cleanup: "dry-run", }); }); + it("refreshes a single todo action through a whitelisted API payload", async () => { + const response = await sdk.trigger("api::todo-refresh-action", req({ + actionId: "act_1", + extra: "ignored", + })) as { status_code: number; body: Record }; + + expect(response.status_code).toBe(200); + expect(response.body).toMatchObject({ + success: true, + actionId: "act_1", + keptOld: false, + reason: "replaced", + scannedObservations: 1, + }); + expect(response.body.extra).toBeUndefined(); + }); + + it("validates single todo action refresh requests", async () => { + const missing = await sdk.trigger("api::todo-refresh-action", req({})) as { status_code: number; body: Record }; + expect(missing.status_code).toBe(400); + expect(missing.body).toMatchObject({ error: "actionId is required" }); + + sdk.registerFunction("mem::todo-refresh-action", async () => ({ + success: false, + keptOld: true, + reason: "action-not-found", + error: "action not found", + scannedObservations: 0, + })); + const notFound = await sdk.trigger("api::todo-refresh-action", req({ actionId: "missing" })) as { status_code: number; body: Record }; + expect(notFound.status_code).toBe(404); + expect(notFound.body).toMatchObject({ error: "action not found" }); + }); + it("exposes todo extractor config without returning the API key", async () => { const oldModel = process.env.LANGEXTRACT_MODEL; 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; restartRequired: boolean } }; + const response = await sdk.trigger("api::todo-extractor-config", req()) as { status_code: number; body: { success: boolean; config: Record; envPath: string } }; if (oldModel === undefined) delete process.env.LANGEXTRACT_MODEL; else process.env.LANGEXTRACT_MODEL = oldModel; if (oldKey === undefined) delete process.env.LANGEXTRACT_API_KEY; @@ -138,22 +171,6 @@ 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/todo-extract.test.ts b/test/todo-extract.test.ts index 393d900e..0a01ffa0 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -1,12 +1,13 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; 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: Number.POSITIVE_INFINITY, + DEFAULT_TODO_EXTRACT_MAX_SESSIONS: 8, getEnvVar: (key: string) => { const values: Record = { AGENTMEMORY_TODO_EXTRACTOR: "rules", @@ -22,11 +23,13 @@ vi.mock("../src/config.js", () => ({ normalizeTodoExtractorProvider: (value?: string) => (value || "openai").toLowerCase(), })); -import { cleanPollutedTodoCards, updateChangedTodoCards, cleanTodoTitle, generateTodosFromSessions, getTodoExtractJobStatus, runTodoExtractJob, startTodoExtractJob, validateTodoEvidence, runLangExtractSidecar, type ExtractedTodo } from "../src/functions/todo-extract.js"; +import { cleanPollutedTodoCards, updateChangedTodoCards, cleanTodoTitle, generateTodosFromSessions, refreshTodoAction, 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"; +const ROOT = join(import.meta.dirname, ".."); + function session(patch: Partial = {}): Session { return { id: "ses_1", @@ -57,6 +60,43 @@ function obs(patch: Partial = {}): CompressedObservation }; } +function generatedAction(patch: Partial = {}): Action { + return { + id: "act_old", + title: "旧卡片标题", + description: "旧卡片说明", + status: "pending", + priority: 5, + createdAt: "2026-06-17T08:00:00.000Z", + updatedAt: "2026-06-17T08:00:00.000Z", + createdBy: "todo-extract", + tags: ["todo-extracted", "type:follow_up"], + sourceObservationIds: ["obs_1"], + sourceMemoryIds: [], + metadata: { + todoExtraction: { + title: "旧卡片标题", + description: "旧卡片说明", + confidence: 0.72, + timeBucket: "current", + typeBucket: "follow_up", + sourceSessionId: "ses_1", + sourceCheckpoint: "2026-06-17T09:00:00.000Z:1", + evidence: { + sourceObservationId: "obs_1", + quote: "旧卡片说明", + }, + dedupeKey: "old-card-title", + }, + }, + ...patch, + }; +} + +function jsonText(value: unknown): string { + return JSON.stringify(value).replace(/\s+/g, " "); +} + describe("todo extraction", () => { let kv: ReturnType; @@ -149,7 +189,7 @@ describe("todo extraction", () => { expect(await kv.list(KV.actions)).toHaveLength(1); }); - it("sends medium-confidence rule todos to review", async () => { + it("discards medium-confidence rule todos instead of sending them to review", async () => { await kv.set(KV.sessions, "ses_1", session()); await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: "下一步请修复 CI 失败,并重新跑测试。" })); @@ -158,14 +198,43 @@ describe("todo extraction", () => { delete process.env.AGENTMEMORY_TODO_DIRECT_CONFIDENCE; expect(result.directCreated).toBe(0); - expect(result.reviewCreated).toBe(1); - const reviews = await kv.list(KV.reviewQueue); - expect(reviews[0]).toMatchObject({ - kind: "action", - payload: { - todoExtraction: expect.objectContaining({ sourceSessionId: "ses_1" }), - }, - }); + expect(result.reviewCreated).toBe(0); + expect(result.discarded).toBe(1); + expect(await kv.list(KV.reviewQueue)).toHaveLength(0); + }); + + it("discards vague process-like rule todos instead of creating review candidates", async () => { + await kv.set(KV.sessions, "ses_1", session()); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + narrative: "TODO: 重启 Codex desktop app 后再测一次。", + })); + + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false }); + + expect(result.directCreated).toBe(0); + expect(result.reviewCreated).toBe(0); + expect(result.discarded).toBe(1); + expect(await kv.list(KV.actions)).toHaveLength(0); + expect(await kv.list(KV.reviewQueue)).toHaveLength(0); + }); + + it("does not persist pure agent process narration as todos", async () => { + await kv.set(KV.sessions, "ses_1", session({ status: "active" })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + narrative: "TODO: 做最后一次状态确认,确保工作区干净、当前分支和 PR 链接明确。", + })); + await kv.set(KV.observations("ses_1"), "obs_2", obs({ + id: "obs_2", + narrative: "TODO: 启动后做健康检查,确认 Viewer 和 Health 都正常。", + })); + + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false }); + + expect(result.scannedObservations).toBe(0); + expect(result.directCreated).toBe(0); + expect(result.reviewCreated).toBe(0); + expect(await kv.list(KV.actions)).toHaveLength(0); + expect(await kv.list(KV.reviewQueue)).toHaveLength(0); }); it("keeps history todos hidden instead of writing them to actions", async () => { @@ -313,6 +382,21 @@ describe("todo extraction", () => { expect(cleaned).toBe("再等一轮"); }); + it("rejects process-check titles that are not durable user tasks", () => { + expect(cleanTodoTitle( + "做最后一次状态确认", + "我会做最后一次状态确认,确保工作区干净、当前分支和 PR 链接明确。", + )).toBeNull(); + expect(cleanTodoTitle( + "启动后做健康检查", + "启动后我会做健康检查,确认 Viewer 和 Health 都正常。", + )).toBeNull(); + expect(cleanTodoTitle( + "修复深色模式按钮对比度", + "修复深色模式按钮对比度,避免主操作在暗色背景下不可读。", + )).toBe("修复深色模式按钮对比度"); + }); + it("anti-truncation: skips fragment titles and trims on a boundary (STEP-08)", () => { // HTTP-status cut "返回 4" is a truncation fragment → fall through to the // clean description rather than emitting it as a card title. @@ -341,6 +425,12 @@ describe("todo extraction", () => { delete process.env.LANGEXTRACT_PYTHON; }); + it("sidecar examples avoid empty extraction groups that break LangExtract alignment", () => { + const sidecar = readFileSync(join(ROOT, "src/functions/todo-extract-langextract.py"), "utf-8"); + expect(sidecar).not.toContain("extractions=[],"); + expect(sidecar).toContain("DO NOT extract completed work"); + }); + it("reports when auto mode fell back from LangExtract to rules", async () => { process.env.AGENTMEMORY_TODO_EXTRACTOR = "auto"; process.env.LANGEXTRACT_PYTHON = "__missing_python__"; @@ -356,6 +446,321 @@ describe("todo extraction", () => { delete process.env.LANGEXTRACT_PYTHON; }); + it("refreshes one generated todo card from nearby source context", async () => { + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 3 })); + await kv.set(KV.observations("ses_1"), "obs_0", obs({ + id: "obs_0", + title: "prompt_submit", + narrative: "请检查单卡刷新。", + timestamp: "2026-06-17T08:00:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + id: "obs_1", + narrative: "这里只是旧卡片来源,没有明确新待办。", + timestamp: "2026-06-17T08:05:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "obs_2", obs({ + id: "obs_2", + narrative: "下一步请修复单卡刷新按钮状态,并重新跑 viewer 测试。", + timestamp: "2026-06-17T08:06:00.000Z", + })); + await kv.set(KV.actions, "act_old", generatedAction()); + + const result = await refreshTodoAction(kv as never, { actionId: "act_old" }); + + expect(result).toMatchObject({ + success: true, + keptOld: false, + reason: "replaced", + engine: "rules", + scannedObservations: 1, + }); + expect(result.action).toMatchObject({ + id: "act_old", + createdAt: "2026-06-17T08:00:00.000Z", + title: expect.stringContaining("单卡刷新按钮"), + sourceObservationIds: ["obs_2"], + }); + const stored = await kv.get(KV.actions, "act_old"); + expect(stored?.title).toContain("单卡刷新按钮"); + expect(stored?.metadata?.refresh).toMatchObject({ + previousTitle: "旧卡片标题", + reason: "replaced", + }); + }); + + it("refreshes from recent interaction context when the source observation is missing", async () => { + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 2 })); + await kv.set(KV.observations("ses_1"), "obs_recent", obs({ + id: "obs_recent", + narrative: "下一步请修复刷新回退路径,并补充回归测试。", + timestamp: "2026-06-17T08:30:00.000Z", + })); + await kv.set(KV.actions, "act_old", generatedAction({ + sourceObservationIds: ["obs_missing"], + metadata: { + todoExtraction: { + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "obs_missing", quote: "旧来源" }, + dedupeKey: "old-card-title", + }, + }, + })); + + const result = await refreshTodoAction(kv as never, { actionId: "act_old" }); + + expect(result).toMatchObject({ success: true, keptOld: false, reason: "replaced" }); + expect(result.action?.sourceObservationIds).toEqual(["obs_recent"]); + expect(result.action?.title).toContain("刷新回退路径"); + }); + + it("keeps the old card for medium-confidence single-card refresh results", async () => { + process.env.AGENTMEMORY_TODO_DIRECT_CONFIDENCE = "0.8"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + narrative: "下一步请修复单卡刷新低置信度路径。", + })); + await kv.set(KV.actions, "act_old", generatedAction()); + + const result = await refreshTodoAction(kv as never, { actionId: "act_old" }); + delete process.env.AGENTMEMORY_TODO_DIRECT_CONFIDENCE; + + expect(result).toMatchObject({ + success: true, + keptOld: true, + reason: "low-confidence", + }); + expect((await kv.get(KV.actions, "act_old"))?.title).toBe("旧卡片标题"); + expect(await kv.list(KV.reviewQueue)).toHaveLength(0); + }); + + it("keeps the old card when single-card refresh finds no valid todo", async () => { + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + narrative: "Tests passed and the PR was merged. No action needed.", + })); + await kv.set(KV.actions, "act_old", generatedAction()); + + const result = await refreshTodoAction(kv as never, { actionId: "act_old" }); + + expect(result).toMatchObject({ + success: true, + keptOld: true, + reason: "no-valid-todo", + scannedObservations: 0, + }); + expect((await kv.get(KV.actions, "act_old"))?.title).toBe("旧卡片标题"); + }); + + it("keeps strong multi-step delivery titles that remain readable", async () => { + await kv.set(KV.sessions, "ses_1", session({ status: "active" })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ + narrative: "下一步需要修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染。", + })); + + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false }); + + expect(result.directCreated).toBe(1); + expect((await kv.list(KV.actions))[0]?.title).toBe("修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染"); + }); + + it("compacts long technical identifiers out of generated card titles", async () => { + const quote = "下一步需要推送 codex/todo-cleanup-flash-model 分支到远程仓库。"; + await kv.set(KV.sessions, "ses_1", session({ status: "active" })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: quote })); + + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false }); + + expect(result.directCreated).toBe(1); + const action = (await kv.list(KV.actions))[0]; + expect(action.title).toBe("推送当前工作分支到远程仓库"); + expect(action.title).not.toContain("codex/todo-cleanup-flash-model"); + expect(action.description).toContain("codex/todo-cleanup-flash-model"); + expect(jsonText(action.metadata?.todoQuality)).toContain("titleCompacted"); + }); + + it("rejects incomplete dangling titles from single-card refresh with a specific reason", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + const quote = "准备推送分支 codex/todo-cleanup-flash-model 到"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: quote })); + await kv.set(KV.actions, "act_old", generatedAction({ + title: quote, + description: quote, + metadata: { + todoExtraction: { + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "obs_1", quote }, + dedupeKey: "push-branch-fragment", + }, + }, + })); + + const result = await (refreshTodoAction as unknown as ( + kvArg: typeof kv, + data: { actionId: string }, + deps: { runLangExtractSidecar: (input: Record) => Promise }, + ) => ReturnType)(kv, { actionId: "act_old" }, { + runLangExtractSidecar: async () => [{ + title: quote, + description: quote, + confidence: 0.99, + timeBucket: "current", + typeBucket: "to_start", + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "obs_1", quote }, + dedupeKey: "push-branch-fragment", + }], + }); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + + expect(result).toMatchObject({ + success: true, + keptOld: true, + reason: "incomplete-title", + engine: "langextract", + }); + expect((await kv.get(KV.actions, "act_old"))?.title).toBe(quote); + }); + + it("repairs a dangling single-card title from existing evidence when LangExtract returns no todo", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + const quote = "网络和代理现在可用。准备推送 `codex/todo-cleanup-flash-model` 到 origin。"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: quote })); + await kv.set(KV.actions, "act_old", generatedAction({ + title: "准备推送分支 codex/todo-cleanup-flash-model 到", + description: quote, + status: "active", + metadata: { + todoExtraction: { + sourceSessionId: "ses_1", + timeBucket: "current", + typeBucket: "in_progress", + evidence: { sourceObservationId: "obs_1", quote }, + dedupeKey: "push-branch-fragment", + }, + }, + })); + + const result = await (refreshTodoAction as unknown as ( + kvArg: typeof kv, + data: { actionId: string }, + deps: { runLangExtractSidecar: (input: Record) => Promise }, + ) => ReturnType)(kv, { actionId: "act_old" }, { + runLangExtractSidecar: async () => { + throw new Error("LangExtract should not run for locally repairable dangling cards"); + }, + }); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + + expect(result).toMatchObject({ + success: true, + keptOld: false, + reason: "replaced-from-existing-evidence", + engine: "rules", + }); + const stored = await kv.get(KV.actions, "act_old"); + expect(stored?.title).toBe("推送当前工作分支到 origin"); + expect(stored?.description).toContain("codex/todo-cleanup-flash-model"); + expect(jsonText(stored?.metadata?.todoQuality)).toContain("titleCompacted"); + }); + + it("rejects single-card refresh for manual actions", async () => { + await kv.set(KV.actions, "act_manual", generatedAction({ + id: "act_manual", + createdBy: "manual", + tags: [], + metadata: {}, + })); + + const result = await refreshTodoAction(kv as never, { actionId: "act_manual" }); + + expect(result).toMatchObject({ + success: false, + keptOld: true, + reason: "not-generated", + scannedObservations: 0, + }); + }); + + it("passes the old card and cleanup metadata into LangExtract single-card refresh", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + let sidecarInput: Record | null = null; + const quote = "内层仓库当前基线等于 origin/main,建 codex/ 分支、提交并 push,push 前跑 secrets diff 检查。"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: quote })); + await kv.set(KV.actions, "act_old", generatedAction({ + title: "创建 codex 分支、提交并 push AI-Todo 仓库修改", + description: quote, + metadata: { + todoExtraction: { + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "obs_1", quote }, + dedupeKey: "old-card-title", + }, + cleanup: { + decision: "rewrite", + title: "提交 AI-Todo 清理分支", + reason: "Title is process-like; make it a concrete user-facing action.", + previousTitle: "创建 codex 分支、提交并 push AI-Todo 仓库修改", + }, + }, + })); + + const result = await (refreshTodoAction as unknown as ( + kvArg: typeof kv, + data: { actionId: string }, + deps: { runLangExtractSidecar: (input: Record) => Promise }, + ) => ReturnType)(kv, { actionId: "act_old" }, { + runLangExtractSidecar: async (input) => { + sidecarInput = input; + return [{ + title: "提交 AI-Todo 清理分支", + description: "提交 AI-Todo 清理分支,并在 push 前跑 secrets diff 检查。", + confidence: 0.95, + timeBucket: "current", + typeBucket: "to_start", + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "obs_1", quote }, + dedupeKey: "submit-aitodo-cleanup-branch", + }]; + }, + }); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + + expect(result).toMatchObject({ success: true, keptOld: false, reason: "replaced", engine: "langextract" }); + expect(sidecarInput?.refreshAction).toMatchObject({ + id: "act_old", + title: "创建 codex 分支、提交并 push AI-Todo 仓库修改", + cleanup: { + title: "提交 AI-Todo 清理分支", + reason: "Title is process-like; make it a concrete user-facing action.", + }, + }); + expect((await kv.get(KV.actions, "act_old"))?.title).toBe("提交 AI-Todo 清理分支"); + }); + + it("reports LangExtract refresh failures instead of calling them no better card", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + process.env.LANGEXTRACT_PYTHON = "__missing_python__"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_1"), "obs_1", obs({ narrative: "Tests passed and the PR was merged. No action needed." })); + await kv.set(KV.actions, "act_old", generatedAction()); + + const result = await refreshTodoAction(kv as never, { actionId: "act_old" }); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + delete process.env.LANGEXTRACT_PYTHON; + + expect(result).toMatchObject({ + success: false, + keptOld: true, + reason: "llm-refresh-failed", + engine: "rules", + }); + expect(result.fallbackReason).toBeTruthy(); + }); + it("cleans generated command-log cards from actions and review queue", async () => { await kv.set(KV.actions, "act_bad", { id: "act_bad", @@ -614,6 +1019,36 @@ describe("todo extraction", () => { }); }); + it("flags agent-process titles for LLM rewrite during full maintenance", async () => { + await kv.set(KV.sessions, "ses_same", session({ id: "ses_same", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 3 })); + await kv.set(KV.actions, "a_process", { + id: "a_process", title: "重启 Codex desktop app 后再测一次", description: "建议处理顺序:2. 重启 Codex desktop app 后再测一次。", status: "pending", priority: 5, + createdAt: "2026-06-17T08:00:00.000Z", updatedAt: "2026-06-17T08:00:00.000Z", + createdBy: "todo-extract", tags: ["todo-extracted"], sourceObservationIds: [], sourceMemoryIds: [], + metadata: { todoExtraction: { sourceSessionId: "ses_same", sourceCheckpoint: "2026-06-18T09:00:00.000Z:3" } }, + }); + let captured: Array<{ title: string; titleQualityHint?: string }> = []; + const decide = async (cards: Array<{ title: string; titleQualityHint?: string }>) => { + captured = cards; + return [{ + id: "a:a_process", + decision: "REWRITE" as const, + newTitle: "验证重启后的 Codex 桌面端", + newDescription: "重启 Codex desktop app 后再验证问题是否仍存在。", + }]; + }; + + const result = await updateChangedTodoCards(kv as never, { mode: "apply", scope: "all", decide }); + + expect(captured[0]?.titleQualityHint).toContain("process or status-check"); + expect(result).toMatchObject({ engine: "llm", scanned: 1, rewritten: 1 }); + expect((await kv.list(KV.actions)).find((a) => a.id === "a_process")).toMatchObject({ + title: "验证重启后的 Codex 桌面端", + status: "pending", + metadata: { cleanup: expect.objectContaining({ decision: "rewrite", previousTitle: "重启 Codex desktop app 后再测一次" }) }, + }); + }); + it("update sorts before maxCards so near-duplicate titles can enter the same LLM batch (STEP-10)", async () => { await kv.set(KV.sessions, "ses_same", session({ id: "ses_same", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 3 })); const mkAction = (id: string, title: string) => @@ -865,9 +1300,6 @@ 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 @@ -916,38 +1348,6 @@ 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 { @@ -976,100 +1376,6 @@ 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-done-section.test.ts b/test/viewer-done-section.test.ts index d04c3a82..7b38c908 100644 --- a/test/viewer-done-section.test.ts +++ b/test/viewer-done-section.test.ts @@ -112,7 +112,7 @@ describe("STEP-C4 已完成折叠区", () => { (a: { title: string }) => "
" + a.title + "
", ); expect(html).toContain("done-today-section"); - expect(html).toContain("今天完成了 2 件"); // old 不计入 + expect(html).toContain("Done 2 items"); // old 不计入 expect(html).toContain('aria-expanded="false"'); expect(html).toContain("▸"); // 折叠时不渲染卡片正文 @@ -156,8 +156,8 @@ describe("STEP-C4 已完成折叠区", () => { sandbox.renderActions(); const html = getElement("view-actions").innerHTML; expect(html).toContain("done-today-section"); - expect(html).toContain("今天完成了 1 件"); - expect(html).toContain("进行中"); // active 分组照常显示(STEP-01 起状态标签统一走 i18n 目录) + expect(html).toContain("Done 1 items"); + expect(html).toContain("Todo"); // active 合并进 Todo 分组 // 默认折叠:done 卡正文不出现(在折叠区里、未展开) expect(html).not.toContain("今天完成项"); // 切到 done 筛选:照常全列(走 inline 分组,不进折叠区) diff --git a/test/viewer-inbox-section.test.ts b/test/viewer-inbox-section.test.ts index dc2f0a1e..e98a493e 100644 --- a/test/viewer-inbox-section.test.ts +++ b/test/viewer-inbox-section.test.ts @@ -2,9 +2,8 @@ import * as vm from "node:vm"; import { describe, expect, it } from "vitest"; import { renderViewerDocument } from "../src/viewer/document.js"; -// STEP-C2: 待回应分区接真实 inbox 数据。这些用例锁定 renderAwaitingReplySection() -// 的纯渲染契约——给定 state.inbox.items,产出 question/briefing 两类卡片、 -// 复用「看原文 →」跳证据、空态去掉「尚未接通」。不依赖运行中的 daemon。 +// STEP-C2/C3: inbox actions are still supported, but Todo no longer renders +// a separate awaiting-reply candidate section. function htmlEscape(value: string): string { return value @@ -113,140 +112,28 @@ function loadViewerSandbox() { } describe("STEP-C2 viewer 待回应分区接真数据", () => { - it("空 inbox 渲染诚实空态,且不再出现「尚未接通」", () => { + it("Todo 页不再渲染待回应 inbox 分区", () => { const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { loaded: true, items: [] }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("暂无待回应"); - expect(html).not.toContain("尚未接通"); - expect(html).not.toContain("即将上线"); - expect(html).not.toContain("inbox-card"); - }); - - it("question 渲染为 🔴 卡片,带「来自」与计数", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { loaded: true, - items: [ - { id: "inbox_1", kind: "question", body: "要不要给 `/admin/*` 加鉴权?", fromAgent: "auth-refactor", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - ], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("inbox-card-question"); - expect(html).toContain("待回应 (1)"); - expect(html).toContain("来自 auth-refactor"); - expect(html).toContain("Agent 在等你回"); - // body 走 renderMarkdownSafe:反引号代码片段成 - expect(html).toContain(''); - expect(html).not.toContain("inbox-card-briefing"); - }); - - it("briefing 渲染为 📋 子区卡片,不计入待回应计数", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, briefingExpanded: true, - items: [ - { id: "inbox_b", kind: "briefing", body: "今天完成了 3 件", fromAgent: "line-c", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - ], + statusFilter: "", + search: "", + reviewItems: [], + frontier: [], + items: [{ id: "a1", status: "pending", title: "处理真实待办", updatedAt: new Date().toISOString() }], }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("inbox-card-briefing"); - expect(html).toContain("Agent 整理 (1)"); - expect(html).toContain("知悉即可"); - // 没有 question 时,标题不带 (n) 计数、不显示「在等你回」徽标 - expect(html).not.toContain("待回应 ("); - expect(html).not.toContain("Agent 在等你回"); - }); - - it("有 sourceObservationIds 时渲染「看原文 →」按钮,复用 jump-to-evidence", () => { - const { sandbox } = loadViewerSandbox(); sandbox.state.inbox = { loaded: true, - items: [ - { id: "inbox_e", kind: "question", body: "看证据", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", sourceObservationIds: ["obs_xyz"] }, - ], + items: [{ id: "q1", kind: "question", body: "要不要加鉴权", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain('data-action="jump-to-evidence"'); - expect(html).toContain('data-obs-id="obs_xyz"'); - expect(html).toContain("看原文"); - }); - it("无 sourceObservationIds 时不渲染「看原文 →」", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, - items: [{ id: "inbox_n", kind: "question", body: "无证据", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).not.toContain("jump-to-evidence"); - expect(html).not.toContain("看原文"); - }); - - it("question 与 briefing 混合:各自分区,question 在前", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, briefingExpanded: true, - items: [ - { id: "b1", kind: "briefing", body: "汇报", status: "awaiting", createdAt: "2026-06-13T09:05:00Z" }, - { id: "q1", kind: "question", body: "问题", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - ], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("inbox-card-question"); - expect(html).toContain("inbox-card-briefing"); - // question 子区在 briefing 子区之前 - expect(html.indexOf("inbox-card-question")).toBeLessThan(html.indexOf("Agent 整理 (")); - }); - - it("body 经 renderMarkdownSafe 转义,杜绝 XSS 注入", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, - items: [{ id: "x", kind: "question", body: "", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).not.toContain(" { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, - items: [{ id: "q9", kind: "question", body: "要加鉴权吗?", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain('data-action="inbox-reply"'); - expect(html).toContain('data-action="inbox-to-todo"'); - expect(html).toContain('data-action="inbox-ack"'); - expect(html).toContain('data-inbox-id="q9"'); - }); - - it("briefing 卡只有 知道了/转待处理,无 回应", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, briefingExpanded: true, - items: [{ id: "b9", kind: "briefing", body: "完成 3 件", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain('data-action="inbox-ack"'); - expect(html).toContain('data-action="inbox-to-todo"'); - expect(html).not.toContain('data-action="inbox-reply"'); - }); - - it("回应输入框仅在 replyingId 命中该卡时渲染", () => { - const { sandbox } = loadViewerSandbox(); - const base = { id: "q5", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }; - sandbox.state.inbox = { loaded: true, replyingId: null, items: [base] }; - expect(sandbox.renderAwaitingReplySection()).not.toContain("inbox-reply-input-q5"); - sandbox.state.inbox.replyingId = "q5"; - const opened = sandbox.renderAwaitingReplySection(); - expect(opened).toContain('id="inbox-reply-input-q5"'); - expect(opened).toContain('data-action="inbox-reply-submit"'); - expect(opened).toContain('data-action="inbox-reply-cancel"'); + sandbox.renderActions(); + const html = sandbox.document.getElementById("view-actions").innerHTML; + expect(html).toContain("处理真实待办"); + expect(html).not.toContain("awaiting-reply-section"); + expect(html).not.toContain("要不要加鉴权"); + expect(html).not.toContain("inbox-card"); }); it("removeInboxItemLocal 本地剔除该项并清回应态", () => { @@ -397,204 +284,6 @@ describe("STEP-C2 viewer 待回应分区接真数据", () => { expect(sandbox.state.inbox.pendingById.q9).toBeUndefined(); }); - // --- P1 搜索过滤:搜索框作用于待回应区 --- - - it("搜索词过滤 inbox 项(匹配 body 或 fromAgent)", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [ - { id: "q1", kind: "question", body: "要不要加鉴权", fromAgent: "auth-refactor", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - { id: "q2", kind: "question", body: "导出格式选哪个", fromAgent: "export-fmt", status: "awaiting", createdAt: "2026-06-13T09:01:00Z" }, - { id: "b1", kind: "briefing", body: "鉴权批量加固完成", fromAgent: "auth-batch", status: "awaiting", createdAt: "2026-06-13T09:02:00Z" }, - ], - }; - // 搜 fromAgent - sandbox.state.actions.search = "auth-refactor"; - let html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("要不要加鉴权"); - expect(html).not.toContain("导出格式选哪个"); - expect(html).not.toContain("鉴权批量加固完成"); - // 搜 body 关键词(跨 question/briefing) - sandbox.state.actions.search = "鉴权"; - html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("要不要加鉴权"); - expect(html).toContain("鉴权批量加固完成"); - expect(html).not.toContain("导出格式选哪个"); - }); - - it("搜索无命中时整区不渲染(返回空串)", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ id: "q1", kind: "question", body: "abc", fromAgent: "x", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - sandbox.state.actions.search = "zzz-no-match"; - expect(sandbox.renderAwaitingReplySection()).toBe(""); - }); - - it("无搜索词时照常渲染全部", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, briefingExpanded: true, - items: [ - { id: "q1", kind: "question", body: "问一", fromAgent: "a", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - { id: "b1", kind: "briefing", body: "报一", fromAgent: "b", status: "awaiting", createdAt: "2026-06-13T09:01:00Z" }, - ], - }; - sandbox.state.actions.search = ""; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("问一"); - expect(html).toContain("报一"); - }); - - // --- P1 briefing 分区折叠 --- - - it("briefing 默认折叠:显示可点开的子区头但不渲染卡片", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, briefingExpanded: false, - items: [ - { id: "q1", kind: "question", body: "问题正文", fromAgent: "a", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }, - { id: "b1", kind: "briefing", body: "简报正文", fromAgent: "b", status: "awaiting", createdAt: "2026-06-13T09:01:00Z" }, - ], - }; - const html = sandbox.renderAwaitingReplySection(); - // question 仍直接显示 - expect(html).toContain("问题正文"); - // briefing 子区头在,但折叠态:卡片正文不渲染 - expect(html).toContain('data-action="toggle-briefings"'); - expect(html).toContain('aria-expanded="false"'); - expect(html).toContain("Agent 整理 (1)"); - expect(html).not.toContain("简报正文"); - }); - - it("briefingExpanded 为真时渲染 briefing 卡片", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, briefingExpanded: true, - items: [{ id: "b1", kind: "briefing", body: "简报正文", fromAgent: "b", status: "awaiting", createdAt: "2026-06-13T09:01:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain('aria-expanded="true"'); - expect(html).toContain("简报正文"); - }); - - it("搜索命中时强制展开 briefing(否则命中项被折叠藏住)", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, briefingExpanded: false, - items: [{ id: "b1", kind: "briefing", body: "鉴权加固完成", fromAgent: "auth", status: "awaiting", createdAt: "2026-06-13T09:01:00Z" }], - }; - sandbox.state.actions.search = "鉴权"; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain('aria-expanded="true"'); - expect(html).toContain("鉴权加固完成"); - }); - - // --- STEP-D4 飞书投递状态徽标(只读,join 自 mem:delivery) --- - - it("delivery sent → 显示「已推送 ✓」徽标", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "sent", messageId: "om_x", urgent: false, attempts: 1, deliveredAt: "2026-06-13T09:00:05Z" }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("inbox-delivery-sent"); - expect(html).toContain("已推送 ✓"); - expect(html).not.toContain("推送失败"); - }); - - it("delivery sent + urgent → 徽标带「加急」", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "sent", messageId: "om_x", urgent: true, attempts: 1 }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("已推送 ✓"); - expect(html).toContain("加急"); - }); - - it("delivery failed → 显示「推送失败 ⚠」+ 短错误摘要", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "failed", error: "missing scope im:message", attempts: 1 }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).toContain("inbox-delivery-failed"); - expect(html).toContain("推送失败 ⚠"); - expect(html).toContain("missing scope im:message"); - }); - - it("delivery failed 超长错误被截断到 ≤61 字符(含省略号)", () => { - const { sandbox } = loadViewerSandbox(); - const longErr = "x".repeat(200); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "failed", error: longErr, attempts: 2 }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - // 截断后的可见摘要不含完整 200 长串 - const m = html.match(/inbox-delivery-err">([^<]*) { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "skipped", attempts: 0 }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).not.toContain("inbox-delivery"); - expect(html).not.toContain("已推送"); - expect(html).not.toContain("推送失败"); - }); - - it("无 delivery 字段 → 不显示徽标(向后兼容 D3 前的数据)", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z" }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).not.toContain("inbox-delivery"); - }); - - it("failed 错误摘要经 esc 转义,杜绝 XSS", () => { - const { sandbox } = loadViewerSandbox(); - sandbox.state.inbox = { - loaded: true, replyingId: null, pendingById: {}, - items: [{ - id: "q1", kind: "question", body: "问", status: "awaiting", createdAt: "2026-06-13T09:00:00Z", - delivery: { channel: "lark", status: "failed", error: "", attempts: 1 }, - }], - }; - const html = sandbox.renderAwaitingReplySection(); - expect(html).not.toContain(" { const { sandbox } = loadViewerSandbox(); sandbox.state.inbox = { @@ -635,7 +324,6 @@ describe("STEP-C2 viewer 待回应分区接真数据", () => { ], answeredExpanded: true, }; - expect(sandbox.renderAwaitingReplySection()).toContain("暂无待回应"); const html = sandbox.renderInboxArchiveSection(); expect(html).toContain("已知悉 (2)"); expect(html).toContain("本次整理完成"); diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index e499c5ae..db5ccfab 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -225,6 +225,8 @@ async function waitFor(predicate: () => boolean, attempts = 20) { } describe("viewer session rendering", () => { + const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + it("does not throw when dashboard sessions are missing ids", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.dashboard = { @@ -245,9 +247,13 @@ describe("viewer session rendering", () => { const html = getElement("view-dashboard").innerHTML; expect(html).toContain("Unnamed session"); expect(html).toContain("Todos"); - expect(html).toContain("Awaiting reply"); - expect(html).toContain("To confirm"); - expect(html).toContain("To follow up"); + expect(html).toContain("Todos"); + expect(html).toContain("1 open · 0 done"); + expect(html).not.toContain("Needs attention"); + expect(html).not.toContain("reply ·"); + expect(html).not.toContain("Reply queue"); + expect(html).not.toContain("Action candidates"); + expect(html).not.toContain("Pending actions"); expect(html).not.toContain("Memories"); expect(html).not.toContain("Lessons"); expect(html).not.toContain("Graph nodes"); @@ -261,7 +267,6 @@ describe("viewer session rendering", () => { urls.push(url); if (url.includes("sessions")) return { ok: true, json: async () => ({ sessions: [] }) }; if (url.includes("actions")) return { ok: true, json: async () => ({ actions: [] }) }; - if (url.includes("review?status=pending&kind=action")) return { ok: true, json: async () => ({ items: [] }) }; if (url.includes("inbox?status=awaiting")) return { ok: true, json: async () => ({ items: [] }) }; return { ok: true, json: async () => ({}) }; }; @@ -269,7 +274,7 @@ describe("viewer session rendering", () => { await sandbox.loadDashboard(); expect(urls.some((url) => url.includes("actions"))).toBe(true); - expect(urls.some((url) => url.includes("review?status=pending&kind=action"))).toBe(true); + expect(urls.some((url) => url.includes("review?status=pending&kind=action"))).toBe(false); expect(urls.some((url) => url.includes("inbox?status=awaiting"))).toBe(true); expect(urls.some((url) => url.includes("memories?latest=true"))).toBe(false); expect(urls.some((url) => url.includes("graph/stats"))).toBe(false); @@ -829,9 +834,6 @@ describe("viewer session rendering", () => { posts.push({ url, body: init?.body ? JSON.parse(init.body) : null }); return { ok: true, json: async () => ({ success: true, directCreated: 1, reviewCreated: 0 }) }; } - if (url.includes("review?status=pending")) { - return { ok: true, json: async () => ({ items: [] }) }; - } if (url.includes("frontier")) { return { ok: true, json: async () => ({ frontier: [] }) }; } @@ -860,7 +862,7 @@ describe("viewer session rendering", () => { expect(urls.some((url) => url.includes("inbox?status=awaiting"))).toBe(true); expect(urls.some((url) => url.includes("inbox?status=answered"))).toBe(true); expect(urls.some((url) => url.includes("inbox?status=dismissed"))).toBe(true); - expect(urls.some((url) => url.includes("review?status=pending&kind=action"))).toBe(true); + expect(urls.some((url) => url.includes("review?status=pending&kind=action"))).toBe(false); expect(urls.some((url) => url.includes("todo-extract/generate"))).toBe(false); expect(urls.some((url) => url.includes("review/actions/generate"))).toBe(false); expect(posts).toHaveLength(0); @@ -919,6 +921,37 @@ describe("viewer session rendering", () => { expect(posts).toHaveLength(1); }); + it("does not show Updated when full card update scans no cards", async () => { + const { sandbox, getElement, dispatchDocumentClick } = loadViewerSandbox(); + const posts: Array<{ url: string; body: unknown }> = []; + sandbox.fetch = async (input: unknown, init?: { body?: string }) => { + const url = String(input); + if (url.includes("todo/update")) { + posts.push({ url, body: init?.body ? JSON.parse(init.body) : null }); + return { ok: true, json: async () => ({ engine: "llm", scanned: 0, kept: 0, dropped: 0, completed: 0, rewritten: 0, merged: 0, preview: [], decisions: [] }) }; + } + 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: [] }) }; + return { ok: true, json: async () => ({}) }; + }; + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { loaded: true, items: [], frontier: [], statusFilter: "", search: "", reviewItems: [] }; + sandbox.state.inbox = { loaded: true, items: [] }; + sandbox.renderActions(); + + const target = Object.create(sandbox.Element.prototype); + target.getAttribute = (name: string) => name === "data-action" ? "update-cards" : null; + target.closest = (selector: string) => selector === "[data-action]" ? target : null; + dispatchDocumentClick(target); + await waitFor(() => sandbox.state.actions.cleanupMessage === "No cards need updating"); + + expect(posts).toHaveLength(1); + expect(sandbox.state.actions.cleanupStatus).toBe("idle"); + expect(getElement("view-actions").innerHTML).toContain("Update"); + expect(getElement("view-actions").innerHTML).not.toContain(">Updated"); + }); + it("marks actions stale instead of reloading them on websocket updates", () => { const { sandbox, getElement } = loadViewerSandbox(); let loadCalls = 0; @@ -929,7 +962,7 @@ describe("viewer session rendering", () => { sandbox.window.pageYOffset = 200; sandbox.state.actions = { loaded: true, - items: [{ id: "act-1", title: "Keep scroll", status: "pending" }], + items: [{ id: "act-1", title: "Keep scroll", status: "pending", updatedAt: daysAgo(1) }], frontier: [], statusFilter: "", search: "", @@ -952,7 +985,7 @@ describe("viewer session rendering", () => { sandbox.window.pageYOffset = 0; sandbox.state.actions = { loaded: true, - items: [{ id: "act-1", title: "Keep scroll", status: "pending" }], + items: [{ id: "act-1", title: "Keep scroll", status: "pending", updatedAt: daysAgo(1) }], frontier: [], statusFilter: "", search: "", @@ -1010,7 +1043,7 @@ describe("viewer session rendering", () => { sandbox.state.activeTab = "actions"; sandbox.state.actions = { loaded: true, - items: [{ id: "act-1", title: "Finish me", status: "pending", tags: [] }], + items: [{ id: "act-1", title: "Finish me", status: "pending", tags: [], sourceObservationIds: ["obs_1"], updatedAt: daysAgo(1) }], frontier: [], statusFilter: "", search: "", @@ -1020,6 +1053,11 @@ describe("viewer session rendering", () => { sandbox.renderActions(); expect(getElement("view-actions").innerHTML).toContain("Complete"); expect(getElement("view-actions").innerHTML).toContain("Archive"); + expect(getElement("view-actions").innerHTML).toContain("btn-primary-sm"); + expect(getElement("view-actions").innerHTML).toContain("action-source-link"); + expect(getElement("view-actions").innerHTML).toContain("action-refresh-link"); + expect(getElement("view-actions").innerHTML).toContain("action-archive-link"); + expect(getElement("view-actions").innerHTML).toMatch(/action-source-link[\s\S]*action-refresh-link[\s\S]*action-archive-link[\s\S]*btn-primary-sm/); // STEP-13: the duplicate "Delete" button (also status=cancelled) was removed. expect(getElement("view-actions").innerHTML).not.toContain("Delete"); @@ -1038,6 +1076,144 @@ describe("viewer session rendering", () => { expect(sandbox.state.actions.items[0].status).toBe("done"); }); + it("refreshes one action card and replaces only that card", async () => { + const { sandbox, getElement, dispatchDocumentClick } = loadViewerSandbox(); + const posts: Array<{ url: string; body: any }> = []; + sandbox.fetch = async (input: unknown, init?: { body?: string }) => { + const url = String(input); + if (url.includes("todo/action-refresh")) { + posts.push({ url, body: init?.body ? JSON.parse(init.body) : null }); + return { + ok: true, + json: async () => ({ + success: true, + keptOld: false, + reason: "replaced", + action: { + id: "act-1", + title: "Fresh single-card title", + description: "A cleaner executable card.", + status: "pending", + tags: [], + sourceObservationIds: ["obs_2"], + updatedAt: daysAgo(0), + }, + }), + }; + } + return { ok: true, json: async () => ({}) }; + }; + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { id: "act-1", title: "Old card title", status: "pending", tags: [], sourceObservationIds: ["obs_1"], updatedAt: daysAgo(1) }, + { id: "act-2", title: "Other card", status: "pending", tags: [], sourceObservationIds: ["obs_3"], updatedAt: daysAgo(1) }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + sandbox.renderActions(); + + const target = Object.create(sandbox.Element.prototype); + target.getAttribute = (name: string) => { + if (name === "data-action") return "refresh-action-card"; + if (name === "data-action-id") return "act-1"; + return null; + }; + target.closest = (selector: string) => selector === "[data-action]" ? target : null; + dispatchDocumentClick(target); + + expect(sandbox.state.actions.cardRefreshInFlight["act-1"]).toBe(true); + expect(getElement("view-actions").innerHTML).toContain("Updating..."); + await waitFor(() => sandbox.state.actions.items[0].title === "Fresh single-card title"); + + expect(posts[0].body).toEqual({ actionId: "act-1" }); + expect(sandbox.state.actions.items[0].description).toBe("A cleaner executable card."); + expect(sandbox.state.actions.items[1].title).toBe("Other card"); + expect(sandbox.state.actions.cardRefreshInFlight["act-1"]).toBeFalsy(); + expect(sandbox.state.actions.cardRefreshNotice).toBe("Updated from source"); + }); + + it("keeps the old card when card refresh returns a non-replacing result", async () => { + const { sandbox, dispatchDocumentClick } = loadViewerSandbox(); + sandbox.fetch = async (input: unknown) => { + const url = String(input); + if (url.includes("todo/action-refresh")) { + return { ok: true, json: async () => ({ success: true, keptOld: true, reason: "low-quality" }) }; + } + if (url.includes("frontier")) return { ok: true, json: async () => ({ frontier: [] }) }; + if (url.includes("actions")) return { ok: true, json: async () => ({ actions: sandbox.state.actions.items }) }; + return { ok: true, json: async () => ({}) }; + }; + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [{ id: "act-1", title: "Old card title", status: "pending", tags: [], sourceObservationIds: ["obs_1"], updatedAt: daysAgo(1) }], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + sandbox.renderActions(); + + const target = Object.create(sandbox.Element.prototype); + target.getAttribute = (name: string) => { + if (name === "data-action") return "refresh-action-card"; + if (name === "data-action-id") return "act-1"; + return null; + }; + target.closest = (selector: string) => selector === "[data-action]" ? target : null; + dispatchDocumentClick(target); + await waitFor(() => !sandbox.state.actions.cardRefreshInFlight["act-1"]); + + expect(sandbox.state.actions.items[0].title).toBe("Old card title"); + expect(sandbox.state.actions.reviewItems).toEqual([]); + expect(sandbox.state.actions.cardRefreshNotice).toBe("Candidate was too vague"); + }); + + it("shows a specific card refresh reason when the old card is kept", async () => { + const { sandbox, dispatchDocumentClick } = loadViewerSandbox(); + sandbox.fetch = async (input: unknown) => { + const url = String(input); + if (url.includes("todo/action-refresh")) { + return { ok: true, json: async () => ({ success: true, keptOld: true, reason: "incomplete-title" }) }; + } + 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: sandbox.state.actions.items }) }; + return { ok: true, json: async () => ({}) }; + }; + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [{ id: "act-1", title: "准备推送分支 codex/todo-cleanup-flash-model 到", status: "pending", tags: [], sourceObservationIds: ["obs_1"], updatedAt: daysAgo(1) }], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + sandbox.renderActions(); + + const target = Object.create(sandbox.Element.prototype); + target.getAttribute = (name: string) => { + if (name === "data-action") return "refresh-action-card"; + if (name === "data-action-id") return "act-1"; + return null; + }; + target.closest = (selector: string) => selector === "[data-action]" ? target : null; + dispatchDocumentClick(target); + await waitFor(() => sandbox.state.actions.cardRefreshNotice.length > 0); + + expect(sandbox.state.actions.items[0].title).toBe("准备推送分支 codex/todo-cleanup-flash-model 到"); + expect(sandbox.state.actions.cardRefreshNotice).toBe("Title is incomplete"); + }); + it("renders and saves todo extractor config from the global settings panel", async () => { const { sandbox, getElement, dispatchDocumentClick } = loadViewerSandbox(); const posts: any[] = []; @@ -1075,10 +1251,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. It applies to the next organize run."); + await waitFor(() => sandbox.state.actions.extractMessage === "Config saved. Restart the service to apply it."); 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. It applies to the next organize run."); + expect(sandbox.state.actions.extractMessage).toBe("Config saved. Restart the service to apply it."); }); it("keeps unsaved todo extractor config while the settings panel rerenders", () => { @@ -1157,12 +1333,15 @@ describe("viewer session rendering", () => { expect(html).toContain("missing key"); }); - it("filters actions from metric cards", () => { + it("filters actions from Todo and Done metric cards", () => { const { sandbox, getElement, dispatchDocumentClick } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; sandbox.state.actions = { loaded: true, - items: [{ id: "act-1", title: "Doing", status: "active", tags: [] }], + items: [ + { id: "act-1", title: "Doing", status: "active", tags: [] }, + { id: "act-2", title: "Closed", status: "done", tags: [] }, + ], frontier: [], statusFilter: "", search: "", @@ -1170,18 +1349,21 @@ describe("viewer session rendering", () => { }; sandbox.state.inbox = { loaded: true, items: [] }; sandbox.renderActions(); - expect(getElement("view-actions").innerHTML).toContain('data-status="active"'); + expect(getElement("view-actions").innerHTML).toContain('data-status="todo"'); + expect(getElement("view-actions").innerHTML).toContain('data-status="done"'); + expect(getElement("view-actions").innerHTML).not.toContain('data-status="active"'); + expect(getElement("view-actions").innerHTML).not.toContain('data-status="attention"'); const target = Object.create(sandbox.Element.prototype); target.getAttribute = (name: string) => { if (name === "data-action") return "filter-actions-status"; - if (name === "data-status") return "active"; + if (name === "data-status") return "todo"; return null; }; target.closest = (selector: string) => selector === "[data-action]" ? target : null; dispatchDocumentClick(target); - expect(sandbox.state.actions.statusFilter).toBe("active"); + expect(sandbox.state.actions.statusFilter).toBe("todo"); }); it("soft-refreshes actions while todo extraction is still running", async () => { @@ -1212,30 +1394,7 @@ 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", () => { + it("renders only Todo and Done metrics and never shows awaiting as a todo class", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; sandbox.state.actions = { @@ -1249,7 +1408,17 @@ describe("viewer session rendering", () => { sandbox.state.inbox = { loaded: true, items: [] }; sandbox.renderActions(); const html = getElement("view-actions").innerHTML; - expect(html).toContain("Awaiting reply"); + expect(html).toContain("Todo"); + expect(html).toContain("Done"); + expect(html).toContain("data-status=\"todo\""); + expect(html).toContain("data-status=\"done\""); + expect(html).not.toContain("Needs attention"); + expect(html).not.toContain("In progress"); + expect(html).not.toContain("Follow up"); + expect(html).not.toContain("Reply"); + expect(html).not.toContain("Confirm"); + expect(html).not.toContain("to confirm"); + expect(html).not.toContain("attention-chip-row"); expect(html).toContain("No todos yet"); expect(html).not.toContain("awaiting-reply-section"); expect(html).not.toContain("No awaiting replies"); @@ -1262,10 +1431,162 @@ describe("viewer session rendering", () => { }; sandbox.renderActions(); const withQuestion = getElement("view-actions").innerHTML; - const idxAwaiting = withQuestion.indexOf("awaiting-reply-section"); - const idxGroups = withQuestion.indexOf("action-group"); - expect(idxAwaiting).toBeGreaterThan(-1); - expect(idxGroups === -1 || idxAwaiting < idxGroups).toBe(true); + expect(withQuestion).not.toContain("awaiting-reply-section"); + expect(withQuestion).not.toContain("需要拍板"); + }); + + it("keeps the default action view focused on recent open todos", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { id: "act_recent", title: "Current build check", status: "pending", priority: "normal", tags: [], updatedAt: daysAgo(1) }, + { id: "act_earlier", title: "Earlier follow up", status: "pending", priority: "normal", tags: [], updatedAt: daysAgo(5) }, + { id: "act_old", title: "Old migration reminder", status: "active", priority: "normal", tags: [], updatedAt: daysAgo(12) }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [{ id: "review-1", status: "pending", kind: "action", title: "Confirm launch", content: "Confirm this todo." }], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).not.toContain("action-focus-guide"); + expect(html).not.toContain("Focus:"); + expect(html).not.toContain("Confirm launch"); + expect(html).toContain("Current build check"); + expect(html).toContain("Earlier open items"); + expect(html).toContain("Older backlog"); + expect(html).not.toContain("Earlier follow up"); + expect(html).not.toContain("Old migration reminder"); + }); + + it("renders the Todo toolbar as search, Todo, Done, organize, update, refresh", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { id: "act-1", title: "Open item", status: "pending", tags: [] }, + { id: "act-2", title: "Done item", status: "done", tags: [] }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [{ id: "review-1", status: "pending", kind: "action", title: "Confirm me", content: "Confirm this todo." }], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + const searchIndex = html.indexOf('id="actions-search"'); + const todoIndex = html.indexOf('data-status="todo"'); + const doneIndex = html.indexOf('data-status="done"'); + const extractIndex = html.indexOf('data-action="extract-actions"'); + const updateIndex = html.indexOf('data-action="update-cards"'); + const refreshIndex = html.indexOf('data-action="refresh-actions"'); + + expect([searchIndex, todoIndex, doneIndex, extractIndex, updateIndex, refreshIndex].every((i) => i >= 0)).toBe(true); + expect(searchIndex).toBeLessThan(todoIndex); + expect(todoIndex).toBeLessThan(doneIndex); + expect(doneIndex).toBeLessThan(extractIndex); + expect(extractIndex).toBeLessThan(updateIndex); + expect(updateIndex).toBeLessThan(refreshIndex); + expect(html).not.toContain("action-focus-guide"); + expect(html).not.toContain("59 Todo · 0 Done"); + expect(html).not.toContain("Confirm me"); + expect(html).not.toContain("action-candidate-card"); + }); + + it("uses source checkpoints instead of cleanup updatedAt for default backlog folding", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_old_source", + title: "Old source task rewritten today", + status: "pending", + priority: "normal", + tags: [], + createdAt: daysAgo(1), + updatedAt: daysAgo(0), + metadata: { todoExtraction: { sourceCheckpoint: `${daysAgo(12)}:1234` } }, + }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).toContain("Older backlog"); + expect(html).not.toContain("Old source task rewritten today"); + }); + + it("shows source age on stale backlog cards instead of cleanup updatedAt", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_old_source", + title: "Old source task rewritten today", + status: "pending", + priority: "normal", + tags: [], + createdAt: daysAgo(1), + updatedAt: daysAgo(0), + metadata: { todoExtraction: { sourceCheckpoint: `${daysAgo(12)}:1234` } }, + }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + olderBacklogExpanded: true, + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).toContain("Old source task rewritten today"); + expect(html).toContain("12d ago"); + expect(html).not.toContain("just now"); + }); + + it("surfaces old open todos when search matches them", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { id: "act_recent", title: "Current build check", status: "pending", priority: "normal", tags: [], updatedAt: daysAgo(1) }, + { id: "act_old", title: "Old migration reminder", status: "active", priority: "normal", tags: [], updatedAt: daysAgo(12) }, + ], + frontier: [], + statusFilter: "", + search: "migration", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).toContain("Old migration reminder"); + expect(html).not.toContain("Older backlog"); }); it("calm action card shows title without classification tags (STEP-16)", () => { @@ -1282,7 +1603,7 @@ describe("viewer session rendering", () => { priority: "normal", tags: ["todo-extracted", "time:current", "type:to_start"], sourceObservationIds: ["obs_1"], - updatedAt: "2026-06-17T10:00:00Z", + updatedAt: daysAgo(1), }, ], frontier: [], @@ -1352,7 +1673,7 @@ describe("viewer session rendering", () => { expect(html).not.toContain("limit 20"); }); - it("keeps review candidates out of the default action view", () => { + it("ignores review candidates in the Todo view", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; sandbox.state.actions = { @@ -1366,7 +1687,7 @@ describe("viewer session rendering", () => { priority: "normal", tags: ["todo-extracted", "time:current", "type:to_start"], sourceObservationIds: ["obs_1"], - updatedAt: "2026-06-17T10:00:00Z", + updatedAt: daysAgo(1), }, ], frontier: [], @@ -1399,19 +1720,21 @@ describe("viewer session rendering", () => { const html = getElement("view-actions").innerHTML; expect(html).toContain("整理验收截图"); - expect(html).toContain("1 to confirm"); + expect(html).not.toContain("修复待办候选展示"); expect(html).not.toContain("action-candidate-card"); + expect(html).not.toContain("Confirm"); + expect(html).not.toContain("Ignore"); expect(html).not.toContain("记忆总结卡片"); expect(html).not.toContain("No todos yet"); }); - it("renders action reviews as compact decision cards while keeping tool pollution hidden", () => { + it("does not render action reviews as Todo decision cards", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.actions = { loaded: true, items: [], frontier: [], - statusFilter: "review", + statusFilter: "todo", search: "", reviewItems: [ { @@ -1465,12 +1788,15 @@ describe("viewer session rendering", () => { sandbox.renderActions(); const html = getElement("view-actions").innerHTML; - expect(html).toContain("修复待办候选展示"); - expect(html).toContain("action-candidate-card"); + expect(html).not.toContain("修复待办候选展示"); + expect(html).not.toContain("action-candidate-card"); expect(html).not.toContain("记忆总结卡片"); - expect(html).toContain("To confirm"); - expect(html).toContain("Confirm"); - expect(html).toContain("Ignore"); + expect(html).toContain("Todo"); + expect(html).not.toContain("To confirm"); + expect(html).not.toContain("Needs confirmation"); + expect(html).not.toContain("to confirm"); + expect(html).not.toContain("Confirm"); + expect(html).not.toContain("Ignore"); expect(html).not.toContain("View original"); expect(html).not.toContain("待办生成链路与前端展示修复计划"); expect(html).not.toContain("## Summary"); @@ -1478,14 +1804,17 @@ describe("viewer session rendering", () => { expect(html).not.toContain(">action-candidate<"); }); - it("keeps action metric filters exclusive", () => { + it("folds former attention and active work into Todo without subfilters", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; sandbox.state.actions = { loaded: true, - items: [{ id: "act-1", title: "Follow up", status: "pending", tags: [] }], + items: [ + { id: "act-1", title: "Call project owner", status: "pending", tags: [] }, + { id: "act-2", title: "Keep building", status: "active", tags: [] }, + ], frontier: [], - statusFilter: "awaiting", + statusFilter: "todo", search: "", reviewItems: [{ id: "review-1", status: "pending", kind: "action", title: "Confirm me", content: "Confirm this todo." }], }; @@ -1493,25 +1822,31 @@ describe("viewer session rendering", () => { sandbox.renderActions(); let html = getElement("view-actions").innerHTML; - expect(html).toContain("Need an answer?"); + expect(html).toContain('
Todo
2
'); + expect(html).toContain('data-status="todo"'); + expect(html).not.toContain("attention-chip-row"); + expect(html).not.toContain("Need an answer?"); + expect(html).not.toContain("Confirm me"); + expect(html).toContain("Call project owner"); + expect(html).toContain("Keep building"); + expect(html).not.toContain("Needs your reply"); + expect(html).not.toContain("Needs confirmation"); + expect(html).not.toContain("Needs follow-up"); + expect(html).not.toContain("In progress"); expect(html).not.toContain("Follow up"); - expect(html).not.toContain("action-candidate-card"); sandbox.state.actions.statusFilter = "review"; sandbox.renderActions(); html = getElement("view-actions").innerHTML; - expect(html).toContain("action-candidate-card"); - expect(html).toContain('
To follow up
1
'); - expect(html).toContain('
To confirm
1
'); - expect(html).not.toContain("Need an answer?"); - expect(html).not.toContain("Follow up"); + expect(html).not.toContain("action-candidate-card"); + expect(html).toContain("Call project owner"); + expect(html).toContain("Keep building"); sandbox.state.actions.statusFilter = "pending"; sandbox.renderActions(); html = getElement("view-actions").innerHTML; - expect(html).toContain("Follow up"); - expect(html).not.toContain("Need an answer?"); - expect(html).not.toContain("action-candidate-card"); + expect(html).toContain("Call project owner"); + expect(html).not.toContain("Confirm me"); }); it("does not load memory or lesson review candidates in the frontend", async () => {