From 66afd072c4be106b0e6423b8a1b691300f2ffba7 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 21:44:23 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(todo):=20add=20extraction=20scope=20?= =?UTF-8?q?settings=20=E2=80=94=20sinceDays=20window=20+=20max=20interacti?= =?UTF-8?q?ons/session=20(STEP-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new user-configurable extraction-scope params, surfaced in the viewer Settings panel and read by generateTodosFromSessions: - AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS (default 7): only sessions whose endedAt/startedAt falls within the last N days are eligible. This is the primary scope control; maxSessions stays an internal safety cap (20). - AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION (default 10): per session, keep only the most-recent M interaction records (a 'turn': one user message through everything before the next), segmented at user-message boundaries (synthetic-compression title 'prompt_submit'); degrades to whole-session when no boundary is found. Also stops the viewer hard-coding maxSessions:1/maxObservationsPerSession:20 on the 'organize with LLM' path so the saved settings actually take effect. Body params win, else env, else config default; new REST body params are whitelisted on /agentmemory/todo-extract/generate. No endpoint/MCP/version changes (count stays 141/55). Signed-off-by: yujiezhang-ops --- .env.example | 2 + src/config.ts | 13 ++++ src/functions/todo-extract.ts | 70 ++++++++++++++++++- src/triggers/api.ts | 10 +++ src/viewer/index.html | 16 ++++- test/todo-extract.test.ts | 105 ++++++++++++++++++++++++++++- test/todo-extractor-config.test.ts | 23 +++++++ test/viewer-session-id.test.ts | 10 +-- 8 files changed, 238 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 7b15b3a0..9a6e1fcb 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ # LANGEXTRACT_THINKING_DEPTH=medium # reasoning_effort; leave empty for non-reasoning models (e.g. gpt-4o-mini) # LANGEXTRACT_PYTHON=/path/to/python # AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS=120000 +# AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS=7 # only extract sessions from the last N days (primary scope control) +# AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION=10 # per session, keep at most M most-recent interaction records (one user request → agent reply) # ----------------------------------------------------------------------------- # 2. Auth & security (web UI / REST) diff --git a/src/config.ts b/src/config.ts index 71a4d05f..30254ceb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,13 @@ export const DEFAULT_LANGEXTRACT_MODEL = "deepseek/deepseek-v4-pro"; 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; +// STEP-11: extraction scope. sinceDays = only sessions whose endedAt/startedAt +// falls within the last N days are eligible (the primary scope control). Max +// interactions = per session, keep at most M most-recent interaction records +// (a "turn": one user message through everything before the next). maxSessions +// stays a backend safety cap (not surfaced as a setting). +export const DEFAULT_TODO_EXTRACT_SINCE_DAYS = 7; +export const DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS = 10; const LEGACY_LANGEXTRACT_MODELS = new Set(["pa/gpt-5.5"]); export const WRITABLE_TODO_EXTRACT_KEYS = new Set([ "AGENTMEMORY_TODO_EXTRACTOR", @@ -32,6 +39,8 @@ export const WRITABLE_TODO_EXTRACT_KEYS = new Set([ "LANGEXTRACT_BASE_URL", "LANGEXTRACT_THINKING_DEPTH", "AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS", + "AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS", + "AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION", ]); let warnPremiumModelShown = false; @@ -82,6 +91,10 @@ 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_SINCE_DAYS: + env["AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS"] || String(DEFAULT_TODO_EXTRACT_SINCE_DAYS), + AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION: + env["AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION"] || String(DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS), LANGEXTRACT_API_KEY_CONFIGURED: hasRealValue(env["LANGEXTRACT_API_KEY"]), LANGEXTRACT_API_KEY_MASKED: maskSecret(env["LANGEXTRACT_API_KEY"]), }; diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index 4d05415e..4fd18a1b 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -9,6 +9,8 @@ import type { Action, CompressedObservation, ReviewQueueItem, ScanCheckpoint, Se import { DEFAULT_LANGEXTRACT_BASE_URL, DEFAULT_TODO_EXTRACT_TIMEOUT_MS, + DEFAULT_TODO_EXTRACT_SINCE_DAYS, + DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS, getEnvVar, normalizeTodoExtractorModel, normalizeTodoExtractorProvider, @@ -49,6 +51,13 @@ type ObservationBlock = { type TodoExtractOptions = { maxSessions?: number; maxObservationsPerSession?: number; + // STEP-11: scope controls. sinceDays = only sessions within the last N days + // are eligible (primary control). maxInteractionsPerSession = per session, + // keep at most M most-recent interaction records (turns). Both fall back to + // env (AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS / _MAX_INTERACTIONS_PER_SESSION) + // then the config defaults when omitted. + sinceDays?: number; + maxInteractionsPerSession?: number; project?: string; force?: boolean; scanSources?: boolean; @@ -307,6 +316,36 @@ function sessionSortTime(session: Session): string { return session.endedAt || session.startedAt || ""; } +// STEP-11 interaction windowing. +// A compressed observation starts a new "interaction record" (turn) when it is +// a user message. The default zero-LLM synthetic compression (replay.ts → +// buildSyntheticCompression) types a user prompt as "conversation" and titles it +// with its raw hookType "prompt_submit" — the only role signal that survives +// compression. If LLM auto-compress is on (non-default), titles are richer and +// no boundary is found, in which case takeRecentInteractions degrades to +// "whole session = one interaction" (keep everything). +function observationStartsInteraction(obs: CompressedObservation): boolean { + return obs.type === "conversation" && /^prompt_submit$/i.test((obs.title || "").trim()); +} + +// Keep only the most recent `maxInteractions` interaction records of a +// timestamp-ascending observation list. One interaction = a user message +// through everything before the next user message (its tool calls + agent +// replies). Returns a contiguous chronological tail. +function takeRecentInteractions( + observations: CompressedObservation[], + maxInteractions: number, +): CompressedObservation[] { + if (maxInteractions <= 0 || observations.length <= 1) return observations; + const boundaries: number[] = []; + for (let i = 0; i < observations.length; i++) { + if (observationStartsInteraction(observations[i])) boundaries.push(i); + } + if (boundaries.length <= maxInteractions) return observations; + const cutoff = boundaries[boundaries.length - maxInteractions]; + return observations.slice(cutoff); +} + function timeBucketFor(session: Session, now = Date.now()): TimeBucket { if (session.status === "active") return "current"; const raw = session.endedAt || session.startedAt; @@ -999,6 +1038,19 @@ export async function generateTodosFromSessions( } const maxSessions = clampPositiveInt(data.maxSessions, 20, 100); const maxObservationsPerSession = clampPositiveInt(data.maxObservationsPerSession, 300, 1000); + // STEP-11: body wins, else env, else config default. sinceDays caps at ~10y + // (effectively unbounded); maxInteractions at 500 (well above any real cap). + const sinceDays = clampPositiveInt( + data.sinceDays ?? getEnvVar("AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS"), + DEFAULT_TODO_EXTRACT_SINCE_DAYS, + 3650, + ); + const maxInteractionsPerSession = clampPositiveInt( + data.maxInteractionsPerSession ?? getEnvVar("AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION"), + DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS, + 500, + ); + 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); @@ -1026,6 +1078,15 @@ export async function generateTodosFromSessions( const processed = parseCheckpoint(checkpoint?.cursor); const sessions = allSessions .filter((session) => !data.project || session.project === data.project || session.cwd === data.project) + // STEP-11: day-window is the primary scope control. Sessions with no/invalid + // timestamp are kept (never silently drop work); maxSessions is the cap that + // still bounds a day with a flood of sessions. + .filter((session) => { + const raw = sessionSortTime(session); + if (!raw) return true; + const at = new Date(raw).getTime(); + return !Number.isFinite(at) || at >= sinceCutoffMs; + }) .sort((a, b) => sessionSortTime(b).localeCompare(sessionSortTime(a))) .slice(0, maxSessions); let scannedObservations = 0; @@ -1041,9 +1102,12 @@ export async function generateTodosFromSessions( for (const session of sessions) { const key = checkpointKey(session); if (!data.force && processed[session.id] === key) continue; - const observations = (await kv.list(KV.observations(session.id)).catch(() => [])) - .sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")) - .slice(0, maxObservationsPerSession); + 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 + // per-session observation safety cap to the most recent tail. + const observations = takeRecentInteractions(sortedObservations, maxInteractionsPerSession) + .slice(-maxObservationsPerSession); const { ruleObservations, llmObservations } = prefilterTodoObservations(session, observations); scannedObservations += ruleObservations.length; const evidenceObservations = mode === "rules" ? ruleObservations : llmObservations.length ? llmObservations : ruleObservations; diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 4d73b273..f9a1f861 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1520,9 +1520,19 @@ export function registerApiTriggers( if (maxObservationsPerSession === null) { return { status_code: 400, body: { error: "maxObservationsPerSession must be a positive integer" } }; } + const sinceDays = parseOptionalPositiveInt(body.sinceDays); + if (sinceDays === null) { + return { status_code: 400, body: { error: "sinceDays must be a positive integer" } }; + } + const maxInteractionsPerSession = parseOptionalPositiveInt(body.maxInteractionsPerSession); + if (maxInteractionsPerSession === null) { + return { status_code: 400, body: { error: "maxInteractionsPerSession 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; const project = asNonEmptyString(body.project); if (project) payload.project = project; if (body.force === true) payload.force = true; diff --git a/src/viewer/index.html b/src/viewer/index.html index f4bdac1c..8df413cb 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -4168,6 +4168,8 @@

AI Todo

'settings.close': 'Close', 'settings.language': 'UI language', 'settings.extractor': 'LLM extraction config', + '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', 'settings.apiKeyMissing': 'Not configured', 'settings.apiKeyLabel': 'API key:', @@ -4368,6 +4370,8 @@

AI Todo

'settings.close': '关闭', 'settings.language': '界面语言', 'settings.extractor': '大模型抽取配置', + 'settings.sinceDays': '回溯天数:只抽取最近 N 天内的会话', + 'settings.maxInteractions': '每会话最多交互记录数(一次用户派发→Agent 回复为一条)', 'settings.apiKeyKeep': '输入新 API key 覆盖,留空保持不变', 'settings.apiKeyMissing': '未配置', 'settings.apiKeyLabel': 'API key:', @@ -8806,6 +8810,8 @@

AI Todo

html += ''; html += ''; html += ''; + html += '
' + esc(t('settings.sinceDays')) + '
'; + html += '
' + esc(t('settings.maxInteractions')) + '
'; html += '
'; if (maskedKey) html += '
' + esc(t('settings.apiKeyLabel')) + ' ' + esc(maskedKey) + '
'; html += '
'; @@ -8828,6 +8834,8 @@

AI Todo

'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' ].forEach(function(key) { var el = document.getElementById('todo-config-' + key); @@ -8846,6 +8854,8 @@

AI Todo

'LANGEXTRACT_BASE_URL', 'LANGEXTRACT_THINKING_DEPTH', 'AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS', + 'AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS', + 'AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION', 'LANGEXTRACT_API_KEY' ]; var body = {}; @@ -8914,9 +8924,11 @@

AI Todo

if (state.activeTab === 'actions') renderActions(); }).catch(function() {}); }, 12000); + // STEP-11: scope is governed by saved settings (sinceDays + max + // interactions per session) which the backend reads from env config; + // don't hard-code maxSessions/maxObservationsPerSession here or the + // settings would never take effect on this primary extraction path. apiPost('todo-extract/generate', { - maxSessions: 1, - maxObservationsPerSession: 20, force: force === true }).then(function(result) { var delta = todoExtractionDelta(result); diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 482a6634..e5bddf3e 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; +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_SINCE_DAYS: 7, + DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS: 10, getEnvVar: (key: string) => { const values: Record = { AGENTMEMORY_TODO_EXTRACTOR: "rules", AGENTMEMORY_TODO_DIRECT_CONFIDENCE: "0.6", AGENTMEMORY_TODO_REVIEW_CONFIDENCE: "0.55", + // Wide default window so existing time-agnostic tests are never excluded by + // the STEP-11 sinceDays filter; scope tests set process.env explicitly. + AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS: "3650", }; return process.env[key] ?? values[key]; }, @@ -598,3 +603,101 @@ describe("todo extraction", () => { expect((await kv.list(KV.actions))[0].sourceObservationIds).toEqual(["obs_real"]); }); }); + +describe("todo extraction scope — sinceDays + interaction window (STEP-11)", () => { + let kv: ReturnType; + const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); + + beforeEach(() => { + kv = mockKV(); + }); + afterEach(() => { + delete process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS; + delete process.env.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION; + }); + + // Two completed sessions, both inside the 14d "recent" bucket (so neither is + // hidden as history), but one older than a tight sinceDays window. + async function seedTwoSessions() { + await kv.set(KV.sessions, "ses_recent", session({ + id: "ses_recent", status: "completed", startedAt: daysAgo(1), endedAt: daysAgo(1), observationCount: 1, + })); + await kv.set(KV.sessions, "ses_mid", session({ + id: "ses_mid", status: "completed", startedAt: daysAgo(5), endedAt: daysAgo(5), observationCount: 1, + })); + await kv.set(KV.observations("ses_recent"), "o_recent", obs({ + id: "o_recent", sessionId: "ses_recent", timestamp: daysAgo(1), + narrative: "后续需要修复登录接口的超时问题。", + })); + await kv.set(KV.observations("ses_mid"), "o_mid", obs({ + id: "o_mid", sessionId: "ses_mid", timestamp: daysAgo(5), + narrative: "后续需要更新数据库驱动到 v5。", + })); + } + + it("control: extracts both sessions when the window covers both", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; + await seedTwoSessions(); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + expect(result.directCreated).toBe(2); + expect(await kv.list(KV.actions)).toHaveLength(2); + }); + + it("skips sessions whose endedAt is older than the sinceDays window", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "3"; + await seedTwoSessions(); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + const actions = await kv.list(KV.actions); + expect(actions).toHaveLength(1); + expect(actions[0].metadata?.todoExtraction).toMatchObject({ sourceSessionId: "ses_recent" }); + }); + + it("lets a body sinceDays override the env window", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; + await seedTwoSessions(); + const result = await generateTodosFromSessions(kv as never, { + force: true, scanSources: false, cleanup: "none", sinceDays: 3, + }); + expect(result.directCreated).toBe(1); + expect((await kv.list(KV.actions))[0].metadata?.todoExtraction).toMatchObject({ sourceSessionId: "ses_recent" }); + }); + + // 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 { + return obs({ id, sessionId: "ses_turns", type: "conversation", title: "prompt_submit", narrative, timestamp }); + } + async function seedThreeInteractions() { + await kv.set(KV.sessions, "ses_turns", session({ id: "ses_turns", status: "active", observationCount: 3 })); + await kv.set(KV.observations("ses_turns"), "t1", promptObs("t1", "后续需要修复登录接口的超时问题。", daysAgo(3))); + await kv.set(KV.observations("ses_turns"), "t2", promptObs("t2", "后续需要补充新用户的上手文档。", daysAgo(2))); + await kv.set(KV.observations("ses_turns"), "t3", promptObs("t3", "后续需要更新数据库驱动到 v5。", daysAgo(1))); + } + + it("control: extracts every interaction when the window covers them all", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; + await seedThreeInteractions(); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + expect(result.directCreated).toBe(3); + }); + + it("keeps only the most recent N interaction records per session", async () => { + process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS = "30"; + process.env.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION = "1"; + await seedThreeInteractions(); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + expect(result.directCreated).toBe(1); + expect((await kv.list(KV.actions))[0].sourceObservationIds).toEqual(["t3"]); + }); + + 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"; + await kv.set(KV.sessions, "ses_noturn", session({ id: "ses_noturn", status: "active", observationCount: 1 })); + await kv.set(KV.observations("ses_noturn"), "n1", obs({ + id: "n1", sessionId: "ses_noturn", title: "assistant", narrative: "后续需要修复登录接口的超时问题。", + })); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + expect(result.directCreated).toBe(1); + }); +}); diff --git a/test/todo-extractor-config.test.ts b/test/todo-extractor-config.test.ts index ccfa44da..6a132203 100644 --- a/test/todo-extractor-config.test.ts +++ b/test/todo-extractor-config.test.ts @@ -23,6 +23,8 @@ describe("todo extractor user config", () => { delete process.env.LANGEXTRACT_PROVIDER; delete process.env.LANGEXTRACT_BASE_URL; delete process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS; + delete process.env.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS; + delete process.env.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION; }); afterEach(() => { @@ -80,4 +82,25 @@ describe("todo extractor user config", () => { expect(readFileSync(getUserEnvPath(), "utf-8")).toContain("AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS=45000"); expect(getTodoExtractorUserConfig().AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS).toBe("45000"); }); + + it("round-trips the STEP-11 scope settings (sinceDays + max interactions): defaults when unset, persists when set", async () => { + const { getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv } = await freshConfig(); + + // defaults surface in the GET path so the UI shows real values, not blanks + const defaults = getTodoExtractorUserConfig(); + expect(defaults.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS).toBe("7"); + expect(defaults.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION).toBe("10"); + + // both are allowed keys: writes persist and read back + writeUserEnv({ + AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS: "14", + AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION: "5", + }); + const raw = readFileSync(getUserEnvPath(), "utf-8"); + expect(raw).toContain("AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS=14"); + expect(raw).toContain("AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION=5"); + const cfg = getTodoExtractorUserConfig(); + expect(cfg.AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS).toBe("14"); + expect(cfg.AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION).toBe("5"); + }); }); diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 87771780..4fbef346 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -889,11 +889,11 @@ describe("viewer session rendering", () => { await sandbox.loadActions({ generate: true, force: true }); await flushPromises(16); expect(posts).toHaveLength(1); - expect(posts[0].body).toMatchObject({ - maxSessions: 1, - maxObservationsPerSession: 20, - force: true, - }); + // STEP-11: the viewer no longer hard-codes maxSessions/maxObservationsPerSession; + // scope now comes from saved settings the backend reads from env config. + expect(posts[0].body).toMatchObject({ force: true }); + expect(posts[0].body).not.toHaveProperty("maxSessions"); + expect(posts[0].body).not.toHaveProperty("maxObservationsPerSession"); }); it("does not start duplicate todo extraction while one is in flight", async () => { From 3eb6eaf6ca4e7ddbbe7c7985276a96f64b09819b Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 22:03:48 +0800 Subject: [PATCH 02/10] feat(todo): replace the cleanup button with an LLM update button (STEP-12) The To-Do page's 'clean up' button becomes 'update'. Instead of curating all open cards, it now re-judges only the cards whose source session has gained new activity since they were recorded, feeding the LLM the card plus that session's recent activity (sessionDelta) and applying KEEP/DROP/DONE/REWRITE/MERGE. - updateChangedTodoCards replaces cleanTodoCardsWithLlm: selects open generated actions/reviews where the stored sourceCheckpoint != the session's current checkpointKey (sessionChangedSinceExtraction), attaches per-session delta text (most-recent interactions), and after applying advances each card's checkpoint and clears the todo-recheck markers so it isn't reprocessed until the session changes again. createdAt is preserved (in-place rewrite, only updatedAt bumps). - Reuses applyActionCleanup/applyReviewCleanup audit bag + dry-run/apply two-phase UX. LLM-only: on sidecar failure it leaves every card untouched (no rule fallback) and reports why. - Sidecar todo-cleanup-llm.py -> todo-update-llm.py with an update-oriented, conservative prompt that judges against sessionDelta. - Endpoint /agentmemory/todo/cleanup -> /agentmemory/todo/update; mem::todo-cleanup -> mem::todo-update; viewer button + i18n flipped to update wording. Endpoint is renamed not added, so the REST count stays 141 and MCP stays 55. Decisions (meeting req 2): rewrite + adjudicate in one button; scope = only cards whose source session changed. Signed-off-by: yujiezhang-ops --- package.json | 2 +- src/functions/todo-extract.ts | 174 +++++++++++++----- ...todo-cleanup-llm.py => todo-update-llm.py} | 77 ++++---- src/triggers/api.ts | 8 +- src/viewer/index.html | 52 +++--- src/viewer/server.ts | 6 +- test/todo-extract.test.ts | 63 ++++++- 7 files changed, 251 insertions(+), 131 deletions(-) rename src/functions/{todo-cleanup-llm.py => todo-update-llm.py} (66%) diff --git a/package.json b/package.json index 76285af3..cbff1701 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "agentmemory-lab": "dist/cli.mjs" }, "scripts": { - "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer/agent-avatars dist/viewer/demo dist/functions && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/ && cp src/viewer/agent-avatars/* dist/viewer/agent-avatars/ && cp src/viewer/demo/* dist/viewer/demo/ && cp src/functions/todo-extract-langextract.py dist/functions/ && cp src/functions/todo-cleanup-llm.py dist/functions/", + "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer/agent-avatars dist/viewer/demo dist/functions && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/ && cp src/viewer/agent-avatars/* dist/viewer/agent-avatars/ && cp src/viewer/demo/* dist/viewer/demo/ && cp src/functions/todo-extract-langextract.py dist/functions/ && cp src/functions/todo-update-llm.py dist/functions/", "dev": "tsx src/index.ts", "start": "node dist/cli.mjs", "start:local-memory": "AGENTMEMORY_III_CONFIG=./iii-config.local-memory.yaml node dist/cli.mjs", diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index 4fd18a1b..f2be1e0d 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -67,7 +67,7 @@ type TodoExtractOptions = { 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"; -const CLEANUP_SIDE_CAR = "todo-cleanup-llm.py"; +const CLEANUP_SIDE_CAR = "todo-update-llm.py"; const MAX_LLM_OBSERVATIONS_PER_SESSION = 40; const SIDE_CAR_ENV_KEYS = [ "LANGEXTRACT_API_KEY", @@ -770,7 +770,7 @@ type LlmCleanupItem = { newDescription?: string; mergeIntoId?: string; }; -type CleanupCard = { id: string; title: string; description: string; status: string; evidence?: string }; +type CleanupCard = { id: string; title: string; description: string; status: string; evidence?: string; sessionDelta?: string }; const DEFAULT_CLEANUP_MAX_CARDS = 60; @@ -871,6 +871,46 @@ function applyReviewCleanup(item: ReviewQueueItem, d: LlmCleanupItem, now: strin return { ...item, status: "dismissed", updatedAt: now, payload: { ...(item.payload || {}), cleanup } }; } +// STEP-12: after an update pass, advance the card's stored source checkpoint to +// the session's current fingerprint and clear the recheck markers, so a card is +// not re-selected until its session changes again. +function advanceActionCheckpoint(action: Action, checkpoint: string, now: string): Action { + const extraction = (action.metadata?.todoExtraction || {}) as Record; + const tags = Array.isArray(action.tags) ? action.tags.filter((tag) => tag !== "todo-recheck") : action.tags; + return { + ...action, + updatedAt: now, + tags, + metadata: { + ...(action.metadata || {}), + todoExtraction: { ...extraction, sourceCheckpoint: checkpoint, needsRecheck: false }, + }, + }; +} + +function advanceReviewCheckpoint(item: ReviewQueueItem, checkpoint: string, now: string): ReviewQueueItem { + const extraction = (item.payload?.todoExtraction || {}) as Record; + return { + ...item, + updatedAt: now, + payload: { + ...(item.payload || {}), + todoExtraction: { ...extraction, sourceCheckpoint: checkpoint, needsRecheck: false }, + }, + }; +} + +// The session's most-recent activity (last ~2 interaction records), as text, so +// the updater LLM can re-judge a card against what happened after it was first +// recorded. Capped so the prompt stays bounded. +async function buildSessionDelta(kv: Pick, sessionId: string): Promise { + const observations = (await kv.list(KV.observations(sessionId)).catch(() => [])) + .sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); + const recent = takeRecentInteractions(observations, 2); + const text = recent.map((obs) => textForObservation(obs)).filter(Boolean).join("\n").trim(); + return text.length > 2000 ? text.slice(text.length - 2000) : text; +} + export type LlmCleanupResult = { engine: "llm" | "rules"; scanned: number; @@ -883,28 +923,54 @@ export type LlmCleanupResult = { fallbackReason?: string; }; -export async function cleanTodoCardsWithLlm( +export async function updateChangedTodoCards( kv: Pick, opts: { mode?: CleanupMode; maxCards?: number; decide?: (cards: CleanupCard[]) => Promise } = {}, ): Promise { const mode = opts.mode ?? "apply"; const maxCards = opts.maxCards ?? DEFAULT_CLEANUP_MAX_CARDS; const decide = opts.decide ?? runCleanupSidecar; - const [actions, reviews] = await Promise.all([ + const [actions, reviews, sessions] = await Promise.all([ kv.list(KV.actions).catch(() => []), kv.list(KV.reviewQueue).catch(() => []), + kv.list(KV.sessions).catch(() => []), ]); - const openActions = actions.filter( - (a) => actionLooksGenerated(a) && a.status !== "done" && a.status !== "cancelled", - ); - const openReviews = reviews.filter((r) => reviewLooksGenerated(r) && r.status === "pending"); - const actionById = new Map(openActions.map((a) => [`a:${a.id}`, a] as [string, Action])); - const reviewById = new Map(openReviews.map((r) => [`r:${r.id}`, r] as [string, ReviewQueueItem])); - - const cards: CleanupCard[] = [ - ...openActions.map((a) => ({ id: `a:${a.id}`, title: a.title, description: a.description, status: a.status, evidence: cardEvidence(a.metadata) })), - ...openReviews.map((r) => ({ id: `r:${r.id}`, title: r.title, description: r.content, status: r.status, evidence: cardEvidence(r.payload) })), + const sessionsById = new Map(sessions.map((s) => [s.id, s] as [string, Session])); + // Only open, generated cards whose source-session fingerprint moved on since + // the card was recorded (sessionChangedSinceExtraction) — the "the session + // updated, so update its cards" scope. + const changedActions = actions.filter((a) => { + if (!actionLooksGenerated(a) || a.status === "done" || a.status === "cancelled") return false; + const ex = a.metadata?.todoExtraction as (ExtractedTodo & { sourceCheckpoint?: string }) | undefined; + const session = ex?.sourceSessionId ? sessionsById.get(ex.sourceSessionId) : undefined; + return !!ex && !!session && sessionChangedSinceExtraction(ex, session); + }); + const changedReviews = reviews.filter((r) => { + if (!reviewLooksGenerated(r) || r.status !== "pending") return false; + const ex = r.payload?.todoExtraction as (ExtractedTodo & { sourceCheckpoint?: string }) | undefined; + const session = ex?.sourceSessionId ? sessionsById.get(ex.sourceSessionId) : undefined; + return !!ex && !!session && sessionChangedSinceExtraction(ex, session); + }); + const actionById = new Map(changedActions.map((a) => [`a:${a.id}`, a] as [string, Action])); + const reviewById = new Map(changedReviews.map((r) => [`r:${r.id}`, r] as [string, ReviewQueueItem])); + + // Build the LLM card batch (capped), attaching each card's session delta — the + // session's most-recent activity — so the model can re-judge it against what + // happened after it was first recorded. Delta is built once per session. + const deltaCache = new Map(); + const sessionDeltaFor = async (sessionId: string | undefined): Promise => { + if (!sessionId) return ""; + if (!deltaCache.has(sessionId)) deltaCache.set(sessionId, await buildSessionDelta(kv, sessionId)); + return deltaCache.get(sessionId) || ""; + }; + const entries = [ + ...changedActions.map((a) => ({ id: `a:${a.id}`, sessionId: (a.metadata?.todoExtraction as ExtractedTodo | undefined)?.sourceSessionId, title: a.title, description: a.description, status: a.status, evidence: cardEvidence(a.metadata) })), + ...changedReviews.map((r) => ({ id: `r:${r.id}`, sessionId: (r.payload?.todoExtraction as ExtractedTodo | undefined)?.sourceSessionId, title: r.title, description: r.content, status: r.status, evidence: cardEvidence(r.payload) })), ].slice(0, maxCards); + const cards: CleanupCard[] = []; + for (const entry of entries) { + cards.push({ id: entry.id, title: entry.title, description: entry.description, status: entry.status, evidence: entry.evidence, sessionDelta: await sessionDeltaFor(entry.sessionId) }); + } if (cards.length === 0) { return { engine: "llm", scanned: 0, kept: 0, dropped: 0, completed: 0, rewritten: 0, merged: 0, preview: [] }; @@ -914,60 +980,66 @@ export async function cleanTodoCardsWithLlm( try { decisions = await decide(cards); } catch (err) { - // LLM unavailable → rule-based cleanup as a safety net. - const rules = await cleanPollutedTodoCards(kv, mode); - const dropped = rules.cleanedActions + rules.cleanedReviews; - const completed = rules.completedActions + rules.completedReviews; + // Update is an LLM-only operation (it re-judges cards against new session + // content); there is no rule equivalent, so on failure leave every card + // untouched and report why. return { engine: "rules", scanned: cards.length, - kept: Math.max(0, cards.length - dropped - completed), - dropped, - completed, + kept: cards.length, + dropped: 0, + completed: 0, rewritten: 0, merged: 0, - preview: [...rules.preview.actions, ...rules.preview.reviews].map((p) => ({ - id: p.id, - title: p.title, - decision: (p.decision === "done" ? "DONE" : "DROP") as LlmCleanupDecision, - reason: "rule-based fallback", - })), + preview: [], fallbackReason: err instanceof Error ? err.message : String(err), }; } const now = new Date().toISOString(); + const decisionById = new Map(decisions.filter((d) => d && d.id).map((d) => [d.id, d] as [string, LlmCleanupItem])); const writes: Array> = []; - let dropped = 0, completed = 0, rewritten = 0, merged = 0; + let kept = 0, dropped = 0, completed = 0, rewritten = 0, merged = 0; const preview: LlmCleanupResult["preview"] = []; - for (const d of decisions) { - if (!d || d.decision === "KEEP") continue; - const action = actionById.get(d.id); - const review = reviewById.get(d.id); - if (!action && !review) continue; - if (d.decision === "DROP") dropped++; - else if (d.decision === "DONE") completed++; - else if (d.decision === "REWRITE") rewritten++; - else if (d.decision === "MERGE") merged++; - preview.push({ - id: d.id, - title: action?.title ?? review?.title ?? "", - decision: d.decision, - reason: d.reason, - newTitle: d.newTitle, - mergeIntoId: d.mergeIntoId, - }); + // Process every changed card in the batch. A KEEP (or a card the LLM returned + // no decision for) keeps its text but still has its checkpoint advanced — it + // has now been re-judged against the latest session, so it should not reappear + // until the session changes again. + for (const card of cards) { + const action = actionById.get(card.id); + const review = reviewById.get(card.id); + const target = action ?? review; + if (!target) continue; + const ex = (action ? action.metadata?.todoExtraction : review!.payload?.todoExtraction) as ExtractedTodo | undefined; + const session = ex?.sourceSessionId ? sessionsById.get(ex.sourceSessionId) : undefined; + const checkpoint = session ? checkpointKey(session) : undefined; + const d = decisionById.get(card.id); + const decision = d?.decision ?? "KEEP"; + if (decision === "KEEP") kept++; + else if (decision === "DROP") dropped++; + else if (decision === "DONE") completed++; + else if (decision === "REWRITE") rewritten++; + else if (decision === "MERGE") merged++; + if (decision !== "KEEP") { + preview.push({ id: card.id, title: target.title, decision, reason: d?.reason, newTitle: d?.newTitle, mergeIntoId: d?.mergeIntoId }); + } if (mode !== "apply") continue; - if (action) writes.push(kv.set(KV.actions, action.id, applyActionCleanup(action, d, now))); - else if (review) writes.push(kv.set(KV.reviewQueue, review.id, applyReviewCleanup(review, d, now))); + if (action) { + let next = decision === "KEEP" ? action : applyActionCleanup(action, d!, now); + if (checkpoint) next = advanceActionCheckpoint(next, checkpoint, now); + writes.push(kv.set(KV.actions, action.id, next)); + } else if (review) { + let next = decision === "KEEP" ? review : applyReviewCleanup(review, d!, now); + if (checkpoint) next = advanceReviewCheckpoint(next, checkpoint, now); + writes.push(kv.set(KV.reviewQueue, review.id, next)); + } } await Promise.all(writes); - const touched = dropped + completed + rewritten + merged; return { engine: "llm", scanned: cards.length, - kept: Math.max(0, cards.length - touched), + kept, dropped, completed, rewritten, @@ -1197,7 +1269,7 @@ export function registerTodoExtractFunctions(sdk: ISdk, kv: StateKV): void { sdk.registerFunction("mem::todo-extract-generate", async (data: TodoExtractOptions = {}) => generateTodosFromSessions(kv, data), ); - sdk.registerFunction("mem::todo-cleanup", async (data: { mode?: CleanupMode; maxCards?: number } = {}) => - cleanTodoCardsWithLlm(kv, data), + sdk.registerFunction("mem::todo-update", async (data: { mode?: CleanupMode; maxCards?: number } = {}) => + updateChangedTodoCards(kv, data), ); } diff --git a/src/functions/todo-cleanup-llm.py b/src/functions/todo-update-llm.py similarity index 66% rename from src/functions/todo-cleanup-llm.py rename to src/functions/todo-update-llm.py index 83b4856c..cd506954 100644 --- a/src/functions/todo-cleanup-llm.py +++ b/src/functions/todo-update-llm.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 -"""LLM curation sidecar for AI Todo card cleanup (PLAN-002 STEP-10). +"""LLM update sidecar for AI Todo cards (PLAN-002 STEP-12). Reads {"cards": [...]} from stdin and writes {"decisions": [...]} to stdout. -Unlike todo-extract-langextract.py (which extracts NEW todos from session text), -this judges EXISTING cards: KEEP / DROP / DONE / REWRITE / MERGE. Uses a direct -OpenAI-compatible chat completion (stdlib urllib, no extra deps), reusing the -same LANGEXTRACT_* config. On any failure (no key, network, bad JSON) it exits -non-zero so TypeScript falls back to rule-based cleanup. +Each card's source session has gained new activity since the card was recorded +(`sessionDelta` carries that recent activity). This re-judges the card in light +of it: KEEP / DROP / DONE / REWRITE / MERGE. Uses a direct OpenAI-compatible +chat completion (stdlib urllib, no extra deps), reusing the same LANGEXTRACT_* +config. On any failure (no key, network, bad JSON) it exits non-zero so the +TypeScript caller leaves every card untouched and reports why. """ from __future__ import annotations @@ -23,33 +24,36 @@ VALID_DECISIONS = {"KEEP", "DROP", "DONE", "REWRITE", "MERGE"} SYSTEM_PROMPT = """\ -You are a strict, conservative curator of a developer's personal Todo list. Each -card was auto-extracted from AI coding-agent sessions, so the list is noisy: -tool-call echoes, command output, status narration ("running tests…"), git-ref -fragments, completed work, vague fragments, and duplicates. Your job is to make -the list trustworthy — every surviving card must be a REAL, SPECIFIC, STILL-OPEN +You are a strict, conservative maintainer of a developer's personal Todo list. +Each card was auto-extracted earlier from an AI coding-agent session, and that +session has SINCE gained new activity, provided as `sessionDelta`. Your job is to +UPDATE each card in light of what happened after it was recorded, keeping the +list trustworthy — every surviving card must be a REAL, SPECIFIC, STILL-OPEN action. For EACH input card, output exactly one decision: -- KEEP — genuine, actionable, still-open; clear enough as-is. -- DROP — not a real todo: tool/command output, a status report, a git - ref/hash fragment, meta-commentary, or too vague to act on. -- DONE — a real task the card's own text/evidence shows is already completed. -- REWRITE — a real, still-open todo whose title/description is unclear, noisy, - or truncated. Provide a corrected concise `newTitle` (<= 80 chars) - and one-line `newDescription`, PRESERVING the original meaning. - Never invent scope, steps, or details not in the original. +- KEEP — still a genuine, still-open action and the new activity does not + change it; clear enough as-is. +- DROP — the new activity (or the card itself) shows it is not a real todo: + tool/command output, a status report, a git ref/hash fragment, + meta-commentary, or too vague to act on. +- DONE — the new session activity shows this task is now completed. +- REWRITE — still a real, still-open todo, but the new activity means its + title/description should be updated (clearer, or reflecting the + latest state). Provide a corrected concise `newTitle` (<= 80 chars) + and one-line `newDescription`. Stay faithful to the card + evidence + + sessionDelta; never invent scope or steps not supported by them. - MERGE — a duplicate of another card in this same batch; give `mergeIntoId` of the canonical (clearest) card to merge into. Rules: -1. BE CONSERVATIVE. When unsure whether something is a real todo, choose KEEP. - Only DROP or DONE when you are confident from the card's own - title/description/evidence. It is far worse to delete a real todo than to - keep a borderline one. -2. Ground every decision ONLY in the card's own text/evidence. No outside - knowledge, no assumed facts. -3. For REWRITE, only clarify wording. Do not add scope, steps, or detail. +1. BE CONSERVATIVE. When unsure, choose KEEP. Only DROP or DONE when the card's + own text/evidence or the sessionDelta makes you confident. It is far worse to + wrongly drop or close a real todo than to keep a borderline one. +2. Ground every decision ONLY in the card's own text/evidence and its + sessionDelta. No outside knowledge, no assumed facts. +3. For REWRITE, only clarify or reflect the new state; do not add unsupported + 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. @@ -60,13 +64,11 @@ REWRITE>","mergeIntoId":""}]} Examples of decisions: -- "⏺ Bash(npm test)" / "Viewer: http://localhost:3114" / "abc1234 (HEAD)" -> DROP -- "running the test suite now" -> DROP (status report) -- "看一下那个东西" (too vague) -> DROP -- "Fix the N+1 query in the dashboard loader" -> KEEP -- title truncated mid-sentence but clearly a real task -> REWRITE -- two cards describing the same fix -> MERGE (one) + KEEP (canonical) -- evidence shows the change was committed/merged/passed -> DONE +- sessionDelta shows the change was committed/merged/tests passed -> DONE +- "⏺ Bash(npm test)" / "Viewer: http://localhost:3114" / "abc1234 (HEAD)" -> DROP +- a real task whose wording is now stale given the new activity -> REWRITE +- two cards describing the same fix -> MERGE (one) + KEEP (canonical) +- a still-open task the new activity does not touch -> KEEP """ @@ -79,7 +81,7 @@ def call_llm(cards: list) -> list: """Direct OpenAI-compatible chat completion. Raises SystemExit on failure.""" api_key = os.environ.get("LANGEXTRACT_API_KEY", "").strip() if not api_key: - raise SystemExit("LANGEXTRACT_API_KEY is required for LLM cleanup") + raise SystemExit("LANGEXTRACT_API_KEY is required for LLM update") base_url = (os.environ.get("LANGEXTRACT_BASE_URL", DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL).rstrip("/") body = { "model": model_id(), @@ -87,8 +89,9 @@ def call_llm(cards: list) -> list: {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", - "content": "Curate these existing cards. Return strict JSON " - '{"decisions":[...]} with one entry per card.\n' + "content": "Update these existing cards given their session's new " + 'activity. Return strict JSON {"decisions":[...]} with one entry ' + "per card.\n" + json.dumps({"cards": cards}, ensure_ascii=False), }, ], @@ -156,7 +159,7 @@ def main() -> int: if __name__ == "__main__": - if os.environ.get("AGENTMEMORY_TODO_CLEANUP_SELF_TEST") == "1": + if os.environ.get("AGENTMEMORY_TODO_UPDATE_SELF_TEST") == "1": # Offline smoke: exercise normalize() without any network call. _cards = [{"id": "a", "title": "x"}, {"id": "b", "title": "y"}, {"id": "c", "title": "z"}] _raw = [ diff --git a/src/triggers/api.ts b/src/triggers/api.ts index f9a1f861..bcf5489b 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1547,7 +1547,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/todo-extract/generate", http_method: "POST" }, }); - sdk.registerFunction("api::todo-cleanup", + sdk.registerFunction("api::todo-update", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1559,14 +1559,14 @@ export function registerApiTriggers( const payload: Record = {}; if (body.mode === "dry-run" || body.mode === "apply") payload.mode = body.mode; if (maxCards !== undefined) payload.maxCards = maxCards; - const result = await sdk.trigger({ function_id: "mem::todo-cleanup", payload }); + const result = await sdk.trigger({ function_id: "mem::todo-update", payload }); return { status_code: 200, body: result }; }, ); sdk.registerTrigger({ type: "http", - function_id: "api::todo-cleanup", - config: { api_path: "/agentmemory/todo/cleanup", http_method: "POST" }, + function_id: "api::todo-update", + config: { api_path: "/agentmemory/todo/update", http_method: "POST" }, }); sdk.registerFunction("api::review-approve", diff --git a/src/viewer/index.html b/src/viewer/index.html index 8df413cb..7361e9af 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -4146,17 +4146,17 @@

AI Todo

'act.extract.discarded': 'discarded', 'act.extract.cleaned': 'cleaned', 'act.extract.reason': 'reason', - 'act.cleanup.title': 'Use LLM to curate existing cards', - 'act.cleanup.run': 'Clean up', - 'act.cleanup.running': 'Cleaning...', - 'act.cleanup.applying': 'Applying cleanup...', - 'act.cleanup.done': 'Cleaned', - 'act.cleanup.error': 'Cleanup failed', - 'act.cleanup.failed': 'Cleanup failed; cards unchanged', - 'act.cleanup.clean': 'Cards already look clean', + 'act.cleanup.title': 'Update recorded cards from sessions that changed', + 'act.cleanup.run': 'Update', + 'act.cleanup.running': 'Updating...', + 'act.cleanup.applying': 'Applying updates...', + 'act.cleanup.done': 'Updated', + 'act.cleanup.error': 'Update failed', + 'act.cleanup.failed': 'Update failed; cards unchanged', + 'act.cleanup.clean': 'All cards are up to date', 'act.cleanup.llmUnavailable': 'LLM unavailable — no changes', - 'act.cleanup.confirm': 'Apply these cleanup changes?', - 'act.cleanup.summary': 'drop {dropped} · done {completed} · rewrite {rewritten} · merge {merged}', + 'act.cleanup.confirm': 'Apply these updates?', + 'act.cleanup.summary': 'update {rewritten} · done {completed} · drop {dropped} · merge {merged}', 'act.status.complete': 'Complete', 'act.status.archive': 'Archive', 'act.status.delete': 'Delete', @@ -4348,17 +4348,17 @@

AI Todo

'act.extract.discarded': '丢弃', 'act.extract.cleaned': '清理', 'act.extract.reason': '原因', - 'act.cleanup.title': '用大模型清洗现有卡片', - 'act.cleanup.run': '清洗', - 'act.cleanup.running': '清洗中...', - 'act.cleanup.applying': '正在应用清洗...', - 'act.cleanup.done': '已清洗', - 'act.cleanup.error': '清洗失败', - 'act.cleanup.failed': '清洗失败,卡片未改动', - 'act.cleanup.clean': '卡片已经很干净', + 'act.cleanup.title': '用大模型更新已记录的卡片(来源会话有新进展时)', + 'act.cleanup.run': '更新', + 'act.cleanup.running': '更新中...', + 'act.cleanup.applying': '正在应用更新...', + 'act.cleanup.done': '已更新', + 'act.cleanup.error': '更新失败', + 'act.cleanup.failed': '更新失败,卡片未改动', + 'act.cleanup.clean': '卡片已是最新', 'act.cleanup.llmUnavailable': '大模型不可用 — 未改动', - 'act.cleanup.confirm': '应用这些清洗改动?', - 'act.cleanup.summary': '丢弃 {dropped} · 完成 {completed} · 改写 {rewritten} · 合并 {merged}', + 'act.cleanup.confirm': '应用这些更新?', + 'act.cleanup.summary': '更新 {rewritten} · 完成 {completed} · 丢弃 {dropped} · 合并 {merged}', 'act.status.complete': '完成', 'act.status.archive': '归档', 'act.status.delete': '删除', @@ -8970,13 +8970,13 @@

AI Todo

// LLM card cleanup (STEP-10): curate EXISTING cards. Always dry-run first so // the user confirms before anything is soft-deleted; apply only on confirm. - function startTodoCleanup() { + function startTodoUpdate() { if (state.actions.cleanupInFlight || state.actions.extractInFlight) return; state.actions.cleanupInFlight = true; state.actions.cleanupStatus = 'running'; state.actions.cleanupMessage = t('act.cleanup.running'); if (state.activeTab === 'actions') renderActions(); - apiPost('todo/cleanup', { mode: 'dry-run' }).then(function(preview) { + apiPost('todo/update', { mode: 'dry-run' }).then(function(preview) { state.actions.cleanupInFlight = false; if (!preview || typeof preview.scanned !== 'number') { state.actions.cleanupStatus = 'error'; @@ -9001,7 +9001,7 @@

AI Todo

state.actions.cleanupStatus = 'running'; state.actions.cleanupMessage = t('act.cleanup.applying'); if (state.activeTab === 'actions') renderActions(); - apiPost('todo/cleanup', { mode: 'apply' }).then(function(result) { + apiPost('todo/update', { mode: 'apply' }).then(function(result) { state.actions.cleanupStatus = 'done'; state.actions.cleanupMessage = todoCleanupSummary(result || preview); return refreshActionListsAfterExtract(); @@ -9683,7 +9683,7 @@

AI Todo

if (state.actions.cleanupInFlight) cleanupLabel = t('act.cleanup.running'); else if (state.actions.cleanupStatus === 'done') cleanupLabel = t('act.cleanup.done'); else if (state.actions.cleanupStatus === 'error') cleanupLabel = t('act.cleanup.error'); - html += ''; + html += ''; html += ''; html += ''; return html; @@ -10527,8 +10527,8 @@

AI Todo

startTodoExtraction(true); return; } - if (action === 'cleanup-cards') { - startTodoCleanup(); + if (action === 'update-cards') { + startTodoUpdate(); return; } if (action === 'toggle-settings') { diff --git a/src/viewer/server.ts b/src/viewer/server.ts index c4ef5519..241d3599 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -13,7 +13,7 @@ import { renderViewerDocument } from "./document.js"; import type { Action, CompressedObservation, Memory, ReviewQueueItem, Session } from "../types.js"; import { KV, fingerprintId } from "../state/schema.js"; import { buildTurnActionDrafts } from "../functions/action-candidates.js"; -import { generateTodosFromSessions, cleanTodoCardsWithLlm } from "../functions/todo-extract.js"; +import { generateTodosFromSessions, 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 @@ -1420,13 +1420,13 @@ export function startViewerServer( return; } - if (method === "POST" && pathname === "/agentmemory/todo/cleanup") { + if (method === "POST" && pathname === "/agentmemory/todo/update") { const raw = await readBody(req); const body = raw ? JSON.parse(raw) as Record : {}; const mode = body.mode === "apply" ? "apply" : "dry-run"; const maxCards = typeof body.maxCards === "number" && body.maxCards > 0 ? Math.floor(body.maxCards) : undefined; - const result = await cleanTodoCardsWithLlm(kv as ViewerKv, { mode, maxCards }); + const result = await updateChangedTodoCards(kv as ViewerKv, { mode, maxCards }); json(res, 200, result, req); return; } diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index e5bddf3e..da336b11 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -20,7 +20,7 @@ vi.mock("../src/config.js", () => ({ normalizeTodoExtractorProvider: (value?: string) => (value || "openai").toLowerCase(), })); -import { cleanPollutedTodoCards, cleanTodoCardsWithLlm, cleanTodoTitle, generateTodosFromSessions, validateTodoEvidence, runLangExtractSidecar, type ExtractedTodo } from "../src/functions/todo-extract.js"; +import { cleanPollutedTodoCards, updateChangedTodoCards, cleanTodoTitle, generateTodosFromSessions, 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"; @@ -529,12 +529,14 @@ describe("todo extraction", () => { expect((await kv.list(KV.actions)).find((a) => a.id === "act_noise")).toMatchObject({ status: "pending" }); }); - it("LLM cleanup applies KEEP/DROP/DONE/REWRITE/MERGE with audit (STEP-10)", async () => { + it("update applies KEEP/DROP/DONE/REWRITE/MERGE on changed-session cards with audit (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 9 })); const mkAction = (id: string, title: string) => kv.set(KV.actions, id, { id, title, description: title + " — details", 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: [], + createdBy: "todo-extract", tags: ["todo-extracted", "todo-recheck"], sourceObservationIds: [], sourceMemoryIds: [], + metadata: { todoExtraction: { sourceSessionId: "ses_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, }); await mkAction("a_keep", "Fix the login bug"); await mkAction("a_drop", "npm test output"); @@ -548,7 +550,7 @@ describe("todo extraction", () => { { id: "a:a_rw", decision: "REWRITE" as const, newTitle: "Fix the dashboard N+1", newDescription: "fix the slow query" }, { id: "a:a_merge", decision: "MERGE" as const, mergeIntoId: "a:a_rw" }, ]; - const result = await cleanTodoCardsWithLlm(kv as never, { mode: "apply", decide }); + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); expect(result).toMatchObject({ engine: "llm", scanned: 5, kept: 1, dropped: 1, completed: 1, rewritten: 1, merged: 1 }); const actions = await kv.list(KV.actions); const byId = (id: string) => actions.find((a) => a.id === id); @@ -557,32 +559,75 @@ describe("todo extraction", () => { expect(byId("a_done")).toMatchObject({ status: "done", metadata: { cleanup: expect.objectContaining({ decision: "done" }) } }); expect(byId("a_rw")).toMatchObject({ title: "Fix the dashboard N+1", status: "pending", metadata: { cleanup: expect.objectContaining({ decision: "rewrite", previousTitle: "fix" }) } }); expect(byId("a_merge")).toMatchObject({ status: "cancelled", metadata: { cleanup: expect.objectContaining({ decision: "merge", mergeIntoId: "a:a_rw" }) } }); + // every processed card advances its checkpoint and drops the recheck tag, so + // it won't be reprocessed until its session changes again. + expect(byId("a_keep")?.metadata?.todoExtraction).toMatchObject({ sourceCheckpoint: "2026-06-18T09:00:00.000Z:9", needsRecheck: false }); + expect(byId("a_keep")?.tags).not.toContain("todo-recheck"); }); - it("LLM cleanup dry-run previews without mutating (STEP-10)", async () => { + it("update only touches cards whose source session changed (STEP-12)", async () => { + // unchanged: stored checkpoint == current session fingerprint + 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_same", { + id: "a_same", title: "Fix the login bug", description: "x", 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 called = false; + const decide = async () => { called = true; return []; }; + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); + expect(result.scanned).toBe(0); + expect(called).toBe(false); // decide never runs when nothing changed + expect((await kv.list(KV.actions)).find((a) => a.id === "a_same")).toMatchObject({ status: "pending" }); + }); + + it("update passes the session delta to the LLM (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + await kv.set(KV.observations("ses_x"), "o1", obs({ id: "o1", sessionId: "ses_x", narrative: "刚刚已经修复并合并了登录超时。" })); + await kv.set(KV.actions, "a1", { + id: "a1", title: "Fix login timeout", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + }); + let captured: Array<{ sessionDelta?: string }> = []; + const decide = async (cards: Array<{ sessionDelta?: string }>) => { captured = cards; return [{ id: "a:a1", decision: "DONE" as const }]; }; + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); + expect(result.completed).toBe(1); + expect(captured[0]?.sessionDelta).toContain("登录超时"); + expect((await kv.list(KV.actions)).find((a) => a.id === "a1")).toMatchObject({ status: "done" }); + }); + + it("update dry-run previews without mutating (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); await kv.set(KV.actions, "a_drop", { id: "a_drop", title: "npm test output", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, }); const decide = async () => [{ id: "a:a_drop", decision: "DROP" as const, reason: "noise" }]; - const result = await cleanTodoCardsWithLlm(kv as never, { mode: "dry-run", decide }); + const result = await updateChangedTodoCards(kv as never, { mode: "dry-run", decide }); expect(result).toMatchObject({ engine: "llm", dropped: 1 }); expect(result.preview).toHaveLength(1); expect((await kv.list(KV.actions)).find((a) => a.id === "a_drop")).toMatchObject({ status: "pending" }); }); - it("LLM cleanup falls back to rule-based cleanup when the LLM fails (STEP-10)", async () => { + it("update leaves cards untouched when the LLM fails — no rule fallback (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); await kv.set(KV.actions, "a_bad", { id: "a_bad", title: "gh pr list --json number", description: "{\"cmd\":\"gh pr list\"}", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, }); const decide = async (): Promise => { throw new Error("LLM down"); }; - const result = await cleanTodoCardsWithLlm(kv as never, { mode: "apply", decide }); + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); expect(result.engine).toBe("rules"); expect(result.fallbackReason).toContain("LLM down"); - expect((await kv.list(KV.actions)).find((a) => a.id === "a_bad")).toMatchObject({ status: "cancelled" }); + // update has no rule equivalent, so the card stays exactly as it was + expect((await kv.list(KV.actions)).find((a) => a.id === "a_bad")).toMatchObject({ status: "pending" }); }); it("filters agent progress observations before rules extraction", async () => { From 43b8a10c7ebc84856a8831121fbe28d44a28ab94 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 22:09:49 +0800 Subject: [PATCH 03/10] feat(viewer): simplify the To-Do filter UI to one status row (STEP-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The To-Do view had two parallel, equivalent status filters sharing the same state.actions.statusFilter: a text chip row (pending/review/active/done/all) and the metric overview cards (waiting/review/followUp/active/done). The duplication is the 'too many similar category buttons' confusion from the meeting. - Drop the chip row; keep the metric cards (they carry counts) as the single status filter. Clicking the already-active card now clears the filter, which restores the 'show all' the chip row used to provide. - Collapse the card-level Archive + Delete buttons (both wrote status=cancelled) into a single Archive — cancelled is a soft, recoverable state, not a hard delete. Pure presentation-layer change: no storage enum, REST, MCP, or version changes. (Decision, meeting req 3: merge the two rows into one.) Signed-off-by: yujiezhang-ops --- src/viewer/index.html | 27 ++++++++++++--------------- test/viewer-session-id.test.ts | 3 ++- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index 7361e9af..42fb8bd7 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -9657,21 +9657,13 @@

AI Todo

return html; } function renderActionFilters() { - var chips = [ - { value: 'pending', label: t('status.pending') }, - { value: 'review', label: t('filter.review') }, - { value: 'active', label: t('status.active') }, - { value: 'done', label: t('status.done') }, - { value: '', label: t('filter.all') } - ]; + // 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 += '
'; - chips.forEach(function(chip) { - var active = (state.actions.statusFilter || '') === chip.value; - html += ''; - }); - html += '
'; html += '' + metricReviewItems.length + ' ' + t('act.nToConfirm') + ' · ' + metricItems.length + ' ' + t('act.nConfirmed') + ''; var extractTitle = state.actions.extractMessage || t('act.extract.title'); var extractLabel = t('act.extract.run'); @@ -9732,8 +9724,10 @@

AI Todo

html += '
'; html += '
'; if (a.status !== 'done') html += ''; + // STEP-13: "Archive" and "Delete" both wrote status=cancelled — two + // buttons for one outcome. Keep a single Archive (cancelled is a soft, + // recoverable state, not a hard delete). if (a.status !== 'cancelled') html += ''; - if (a.status !== 'cancelled') html += ''; html += statusIconMarkup(a.status, statusLabel(a.status)); html += '
'; html += ''; @@ -10698,7 +10692,10 @@

AI Todo

return; } if (action === 'filter-actions-status') { - state.actions.statusFilter = target.getAttribute('data-status') || ''; + var nextFilter = target.getAttribute('data-status') || ''; + // 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; renderActions(); return; } diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 4fbef346..03d885d4 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -1020,7 +1020,8 @@ 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("Delete"); + // STEP-13: the duplicate "Delete" button (also status=cancelled) was removed. + expect(getElement("view-actions").innerHTML).not.toContain("Delete"); const target = Object.create(sandbox.Element.prototype); target.getAttribute = (name: string) => { From 24d3b0f87e630059af74c03fafd1b6a4688b2d1c Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 22:34:23 +0800 Subject: [PATCH 04/10] fix(todo): address adversarial review of the 3-STEP diff Confirmed findings from the review pass: - (major) Bound a single 'organize' click: removing the hard-coded maxSessions:1 let the day-window default to 20 serial LLM sidecar calls. Add DEFAULT_TODO_EXTRACT_MAX_SESSIONS=8 as the interactive safety cap (REST can still override). - (major) All-KEEP updates never converged: when every changed card is kept, the viewer's dry-run early-return skipped apply, so checkpoints never advanced and the same cards were re-sent to the LLM on every click. The viewer now silently applies (advancing checkpoints, no content change) when scanned>0 && touched==0. - (correctness) makeReview now stores the source checkpoint like makeAction, so the review branch of updateChangedTodoCards is actually reachable (was dead). - (minor) updateChangedTodoCards now validates MERGE targets TS-side: a MERGE whose target isn't in the batch (or is self) downgrades to KEEP instead of orphaning the source. - (minor) The 'follow-up' metric counts pending+blocked; its filter now shows blocked too (count and results agreed before only for pending). - (nit) LLM-failure result returns engine:'llm'+fallbackReason (not the 'rules' misnomer); the viewer detects unavailability via fallbackReason. - (nit) Remove STEP-13 dead code (.actions-filter-chip CSS, act.filterAria i18n) and refresh stale STEP-10/cleanup comments to STEP-12/update. Build + full suite green (1503 passed, +2 new tests for the review/MERGE fixes). Signed-off-by: yujiezhang-ops --- src/config.ts | 5 +++ src/functions/todo-extract.ts | 35 ++++++++++++----- src/viewer/index.html | 72 ++++++++++++++++++----------------- test/todo-extract.test.ts | 34 ++++++++++++++++- 4 files changed, 101 insertions(+), 45 deletions(-) diff --git a/src/config.ts b/src/config.ts index 30254ceb..6ecd77b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,11 @@ export const DEFAULT_TODO_EXTRACT_TIMEOUT_MS = 120_000; // 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; +// 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", diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index f2be1e0d..c7215a94 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -11,6 +11,7 @@ import { DEFAULT_TODO_EXTRACT_TIMEOUT_MS, DEFAULT_TODO_EXTRACT_SINCE_DAYS, DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS, + DEFAULT_TODO_EXTRACT_MAX_SESSIONS, getEnvVar, normalizeTodoExtractorModel, normalizeTodoExtractorProvider, @@ -600,7 +601,10 @@ function makeReview(todo: ExtractedTodo, session: Session, now: string): ReviewQ confidence: todo.confidence, sourceObservationIds: [todo.evidence.sourceObservationId], }, - todoExtraction: todo, + // STEP-12: store the session fingerprint like makeAction does, so a review + // 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), }, }; } @@ -755,11 +759,13 @@ export async function cleanPollutedTodoCards( }; } -// ── LLM card cleanup (STEP-10) ────────────────────────────────────────────── -// Curate EXISTING cards (vs. extracting new ones): an LLM judges each open -// generated card KEEP/DROP/DONE/REWRITE/MERGE. Reuses the soft-delete + cleanup -// audit shape of cleanPollutedTodoCards; falls back to the rule-based cleaner -// when the LLM is unavailable. +// ── LLM card update (STEP-12, evolved from the STEP-10 cleanup) ───────────── +// Re-judges EXISTING cards whose source session changed (vs. extracting new +// ones): an LLM decides KEEP/DROP/DONE/REWRITE/MERGE against the card plus its +// session's recent activity. Reuses the soft-delete + audit shape below. Update +// is LLM-only — on failure it leaves cards untouched (no rule fallback). The +// shared helpers/types keep the "Cleanup" name for continuity with the +// persisted metadata.cleanup audit bag. type LlmCleanupDecision = "KEEP" | "DROP" | "DONE" | "REWRITE" | "MERGE"; type LlmCleanupItem = { @@ -982,9 +988,10 @@ export async function updateChangedTodoCards( } catch (err) { // Update is an LLM-only operation (it re-judges cards against new session // content); there is no rule equivalent, so on failure leave every card - // untouched and report why. + // untouched and report why via fallbackReason. Callers detect "LLM + // unavailable" by fallbackReason, not by a misnomer engine value. return { - engine: "rules", + engine: "llm", scanned: cards.length, kept: cards.length, dropped: 0, @@ -998,6 +1005,7 @@ export async function updateChangedTodoCards( const now = new Date().toISOString(); const decisionById = new Map(decisions.filter((d) => d && d.id).map((d) => [d.id, d] as [string, LlmCleanupItem])); + const batchIds = new Set(cards.map((c) => c.id)); const writes: Array> = []; let kept = 0, dropped = 0, completed = 0, rewritten = 0, merged = 0; const preview: LlmCleanupResult["preview"] = []; @@ -1015,7 +1023,14 @@ export async function updateChangedTodoCards( const session = ex?.sourceSessionId ? sessionsById.get(ex.sourceSessionId) : undefined; const checkpoint = session ? checkpointKey(session) : undefined; const d = decisionById.get(card.id); - const decision = d?.decision ?? "KEEP"; + let decision = d?.decision ?? "KEEP"; + // TS-side invariant: a MERGE whose target card isn't in this batch (or points + // at itself) would cancel the source with nothing to merge into. Downgrade it + // to KEEP so the source is preserved. (The sidecar normalizes this too, but an + // injected decide must not be able to bypass it.) + if (decision === "MERGE" && (!d?.mergeIntoId || d.mergeIntoId === card.id || !batchIds.has(d.mergeIntoId))) { + decision = "KEEP"; + } if (decision === "KEEP") kept++; else if (decision === "DROP") dropped++; else if (decision === "DONE") completed++; @@ -1108,7 +1123,7 @@ export async function generateTodosFromSessions( const scan = await scanCodexSource(kv as StateKV).catch(() => null); if (scan) sourceScan = { imported: scan.imported, skipped: scan.skipped, errors: scan.errors }; } - const maxSessions = clampPositiveInt(data.maxSessions, 20, 100); + const maxSessions = clampPositiveInt(data.maxSessions, DEFAULT_TODO_EXTRACT_MAX_SESSIONS, 100); const maxObservationsPerSession = clampPositiveInt(data.maxObservationsPerSession, 300, 1000); // STEP-11: body wins, else env, else config default. sinceDays caps at ~10y // (effectively unbounded); maxInteractions at 500 (well above any real cap). diff --git a/src/viewer/index.html b/src/viewer/index.html index 42fb8bd7..56e4b359 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -2038,27 +2038,6 @@ flex-wrap: wrap; align-items: center; } - .actions-filter-chips { - display: inline-flex; - gap: 6px; - flex-wrap: wrap; - } - .actions-filter-chip { - border: 1px solid var(--border-light); - background: #ffffff; - color: var(--ink-muted); - border-radius: 999px; - padding: 5px 10px; - font-size: 12px; - font-family: var(--font-ui); - cursor: pointer; - } - .actions-filter-chip.active { - border-color: var(--accent); - color: var(--ink); - background: var(--bg-warm); - font-weight: 650; - } .action-original-disclosure { margin-top: 7px; color: var(--ink-muted); @@ -2535,19 +2514,13 @@ border-color: var(--border-heavy); } html[data-theme="dark"] button.action-overview-card:hover, - html[data-theme="dark"] .action-overview-card.primary, - html[data-theme="dark"] .actions-filter-chip.active { + html[data-theme="dark"] .action-overview-card.primary { background: var(--bg-inset); } html[data-theme="dark"] .action-overview-card.active { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); } - html[data-theme="dark"] .actions-filter-chip { - background: var(--surface-card); - border-color: var(--border-heavy); - color: var(--ink-secondary); - } html[data-theme="dark"] .action-item-actions .btn { background: transparent; color: var(--ink); @@ -4124,7 +4097,7 @@

AI Todo

'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.searchPlaceholder': 'Search todos...', 'act.filterAria': 'Filter todos by status', + 'act.searchPlaceholder': 'Search todos...', 'act.nToConfirm': 'to confirm', 'act.nConfirmed': 'confirmed', 'act.refresh': 'Refresh', 'act.viewOriginal': 'View original', 'act.confirm': 'Confirm', 'act.ignore': 'Ignore', 'act.from': 'From', 'act.viewSource': 'View source →', 'act.updated': 'Updated', @@ -4326,7 +4299,7 @@

AI Todo

'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.searchPlaceholder': '搜索待办...', 'act.filterAria': '待办状态筛选', + 'act.searchPlaceholder': '搜索待办...', 'act.nToConfirm': '条待确认', 'act.nConfirmed': '件已确认', 'act.refresh': '刷新', 'act.viewOriginal': '查看原文', 'act.confirm': '确认', 'act.ignore': '忽略', 'act.from': '来自', 'act.viewSource': '看原文 →', 'act.updated': '更新', @@ -8968,8 +8941,10 @@

AI Todo

.replace('{merged}', r.merged || 0); } - // LLM card cleanup (STEP-10): curate EXISTING cards. Always dry-run first so - // the user confirms before anything is soft-deleted; apply only on confirm. + // LLM card update (STEP-12): re-judge cards whose source session changed. + // Always dry-run first so the user confirms before anything is dropped/closed; + // apply only on confirm (or silently when there is nothing to confirm — see + // the all-KEEP branch below). function startTodoUpdate() { if (state.actions.cleanupInFlight || state.actions.extractInFlight) return; state.actions.cleanupInFlight = true; @@ -8986,8 +8961,32 @@

AI Todo

} var touched = (preview.dropped || 0) + (preview.completed || 0) + (preview.rewritten || 0) + (preview.merged || 0); if (touched === 0) { + if (preview.fallbackReason) { + // LLM unavailable — nothing ran, nothing to advance. + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = t('act.cleanup.llmUnavailable'); + if (state.activeTab === 'actions') renderActions(); + return; + } + if ((preview.scanned || 0) > 0) { + // Changed cards existed but the LLM kept them all. Silently apply so + // their checkpoints advance and they aren't re-evaluated (and re-sent + // to the LLM) on every future click. No card content changes, so no + // confirmation is needed. + state.actions.cleanupInFlight = true; + apiPost('todo/update', { mode: 'apply' }).then(function() { + return refreshActionListsAfterExtract(); + }).catch(function() {}).then(function() { + state.actions.cleanupInFlight = false; + state.actions.cleanupStatus = 'done'; + state.actions.cleanupMessage = t('act.cleanup.clean'); + if (state.activeTab === 'actions') renderActions(); + }); + return; + } + // Nothing changed at all. state.actions.cleanupStatus = 'done'; - state.actions.cleanupMessage = preview.engine === 'rules' ? t('act.cleanup.llmUnavailable') : t('act.cleanup.clean'); + state.actions.cleanupMessage = t('act.cleanup.clean'); if (state.activeTab === 'actions') renderActions(); return; } @@ -9504,7 +9503,12 @@

AI Todo

var metricItems = items.slice(); var metricReviewItems = reviewItems.slice(); if (statusFilter && statusFilter !== 'review' && statusFilter !== 'awaiting') { - items = items.filter(function(a) { return a.status === statusFilter; }); + // 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. + items = items.filter(function(a) { + return statusFilter === 'pending' ? (a.status === 'pending' || a.status === 'blocked') : a.status === statusFilter; + }); } if (statusFilter === 'review' || statusFilter === 'awaiting') { items = []; diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index da336b11..87b2535b 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -5,6 +5,7 @@ vi.mock("../src/config.js", () => ({ DEFAULT_TODO_EXTRACT_TIMEOUT_MS: 120_000, DEFAULT_TODO_EXTRACT_SINCE_DAYS: 7, DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS: 10, + DEFAULT_TODO_EXTRACT_MAX_SESSIONS: 8, getEnvVar: (key: string) => { const values: Record = { AGENTMEMORY_TODO_EXTRACTOR: "rules", @@ -624,12 +625,43 @@ describe("todo extraction", () => { }); const decide = async (): Promise => { throw new Error("LLM down"); }; const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); - expect(result.engine).toBe("rules"); + // update is LLM-only: failure is signalled by fallbackReason, not engine. expect(result.fallbackReason).toContain("LLM down"); + expect(result.dropped).toBe(0); // update has no rule equivalent, so the card stays exactly as it was expect((await kv.list(KV.actions)).find((a) => a.id === "a_bad")).toMatchObject({ status: "pending" }); }); + it("update also re-judges changed-session review cards (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + await kv.set(KV.reviewQueue, "rev1", { + id: "rev1", createdAt: "2026-06-17T08:00:00.000Z", updatedAt: "2026-06-17T08:00:00.000Z", + status: "pending", kind: "action", title: "maybe fix the thing", content: "x", source: "viewer", + payload: { tags: ["todo-extracted"], todoExtraction: { sourceSessionId: "ses_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + } as never); + const decide = async () => [{ id: "r:rev1", decision: "DROP" as const, reason: "noise" }]; + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); + expect(result.scanned).toBe(1); + expect(result.dropped).toBe(1); + expect((await kv.list(KV.reviewQueue)).find((r) => r.id === "rev1")).toMatchObject({ status: "dismissed" }); + }); + + it("update downgrades a MERGE whose target is not in the batch to KEEP (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + await kv.set(KV.actions, "a_src", { + id: "a_src", title: "real todo", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + }); + const decide = async () => [{ id: "a:a_src", decision: "MERGE" as const, mergeIntoId: "a:ghost" }]; + const result = await updateChangedTodoCards(kv as never, { mode: "apply", decide }); + expect(result.merged).toBe(0); + expect(result.kept).toBe(1); + // dangling MERGE downgraded → source preserved, not cancelled + expect((await kv.list(KV.actions)).find((a) => a.id === "a_src")).toMatchObject({ status: "pending" }); + }); + it("filters agent progress observations before rules extraction", async () => { await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 2 })); await kv.set(KV.observations("ses_1"), "obs_noise", obs({ From c1bb3bdc654618c7944f742c2f68d2f22e2de43d Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 23:10:55 +0800 Subject: [PATCH 05/10] fix(todo): update sidecar 403 (User-Agent) + rule-filter sessionDelta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the LLM 403 on the update button was NOT request size/count — it was the missing User-Agent: the raw-urllib sidecar sent Python-urllib's default UA, which Cloudflare in front of Novita bans with 'error code: 1010' (confirmed by isolation test: same body, default UA -> 403, any normal UA -> 200, for both tiny and ~20KB requests). - todo-update-llm.py: set 'User-Agent: AI-Todo' on the request (the codex/todo-cleanup-flash-model branch already did this; main lacked it). - buildSessionDelta: rule-filter the session text before sending — drop tool traces / commands / paths / JSON (isPollutedTodoText), keep only human-readable narrative. The model must never receive raw session/tool dumps (extract -> rules -> send -> parse). - Revert the per-request batching added on the (now-disproven) 'too many items' hypothesis; with the UA fix a single request of all cards returns 200. Verified live against real data: update dry-run returns engine=llm, no fallbackReason, real MERGE/KEEP decisions. Build + full suite green (1504). Signed-off-by: yujiezhang-ops --- src/functions/todo-extract.ts | 10 +++++++++- src/functions/todo-update-llm.py | 8 +++++++- test/todo-extract.test.ts | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index c7215a94..b0821d9d 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -912,8 +912,16 @@ function advanceReviewCheckpoint(item: ReviewQueueItem, checkpoint: string, now: async function buildSessionDelta(kv: Pick, sessionId: string): Promise { const observations = (await kv.list(KV.observations(sessionId)).catch(() => [])) .sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); + // Rule-filter before sending to the LLM — never ship raw session/tool dumps. + // Keep the human-readable narrative of recent interactions and drop tool + // traces / commands / paths / JSON (isPollutedTodoText), mirroring what the + // extract path does with prefilterTodoObservations. const recent = takeRecentInteractions(observations, 2); - const text = recent.map((obs) => textForObservation(obs)).filter(Boolean).join("\n").trim(); + const text = recent + .map((obs) => textForObservation(obs)) + .filter((line) => line && !isPollutedTodoText(line)) + .join("\n") + .trim(); return text.length > 2000 ? text.slice(text.length - 2000) : text; } diff --git a/src/functions/todo-update-llm.py b/src/functions/todo-update-llm.py index cd506954..a47b4396 100644 --- a/src/functions/todo-update-llm.py +++ b/src/functions/todo-update-llm.py @@ -101,7 +101,13 @@ def call_llm(cards: list) -> list: req = urllib.request.Request( base_url + "/chat/completions", data=json.dumps(body).encode("utf-8"), - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + # Cloudflare in front of Novita 403s the default Python-urllib UA + # ("error code: 1010"); a stable explicit UA is required. + "User-Agent": "AI-Todo", + }, method="POST", ) timeout = float(os.environ.get("AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS", "120000")) / 1000.0 diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 87b2535b..391401f4 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -600,6 +600,24 @@ describe("todo extraction", () => { expect((await kv.list(KV.actions)).find((a) => a.id === "a1")).toMatchObject({ status: "done" }); }); + it("update rule-filters tool dumps out of the session delta (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + // a tool-trace observation (must be filtered) + a human one (must survive) + await kv.set(KV.observations("ses_x"), "o_tool", obs({ id: "o_tool", sessionId: "ses_x", timestamp: "2026-06-18T08:00:00.000Z", narrative: '{"cmd":"gh pr list --json number"}' })); + await kv.set(KV.observations("ses_x"), "o_human", obs({ id: "o_human", sessionId: "ses_x", timestamp: "2026-06-18T08:01:00.000Z", narrative: "刚刚已经修复并合并了登录超时。" })); + await kv.set(KV.actions, "a1", { + id: "a1", title: "Fix login timeout", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + }); + let captured: Array<{ sessionDelta?: string }> = []; + const decide = async (cards: Array<{ sessionDelta?: string }>) => { captured = cards; return [{ id: "a:a1", decision: "KEEP" as const }]; }; + await updateChangedTodoCards(kv as never, { mode: "dry-run", decide }); + expect(captured[0]?.sessionDelta).toContain("登录超时"); // human text kept + expect(captured[0]?.sessionDelta).not.toContain("gh pr list"); // tool dump dropped + }); + it("update dry-run previews without mutating (STEP-12)", async () => { await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); await kv.set(KV.actions, "a_drop", { From 1d20e26a92ca6c40bce9d7a7abaf02a2ffeba390 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 23:30:59 +0800 Subject: [PATCH 06/10] fix(todo): apply the dry-run decisions verbatim instead of re-calling the LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: the update button's two-phase flow called the LLM twice — once for the dry-run preview the user confirms, and again on apply. Reasoning models (deepseek-v4-flash) are not deterministic even at temperature 0, so the apply could diverge from the preview: users saw MERGE in the confirm dialog but the apply marked the cards DONE, and the checkpoints still advanced so it looked like 'nothing happened / the merge didn't take effect'. Fix: updateChangedTodoCards now returns the decisions it used, and accepts an opts.decisions to apply verbatim (no decide() / LLM call, no session-delta build). The viewer captures the dry-run decisions and passes them to the apply POST, so what the user confirms is exactly what is applied — and it's one LLM call instead of two. Threaded through the viewer handler + the REST endpoint. Build + full suite green (1506, +2 tests: apply reuses decisions w/o re-calling; dry-run returns decisions). Signed-off-by: yujiezhang-ops --- src/functions/todo-extract.ts | 59 ++++++++++++++++++++++------------- src/triggers/api.ts | 2 ++ src/viewer/index.html | 4 +-- src/viewer/server.ts | 5 ++- test/todo-extract.test.ts | 35 +++++++++++++++++++++ 5 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index b0821d9d..3aff5b97 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -934,12 +934,15 @@ export type LlmCleanupResult = { rewritten: number; merged: number; preview: Array<{ id: string; title: string; decision: LlmCleanupDecision; reason?: string; newTitle?: string; mergeIntoId?: string }>; + // The exact decisions used, so a dry-run preview can be applied verbatim + // (the caller passes them back as opts.decisions) without re-calling the LLM. + decisions?: LlmCleanupItem[]; fallbackReason?: string; }; export async function updateChangedTodoCards( kv: Pick, - opts: { mode?: CleanupMode; maxCards?: number; decide?: (cards: CleanupCard[]) => Promise } = {}, + opts: { mode?: CleanupMode; maxCards?: number; decide?: (cards: CleanupCard[]) => Promise; decisions?: LlmCleanupItem[] } = {}, ): Promise { const mode = opts.mode ?? "apply"; const maxCards = opts.maxCards ?? DEFAULT_CLEANUP_MAX_CARDS; @@ -981,34 +984,45 @@ export async function updateChangedTodoCards( ...changedActions.map((a) => ({ id: `a:${a.id}`, sessionId: (a.metadata?.todoExtraction as ExtractedTodo | undefined)?.sourceSessionId, title: a.title, description: a.description, status: a.status, evidence: cardEvidence(a.metadata) })), ...changedReviews.map((r) => ({ id: `r:${r.id}`, sessionId: (r.payload?.todoExtraction as ExtractedTodo | undefined)?.sourceSessionId, title: r.title, description: r.content, status: r.status, evidence: cardEvidence(r.payload) })), ].slice(0, maxCards); + // When applying a previously-previewed result, the caller passes the dry-run + // decisions back so we apply them verbatim — no LLM re-call, no session delta. + const reuseDecisions = opts.decisions; const cards: CleanupCard[] = []; for (const entry of entries) { - cards.push({ id: entry.id, title: entry.title, description: entry.description, status: entry.status, evidence: entry.evidence, sessionDelta: await sessionDeltaFor(entry.sessionId) }); + cards.push({ id: entry.id, title: entry.title, description: entry.description, status: entry.status, evidence: entry.evidence, sessionDelta: reuseDecisions ? "" : await sessionDeltaFor(entry.sessionId) }); } if (cards.length === 0) { - return { engine: "llm", scanned: 0, kept: 0, dropped: 0, completed: 0, rewritten: 0, merged: 0, preview: [] }; + return { engine: "llm", scanned: 0, kept: 0, dropped: 0, completed: 0, rewritten: 0, merged: 0, preview: [], decisions: [] }; } let decisions: LlmCleanupItem[]; - try { - decisions = await decide(cards); - } catch (err) { - // Update is an LLM-only operation (it re-judges cards against new session - // content); there is no rule equivalent, so on failure leave every card - // untouched and report why via fallbackReason. Callers detect "LLM - // unavailable" by fallbackReason, not by a misnomer engine value. - return { - engine: "llm", - scanned: cards.length, - kept: cards.length, - dropped: 0, - completed: 0, - rewritten: 0, - merged: 0, - preview: [], - fallbackReason: err instanceof Error ? err.message : String(err), - }; + if (reuseDecisions) { + // Apply the exact decisions from the dry-run preview — never re-call the LLM + // on apply. Reasoning models vary even at temperature 0, so a re-call could + // diverge from what the user just confirmed (e.g. preview MERGE, apply DONE). + decisions = reuseDecisions; + } else { + try { + decisions = await decide(cards); + } catch (err) { + // Update is an LLM-only operation (it re-judges cards against new session + // content); there is no rule equivalent, so on failure leave every card + // untouched and report why via fallbackReason. Callers detect "LLM + // unavailable" by fallbackReason, not by a misnomer engine value. + return { + engine: "llm", + scanned: cards.length, + kept: cards.length, + dropped: 0, + completed: 0, + rewritten: 0, + merged: 0, + preview: [], + decisions: [], + fallbackReason: err instanceof Error ? err.message : String(err), + }; + } } const now = new Date().toISOString(); @@ -1068,6 +1082,7 @@ export async function updateChangedTodoCards( rewritten, merged, preview, + decisions, }; } @@ -1292,7 +1307,7 @@ export function registerTodoExtractFunctions(sdk: ISdk, kv: StateKV): void { sdk.registerFunction("mem::todo-extract-generate", async (data: TodoExtractOptions = {}) => generateTodosFromSessions(kv, data), ); - sdk.registerFunction("mem::todo-update", async (data: { mode?: CleanupMode; maxCards?: number } = {}) => + sdk.registerFunction("mem::todo-update", async (data: { mode?: CleanupMode; maxCards?: number; decisions?: LlmCleanupItem[] } = {}) => updateChangedTodoCards(kv, data), ); } diff --git a/src/triggers/api.ts b/src/triggers/api.ts index bcf5489b..04e00eac 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1559,6 +1559,8 @@ export function registerApiTriggers( const payload: Record = {}; if (body.mode === "dry-run" || body.mode === "apply") payload.mode = body.mode; if (maxCards !== undefined) payload.maxCards = maxCards; + // Apply previously-previewed decisions verbatim (no LLM re-call) when given. + if (Array.isArray(body.decisions)) payload.decisions = body.decisions; const result = await sdk.trigger({ function_id: "mem::todo-update", payload }); return { status_code: 200, body: result }; }, diff --git a/src/viewer/index.html b/src/viewer/index.html index 56e4b359..b7bcf2ce 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -8974,7 +8974,7 @@

AI Todo

// to the LLM) on every future click. No card content changes, so no // confirmation is needed. state.actions.cleanupInFlight = true; - apiPost('todo/update', { mode: 'apply' }).then(function() { + apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function() { return refreshActionListsAfterExtract(); }).catch(function() {}).then(function() { state.actions.cleanupInFlight = false; @@ -9000,7 +9000,7 @@

AI Todo

state.actions.cleanupStatus = 'running'; state.actions.cleanupMessage = t('act.cleanup.applying'); if (state.activeTab === 'actions') renderActions(); - apiPost('todo/update', { mode: 'apply' }).then(function(result) { + apiPost('todo/update', { mode: 'apply', decisions: preview.decisions }).then(function(result) { state.actions.cleanupStatus = 'done'; state.actions.cleanupMessage = todoCleanupSummary(result || preview); return refreshActionListsAfterExtract(); diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 241d3599..befb4635 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -1426,7 +1426,10 @@ export function startViewerServer( const mode = body.mode === "apply" ? "apply" : "dry-run"; const maxCards = typeof body.maxCards === "number" && body.maxCards > 0 ? Math.floor(body.maxCards) : undefined; - const result = await updateChangedTodoCards(kv as ViewerKv, { mode, maxCards }); + // On apply, the viewer passes back the dry-run decisions so they are applied + // verbatim instead of re-calling the (non-deterministic) LLM. + const decisions = Array.isArray(body.decisions) ? body.decisions as never : undefined; + const result = await updateChangedTodoCards(kv as ViewerKv, { mode, maxCards, decisions }); json(res, 200, result, req); return; } diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 391401f4..0d808b20 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -680,6 +680,41 @@ describe("todo extraction", () => { expect((await kv.list(KV.actions)).find((a) => a.id === "a_src")).toMatchObject({ status: "pending" }); }); + it("update apply reuses the dry-run decisions verbatim — no LLM re-call (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + await kv.set(KV.actions, "a_drop", { + id: "a_drop", title: "noise", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + }); + let decideCalled = false; + const decide = async () => { decideCalled = true; return [{ id: "a:a_drop", decision: "KEEP" as const }]; }; + // The dry-run decided DROP; apply must honour that, not re-ask the LLM + // (which here would say KEEP). + const result = await updateChangedTodoCards(kv as never, { + mode: "apply", decide, + decisions: [{ id: "a:a_drop", decision: "DROP", reason: "preview said drop" }], + }); + expect(decideCalled).toBe(false); + expect(result.dropped).toBe(1); + expect((await kv.list(KV.actions)).find((a) => a.id === "a_drop")).toMatchObject({ status: "cancelled" }); + }); + + it("update dry-run returns the decisions so apply can replay them (STEP-12)", async () => { + await kv.set(KV.sessions, "ses_x", session({ id: "ses_x", endedAt: "2026-06-18T09:00:00.000Z", observationCount: 5 })); + await kv.set(KV.actions, "a1", { + id: "a1", title: "real todo", description: "x", 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_x", sourceCheckpoint: "2026-06-17T09:00:00.000Z:1" } }, + }); + const decide = async () => [{ id: "a:a1", decision: "DONE" as const, reason: "done now" }]; + const dry = await updateChangedTodoCards(kv as never, { mode: "dry-run", decide }); + expect(dry.decisions).toEqual([{ id: "a:a1", decision: "DONE", reason: "done now" }]); + expect((await kv.list(KV.actions)).find((a) => a.id === "a1")).toMatchObject({ status: "pending" }); // dry-run didn't mutate + }); + it("filters agent progress observations before rules extraction", async () => { await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 2 })); await kv.set(KV.observations("ses_1"), "obs_noise", obs({ From f3941ddda70b3556effdd189ec013784d967eeb7 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Mon, 22 Jun 2026 23:36:20 +0800 Subject: [PATCH 07/10] fix(viewer): hide cancelled cards from the default To-Do view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an update MERGE/DROP (or a manual Archive) a card becomes 'cancelled' but the default view still rendered a '已取消/Cancelled' group, so merged/dropped cards stayed visible in place — making a successful merge look like it 'didn't take effect'. Drop 'cancelled' from the default-view inlineStatuses (done is already folded away); cancelled = archived/soft-deleted and no longer clutters the active list. Filtered views are unchanged. Signed-off-by: yujiezhang-ops --- src/viewer/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index b7bcf2ce..dc7dfcb9 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -9775,9 +9775,11 @@

AI Todo

// STEP-C4:无筛选的默认视图里,done 不混在分组流中,改由底部「已完成」 // 折叠区单独承载、且只显当天完成的(§3.2/§3.3)。点了「已完成」筛选 chip // (statusFilter==='done')时则照常全列,不走折叠区。 + // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 + // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 var defaultView = !statusFilter; var inlineStatuses = defaultView - ? ['active','blocked','pending','cancelled'] + ? ['active','blocked','pending'] : ['active','blocked','pending','done','cancelled']; inlineStatuses.forEach(function(status) { var group = items.filter(function(a) { return a.status === status; }); From fde2b8dbdf83223440b3197a4c23beeeb26910e7 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 09:53:11 +0800 Subject: [PATCH 08/10] feat(todo): retire the browser action-candidates leg; browser flows through todo-extract (STEP-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending To-Do list was polluted with session-narration noise (git refs, tool traces, assistant narration like '现在我会补 mem::…', dev notes) — not user todos. These came from the legacy action-candidates rules engine feeding the review queue, separate from the todo-extract LLM pipeline (which has the STEP-08 pollution filters). Browser sessions are already recorded as Session + observations (recordBrowserSessionFallback / recordBrowserSessionFromReview), so todo-extract already consumes them. This removes the redundant, noisy rule-draft leg: - viewer server: drop persistBrowserActionDrafts (+ its two calls + import). - api::review-create: no longer builds buildTurnActionDrafts for browser sources (+ remove the now-dead actionDrafts persistence/response field + import). - todo-extract: observationStartsInteraction also recognises a browser user turn (title '用户发言') so STEP-11 interaction windowing works for browser too. Kept (dormant, not auto-invoked): extractActionCandidatesFromObservations (still the todo-extract rules fallback), buildTurnActionDrafts, and the manual mem::action-candidates-generate endpoint — so no REST/MCP count change (141/55). Tests: browser review-create records the session but spawns no action drafts; todo-extract extracts from a browser-shaped session. Build + suite green (1507). Signed-off-by: yujiezhang-ops --- src/functions/todo-extract.ts | 7 ++++++- src/triggers/api.ts | 26 ++++---------------------- src/viewer/server.ts | 33 --------------------------------- test/review-action.test.ts | 27 +++++++++++---------------- test/todo-extract.test.ts | 21 +++++++++++++++++++++ 5 files changed, 42 insertions(+), 72 deletions(-) diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index 3aff5b97..6b6ce95d 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -326,7 +326,12 @@ function sessionSortTime(session: Session): string { // no boundary is found, in which case takeRecentInteractions degrades to // "whole session = one interaction" (keep everything). function observationStartsInteraction(obs: CompressedObservation): boolean { - return obs.type === "conversation" && /^prompt_submit$/i.test((obs.title || "").trim()); + if (obs.type !== "conversation") return false; + const title = (obs.title || "").trim(); + // Codex synthetic compression titles a user prompt "prompt_submit"; browser + // capture (recordBrowserSessionFallback) titles a user turn "用户发言". Either + // one starts a new interaction record. + return /^prompt_submit$/i.test(title) || title === "用户发言"; } // Keep only the most recent `maxInteractions` interaction records of a diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 04e00eac..98585c1d 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -13,7 +13,6 @@ import { renderViewerDocument } from "../viewer/document.js"; import { getBoundViewerPort, getViewerSkipped } from "../viewer/server.js"; import { MAX_FILES_UPPER_BOUND } from "../functions/replay.js"; import { logger } from "../logger.js"; -import { buildTurnActionDrafts } from "../functions/action-candidates.js"; import { isGraphExtractionEnabled, isConsolidationEnabled, @@ -1401,23 +1400,9 @@ export function registerApiTriggers( ...(syncId ? { browserSyncId: syncId } : {}), }, }; - const actionDrafts: ReviewQueueItem[] = - (item.source === "browser-extension" || item.source === "browser-sync") && - conversation.turns.length > 0 - ? await buildTurnActionDrafts(kv, { - turns: conversation.turns, - now, - source: item.source, - page: item.page, - conversation, - basePayload: { - ...payload, - ...(item.payload || {}), - provider: conversation.provider || payload.provider, - pageType: item.page?.type || payload.pageType, - }, - }) - : []; + // STEP-14: browser sessions now flow through the todo-extract LLM pipeline + // (recorded below as a session + observations), not the rule-based action + // drafts that produced session-narration noise. if (item.source === "browser-extension" || item.source === "browser-sync") { try { const browserSession = await recordBrowserSessionFromReview(sdk, item); @@ -1434,12 +1419,9 @@ export function registerApiTriggers( } } await kv.set(KV.reviewQueue, item.id, item); - for (const draft of actionDrafts) { - await kv.set(KV.reviewQueue, draft.id, draft); - } return { status_code: existing ? 200 : 201, - body: { success: true, item, ...(actionDrafts.length ? { actionDrafts } : {}) }, + body: { success: true, item }, }; }, ); diff --git a/src/viewer/server.ts b/src/viewer/server.ts index befb4635..b9d3338b 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -12,7 +12,6 @@ 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 { buildTurnActionDrafts } from "../functions/action-candidates.js"; import { generateTodosFromSessions, updateChangedTodoCards } from "../functions/todo-extract.js"; import { getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv, WRITABLE_TODO_EXTRACT_KEYS } from "../config.js"; @@ -786,36 +785,6 @@ function extractBrowserFallbackContent(body: Record): { }; } -// Bring the viewer-server review fallback to parity with api::review-create: -// browser-capture turns also produce action drafts (todos), via the shared -// buildTurnActionDrafts (one dedup path). Gated for rollback without a revert. -async function persistBrowserActionDrafts( - kv: ViewerKv, - item: ReviewQueueItem, - now: string, -): Promise { - if (process.env.AGENTMEMORY_BROWSER_ACTION_EXTRACT === "false") return 0; - const turns = item.conversation?.turns ?? []; - if (!turns.length) return 0; - const drafts = await buildTurnActionDrafts(kv, { - turns, - now, - source: item.source, - page: item.page, - conversation: item.conversation, - basePayload: { - ...(item.payload || {}), - provider: item.conversation?.provider, - pageType: item.page?.type, - viewerFallback: true, - }, - }); - for (const draft of drafts) { - await kv.set(KV.reviewQueue, draft.id, draft); - } - return drafts.length; -} - async function handleReviewFallback( req: IncomingMessage, res: ServerResponse, @@ -896,7 +865,6 @@ async function handleReviewFallback( }, }; const browserSession = await recordBrowserSessionFallback(kv, item, syncId); - await persistBrowserActionDrafts(kv, item, now); json(res, 201, { success: true, item: { @@ -955,7 +923,6 @@ async function handleReviewFallback( browserObservationCount: browserSession.observationCount, }; await kv.set(KV.reviewQueue, item.id, item); - await persistBrowserActionDrafts(kv, item, now); json(res, existing ? 200 : 201, { success: true, item }, req); return true; } diff --git a/test/review-action.test.ts b/test/review-action.test.ts index ecfd0bde..9949f3b5 100644 --- a/test/review-action.test.ts +++ b/test/review-action.test.ts @@ -311,7 +311,7 @@ describe("review action candidates", () => { expect(reviewFindingResponse.body.error).toBe("review_content_not_displayable"); }); - it("keeps normal browser review candidates and explicit action drafts", async () => { + it("keeps browser review candidates but no longer spawns rule action drafts (STEP-14)", async () => { const normal = await sdk.trigger("api::review-create", req({ title: "ChatGPT page", content: "用户希望待审阅内容保持可读。", @@ -322,7 +322,7 @@ describe("review action candidates", () => { expect(normal.status_code).toBe(201); expect(normal.body.item.title).toBe("ChatGPT page"); - const withActionDraft = await sdk.trigger("api::review-create", req({ + const withTurns = await sdk.trigger("api::review-create", req({ title: "ChatGPT page", content: "普通记忆候选", source: "browser-extension", @@ -335,11 +335,11 @@ describe("review action candidates", () => { }, })) as { status_code: number; body: { success: boolean; item: ReviewQueueItem; actionDrafts?: ReviewQueueItem[] } }; - expect(withActionDraft.status_code).toBe(201); - expect(withActionDraft.body.actionDrafts?.[0]).toMatchObject({ - kind: "action", - title: "修复待审阅污染过滤", - }); + expect(withTurns.status_code).toBe(201); + // STEP-14: browser turns no longer spawn rule-based action drafts — they flow + // through the todo-extract LLM pipeline. The review item is still recorded. + expect(withTurns.body.actionDrafts).toBeUndefined(); + expect(withTurns.body.item.title).toBe("ChatGPT page"); }); it("approves action review items into pending Actions", async () => { @@ -450,7 +450,7 @@ describe("review action candidates", () => { expect(await kv.list(KV.actions)).toEqual([]); }); - it("creates browser action review drafts from explicit conversation turns", async () => { + it("records the browser session but creates no action drafts — browser flows through todo-extract (STEP-14)", async () => { const response = await sdk.trigger("api::review-create", req({ title: "ChatGPT page", content: "普通记忆候选", @@ -467,15 +467,10 @@ describe("review action candidates", () => { expect(response.status_code).toBe(201); expect(response.body.item.kind).toBe("memory"); - expect(response.body.actionDrafts).toHaveLength(1); - expect(response.body.actionDrafts?.[0]).toMatchObject({ - kind: "action", - status: "pending", - title: "修复 Viewer 待审阅里的行动候选展示", - }); - + // No rule-based action drafts; only the memory review item itself. + expect(response.body.actionDrafts).toBeUndefined(); const pending = await kv.list(KV.reviewQueue); - expect(pending.filter((item) => item.kind === "action")).toHaveLength(1); + expect(pending.filter((item) => item.kind === "action")).toEqual([]); }); it("does not create browser action drafts from ordinary requests or assistant plans", async () => { diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 0d808b20..bb61e170 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -715,6 +715,27 @@ describe("todo extraction", () => { expect((await kv.list(KV.actions)).find((a) => a.id === "a1")).toMatchObject({ status: "pending" }); // dry-run didn't mutate }); + it("extracts todos from a browser-captured session (STEP-14)", async () => { + // Browser sessions are recorded (recordBrowserSessionFallback) as + // type:conversation observations titled 用户发言/AI发言 — todo-extract must + // consume them like any other session (no separate action-candidate leg). + await kv.set(KV.sessions, "browser_sync_x", session({ + id: "browser_sync_x", project: "ChatGPT", cwd: "browser/chatgpt.com", + status: "completed", observationCount: 2, tags: ["browser"], agentId: "ChatGPT", + })); + await kv.set(KV.observations("browser_sync_x"), "t_ai", obs({ + id: "t_ai", sessionId: "browser_sync_x", title: "AI发言", type: "conversation", + narrative: "这里是页面说明。", + })); + await kv.set(KV.observations("browser_sync_x"), "t_user", obs({ + id: "t_user", sessionId: "browser_sync_x", title: "用户发言", type: "conversation", + narrative: "后续需要修复登录接口的超时问题。", + })); + const result = await generateTodosFromSessions(kv as never, { force: true, scanSources: false, cleanup: "none" }); + expect(result.directCreated).toBe(1); + expect((await kv.list(KV.actions))[0]).toMatchObject({ project: "ChatGPT", sourceObservationIds: ["t_user"] }); + }); + it("filters agent progress observations before rules extraction", async () => { await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 2 })); await kv.set(KV.observations("ses_1"), "obs_noise", obs({ From c67bc84f9c08fe33827b9e541c56945387616afe Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 10:33:35 +0800 Subject: [PATCH 09/10] fix(viewer): remove memory-product wording from the default To-Do frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of every default-reachable surface found the data layer already clean (memory-kind reviews filtered by kind=action; memory dashboards behind ?debug=1; memory/lessons/graph tabs behind the expert toggle). Remaining leaks were user-visible 'memory/记忆/长期记忆' wording in default views: - dashboard first-run empty state (EN+ZH): 'browsable memory and activity' -> 'browsable to-dos and activity' / '可浏览的待办与活动'. - expert toggle tooltip: 'advanced memory, graph, and audit views' -> 'More views'. - browser-sync delivery card: '不在浏览器里做记忆选择' -> '...做内容挑选'. - Evidence view browser observation title '记忆候选' -> '同步会话' (+ drop the 'memory' concept tag). - candidate-confirm modal body: drop '长期记忆'; the type dropdown is now action-only (removed the '记忆'/'经验' options) since To-Do candidates are always actions. No logic change; build + relevant viewer suite green (95). Deeper de-memory (dead-code removal, memory-* CSS class renames, retiring the expert memory tabs + KV memory scopes) left as a follow-up. Signed-off-by: yujiezhang-ops --- src/viewer/index.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index dc7dfcb9..ddca1c51 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -3923,7 +3923,7 @@

AI Todo

- + @@ -4051,7 +4051,7 @@

AI Todo

'filter.review': 'To confirm', 'filter.all': 'All', 'dash.firstRun.kicker': 'First run', 'dash.firstRun.title': 'Import sample data first', - 'dash.firstRun.body': 'No session data yet. Run the demo and the page will fill with browsable memory and activity.', + 'dash.firstRun.body': 'No session data yet. Run the demo and the page will fill with browsable to-dos and activity.', 'dash.firstRun.link': 'View quick start →', 'dash.noRecord': 'No records yet', 'dash.stat.sessions': 'Sessions', @@ -4253,7 +4253,7 @@

AI Todo

'filter.review': '待确认', 'filter.all': '全部', 'dash.firstRun.kicker': '首次使用', 'dash.firstRun.title': '先导入示例数据', - 'dash.firstRun.body': '目前还没有会话数据。先运行 demo,页面就会出现可浏览的记忆与活动。', + 'dash.firstRun.body': '目前还没有会话数据。先运行 demo,页面就会出现可浏览的待办与活动。', 'dash.firstRun.link': '查看快速开始 →', 'dash.noRecord': '暂无记录', 'dash.stat.sessions': '会话', @@ -5197,11 +5197,11 @@

AI Todo

sessionId: 'browser_' + (item.id || ''), timestamp: item.updatedAt || createdAt, type: item.kind === 'lesson' ? 'decision' : 'discovery', - title: item.kind === 'lesson' ? '经验候选' : '记忆候选', + title: item.kind === 'lesson' ? '经验候选' : '同步会话', subtitle: item.status === 'approved' ? '已保存' : item.status === 'dismissed' ? '已忽略' : '待审阅', narrative: item.content, facts: [], - concepts: ['browser', item.kind || 'memory'], + concepts: ['browser', item.kind === 'lesson' ? 'lesson' : 'session'], files: [], importance: 0.7 }); @@ -5908,7 +5908,7 @@

AI Todo

html += ''; html += '
'; if (!showDebug) { - html += '
插件界面只显示同步状态,不在浏览器里做记忆选择。
'; + html += '
插件界面只显示同步状态,不在浏览器里做内容挑选。
'; return html; } html += '
'; @@ -7286,12 +7286,12 @@

AI Todo

var overlay = document.getElementById('modal-overlay'); modal.innerHTML = '

审阅后保存

' + - '

这条内容来自 ' + esc(sourceLabel) + '。确认前可以调整标题、内容和归属,避免临时信息直接进入长期记忆或行动列表。

' + + '

这条内容来自 ' + esc(sourceLabel) + '。确认前可以调整标题、内容和归属,避免临时信息直接进入待办列表。

' + '
' + '' + '' + '
' + - '' + + '' + '' + '
' + '
' + From 668bb689c34c921d25b695ad9b2bd1e93bae160e Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Tue, 23 Jun 2026 10:45:21 +0800 Subject: [PATCH 10/10] refactor(viewer): delete dead memory review-queue code (de-memory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the memory-wording cleanup: remove the dead renderReviewQueue function (no caller; held '进入长期记忆…' text + memory-* section classes) and the reviewKindLabel/reviewKindCandidateLabel helpers it was the only consumer of (returned '记忆'/'记忆候选'). No behaviour change — these were unreachable. After this, every remaining '记忆' string in the viewer is either dead code (openAgentPrompt/buildAgentMemoryPrompt — no UI entry) or behind the expert toggle / ?debug=1 (Memories/Lessons/Crystals tabs + their i18n); none appears in the default Overview/To-Do/Evidence frontend. Removing the whole expert memory surface is a larger follow-up. Signed-off-by: yujiezhang-ops --- src/viewer/index.html | 46 ------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index ddca1c51..055c1507 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -5130,16 +5130,6 @@

AI Todo

if (!isGeneratedAction(action)) return true; return !isReviewTextPolluted(action.title) && !isReviewTextPolluted(action.description); } - function reviewKindLabel(kind) { - if (kind === 'lesson') return '经验'; - if (kind === 'action') return '行动'; - return '记忆'; - } - function reviewKindCandidateLabel(kind) { - if (kind === 'lesson') return '经验候选'; - if (kind === 'action') return '行动候选'; - return '记忆候选'; - } function reviewActionPriority(item) { var payload = reviewPayload(item); var candidate = payload.actionCandidate && typeof payload.actionCandidate === 'object' ? payload.actionCandidate : {}; @@ -7112,42 +7102,6 @@

AI Todo

restoreSearchFocus(__focus); } - function renderReviewQueue(items) { - items = (items || []).filter(isReviewItemDisplayable); - if (!items.length) return ''; - var html = '
'; - html += '
待审阅
浏览器插件送来的候选内容,确认后才会进入长期记忆、经验或行动。
' + items.length + ' 条
'; - html += '
'; - items.forEach(function(item) { - var page = item.page || {}; - var payload = reviewPayload(item); - var tags = reviewTags(item); - var project = reviewProject(item); - var kindLabel = reviewKindLabel(item.kind); - var candidateLabel = reviewKindCandidateLabel(item.kind); - var decision = item.decision || (item.status === 'dismissed' ? 'evidence_only' : 'candidate'); - var meta = [decision === 'evidence_only' ? '会话证据' : candidateLabel, reviewSourceLabel(item), page.url || ''].filter(Boolean).join(' · '); - html += '
'; - html += '
' + esc(decision === 'evidence_only' ? '已同步' : '待审阅') + '' + esc(decision === 'evidence_only' ? '证据' : kindLabel) + '
'; - html += '

' + esc(item.title || '未命名候选') + '

'; - html += '

' + esc(item.content || '').replace(/\n/g, '
') + '

'; - html += '
'; - html += '' + esc(projectDisplayName(project)) + ''; - if (payload.asLesson || item.kind === 'lesson' || item.kind === 'action') html += '' + esc(candidateLabel) + ''; - tags.forEach(function(tag) { html += '' + esc(tag) + ''; }); - html += '
'; - html += '
' + esc(meta) + '
'; - if (decision === 'evidence_only') { - html += '
'; - } else { - html += '
'; - } - html += '
'; - }); - html += '
'; - return html; - } - function deleteMemory(id, title) { var modal = document.getElementById('modal'); var overlay = document.getElementById('modal-overlay');