From dabe01c211dee65f4603285572d5a6f157e55f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Wed, 23 Sep 2026 20:42:48 +0800 Subject: [PATCH 01/13] fix(documents): support continued reading of long documents Preserve complete extraction for PDF and Office documents and return bounded chunks with continuation offsets. Select PDF pages before pagination and keep continuation hints within the tool result budget. Add regression fixtures, runtime recovery checks, reproducible benchmarks, and isolated account-model review scripts. Record 140 passing tests, typecheck results, and three successful live synthetic scenarios. Leave plain-text pagination and extraction caching outside this change. --- .../debug/benchmark-document-reading.ts | 61 ++++++++ .../debug/prepare-live-document-review.ts | 51 ++++++ .../scripts/debug/run-live-document-review.ts | 57 +++++++ .../debug/summarize-live-document-review.ts | 39 +++++ .../core/agent-runtime/tools/filesystem.ts | 83 ++++++---- .../src/templates/agent/tool-contract.md | 4 +- App/memmy-agent/src/utils/document.ts | 33 ++-- .../long-document-review.test.ts | 145 ++++++++++++++++++ .../tools/read-enhancements.test.ts | 111 ++++++++++++-- .../tests/document-parsing.test.ts | 10 ++ .../tests/helpers/long-document-fixtures.ts | 52 +++++++ docs/debug-long-document-live-review.md | 71 +++++++++ docs/debug-long-document-reading.md | 67 ++++++++ docs/debug-long-document-review.md | 85 ++++++++++ 14 files changed, 814 insertions(+), 55 deletions(-) create mode 100644 App/memmy-agent/scripts/debug/benchmark-document-reading.ts create mode 100644 App/memmy-agent/scripts/debug/prepare-live-document-review.ts create mode 100644 App/memmy-agent/scripts/debug/run-live-document-review.ts create mode 100644 App/memmy-agent/scripts/debug/summarize-live-document-review.ts create mode 100644 App/memmy-agent/tests/core/agent-runtime/long-document-review.test.ts create mode 100644 App/memmy-agent/tests/helpers/long-document-fixtures.ts create mode 100644 docs/debug-long-document-live-review.md create mode 100644 docs/debug-long-document-reading.md create mode 100644 docs/debug-long-document-review.md diff --git a/App/memmy-agent/scripts/debug/benchmark-document-reading.ts b/App/memmy-agent/scripts/debug/benchmark-document-reading.ts new file mode 100644 index 000000000..0a793c6ac --- /dev/null +++ b/App/memmy-agent/scripts/debug/benchmark-document-reading.ts @@ -0,0 +1,61 @@ +// Run with: node --import tsx scripts/debug/benchmark-document-reading.ts docx 1000000 +// Or: node --import tsx scripts/debug/benchmark-document-reading.ts pdf 96 +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { ReadFileTool } from "../../src/core/agent-runtime/tools/filesystem.js"; +import { extractText } from "../../src/utils/document.js"; +import { reportText, writeDocx, writePdf } from "../../tests/helpers/long-document-fixtures.js"; + +const kind = process.argv[2]; +const size = Number(process.argv[3]); +if (!["docx", "pdf"].includes(kind) || !Number.isSafeInteger(size) || size < 1) { + throw new Error("Expected docx or pdf "); +} +const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-document-benchmark-")); +try { + const file = kind === "docx" ? await writeDocx(root, reportText(size)) : await writePdf(root, size); + const tool = new ReadFileTool({ workspace: root }); + const durations: number[] = []; + const hash = createHash("sha256"); + const rssBeforeMiB = process.memoryUsage().rss / 1024 ** 2; + let offset = 0; + let complete = false; + const started = performance.now(); + for (let turn = 0; turn < 300; turn += 1) { + const start = performance.now(); + const result = await tool.execute({ path: file, char_offset: offset }); + durations.push(performance.now() - start); + const marker = result.lastIndexOf("\n\n[Document read:"); + if (marker < 0 || result.length > ReadFileTool.MAX_CHARS) throw new Error("Invalid document response"); + hash.update(result.slice(0, marker)); + const next = /Continue with char_offset=(\d+)/.exec(result); + if (!next) { + complete = result.includes("End of document"); + break; + } + if (Number(next[1]) <= offset) throw new Error("Non-progressing cursor"); + offset = Number(next[1]); + } + const totalMs = performance.now() - started; + const peakRssMiB = process.resourceUsage().maxRSS / 1024; + const referenceStart = performance.now(); + const expected = await extractText(file, { maxChars: null }); + const singleExtractionMs = performance.now() - referenceStart; + if (!expected || !complete || hash.digest("hex") !== createHash("sha256").update(expected).digest("hex")) { + throw new Error("Paginated content does not match complete extraction"); + } + const sorted = [...durations].sort((a, b) => a - b); + console.log(JSON.stringify({ + kind, size, fileBytes: (await fs.stat(file)).size, extractedChars: expected.length, + calls: durations.length, firstReadMs: durations[0], + medianReadMs: sorted[Math.floor(sorted.length / 2)], totalReadMs: totalMs, + singleExtractionMs, rssBeforeMiB, peakRssMiB, + exactContentMatch: true, node: process.version, + note: "One synthetic sample per process; peak RSS includes imports and fixture creation. No model/network latency.", + }, null, 2)); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/App/memmy-agent/scripts/debug/prepare-live-document-review.ts b/App/memmy-agent/scripts/debug/prepare-live-document-review.ts new file mode 100644 index 000000000..828444822 --- /dev/null +++ b/App/memmy-agent/scripts/debug/prepare-live-document-review.ts @@ -0,0 +1,51 @@ +// Prepare isolated, private CLI fixtures; never copy account credentials into the repository. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { reportText, REPORT_FACTS, writeDocx, writePdf } from "../../tests/helpers/long-document-fixtures.js"; + +const source = process.argv[2] ?? path.join(os.homedir(), ".memmy", "config.yaml"); +const config = YAML.parse(await fs.readFile(source, "utf8")); +const presetId = config.agents?.defaults?.modelPreset; +const preset = config.modelPresets?.[presetId]; +if (!preset || preset.provider !== "memmy_account" || !config.providers?.memmy_account?.apiKey) { + throw new Error("Expected a configured memmy_account agent preset"); +} +const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-live-document-review-")); +await fs.chmod(root, 0o700); +for (const scenario of ["docx", "pdf", "exec"]) { + const folder = path.join(root, scenario); + const workspace = path.join(folder, "workspace"); + await fs.mkdir(workspace, { recursive: true, mode: 0o700 }); + const isolated = { + providers: { memmy_account: config.providers.memmy_account }, + modelPresets: { [presetId]: preset }, + modelAssignments: { account: { ownerAccountId: preset.ownerAccountId, agent: { candidates: [presetId], default: presetId } } }, + app: { userId: config.app?.userId, userMode: config.app?.userMode }, + agents: { defaults: { ...config.agents.defaults, workspace, maxToolIterations: 40, fallbackModels: [], dream: { enabled: false } } }, + tools: { + restrictToWorkspace: true, mcpServers: {}, web: { enabled: false }, + browser: { enabled: false }, imageGeneration: { enabled: false }, + // Workspace path checks alone do not sandbox package installers or shell side effects. + exec: { enabled: scenario === "exec", allowPatterns: ["^cat source\\.txt(?:\\s*>\\s*extracted\\.txt)?$"] }, + }, + memmyMemory: { enabled: false }, fileMemory: { enabled: false }, + sessionDag: { enabled: false }, contextCompaction: { summaryMode: "text" }, heartbeat: { enabled: false }, + }; + await fs.writeFile(path.join(folder, "config.yaml"), YAML.stringify(isolated), { mode: 0o600 }); + let prompt: string; + if (scenario === "docx") { + await writeDocx(workspace, reportText()); + prompt = "请完整阅读工作区 synthetic-report.docx,并汇总所有 VERIFIED 标记对应的字段和值。不要遗漏后半部分。说明你是否读到了文档末尾;仅根据文档作答,不联网。"; + } else if (scenario === "pdf") { + await writePdf(workspace, 20); + prompt = "请核实工作区 synthetic-report.pdf 第20页的全部 VERIFIED 字段和值,给出页码和准确数值。仅根据文档作答,不联网。"; + } else { + await fs.writeFile(path.join(workspace, "source.txt"), reportText()); + prompt = "请先用 exec 执行 cat source.txt,核实该命令输出中的全部 VERIFIED 字段和值。如果初次输出不完整,请自行获取缺失部分后再作答。只操作当前工作区,不联网。"; + } + await fs.writeFile(path.join(folder, "prompt.txt"), prompt); +} +await fs.writeFile(path.join(root, "expected.json"), JSON.stringify({ facts: REPORT_FACTS, scenarios: ["docx", "pdf", "exec"] }, null, 2)); +console.log(root); diff --git a/App/memmy-agent/scripts/debug/run-live-document-review.ts b/App/memmy-agent/scripts/debug/run-live-document-review.ts new file mode 100644 index 000000000..fb8f3d043 --- /dev/null +++ b/App/memmy-agent/scripts/debug/run-live-document-review.ts @@ -0,0 +1,57 @@ +// Run only against an isolated directory produced by prepare-live-document-review.ts. +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import YAML from "yaml"; +import { prepareStartupMigrations } from "../../src/entrypoints/cli/startup-migrations.js"; + +const folder = path.resolve(process.argv[2] ?? ""); +if (!path.basename(path.dirname(folder)).startsWith("memmy-live-document-review-")) { + throw new Error("Expected an isolated review scenario directory"); +} +const configPath = path.join(folder, "config.yaml"); +const config = YAML.parse(await fs.readFile(configPath, "utf8")); +const key = config.providers.memmy_account.apiKey; +const workspace = path.join(folder, "workspace"); +const entry = fileURLToPath(new URL("../../dist/main.js", import.meta.url)); +const env = { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, + MEMMY_CONFIG: configPath, MEMMY_AGENT_DATA_DIR: path.join(folder, "data"), + MEMMY_AGENT_WORKSPACE: workspace, MEMMY_CLOUD_SERVICE: "https://api.memmy.cn", + MEMMY_AGENT_SESSION_DAG_DIR: path.join(folder, "session-dag"), MEMMY_APP_DATABASE: path.join(folder, "app.sqlite") }; +// Migrations can add the desktop MCP default. Prepare them first, then disable external +// connections and pass their actual target receipt to the child CLI. +const { target } = await prepareStartupMigrations({ config: configPath, workspace }, env); +const migrated = YAML.parse(await fs.readFile(configPath, "utf8")); +migrated.tools.mcpServers = {}; +migrated.contextCompaction = { summaryMode: "text" }; +await fs.writeFile(configPath, YAML.stringify(migrated), { mode: 0o600 }); +Object.assign(env, { + MEMMY_MIGRATIONS_READY_CONFIG: target.runtimeConfigFile, + MEMMY_MIGRATIONS_READY_WORKSPACE: target.agentWorkspace, + MEMMY_MIGRATIONS_READY_SESSION_DAG: target.sessionDagDir, + MEMMY_MIGRATIONS_READY_APP_DATABASE: target.appDatabaseFile, +}); +const started = Date.now(); +const child = spawn(process.execPath, [entry, "agent", "--config", configPath, "--workspace", workspace, "--standalone", "--no-markdown"], { + cwd: workspace, + env, + stdio: ["pipe", "pipe", "pipe"], +}); +let output = ""; +let timedOut = false; +child.stdout.on("data", (chunk) => { output += chunk.toString(); }); +child.stderr.on("data", (chunk) => { output += chunk.toString(); }); +child.stdin.end(await fs.readFile(path.join(folder, "prompt.txt"))); +const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 240_000); +const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); +}).finally(() => clearTimeout(timeout)); +const sanitized = output.split(key).join("[REDACTED]").replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED JWT]"); +await fs.writeFile(path.join(folder, "cli-output.txt"), sanitized, { mode: 0o600 }); +const result = { scenario: path.basename(folder), exitCode: code, timedOut, elapsedMs: Date.now() - started, outputFile: path.join(folder, "cli-output.txt") }; +await fs.writeFile(path.join(folder, "run-result.json"), JSON.stringify(result, null, 2)); +console.log(JSON.stringify(result)); +if (code !== 0) console.log(sanitized.slice(-1500)); +process.exitCode = code === 0 && !timedOut ? 0 : 1; diff --git a/App/memmy-agent/scripts/debug/summarize-live-document-review.ts b/App/memmy-agent/scripts/debug/summarize-live-document-review.ts new file mode 100644 index 000000000..dcb6a15df --- /dev/null +++ b/App/memmy-agent/scripts/debug/summarize-live-document-review.ts @@ -0,0 +1,39 @@ +// Export only visible answers and tool evidence, excluding credentials and reasoning streams. +import fs from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] ?? ""); +const expected = JSON.parse(await fs.readFile(path.join(root, "expected.json"), "utf8")); +const summaries = []; +for (const scenario of expected.scenarios) { + const folder = path.join(root, scenario); + let run; + try { run = JSON.parse(await fs.readFile(path.join(folder, "run-result.json"), "utf8")); } + catch { continue; } + const webui = path.join(folder, "data", "webui"); + const events: any[] = []; + for (const file of await fs.readdir(webui)) { + if (!file.endsWith(".jsonl")) continue; + const lines = (await fs.readFile(path.join(webui, file), "utf8")).trim().split("\n"); + for (const line of lines) { try { events.push(JSON.parse(line)); } catch { /* partial write */ } } + } + const tools = events.flatMap((event) => event.tool_events ?? []).filter((event) => event.phase === "end" || event.phase === "error"); + const final = events.filter((event) => event.event === "message" && !["tool_hint", "progress"].includes(event.kind)).at(-1)?.text ?? ""; + summaries.push({ + scenario, exitCode: run.exitCode, timedOut: run.timedOut, elapsedMs: run.elapsedMs, + finalAnswer: final, + expectedValuesPresent: Object.fromEntries(expected.facts.map((fact: string) => [fact, final.replace(/,/g, "").includes(fact.split("=")[1])])), + tools: tools.map((tool) => { + const result = typeof tool.result === "string" ? tool.result : ""; + return { + name: tool.name, phase: tool.phase, + arguments: Object.fromEntries(Object.entries(tool.arguments ?? {}).filter(([key]) => ["path", "offset", "limit", "char_offset", "char_limit", "pages", "command", "pattern", "query"].includes(key))), + resultChars: result.length, + documentRange: /\[Document read: chars[^\]]+\]/.exec(result)?.[0], + truncated: /truncated/i.test(result), error: tool.error, + }; + }), + }); +} +await fs.writeFile(path.join(root, "summary.json"), JSON.stringify(summaries, null, 2), { mode: 0o600 }); +console.log(JSON.stringify(summaries, null, 2)); diff --git a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts index 671a0564b..9869772c1 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts @@ -83,37 +83,48 @@ function filterExtractedPages(text: string, pages?: string | null): string { .join("\n\n"); } -async function formatDocumentRead(target: string, pages?: string | null, maxChars = 128_000): Promise { - const ext = path.extname(target).toLowerCase(); - const extracted = await extractText(target); - if (extracted == null) return `Error: Unsupported file format: ${ext}`; - if (extracted.startsWith("[error:")) return `Error reading ${ext.toUpperCase()} file: ${extracted}`; - const paged = ext === ".pdf" ? filterExtractedPages(extracted, pages) : extracted; - if (paged.startsWith("Error:")) return paged; - if (!paged) return `(${ext.toUpperCase().replace(".", "")} has no extractable text: ${target})`; - if (paged.length > maxChars) return `${paged.slice(0, maxChars)}\n\n(Document text truncated at ~128K chars)`; - return paged; +interface DocumentReadOptions { + char_offset?: number; + char_limit?: number; } -async function readPdf(target: string, pages?: string | null, maxChars = 128_000): Promise { - const extracted = await extractText(target); - if (extracted == null) return "Error: PDF reading requires PDF text extraction support."; - if (extracted.startsWith("[error:")) return `Error reading PDF: ${extracted}`; - const paged = filterExtractedPages(extracted, pages); - if (paged.startsWith("Error:")) return paged; - if (!paged) return `(PDF has no extractable text: ${target})`; - if (paged.length > maxChars) return `${paged.slice(0, maxChars)}\n\n(PDF text truncated at ~128K chars)`; - return paged; +function paginateDocument(text: string, options: DocumentReadOptions, maxChars: number): string { + const offset = options.char_offset ?? 0; + const limit = options.char_limit ?? 32_000; + if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length) { + return `Error reading document: char_offset must be between 0 and ${text.length}.`; + } + if (!Number.isSafeInteger(limit) || limit < 1) { + return "Error reading document: char_limit must be a positive integer."; + } + // Reserve space for the continuation marker inside the runtime's result budget. + const budget = Math.max(2, maxChars - 512); + let end = Math.min(text.length, offset + Math.min(limit, budget)); + if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]) && /[\uDC00-\uDFFF]/.test(text[end])) { + // A one-unit request must still make progress over a surrogate pair. + end += end - offset === 1 ? 1 : -1; + } + const status = end < text.length + ? `More content remains. Continue with char_offset=${end}. Keep the same path and pages; do not treat this excerpt as the full document.` + : "End of document (selected pages, if any)."; + return `${text.slice(offset, end)}\n\n[Document read: chars ${offset}-${end} of ${text.length} (UTF-16, end exclusive). ${status}]`; } -async function readOfficeDoc(target: string, maxChars = 128_000): Promise { +async function formatDocumentRead( + target: string, + pages?: string | null, + maxChars = 128_000, + options: DocumentReadOptions = {}, +): Promise { const ext = path.extname(target).toLowerCase(); - const extracted = await extractText(target); + // Page filtering and continuation must operate on the complete extraction, not its preview. + const extracted = await extractText(target, { maxChars: null }); if (extracted == null) return `Error: Unsupported file format: ${ext}`; if (extracted.startsWith("[error:")) return `Error reading ${ext.toUpperCase()} file: ${extracted}`; - if (!extracted) return `(${ext.toUpperCase().replace(".", "")} has no extractable text: ${target})`; - if (extracted.length > maxChars) return `${extracted.slice(0, maxChars)}\n\n(Document text truncated at ~128K chars)`; - return extracted; + const paged = ext === ".pdf" ? filterExtractedPages(extracted, pages) : extracted; + if (paged.startsWith("Error:")) return paged; + if (!paged) return `(${ext.toUpperCase().replace(".", "")} has no extractable text: ${target})`; + return paginateDocument(paged, options, maxChars); } function splitLinesKeepEnds(text: string): string[] { @@ -575,7 +586,10 @@ export class ReadFileTool extends Tool { "Images return visual content for analysis. Supports PDF, DOCX, XLSX, PPTX documents. " + "Use find_files/list_dir first when the path is uncertain. " + "Read the relevant range before editing so replacements or patches are based on current content. " + - "Use offset and limit for large text files. Use force=true to re-read content even if unchanged." + "Use offset and limit for text file lines. For documents use pages for PDF page ranges, " + + "and char_offset/char_limit to read extracted text in chunks (also within a large PDF page). " + + "Follow the returned char_offset with the same path/pages until the required content is read; " + + "an excerpt is not the full document. Use force=true to re-read text even if unchanged." ); } @@ -586,7 +600,9 @@ export class ReadFileTool extends Tool { path: { type: "string" }, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 }, - pages: { type: "string" }, + pages: { type: "string", description: "PDF page or inclusive page range, e.g. 15 or 15-20." }, + char_offset: { type: "integer", minimum: 0, description: "Documents only: zero-based UTF-16 offset in extracted text after PDF page selection. Use the returned continuation offset." }, + char_limit: { type: "integer", minimum: 1, description: "Documents only: requested chunk size in UTF-16 units (default 32000; capped to the output budget)." }, force: { type: "boolean" }, }, required: ["path"], @@ -605,7 +621,7 @@ export class ReadFileTool extends Tool { return resolveFileStates(this.fileStateOwner, this.fallbackFileStates, this.requestContext); } - async execute(params: { path?: string; offset?: number; limit?: number; pages?: string; force?: boolean } = {}): Promise { + async execute(params: { path?: string; offset?: number; limit?: number; pages?: string; force?: boolean } & DocumentReadOptions = {}): Promise { const requested = params.path; if (!requested) return "Error reading file: Unknown path"; if (isBlockedDevicePath(requested)) { @@ -622,9 +638,8 @@ export class ReadFileTool extends Tool { const stat = await fs.stat(target); if (!stat.isFile()) return `Error reading file: not a file: ${requested}`; const ext = path.extname(target).toLowerCase(); - if (ext === ".pdf") return this.readPdfFile(target, params.pages ?? null); - if ([".docx", ".xlsx", ".pptx"].includes(ext)) return this.readOfficeDocument(target); - if (DOCUMENT_EXTS.has(ext)) return await formatDocumentRead(target, params.pages, ReadFileTool.MAX_CHARS); + if (ext === ".pdf") return await this.readPdfFile(target, params.pages ?? null, params); + if (DOCUMENT_EXTS.has(ext)) return await this.readOfficeDocument(target, params); if (IMAGE_EXTS.has(ext)) { const data = await fs.readFile(target); const mime = lookup(target) || "image/png"; @@ -665,12 +680,12 @@ export class ReadFileTool extends Tool { } } - readPdfFile(target: string, pages?: string | null): Promise { - return readPdf(target, pages, ReadFileTool.MAX_CHARS); + readPdfFile(target: string, pages?: string | null, options: DocumentReadOptions = {}): Promise { + return formatDocumentRead(target, pages, ReadFileTool.MAX_CHARS, options); } - readOfficeDocument(target: string): Promise { - return readOfficeDoc(target, ReadFileTool.MAX_CHARS); + readOfficeDocument(target: string, options: DocumentReadOptions = {}): Promise { + return formatDocumentRead(target, null, ReadFileTool.MAX_CHARS, options); } } diff --git a/App/memmy-agent/src/templates/agent/tool-contract.md b/App/memmy-agent/src/templates/agent/tool-contract.md index 6eaae19ff..b9c939ad5 100644 --- a/App/memmy-agent/src/templates/agent/tool-contract.md +++ b/App/memmy-agent/src/templates/agent/tool-contract.md @@ -29,7 +29,9 @@ records general tool contracts and less obvious usage patterns. - Use `fixed_strings=true` for literal keywords containing regex characters. - Before reading full matches, use `output_mode="count"` to estimate the scale of broad searches. - Use `head_limit` and `offset` to page through large result sets. -- You may skip binary or very large files to keep results readable. +- For long documents, use `read_file` with `char_offset` and `char_limit`; use `pages` to select PDF pages. Follow the returned continuation offset with the same path and page selection until the required content is read. When the task requires the whole document, continue to the end before claiming it is fully read. +- A truncated excerpt does not establish that a fact is absent or unavailable. Retrieve the missing range before drawing conclusions or asking the user to supply information already in the document. +- If command output is truncated while extracting a document, save the extracted text to a file and read the relevant ranges, or use `read_file` on the original document. Polling a completed command does not recover omitted output. ## File and Coding Workflows diff --git a/App/memmy-agent/src/utils/document.ts b/App/memmy-agent/src/utils/document.ts index 82dde0ddc..c97d54dee 100644 --- a/App/memmy-agent/src/utils/document.ts +++ b/App/memmy-agent/src/utils/document.ts @@ -122,17 +122,17 @@ async function extractDocxOpenXml(file: string): Promise { return texts.join(""); }) .filter((line) => line.trim()); - if (paragraphs.length) return truncate(paragraphs.join("\n\n")); + if (paragraphs.length) return paragraphs.join("\n\n"); const texts: string[] = []; collectXmlText(parsed, new Set(["w:t"]), texts); - return truncate(texts.filter(Boolean).join("\n\n")); + return texts.filter(Boolean).join("\n\n"); } async function extractDocx(file: string): Promise { try { const result = await mammoth.extractRawText({ path: file }); const text = result.value.trim(); - if (text) return truncate(text); + if (text) return text; return extractDocxOpenXml(file); } catch (err) { try { @@ -171,7 +171,7 @@ async function extractXlsxOpenXml(file: string): Promise { } if (rows.length) sheets.push(`--- Sheet: Sheet${index + 1} ---\n${rows.join("\n")}`); } - return truncate(sheets.join("\n\n")); + return sheets.join("\n\n"); } async function extractXlsx(file: string): Promise { @@ -188,7 +188,7 @@ async function extractXlsx(file: string): Promise { }); if (rows.length) sheets.push(`--- Sheet: ${sheet.name} ---\n${rows.join("\n")}`); }); - return truncate(sheets.join("\n\n")); + return sheets.join("\n\n"); } catch (err) { try { return await extractXlsxOpenXml(file); @@ -264,7 +264,7 @@ async function extractPptx(file: string): Promise { const cleaned = texts.map((line) => line.trim()).filter(Boolean); if (cleaned.length) slides.push(`--- Slide ${index + 1} ---\n${cleaned.join("\n")}`); } - return truncate(slides.join("\n\n")); + return slides.join("\n\n"); } catch (err) { return `[error: failed to extract PPTX: ${(err as Error).message}]`; } @@ -292,11 +292,11 @@ async function extractPdf(file: string): Promise { .trim(); pages.push(`--- Page ${i} ---\n${text}`); } - return truncate(pages.join("\n\n")); + return pages.join("\n\n"); } catch (err) { const raw = fs.readFileSync(file); const text = raw.toString("latin1").match(/\(([^()\r\n]{2,})\)/g)?.map((m) => m.slice(1, -1)).join("\n") ?? ""; - if (text) return truncate(`--- Page 1 ---\n${text}`); + if (text) return `--- Page 1 ---\n${text}`; return `[error: failed to extract PDF: ${(err as Error).message}]`; } finally { await (doc as any)?.destroy?.(); @@ -305,13 +305,14 @@ async function extractPdf(file: string): Promise { function extractTextFile(file: string): string { try { - return truncate(fs.readFileSync(file, "utf8")); + return fs.readFileSync(file, "utf8"); } catch { - return truncate(fs.readFileSync(file, "latin1")); + return fs.readFileSync(file, "latin1"); } } -export async function extractText(filePath: string): Promise { +/** Keep extraction lossless so callers can select pages before applying an output budget. */ +async function extractFullText(filePath: string): Promise { const file = String(filePath); if (!fs.existsSync(file)) return `[error: file not found: ${file}]`; const ext = path.extname(file).toLowerCase(); @@ -329,3 +330,13 @@ export async function extractText(filePath: string): Promise { if ([".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext)) return `[image: ${path.basename(file)}]`; return null; } + +/** By default return a bounded preview; paginated readers explicitly request the full text. */ +export async function extractText( + filePath: string, + options: { maxChars?: number | null } = {}, +): Promise { + const text = await extractFullText(filePath); + if (text == null || text.startsWith("[error:") || options.maxChars === null) return text; + return truncate(text, options.maxChars ?? MAX_TEXT_LENGTH); +} diff --git a/App/memmy-agent/tests/core/agent-runtime/long-document-review.test.ts b/App/memmy-agent/tests/core/agent-runtime/long-document-review.test.ts new file mode 100644 index 000000000..73fd689ab --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/long-document-review.test.ts @@ -0,0 +1,145 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentRunner, AgentRunSpec } from "../../../src/core/agent-runtime/runner.js"; +import { ReadFileTool } from "../../../src/core/agent-runtime/tools/filesystem.js"; +import { ExecTool } from "../../../src/core/agent-runtime/tools/shell.js"; +import { ToolRegistry } from "../../../src/core/agent-runtime/tools/registry.js"; +import { ExecSessionManager, WriteStdinTool } from "../../../src/core/agent-runtime/tools/exec-session.js"; +import { SESSION_TOOL_RESULT_MAX_CHARS_BY_NAME } from "../../../src/core/agent-runtime/tool-result-budget.js"; +import { LLMProvider, LLMResponse, ToolCallRequest } from "../../../src/providers/base.js"; +import { renderTemplate } from "../../../src/utils/prompt-templates.js"; +import { REPORT_FACTS, reportText, writeDocx } from "../../helpers/long-document-fixtures.js"; + +const roots: string[] = []; +async function workspace() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-document-review-")); + roots.push(root); + return root; +} +afterEach(async () => { + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }); +}); + +// Scripted decisions validate runtime/tool plumbing, NOT real-model compliance. +class ScriptedReader extends LLMProvider { + calls = 0; + constructor(private readonly decide: (messages: any[], turn: number) => LLMResponse) { super(); } + getDefaultModel() { return "scripted-review-not-a-real-model"; } + async chat(args: any) { return this.decide(args.messages, this.calls++); } + async chatWithRetry(args: any) { return this.chat(args); } +} +function call(name: string, args: any, turn: number) { + return new LLMResponse({ content: null, toolCalls: [new ToolCallRequest({ id: `review-${turn}`, name, arguments: args })] }); +} +function spec(root: string, tools: ToolRegistry) { + return new AgentRunSpec({ + workspace: root, tools, maxIterations: 40, + toolResultMaxCharsByName: SESSION_TOOL_RESULT_MAX_CHARS_BY_NAME, + initialMessages: [ + { role: "system", content: renderTemplate("agent/tool-contract.md") }, + { role: "user", content: "Read the entire synthetic report and return every VERIFIED figure." }, + ], + }); +} +const quote = (text: string) => `'${text.replace(/'/g, "'\\''")}'`; + +describe("long-document review through the real AgentRunner", () => { + it("delivers continuation cursors and all late facts across multiple tool turns", async () => { + const root = await workspace(); + const file = await writeDocx(root, reportText()); + const tools = new ToolRegistry(); + tools.register(new ReadFileTool({ workspace: root })); + const provider = new ScriptedReader((messages, turn) => { + const outputs = messages.filter((m) => m.role === "tool").map((m) => String(m.content)); + if (!outputs.length) return call("read_file", { path: file }, turn); + const last = outputs.at(-1)!; + const next = /Continue with char_offset=(\d+)/.exec(last); + if (next) return call("read_file", { path: file, char_offset: Number(next[1]) }, turn); + expect(last).toContain("End of document"); + const figures = outputs.join("\n").match(/(?:PE|HOLDING|ISSUED|RAISED)=[0-9.%]+/g); + return new LLMResponse({ content: JSON.stringify(figures) }); + }); + const result = await new AgentRunner(provider).run(spec(root, tools)); + expect(JSON.parse(result.finalContent!)).toEqual(REPORT_FACTS); + expect(result.messages.filter((m) => m.role === "tool").length).toBeGreaterThan(8); + expect(result.toolEvents.every((e) => e.status === "ok")).toBe(true); + }); + + it("documents that a model can still stop early despite a continuation marker", async () => { + const root = await workspace(); + const file = await writeDocx(root, reportText()); + const tools = new ToolRegistry(); + tools.register(new ReadFileTool({ workspace: root })); + const provider = new ScriptedReader((_, turn) => turn === 0 + ? call("read_file", { path: file }, turn) + : new LLMResponse({ content: "Stopped without reading the remainder." })); + const result = await new AgentRunner(provider).run(spec(root, tools)); + const outputs = result.messages.filter((m) => m.role === "tool"); + expect(outputs).toHaveLength(1); + expect(outputs[0].content).toContain("Continue with char_offset="); + expect(result.finalContent).toBe("Stopped without reading the remainder."); + }); + + it("recovers exec's omitted middle by saving output and reading the file in ranges", async () => { + const root = await workspace(); + const input = reportText(60_000); + await fs.writeFile(path.join(root, "source.txt"), input); + await fs.writeFile(path.join(root, "emit.cjs"), 'process.stdout.write(require("node:fs").readFileSync("source.txt", "utf8"));'); + const command = `${quote(process.execPath)} emit.cjs`; + const tools = new ToolRegistry(); + tools.register(new ExecTool({ workspace: root })); + tools.register(new ReadFileTool({ workspace: root })); + const provider = new ScriptedReader((messages, turn) => { + const outputs = messages.filter((m) => m.role === "tool").map((m) => String(m.content)); + if (turn === 0) return call("exec", { command }, turn); + if (turn === 1) { + expect(outputs[0]).toContain("truncated"); + expect(outputs[0]).not.toContain(REPORT_FACTS[1]); + return call("exec", { command: `${command} > extracted.txt` }, turn); + } + if (turn === 2) return call("read_file", { path: "extracted.txt", limit: 150 }, turn); + const next = /Use offset=(\d+) to continue/.exec(outputs.at(-1)!); + if (next) return call("read_file", { path: "extracted.txt", offset: Number(next[1]), limit: 150 }, turn); + expect(outputs.at(-1)).toContain("End of file"); + const figures = outputs.slice(2).join("\n").match(/(?:PE|HOLDING|ISSUED|RAISED)=[0-9.%]+/g); + return new LLMResponse({ content: JSON.stringify(figures) }); + }); + const result = await new AgentRunner(provider).run(spec(root, tools)); + expect(JSON.parse(result.finalContent!)).toEqual(REPORT_FACTS); + expect(await fs.readFile(path.join(root, "extracted.txt"), "utf8")).toBe(input); + }); + + it("confirms completed exec sessions cannot replay omitted output", async () => { + const root = await workspace(); + const source = "a".repeat(30_000) + "MISSING_MIDDLE_FACT" + "z".repeat(30_000); + await fs.writeFile(path.join(root, "source.txt"), source); + await fs.writeFile(path.join(root, "emit.cjs"), 'process.stdout.write(require("node:fs").readFileSync("source.txt", "utf8"));'); + const manager = new ExecSessionManager(); + try { + // The manager returns the id even when the completed tool response omits it. + const [id, first] = await manager.start({ command: `${quote(process.execPath)} emit.cjs`, cwd: root, yieldTimeMs: 1000, maxOutputChars: 1000 }); + expect(first.done).toBe(true); + expect(first.truncatedChars).toBeGreaterThan(0); + expect(first.output).not.toContain("MISSING_MIDDLE_FACT"); + const next = await new WriteStdinTool({ manager }).execute({ session_id: id, yield_time_ms: 0 }); + expect(next).not.toContain("MISSING_MIDDLE_FACT"); + expect(next).not.toContain("a".repeat(20)); + expect(next).toContain("exec session not found"); + } finally { + for (const session of manager.sessions.values()) await session.kill(); + } + }); + + it("exposes the remaining oversized single-line text recovery limitation", async () => { + const root = await workspace(); + const file = path.join(root, "single-line-extraction.txt"); + await fs.writeFile(file, "x".repeat(200_000) + "TAIL"); + const result = await new ReadFileTool({ workspace: root }).execute({ path: file }); + // Characterization of a known gap, not evidence that this recovery route is safe. + expect(result.length).toBeGreaterThan(ReadFileTool.MAX_CHARS); + expect(result).not.toContain("Use offset="); + expect(result).toContain("End of file"); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/read-enhancements.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/read-enhancements.test.ts index d44fd4a60..b79290a5e 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/read-enhancements.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/read-enhancements.test.ts @@ -45,7 +45,7 @@ function zipTree(tree: Record, filename: string): string { return out; } -function makePdf(root: string, name: string, parts: string[]): string { +function makePdf(root: string, name: string, parts: string[], fontSize = 24): string { const file = path.join(root, name); const escapePdfText = (text: string) => text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); const objects: string[] = []; @@ -55,7 +55,7 @@ function makePdf(root: string, name: string, parts: string[]): string { parts.forEach((part, index) => { const pageObj = 3 + index * 2; const contentObj = 4 + index * 2; - const stream = `BT /F1 24 Tf 72 720 Td (${escapePdfText(part)}) Tj ET`; + const stream = `BT /F1 ${fontSize} Tf 72 720 Td (${escapePdfText(part)}) Tj ET`; objects[pageObj] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ` + `/Resources << /Font << /F1 ${fontObj} 0 R >> >> /Contents ${contentObj} 0 R >>`; @@ -351,14 +351,107 @@ describe("ReadFileTool enhanced behavior", () => { expect(await new ReadFileTool({ workspace: root }).execute({ path: file })).toContain("no extractable text"); }); - it("truncates large extracted documents", async () => { - documentMocks.extractText.mockReturnValueOnce("x".repeat(200_000)); + it.each(["docx", "xlsx", "pptx", "pdf"])("reads every character of a long %s through continuation offsets", async (ext) => { const root = tmpRoot(); - const file = path.join(root, "large.docx"); - fs.writeFileSync(file, "PK", "utf8"); - const result = await new ReadFileTool({ workspace: root }).execute({ path: file }); - expect(result.length).toBeLessThanOrEqual(ReadFileTool.MAX_CHARS + 100); - expect(result).toContain("truncated at ~128K chars"); + const file = path.join(root, `large.${ext}`); + fs.writeFileSync(file, "fixture"); + const source = "前文🙂".repeat(60_000) + "TAIL: verified figure 38.95"; + const tool = new ReadFileTool({ workspace: root }); + let offset = 0; + let combined = ""; + for (let count = 0; count < 30; count += 1) { + documentMocks.extractText.mockResolvedValueOnce(source); + const result = await tool.execute({ path: file, char_offset: offset }); + expect(result.length).toBeLessThanOrEqual(ReadFileTool.MAX_CHARS); + const separator = result.lastIndexOf("\n\n[Document read:"); + expect(separator).toBeGreaterThanOrEqual(0); + const body = result.slice(0, separator); + expect(body).not.toMatch(/[\uD800-\uDBFF]$/); + combined += body; + const next = /Continue with char_offset=(\d+)/.exec(result); + if (!next) { + expect(result).toContain("End of document"); + break; + } + expect(Number(next[1])).toBeGreaterThan(offset); + offset = Number(next[1]); + } + expect(combined).toBe(source); + expect(documentMocks.extractText).toHaveBeenLastCalledWith(file, { maxChars: null }); + }); + + it("reads a real DOCX tail beyond both former truncation limits", async () => { + const text = "正文段落 ".repeat(50_000) + "FINAL_VERIFIED_NUMBER_38.95"; + const file = zipTree( + { "word/document.xml": `${text}` }, + "long.docx", + ); + const tool = new ReadFileTool({ workspace: path.dirname(file) }); + let offset = 0; + let combined = ""; + for (let count = 0; count < 10; count += 1) { + const result = await tool.execute({ path: file, char_offset: offset, char_limit: 1_000_000 }); + expect(result.length).toBeLessThanOrEqual(ReadFileTool.MAX_CHARS); + combined += result.slice(0, result.lastIndexOf("\n\n[Document read:")); + const next = /Continue with char_offset=(\d+)/.exec(result); + if (!next) { + expect(result).toContain("End of document"); + break; + } + offset = Number(next[1]); + } + expect(combined === text).toBe(true); + }); + + it("reads a real PDF page after more than 200K extracted characters", async () => { + const root = tmpRoot(); + // Keep every glyph inside the page bounds; PDF.js omits off-page text. + const file = makePdf(root, "long.pdf", [...Array.from({ length: 14 }, () => "a".repeat(16_000)), "PAGE_15_VERIFIED_NUMBER"], 0.02); + const extracted = await documentMocks.extractText(file, { maxChars: null }); + expect(extracted.length).toBeGreaterThan(200_000); + const tool = new ReadFileTool({ workspace: root }); + const result = await tool.execute({ path: file, pages: "15" }); + expect(result).toContain("--- Page 15 ---"); + expect(result).toContain("PAGE_15_VERIFIED_NUMBER"); + expect(result).toContain("End of document"); + }); + + it("makes progress when a one-unit chunk encounters an emoji", async () => { + const root = tmpRoot(); + const file = path.join(root, "emoji.docx"); + fs.writeFileSync(file, "fixture"); + documentMocks.extractText.mockResolvedValueOnce("🙂tail"); + const result = await new ReadFileTool({ workspace: root }).execute({ path: file, char_limit: 1 }); + expect(result).toMatch(/^🙂\n\n/); + expect(result).toContain("Continue with char_offset=2"); + }); + + it("keeps PDF page selection when continuing within a long page", async () => { + const root = tmpRoot(); + const file = path.join(root, "pages.pdf"); + fs.writeFileSync(file, "fixture"); + const source = "--- Page 1 ---\n" + "a".repeat(210_000) + "\n\n--- Page 2 ---\n" + "b".repeat(70_000) + "LATE_FIGURE"; + const tool = new ReadFileTool({ workspace: root }); + documentMocks.extractText.mockResolvedValueOnce(source); + const first = await tool.execute({ path: file, pages: "2", char_limit: 1000 }); + expect(first).toContain("--- Page 2 ---"); + expect(first).toContain("Continue with char_offset=1000"); + expect(first).toContain("Keep the same path and pages"); + documentMocks.extractText.mockResolvedValueOnce(source); + const last = await tool.execute({ path: file, pages: "2", char_offset: 70_000 }); + expect(last).toContain("LATE_FIGURE"); + expect(last).toContain("End of document"); + }); + + it("rejects invalid or out-of-range document cursors", async () => { + const root = tmpRoot(); + const file = path.join(root, "small.docx"); + fs.writeFileSync(file, "fixture"); + const tool = new ReadFileTool({ workspace: root }); + for (const options of [{ char_offset: -1 }, { char_offset: 0.5 }, { char_offset: 20 }, { char_limit: 0 }]) { + documentMocks.extractText.mockResolvedValueOnce("short"); + expect(await tool.execute({ path: file, ...options })).toContain("Error"); + } }); it("does not truncate small extracted documents", async () => { diff --git a/App/memmy-agent/tests/document-parsing.test.ts b/App/memmy-agent/tests/document-parsing.test.ts index 2afa5848d..1ca876b5b 100644 --- a/App/memmy-agent/tests/document-parsing.test.ts +++ b/App/memmy-agent/tests/document-parsing.test.ts @@ -91,6 +91,16 @@ describe("document parsing", () => { expect(await extractText(file)).toBe("Hello, world!\nThis is a test."); }); + it("can extract a complete document while retaining the default preview limit", async () => { + const text = "long paragraph ".repeat(20_000) + "TAIL_AFTER_200K"; + const docx = zipTree( + { "word/document.xml": `${text}` }, + "long.docx", + ); + expect(await extractText(docx)).not.toContain("TAIL_AFTER_200K"); + expect(await extractText(docx, { maxChars: null })).toBe(text); + }); + it("truncates large text files", async () => { const root = tempRoot(); const file = path.join(root, "large.txt"); diff --git a/App/memmy-agent/tests/helpers/long-document-fixtures.ts b/App/memmy-agent/tests/helpers/long-document-fixtures.ts new file mode 100644 index 000000000..e56acfc13 --- /dev/null +++ b/App/memmy-agent/tests/helpers/long-document-fixtures.ts @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import JSZip from "jszip"; + +export const REPORT_FACTS = ["PE=38.95", "HOLDING=61.35%", "ISSUED=112000000", "RAISED=2110000000"]; + +export function reportText(chars = 260_000): string { + const section = "Synthetic company disclosure. Figures in this fixture are not investment advice.\n"; + const padding = section.repeat(Math.ceil(chars / 4 / section.length)); + return REPORT_FACTS.map((fact, index) => `Section ${index + 1}\n${padding}\nVERIFIED: ${fact}\n`).join("\n"); +} + +export async function writeDocx(root: string, text: string): Promise { + const zip = new JSZip(); + zip.file("[Content_Types].xml", ''); + zip.file("_rels/.rels", ''); + const escape = (value: string) => value.replace(/&/g, "&").replace(//g, ">"); + const body = text.split("\n").map((line) => `${escape(line)}`).join(""); + zip.file("word/document.xml", `${body}`); + const file = path.join(root, "synthetic-report.docx"); + await fs.writeFile(file, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" })); + return file; +} + +export async function writePdf(root: string, pageCount: number): Promise { + const objects: string[] = []; + const font = 3 + pageCount * 2; + objects[1] = "<< /Type /Catalog /Pages 2 0 R >>"; + objects[2] = `<< /Type /Pages /Kids [${Array.from({ length: pageCount }, (_, i) => `${3 + i * 2} 0 R`).join(" ")}] /Count ${pageCount} >>`; + for (let i = 0; i < pageCount; i += 1) { + // 120 visible lines at 4pt: ~15K extracted chars/page, all within page bounds. + const lines = Array.from({ length: 120 }, (_, row) => `Page ${i + 1} row ${row + 1} synthetic report ` + "data ".repeat(19)); + if (i === pageCount - 1) lines[119] = "VERIFIED: " + REPORT_FACTS.join("; "); + const stream = `BT /F1 4 Tf 5 TL 36 760 Td ${lines.map((line, row) => `${row ? "T* " : ""}(${line}) Tj`).join("\n")} ET`; + objects[3 + i * 2] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${font} 0 R >> >> /Contents ${4 + i * 2} 0 R >>`; + objects[4 + i * 2] = `<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`; + } + objects[font] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + let body = "%PDF-1.4\n"; + const offsets = [0]; + for (let i = 1; i < objects.length; i += 1) { + offsets[i] = Buffer.byteLength(body); + body += `${i} 0 obj\n${objects[i]}\nendobj\n`; + } + const start = Buffer.byteLength(body); + body += `xref\n0 ${objects.length}\n0000000000 65535 f \n`; + for (let i = 1; i < objects.length; i += 1) body += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; + body += `trailer\n<< /Root 1 0 R /Size ${objects.length} >>\nstartxref\n${start}\n%%EOF\n`; + const file = path.join(root, "synthetic-report.pdf"); + await fs.writeFile(file, body); + return file; +} diff --git a/docs/debug-long-document-live-review.md b/docs/debug-long-document-live-review.md new file mode 100644 index 000000000..0953046c8 --- /dev/null +++ b/docs/debug-long-document-live-review.md @@ -0,0 +1,71 @@ +# 账户模型真实会话复测 + +日期:2026-09-23。源码:`fix/debug-session`,基线 `d101fd58`,仓库版本 `1.1.8`。本次使用构建后的 `dist/main.js agent`,不是脚本模型。 + +## 模型与隔离方式 + +复核时 `~/.memmy/config.yaml` 已存在,包含可用的 `memmy_account` 账户通道。使用该配置的 agent preset,模型路由为 `agent_chat`,请求发送至 `https://api.memmy.cn/api/agentExternal/v1`。没有要求提供新密钥,也未把账户 JWT 写入仓库。云端实际底层模型及其版本未单独确认。 + +使用本机可用 Node.js v24.19.0 构建、运行;未使用 Electron。运行时配置、workspace、会话、GUI 事件记录、DAG 路径和 app 数据库路径均指向临时目录。关闭本地记忆、文件记忆、浏览器、Web 工具和 MCP;直接执行单轮 CLI,没有启动 Gateway 或 WebSocket 服务。为关闭 DAG,将上下文摘要模式设为 `text`;测试限制为最多 40 次工具迭代。 + +测试结束后已清除 6 份临时配置中的账户凭据;扫描本次改动和新增文件,未发现该凭据。原始账户配置的修改时间仍为测试前观察到的 2026-09-23 19:57:14(北京时间)。保留的临时配置需重新运行准备脚本才能用于新的账户调用。 + +启动迁移会重新加入默认 MCP,因此准备脚本先执行临时目录的迁移,再清空 MCP,并向子 CLI 传入已完成迁移的目标信息。隔离配置不能仅依赖删减 YAML 字段。 + +## 结果 + +| 场景 | 工具轨迹证据 | 答案 | CLI 耗时 | +| --- | --- | --- | ---: | +| 完整阅读长 DOCX | 8 次 `read_file`,连续覆盖 0–263543,最后一段包含结束标记 | 四组字段和值全部正确 | 70.2 秒 | +| PDF 后部页码 | `read_file(path="synthetic-report.pdf", pages="20")`,返回该页全部 15211 字符;该页位于原 20 万字符提取上限之后 | 页码及四组字段和值全部正确 | 69.3 秒 | +| exec 截断恢复(有效重测) | `cat source.txt` 成功返回 10037 字符且包含截断标记;随后 `grep` 定位,`read_file` 分别核对第 806、1613、2420、3227 行 | 四组字段和值全部正确,无提前结束 | 17.0 秒 | + +DOCX 读取区间依次为: + +```text +0–32000 +32000–64000 +64000–96000 +96000–128000 +128000–160000 +160000–192000 +192000–224000 +224000–263543 End of document +``` + +标准答案:`PE=38.95`、`HOLDING=61.35%`、`ISSUED=112000000`、`RAISED=2110000000`。最终答案和工具记录均已核对,不仅依据模型自称“已读完”。 + +首次 exec 场景使用绝对 Node 路径,被工作区检查拦截;模型直接读取了源文件,虽然答案正确,但没有实际触发命令输出截断,因此不计为有效通过。随后改用工作区内的纯读取命令 `cat source.txt`,扩大样例并重新创建会话,才获得上表中的有效结果。 + +## 复测期间的额外行为 + +DOCX/PDF 模型在得到所需结果后进行了多余的二次核对,包含解包、调用其他提取工具等行为,因此实际会话耗时显著高于单纯解析基准。 + +首轮 PDF 会话还调用 `pip install pypdf pdfplumber`,向用户 Python 目录安装了此前不存在的两个包。这说明 `restrictToWorkspace` 是命令路径检查,并不等同于操作系统沙箱。已向用户说明并获准卸载这两个直接安装的包。由于没有安装前的依赖快照,没有擅自卸载其他可能的传递依赖;不能声称完整回滚了所有 Python 环境变化。 + +首轮 DOCX 会话还出现清理命令被过滤后改用其他命令的行为;本记录不将其视为推荐操作。后续准备脚本对 DOCX/PDF 禁用 exec,对 exec 测试只允许精确匹配 `cat source.txt` 或将其输出重定向到工作区内 `extracted.txt`,从源头限制包安装和其他副作用。有效 exec 重测使用了此白名单。 + +## 可复跑工具 + +从 `App/memmy-agent` 运行(依赖及内部包已构建): + +```bash +node node_modules/typescript/bin/tsc -p tsconfig.build.json +node scripts/copy-build-assets.mjs +node --import tsx scripts/debug/prepare-live-document-review.ts +# 上一步输出私有临时目录。以下 ROOT 替换为该目录,不使用真实账户配置直接运行。 +node --import tsx scripts/debug/run-live-document-review.ts ROOT/docx +node --import tsx scripts/debug/run-live-document-review.ts ROOT/pdf +node --import tsx scripts/debug/run-live-document-review.ts ROOT/exec +node --import tsx scripts/debug/summarize-live-document-review.ts ROOT +``` + +准备脚本会创建私有临时配置,运行脚本记录退出状态和脱敏输出,汇总脚本只导出可见答案与工具证据,不导出模型推理流。运行会消耗正常账户模型用量。最终脚本的命令限制比首轮 DOCX/PDF 更严格,因此复跑时额外核对行为和耗时可能不同。 + +## 验收边界 + +- 三种合成场景各有一轮有效真实模型通过,不代表多轮统计可靠性,也不等同于原文档、原版本、原会话复现。 +- exec 测试证明模型能从截断恢复所需事实;它没有按顺序读完整个文件,也未恢复原会话已丢弃的 stdout,而是读取了可访问的源文件。 +- 超长单行文本分页缺口仍未修复;本次多行样例不覆盖它。 +- 重复全量解析的性能成本仍在,之前的基准数据继续有效。真实会话还包含推理、网络和多余工具调用耗时。 +- 上述复测发生在本地提交之前,尚未推送或安装替换桌面应用。超长单行文本和缓存优化按用户确认的范围作为独立后续事项。 diff --git a/docs/debug-long-document-reading.md b/docs/debug-long-document-reading.md new file mode 100644 index 000000000..bc7ed83e2 --- /dev/null +++ b/docs/debug-long-document-reading.md @@ -0,0 +1,67 @@ +# 长文档读取截断修复记录 + +## 问题与基线 + +- 分支:`fix/debug-session` +- 基线:`d101fd582857ddc046416f5f08741297345992dc` +- 用户现象:Agent 报告原文或 PDF 页面在工具输出中被截断,未继续核实后文。 +- 证据范围:已根据代码和测试复现文档读取链路缺陷;没有截图对应会话的工具调用日志、模型配置或原始文档,尚未复测该会话。 + +## 根因 + +1. `utils/document.ts` 各解析器只保留提取结果的前 200,000 个 UTF-16 单元。 +2. `read_file` 又截取前 128,000 个单元,未提供文档续读游标。文本文件的 `offset`、`limit` 不作用于 PDF 或 Office 文档。 +3. PDF 页码筛选发生在提取层截断之后,因此后部页码可能被误判为无效。 +4. 截断提示附加在输出上限之后,可能再次被运行时结果预算处理。 + +命令工具另有输出上限;此次为该路径增加恢复读取指引,未修改命令输出保存机制。 + +## 修改后行为 + +- 提取器默认仍提供 200,000 单元的预览;分页读取显式请求完整提取结果。 +- 文档工具先选择 PDF 页码,再按 `char_offset`、`char_limit` 返回内容;默认块大小为 32,000 单元。 +- `char_offset` 从 0 开始,按 JavaScript UTF-16 字符串索引计算。调用者应使用工具返回的游标,不自行计算中文或 emoji 的长度。 +- 返回当前范围、总长度,以及后续游标或结束标记。续读时保持 `path` 和 `pages` 不变。 +- 即使请求极大块,也为续读提示预留输出预算;自动分段不会拆开有效的 UTF-16 代理对。 +- Agent 指引要求继续读取任务所需范围;需要完整阅读时必须读到结束。若命令提取输出被截断,应保存到文件分段读取,或改用原始文档的 `read_file`。 + +调用示例: + +```json +{"path":"report.pdf","pages":"15","char_limit":32000} +``` + +如结果提示 `Continue with char_offset=32000`,继续: + +```json +{"path":"report.pdf","pages":"15","char_offset":32000,"char_limit":32000} +``` + +## 验证 + +环境:macOS、本地 Node.js v24.19.0、agent 锁文件中的 Vitest 4.1.7。 + +修复读取逻辑之前,6 个新用例失败,涵盖四种文档续读、PDF 页内续读、无效游标。修复后运行以下测试文件: + +```bash +cd App/memmy-agent +MEMMY_AGENT_DATA_DIR=/private/tmp/memmy-debug-test-data node node_modules/vitest/vitest.mjs run \ + tests/document-parsing.test.ts \ + tests/core/agent-runtime/tools/read-enhancements.test.ts \ + tests/core/agent-runtime/tools/filesystem-tools.test.ts \ + tests/core/agent-runtime/tools/tool-descriptions.test.ts \ + tests/utils/prompt-templates.test.ts \ + tests/core/agent-runtime/tool-result-budget.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit +``` + +测试前按两份锁文件安装依赖,并构建 Migrations、Knowledge、local-api-contracts 内部包。测试数据使用临时目录,避免写入日常 Memmy 数据目录。 + +结果:6 个测试文件、135 个测试通过,类型检查通过。覆盖真实长 DOCX 完整拼接、累计提取量超过 200,000 单元的 PDF 第 15 页、四种文档的无损分段、最大块预算、emoji 边界和错误输入。 + +## 边界与上线验证 + +- 仍依赖文档现有文本提取能力;扫描 PDF 的 OCR 不在此次修复范围。 +- 每次续读会重新提取文档,超大文档可能较慢;读取过程中应保持文件不变。 +- 提供续读能力和模型指引不能保证所有模型都会正确续读。需使用原模型及原文档进行会话级复测,确认核实了目标数据后再报告完成。 +- 代码尚未打包或安装到桌面应用,已安装版本不会因本地源码修改自动更新。 diff --git a/docs/debug-long-document-review.md b/docs/debug-long-document-review.md new file mode 100644 index 000000000..c72df6541 --- /dev/null +++ b/docs/debug-long-document-review.md @@ -0,0 +1,85 @@ +# 长文档修复复查:会话链路、命令恢复与性能 + +日期:2026-09-23。分支:`fix/debug-session`。本次只增加复查样例、测试、基准脚本和记录,没有新增生产代码修改。 + +后续已复用本机账户配置完成三项真实模型复测,见 [账户模型真实会话复测](debug-long-document-live-review.md)。下文的模型配置缺失和阻塞描述保留为首轮检查记录,不再代表当前状态。 + +## 结论 + +| 检查项 | 结果 | 证据边界 | +| --- | --- | --- | +| Agent 多轮续读链路 | 确定性集成测试通过;真实模型复测阻塞 | 使用真实 AgentRunner、工具注册、文档读取与结果预算;模型决策由脚本提供,不能证明真实模型会遵循提示 | +| exec 截断恢复 | 可通过重新输出到文件后分行读取恢复;仍有缺口 | 完成后的会话无法重放已丢弃内容;超过上限的单行文本不具备可靠续读入口 | +| 大文档性能 | 已测量,有优化空间 | 96 页 PDF 全量读取 46 次、11.49 秒;测试进程峰值约 1 GB,包含依赖加载和样例创建 | + +当前不应把“测试通过”解释为原用户问题已完全关闭。 + +## 1. Agent 多轮读取 + +没有发现仓库 `.env`、默认 `~/.memmy/config.yaml` 或当前进程中的 API 凭据变量。已向用户询问模型和可用配置路径,尚未获得真实模型复测配置;没有搜索或使用其他应用的凭据。 + +新增 `tests/core/agent-runtime/long-document-review.test.ts`,通过真实 AgentRunner 运行工具调用。合成 DOCX 中分散放置四组已知数据:PE、持股比例、发行数量和募集金额。脚本模型只根据已收到的工具内容返回游标或提取答案,测试断言答案等于独立定义的标准值。 + +- 正向测试:跨越多轮读取、多个旧截断边界后,四组数据均可读回。 +- 反向测试:脚本模型在第一段后直接回答,AgentRunner 仍结束任务。这证明当前运行时没有强制完整阅读;续读取决于模型行为。 +- 此处没有调用真实模型,不能将脚本模型通过计入真实模型验收。 + +获取配置后的真实模型验收应运行至少“完整阅读”和“指定页核实”两类任务,保存模型名、提示词、工具轨迹、最终答案以及是否到达所需范围。完整阅读任务应核实结束标记;指定范围任务不应要求无关全文读取。若比较修复前后,应使用相同模型、设置和合成文档。 + +## 2. exec 输出截断及恢复 + +使用真实 Node 子进程输出合成报告,未 mock 命令执行。 + +1. 普通 `exec` 返回首尾片段,中部已知数据不在结果中,证明截断会丢失所需事实。 +2. 重新执行相同的纯读取命令,将标准输出重定向到文件。 +3. AgentRunner 使用 `read_file` 的行偏移分段读取,恢复全部四组数据;文件内容与源文本相同。 +4. 独立测试确认:会话完成且输出截断后,`write_stdin` 返回会话不存在,不能恢复遗漏部分。 + +这条恢复流程仍由脚本决策驱动,未验证真实模型会自主采用。只应重跑确定没有副作用的文档提取命令,不能把这一策略无条件应用于任意命令。 + +### 新发现的单行文本缺口 + +生成一个超过 200,000 字符且没有换行的提取结果 `.txt`。当前文本读取逻辑只会减少“行数”;只剩一行时仍返回超出 128,000 上限的结果,同时标记 `End of file`,没有续读偏移。 + +运行时可能将此结果落盘为大工具结果引用,但再次用相同的按行工具读取超长单行,仍会遇到同一预算问题。因此“先保存文件再读”对普通多行文本有效,对超长单行文本还不可靠。应补充文本字符级分页或可靠的输出文件分块能力,再单独验证。当前复查测试明确记录这一已知缺陷;该测试通过表示缺陷可复现,不表示缺陷已经修复。 + +## 3. 性能测量 + +环境:macOS、Node.js v24.19.0。每个规模在独立进程中运行一次。使用真实 DOCX/PDF 解析器,默认每段 32,000 UTF-16 单元;分段内容的 SHA-256 与完整提取结果一致。PDF 文本均置于页面可见范围内。 + +| 样例 | 提取长度 | 读取次数 | 首段耗时 | 全文续读耗时 | 单次完整提取耗时 | 进程峰值 RSS | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| DOCX,约 25 万字符 | 253,375 | 8 | 29.8 ms | 148.2 ms | 15.5 ms | 507 MiB | +| DOCX,约 100 万字符 | 1,012,695 | 32 | 86.5 ms | 1,910.0 ms | 56.2 ms | 1,103 MiB | +| PDF,20 页 | 304,233 | 10 | 227.3 ms | 691.3 ms | 50.6 ms | 660 MiB | +| PDF,96 页 | 1,464,677 | 46 | 418.3 ms | 11,494.3 ms | 247.0 ms | 1,041 MiB | + +注意: + +- 峰值为整个测试进程的高水位,包含模块加载、样例创建和解析;开始读取前 RSS 约 473–492 MiB。不能据此断言存在内存泄漏,或把全部内存归因于本次改动。 +- 单次提取在续读测量之后执行,是热运行参考,不是修复前版本的性能基线。 +- 样例为重复文本,DOCX 压缩文件较小,未覆盖大量图片、复杂表格或扫描 PDF。 +- 没有模型推理、网络往返或上下文压缩耗时;不能把此表当作真实会话的总耗时。 +- 未发生超时或内容缺失,但反复全量解析导致明显累积成本。若继续优化,优先考虑有容量上限、能检测文件变化的提取结果缓存,或 PDF 按所需页提取;保持单次输出预算。 + +## 复跑 + +在依赖安装及内部包构建完成后,从 `App/memmy-agent` 执行: + +```bash +MEMMY_AGENT_DATA_DIR=/private/tmp/memmy-debug-test-data \ + node node_modules/vitest/vitest.mjs run tests/core/agent-runtime/long-document-review.test.ts + +node --import tsx scripts/debug/benchmark-document-reading.ts docx 250000 +node --import tsx scripts/debug/benchmark-document-reading.ts docx 1000000 +node --import tsx scripts/debug/benchmark-document-reading.ts pdf 20 +node --import tsx scripts/debug/benchmark-document-reading.ts pdf 96 +``` + +生成样例位于临时目录,测试后自动清理。基准脚本输出 JSON 指标。原有 135 个相关测试和新增 5 个复查测试均已运行;类型检查与差异格式检查也纳入复查。 + +## 后续状态与本次范围 + +真实账户模型的三项合成场景复测已完成,详见后续报告。用户确认本次只修复长文档截断与续读,保持普通文本读取流程不变;超长单行文本和提取缓存优化单独记录,不作为本次关闭条件。 + +本次关闭前仍需完成提交、PR 与适用的 CI/审查,以及包含修复的桌面版本验收。现有性能数据可用于评审,但不是正式性能 SLA 验收。 From 8114ccc762edad4ae14f5a08978ab4119ee47a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Thu, 24 Sep 2026 15:39:28 +0800 Subject: [PATCH 02/13] fix(agent): keep read_file line counts aligned with apply_patch A trailing newline was shown as an extra blank line, so patches against one-line memory files failed to match. Co-authored-by: Cursor --- .../core/agent-runtime/tools/filesystem.ts | 10 +++++++- .../tools/filesystem-tools.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts index 671a0564b..894cea42d 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts @@ -32,6 +32,14 @@ function normalizeNewlines(text: string): string { return text.replace(/\r\n/g, "\n"); } +/** Same line split as apply_patch: a final newline does not create an extra blank line. */ +function splitFileLines(text: string): string[] { + if (!text) return []; + const lines = normalizeNewlines(text).split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + function lineNumbered(lines: string[], start: number): string { const width = String(start + lines.length - 1).length; return lines.map((line, index) => `${String(start + index).padStart(width, " ")}| ${line}`).join("\n"); @@ -638,7 +646,7 @@ export class ReadFileTool extends Tool { } const content = await fs.readFile(target, "utf8"); if (!content) return "Empty file"; - const lines = normalizeNewlines(content).split("\n"); + const lines = splitFileLines(content); const offset = params.offset ?? 1; if (offset > lines.length) return `Error reading file: offset ${offset} beyond end (${lines.length} lines)`; const limit = params.limit ?? null; diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts index fb57219fa..49db204b4 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts @@ -97,6 +97,30 @@ describe("ReadFileTool", () => { expect(result).toContain("End of file"); }); + it("does not show a trailing newline as an extra blank line", async () => { + const root = workspace(); + const file = path.join(root, "memory.md"); + await fs.writeFile(file, "only one memory line\n"); + + const result = await new ReadFileTool({ workspace: root }).execute({ path: file }); + + expect(result).toContain("1| only one memory line"); + expect(result).not.toMatch(/^2\|/m); + expect(result).toContain("End of file"); + }); + + it("keeps a real blank line before the final newline", async () => { + const root = workspace(); + const file = path.join(root, "memory.md"); + await fs.writeFile(file, "first\n\n"); + + const result = await new ReadFileTool({ workspace: root }).execute({ path: file }); + + expect(result).toContain("1| first"); + expect(result).toMatch(/^2\| $/m); + expect(result).not.toMatch(/^3\|/m); + }); + it("reports empty files", async () => { const root = workspace(); const file = path.join(root, "empty.txt"); From c639753c28e7b0f8ecf0eaee498c6d48c41fff07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Mon, 28 Sep 2026 21:42:47 +0800 Subject: [PATCH 03/13] chore(documents): drop debug scripts and investigation notes Keep the long-document fix limited to the runtime change and its tests. Co-authored-by: Cursor --- .../debug/benchmark-document-reading.ts | 61 ------------- .../debug/prepare-live-document-review.ts | 51 ----------- .../scripts/debug/run-live-document-review.ts | 57 ------------- .../debug/summarize-live-document-review.ts | 39 --------- docs/debug-long-document-live-review.md | 71 ---------------- docs/debug-long-document-reading.md | 67 --------------- docs/debug-long-document-review.md | 85 ------------------- 7 files changed, 431 deletions(-) delete mode 100644 App/memmy-agent/scripts/debug/benchmark-document-reading.ts delete mode 100644 App/memmy-agent/scripts/debug/prepare-live-document-review.ts delete mode 100644 App/memmy-agent/scripts/debug/run-live-document-review.ts delete mode 100644 App/memmy-agent/scripts/debug/summarize-live-document-review.ts delete mode 100644 docs/debug-long-document-live-review.md delete mode 100644 docs/debug-long-document-reading.md delete mode 100644 docs/debug-long-document-review.md diff --git a/App/memmy-agent/scripts/debug/benchmark-document-reading.ts b/App/memmy-agent/scripts/debug/benchmark-document-reading.ts deleted file mode 100644 index 0a793c6ac..000000000 --- a/App/memmy-agent/scripts/debug/benchmark-document-reading.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Run with: node --import tsx scripts/debug/benchmark-document-reading.ts docx 1000000 -// Or: node --import tsx scripts/debug/benchmark-document-reading.ts pdf 96 -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { createHash } from "node:crypto"; -import { performance } from "node:perf_hooks"; -import { ReadFileTool } from "../../src/core/agent-runtime/tools/filesystem.js"; -import { extractText } from "../../src/utils/document.js"; -import { reportText, writeDocx, writePdf } from "../../tests/helpers/long-document-fixtures.js"; - -const kind = process.argv[2]; -const size = Number(process.argv[3]); -if (!["docx", "pdf"].includes(kind) || !Number.isSafeInteger(size) || size < 1) { - throw new Error("Expected docx or pdf "); -} -const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-document-benchmark-")); -try { - const file = kind === "docx" ? await writeDocx(root, reportText(size)) : await writePdf(root, size); - const tool = new ReadFileTool({ workspace: root }); - const durations: number[] = []; - const hash = createHash("sha256"); - const rssBeforeMiB = process.memoryUsage().rss / 1024 ** 2; - let offset = 0; - let complete = false; - const started = performance.now(); - for (let turn = 0; turn < 300; turn += 1) { - const start = performance.now(); - const result = await tool.execute({ path: file, char_offset: offset }); - durations.push(performance.now() - start); - const marker = result.lastIndexOf("\n\n[Document read:"); - if (marker < 0 || result.length > ReadFileTool.MAX_CHARS) throw new Error("Invalid document response"); - hash.update(result.slice(0, marker)); - const next = /Continue with char_offset=(\d+)/.exec(result); - if (!next) { - complete = result.includes("End of document"); - break; - } - if (Number(next[1]) <= offset) throw new Error("Non-progressing cursor"); - offset = Number(next[1]); - } - const totalMs = performance.now() - started; - const peakRssMiB = process.resourceUsage().maxRSS / 1024; - const referenceStart = performance.now(); - const expected = await extractText(file, { maxChars: null }); - const singleExtractionMs = performance.now() - referenceStart; - if (!expected || !complete || hash.digest("hex") !== createHash("sha256").update(expected).digest("hex")) { - throw new Error("Paginated content does not match complete extraction"); - } - const sorted = [...durations].sort((a, b) => a - b); - console.log(JSON.stringify({ - kind, size, fileBytes: (await fs.stat(file)).size, extractedChars: expected.length, - calls: durations.length, firstReadMs: durations[0], - medianReadMs: sorted[Math.floor(sorted.length / 2)], totalReadMs: totalMs, - singleExtractionMs, rssBeforeMiB, peakRssMiB, - exactContentMatch: true, node: process.version, - note: "One synthetic sample per process; peak RSS includes imports and fixture creation. No model/network latency.", - }, null, 2)); -} finally { - await fs.rm(root, { recursive: true, force: true }); -} diff --git a/App/memmy-agent/scripts/debug/prepare-live-document-review.ts b/App/memmy-agent/scripts/debug/prepare-live-document-review.ts deleted file mode 100644 index 828444822..000000000 --- a/App/memmy-agent/scripts/debug/prepare-live-document-review.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Prepare isolated, private CLI fixtures; never copy account credentials into the repository. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { reportText, REPORT_FACTS, writeDocx, writePdf } from "../../tests/helpers/long-document-fixtures.js"; - -const source = process.argv[2] ?? path.join(os.homedir(), ".memmy", "config.yaml"); -const config = YAML.parse(await fs.readFile(source, "utf8")); -const presetId = config.agents?.defaults?.modelPreset; -const preset = config.modelPresets?.[presetId]; -if (!preset || preset.provider !== "memmy_account" || !config.providers?.memmy_account?.apiKey) { - throw new Error("Expected a configured memmy_account agent preset"); -} -const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-live-document-review-")); -await fs.chmod(root, 0o700); -for (const scenario of ["docx", "pdf", "exec"]) { - const folder = path.join(root, scenario); - const workspace = path.join(folder, "workspace"); - await fs.mkdir(workspace, { recursive: true, mode: 0o700 }); - const isolated = { - providers: { memmy_account: config.providers.memmy_account }, - modelPresets: { [presetId]: preset }, - modelAssignments: { account: { ownerAccountId: preset.ownerAccountId, agent: { candidates: [presetId], default: presetId } } }, - app: { userId: config.app?.userId, userMode: config.app?.userMode }, - agents: { defaults: { ...config.agents.defaults, workspace, maxToolIterations: 40, fallbackModels: [], dream: { enabled: false } } }, - tools: { - restrictToWorkspace: true, mcpServers: {}, web: { enabled: false }, - browser: { enabled: false }, imageGeneration: { enabled: false }, - // Workspace path checks alone do not sandbox package installers or shell side effects. - exec: { enabled: scenario === "exec", allowPatterns: ["^cat source\\.txt(?:\\s*>\\s*extracted\\.txt)?$"] }, - }, - memmyMemory: { enabled: false }, fileMemory: { enabled: false }, - sessionDag: { enabled: false }, contextCompaction: { summaryMode: "text" }, heartbeat: { enabled: false }, - }; - await fs.writeFile(path.join(folder, "config.yaml"), YAML.stringify(isolated), { mode: 0o600 }); - let prompt: string; - if (scenario === "docx") { - await writeDocx(workspace, reportText()); - prompt = "请完整阅读工作区 synthetic-report.docx,并汇总所有 VERIFIED 标记对应的字段和值。不要遗漏后半部分。说明你是否读到了文档末尾;仅根据文档作答,不联网。"; - } else if (scenario === "pdf") { - await writePdf(workspace, 20); - prompt = "请核实工作区 synthetic-report.pdf 第20页的全部 VERIFIED 字段和值,给出页码和准确数值。仅根据文档作答,不联网。"; - } else { - await fs.writeFile(path.join(workspace, "source.txt"), reportText()); - prompt = "请先用 exec 执行 cat source.txt,核实该命令输出中的全部 VERIFIED 字段和值。如果初次输出不完整,请自行获取缺失部分后再作答。只操作当前工作区,不联网。"; - } - await fs.writeFile(path.join(folder, "prompt.txt"), prompt); -} -await fs.writeFile(path.join(root, "expected.json"), JSON.stringify({ facts: REPORT_FACTS, scenarios: ["docx", "pdf", "exec"] }, null, 2)); -console.log(root); diff --git a/App/memmy-agent/scripts/debug/run-live-document-review.ts b/App/memmy-agent/scripts/debug/run-live-document-review.ts deleted file mode 100644 index fb8f3d043..000000000 --- a/App/memmy-agent/scripts/debug/run-live-document-review.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Run only against an isolated directory produced by prepare-live-document-review.ts. -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; -import YAML from "yaml"; -import { prepareStartupMigrations } from "../../src/entrypoints/cli/startup-migrations.js"; - -const folder = path.resolve(process.argv[2] ?? ""); -if (!path.basename(path.dirname(folder)).startsWith("memmy-live-document-review-")) { - throw new Error("Expected an isolated review scenario directory"); -} -const configPath = path.join(folder, "config.yaml"); -const config = YAML.parse(await fs.readFile(configPath, "utf8")); -const key = config.providers.memmy_account.apiKey; -const workspace = path.join(folder, "workspace"); -const entry = fileURLToPath(new URL("../../dist/main.js", import.meta.url)); -const env = { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, - MEMMY_CONFIG: configPath, MEMMY_AGENT_DATA_DIR: path.join(folder, "data"), - MEMMY_AGENT_WORKSPACE: workspace, MEMMY_CLOUD_SERVICE: "https://api.memmy.cn", - MEMMY_AGENT_SESSION_DAG_DIR: path.join(folder, "session-dag"), MEMMY_APP_DATABASE: path.join(folder, "app.sqlite") }; -// Migrations can add the desktop MCP default. Prepare them first, then disable external -// connections and pass their actual target receipt to the child CLI. -const { target } = await prepareStartupMigrations({ config: configPath, workspace }, env); -const migrated = YAML.parse(await fs.readFile(configPath, "utf8")); -migrated.tools.mcpServers = {}; -migrated.contextCompaction = { summaryMode: "text" }; -await fs.writeFile(configPath, YAML.stringify(migrated), { mode: 0o600 }); -Object.assign(env, { - MEMMY_MIGRATIONS_READY_CONFIG: target.runtimeConfigFile, - MEMMY_MIGRATIONS_READY_WORKSPACE: target.agentWorkspace, - MEMMY_MIGRATIONS_READY_SESSION_DAG: target.sessionDagDir, - MEMMY_MIGRATIONS_READY_APP_DATABASE: target.appDatabaseFile, -}); -const started = Date.now(); -const child = spawn(process.execPath, [entry, "agent", "--config", configPath, "--workspace", workspace, "--standalone", "--no-markdown"], { - cwd: workspace, - env, - stdio: ["pipe", "pipe", "pipe"], -}); -let output = ""; -let timedOut = false; -child.stdout.on("data", (chunk) => { output += chunk.toString(); }); -child.stderr.on("data", (chunk) => { output += chunk.toString(); }); -child.stdin.end(await fs.readFile(path.join(folder, "prompt.txt"))); -const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 240_000); -const code = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", resolve); -}).finally(() => clearTimeout(timeout)); -const sanitized = output.split(key).join("[REDACTED]").replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED JWT]"); -await fs.writeFile(path.join(folder, "cli-output.txt"), sanitized, { mode: 0o600 }); -const result = { scenario: path.basename(folder), exitCode: code, timedOut, elapsedMs: Date.now() - started, outputFile: path.join(folder, "cli-output.txt") }; -await fs.writeFile(path.join(folder, "run-result.json"), JSON.stringify(result, null, 2)); -console.log(JSON.stringify(result)); -if (code !== 0) console.log(sanitized.slice(-1500)); -process.exitCode = code === 0 && !timedOut ? 0 : 1; diff --git a/App/memmy-agent/scripts/debug/summarize-live-document-review.ts b/App/memmy-agent/scripts/debug/summarize-live-document-review.ts deleted file mode 100644 index dcb6a15df..000000000 --- a/App/memmy-agent/scripts/debug/summarize-live-document-review.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Export only visible answers and tool evidence, excluding credentials and reasoning streams. -import fs from "node:fs/promises"; -import path from "node:path"; - -const root = path.resolve(process.argv[2] ?? ""); -const expected = JSON.parse(await fs.readFile(path.join(root, "expected.json"), "utf8")); -const summaries = []; -for (const scenario of expected.scenarios) { - const folder = path.join(root, scenario); - let run; - try { run = JSON.parse(await fs.readFile(path.join(folder, "run-result.json"), "utf8")); } - catch { continue; } - const webui = path.join(folder, "data", "webui"); - const events: any[] = []; - for (const file of await fs.readdir(webui)) { - if (!file.endsWith(".jsonl")) continue; - const lines = (await fs.readFile(path.join(webui, file), "utf8")).trim().split("\n"); - for (const line of lines) { try { events.push(JSON.parse(line)); } catch { /* partial write */ } } - } - const tools = events.flatMap((event) => event.tool_events ?? []).filter((event) => event.phase === "end" || event.phase === "error"); - const final = events.filter((event) => event.event === "message" && !["tool_hint", "progress"].includes(event.kind)).at(-1)?.text ?? ""; - summaries.push({ - scenario, exitCode: run.exitCode, timedOut: run.timedOut, elapsedMs: run.elapsedMs, - finalAnswer: final, - expectedValuesPresent: Object.fromEntries(expected.facts.map((fact: string) => [fact, final.replace(/,/g, "").includes(fact.split("=")[1])])), - tools: tools.map((tool) => { - const result = typeof tool.result === "string" ? tool.result : ""; - return { - name: tool.name, phase: tool.phase, - arguments: Object.fromEntries(Object.entries(tool.arguments ?? {}).filter(([key]) => ["path", "offset", "limit", "char_offset", "char_limit", "pages", "command", "pattern", "query"].includes(key))), - resultChars: result.length, - documentRange: /\[Document read: chars[^\]]+\]/.exec(result)?.[0], - truncated: /truncated/i.test(result), error: tool.error, - }; - }), - }); -} -await fs.writeFile(path.join(root, "summary.json"), JSON.stringify(summaries, null, 2), { mode: 0o600 }); -console.log(JSON.stringify(summaries, null, 2)); diff --git a/docs/debug-long-document-live-review.md b/docs/debug-long-document-live-review.md deleted file mode 100644 index 0953046c8..000000000 --- a/docs/debug-long-document-live-review.md +++ /dev/null @@ -1,71 +0,0 @@ -# 账户模型真实会话复测 - -日期:2026-09-23。源码:`fix/debug-session`,基线 `d101fd58`,仓库版本 `1.1.8`。本次使用构建后的 `dist/main.js agent`,不是脚本模型。 - -## 模型与隔离方式 - -复核时 `~/.memmy/config.yaml` 已存在,包含可用的 `memmy_account` 账户通道。使用该配置的 agent preset,模型路由为 `agent_chat`,请求发送至 `https://api.memmy.cn/api/agentExternal/v1`。没有要求提供新密钥,也未把账户 JWT 写入仓库。云端实际底层模型及其版本未单独确认。 - -使用本机可用 Node.js v24.19.0 构建、运行;未使用 Electron。运行时配置、workspace、会话、GUI 事件记录、DAG 路径和 app 数据库路径均指向临时目录。关闭本地记忆、文件记忆、浏览器、Web 工具和 MCP;直接执行单轮 CLI,没有启动 Gateway 或 WebSocket 服务。为关闭 DAG,将上下文摘要模式设为 `text`;测试限制为最多 40 次工具迭代。 - -测试结束后已清除 6 份临时配置中的账户凭据;扫描本次改动和新增文件,未发现该凭据。原始账户配置的修改时间仍为测试前观察到的 2026-09-23 19:57:14(北京时间)。保留的临时配置需重新运行准备脚本才能用于新的账户调用。 - -启动迁移会重新加入默认 MCP,因此准备脚本先执行临时目录的迁移,再清空 MCP,并向子 CLI 传入已完成迁移的目标信息。隔离配置不能仅依赖删减 YAML 字段。 - -## 结果 - -| 场景 | 工具轨迹证据 | 答案 | CLI 耗时 | -| --- | --- | --- | ---: | -| 完整阅读长 DOCX | 8 次 `read_file`,连续覆盖 0–263543,最后一段包含结束标记 | 四组字段和值全部正确 | 70.2 秒 | -| PDF 后部页码 | `read_file(path="synthetic-report.pdf", pages="20")`,返回该页全部 15211 字符;该页位于原 20 万字符提取上限之后 | 页码及四组字段和值全部正确 | 69.3 秒 | -| exec 截断恢复(有效重测) | `cat source.txt` 成功返回 10037 字符且包含截断标记;随后 `grep` 定位,`read_file` 分别核对第 806、1613、2420、3227 行 | 四组字段和值全部正确,无提前结束 | 17.0 秒 | - -DOCX 读取区间依次为: - -```text -0–32000 -32000–64000 -64000–96000 -96000–128000 -128000–160000 -160000–192000 -192000–224000 -224000–263543 End of document -``` - -标准答案:`PE=38.95`、`HOLDING=61.35%`、`ISSUED=112000000`、`RAISED=2110000000`。最终答案和工具记录均已核对,不仅依据模型自称“已读完”。 - -首次 exec 场景使用绝对 Node 路径,被工作区检查拦截;模型直接读取了源文件,虽然答案正确,但没有实际触发命令输出截断,因此不计为有效通过。随后改用工作区内的纯读取命令 `cat source.txt`,扩大样例并重新创建会话,才获得上表中的有效结果。 - -## 复测期间的额外行为 - -DOCX/PDF 模型在得到所需结果后进行了多余的二次核对,包含解包、调用其他提取工具等行为,因此实际会话耗时显著高于单纯解析基准。 - -首轮 PDF 会话还调用 `pip install pypdf pdfplumber`,向用户 Python 目录安装了此前不存在的两个包。这说明 `restrictToWorkspace` 是命令路径检查,并不等同于操作系统沙箱。已向用户说明并获准卸载这两个直接安装的包。由于没有安装前的依赖快照,没有擅自卸载其他可能的传递依赖;不能声称完整回滚了所有 Python 环境变化。 - -首轮 DOCX 会话还出现清理命令被过滤后改用其他命令的行为;本记录不将其视为推荐操作。后续准备脚本对 DOCX/PDF 禁用 exec,对 exec 测试只允许精确匹配 `cat source.txt` 或将其输出重定向到工作区内 `extracted.txt`,从源头限制包安装和其他副作用。有效 exec 重测使用了此白名单。 - -## 可复跑工具 - -从 `App/memmy-agent` 运行(依赖及内部包已构建): - -```bash -node node_modules/typescript/bin/tsc -p tsconfig.build.json -node scripts/copy-build-assets.mjs -node --import tsx scripts/debug/prepare-live-document-review.ts -# 上一步输出私有临时目录。以下 ROOT 替换为该目录,不使用真实账户配置直接运行。 -node --import tsx scripts/debug/run-live-document-review.ts ROOT/docx -node --import tsx scripts/debug/run-live-document-review.ts ROOT/pdf -node --import tsx scripts/debug/run-live-document-review.ts ROOT/exec -node --import tsx scripts/debug/summarize-live-document-review.ts ROOT -``` - -准备脚本会创建私有临时配置,运行脚本记录退出状态和脱敏输出,汇总脚本只导出可见答案与工具证据,不导出模型推理流。运行会消耗正常账户模型用量。最终脚本的命令限制比首轮 DOCX/PDF 更严格,因此复跑时额外核对行为和耗时可能不同。 - -## 验收边界 - -- 三种合成场景各有一轮有效真实模型通过,不代表多轮统计可靠性,也不等同于原文档、原版本、原会话复现。 -- exec 测试证明模型能从截断恢复所需事实;它没有按顺序读完整个文件,也未恢复原会话已丢弃的 stdout,而是读取了可访问的源文件。 -- 超长单行文本分页缺口仍未修复;本次多行样例不覆盖它。 -- 重复全量解析的性能成本仍在,之前的基准数据继续有效。真实会话还包含推理、网络和多余工具调用耗时。 -- 上述复测发生在本地提交之前,尚未推送或安装替换桌面应用。超长单行文本和缓存优化按用户确认的范围作为独立后续事项。 diff --git a/docs/debug-long-document-reading.md b/docs/debug-long-document-reading.md deleted file mode 100644 index bc7ed83e2..000000000 --- a/docs/debug-long-document-reading.md +++ /dev/null @@ -1,67 +0,0 @@ -# 长文档读取截断修复记录 - -## 问题与基线 - -- 分支:`fix/debug-session` -- 基线:`d101fd582857ddc046416f5f08741297345992dc` -- 用户现象:Agent 报告原文或 PDF 页面在工具输出中被截断,未继续核实后文。 -- 证据范围:已根据代码和测试复现文档读取链路缺陷;没有截图对应会话的工具调用日志、模型配置或原始文档,尚未复测该会话。 - -## 根因 - -1. `utils/document.ts` 各解析器只保留提取结果的前 200,000 个 UTF-16 单元。 -2. `read_file` 又截取前 128,000 个单元,未提供文档续读游标。文本文件的 `offset`、`limit` 不作用于 PDF 或 Office 文档。 -3. PDF 页码筛选发生在提取层截断之后,因此后部页码可能被误判为无效。 -4. 截断提示附加在输出上限之后,可能再次被运行时结果预算处理。 - -命令工具另有输出上限;此次为该路径增加恢复读取指引,未修改命令输出保存机制。 - -## 修改后行为 - -- 提取器默认仍提供 200,000 单元的预览;分页读取显式请求完整提取结果。 -- 文档工具先选择 PDF 页码,再按 `char_offset`、`char_limit` 返回内容;默认块大小为 32,000 单元。 -- `char_offset` 从 0 开始,按 JavaScript UTF-16 字符串索引计算。调用者应使用工具返回的游标,不自行计算中文或 emoji 的长度。 -- 返回当前范围、总长度,以及后续游标或结束标记。续读时保持 `path` 和 `pages` 不变。 -- 即使请求极大块,也为续读提示预留输出预算;自动分段不会拆开有效的 UTF-16 代理对。 -- Agent 指引要求继续读取任务所需范围;需要完整阅读时必须读到结束。若命令提取输出被截断,应保存到文件分段读取,或改用原始文档的 `read_file`。 - -调用示例: - -```json -{"path":"report.pdf","pages":"15","char_limit":32000} -``` - -如结果提示 `Continue with char_offset=32000`,继续: - -```json -{"path":"report.pdf","pages":"15","char_offset":32000,"char_limit":32000} -``` - -## 验证 - -环境:macOS、本地 Node.js v24.19.0、agent 锁文件中的 Vitest 4.1.7。 - -修复读取逻辑之前,6 个新用例失败,涵盖四种文档续读、PDF 页内续读、无效游标。修复后运行以下测试文件: - -```bash -cd App/memmy-agent -MEMMY_AGENT_DATA_DIR=/private/tmp/memmy-debug-test-data node node_modules/vitest/vitest.mjs run \ - tests/document-parsing.test.ts \ - tests/core/agent-runtime/tools/read-enhancements.test.ts \ - tests/core/agent-runtime/tools/filesystem-tools.test.ts \ - tests/core/agent-runtime/tools/tool-descriptions.test.ts \ - tests/utils/prompt-templates.test.ts \ - tests/core/agent-runtime/tool-result-budget.test.ts -node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit -``` - -测试前按两份锁文件安装依赖,并构建 Migrations、Knowledge、local-api-contracts 内部包。测试数据使用临时目录,避免写入日常 Memmy 数据目录。 - -结果:6 个测试文件、135 个测试通过,类型检查通过。覆盖真实长 DOCX 完整拼接、累计提取量超过 200,000 单元的 PDF 第 15 页、四种文档的无损分段、最大块预算、emoji 边界和错误输入。 - -## 边界与上线验证 - -- 仍依赖文档现有文本提取能力;扫描 PDF 的 OCR 不在此次修复范围。 -- 每次续读会重新提取文档,超大文档可能较慢;读取过程中应保持文件不变。 -- 提供续读能力和模型指引不能保证所有模型都会正确续读。需使用原模型及原文档进行会话级复测,确认核实了目标数据后再报告完成。 -- 代码尚未打包或安装到桌面应用,已安装版本不会因本地源码修改自动更新。 diff --git a/docs/debug-long-document-review.md b/docs/debug-long-document-review.md deleted file mode 100644 index c72df6541..000000000 --- a/docs/debug-long-document-review.md +++ /dev/null @@ -1,85 +0,0 @@ -# 长文档修复复查:会话链路、命令恢复与性能 - -日期:2026-09-23。分支:`fix/debug-session`。本次只增加复查样例、测试、基准脚本和记录,没有新增生产代码修改。 - -后续已复用本机账户配置完成三项真实模型复测,见 [账户模型真实会话复测](debug-long-document-live-review.md)。下文的模型配置缺失和阻塞描述保留为首轮检查记录,不再代表当前状态。 - -## 结论 - -| 检查项 | 结果 | 证据边界 | -| --- | --- | --- | -| Agent 多轮续读链路 | 确定性集成测试通过;真实模型复测阻塞 | 使用真实 AgentRunner、工具注册、文档读取与结果预算;模型决策由脚本提供,不能证明真实模型会遵循提示 | -| exec 截断恢复 | 可通过重新输出到文件后分行读取恢复;仍有缺口 | 完成后的会话无法重放已丢弃内容;超过上限的单行文本不具备可靠续读入口 | -| 大文档性能 | 已测量,有优化空间 | 96 页 PDF 全量读取 46 次、11.49 秒;测试进程峰值约 1 GB,包含依赖加载和样例创建 | - -当前不应把“测试通过”解释为原用户问题已完全关闭。 - -## 1. Agent 多轮读取 - -没有发现仓库 `.env`、默认 `~/.memmy/config.yaml` 或当前进程中的 API 凭据变量。已向用户询问模型和可用配置路径,尚未获得真实模型复测配置;没有搜索或使用其他应用的凭据。 - -新增 `tests/core/agent-runtime/long-document-review.test.ts`,通过真实 AgentRunner 运行工具调用。合成 DOCX 中分散放置四组已知数据:PE、持股比例、发行数量和募集金额。脚本模型只根据已收到的工具内容返回游标或提取答案,测试断言答案等于独立定义的标准值。 - -- 正向测试:跨越多轮读取、多个旧截断边界后,四组数据均可读回。 -- 反向测试:脚本模型在第一段后直接回答,AgentRunner 仍结束任务。这证明当前运行时没有强制完整阅读;续读取决于模型行为。 -- 此处没有调用真实模型,不能将脚本模型通过计入真实模型验收。 - -获取配置后的真实模型验收应运行至少“完整阅读”和“指定页核实”两类任务,保存模型名、提示词、工具轨迹、最终答案以及是否到达所需范围。完整阅读任务应核实结束标记;指定范围任务不应要求无关全文读取。若比较修复前后,应使用相同模型、设置和合成文档。 - -## 2. exec 输出截断及恢复 - -使用真实 Node 子进程输出合成报告,未 mock 命令执行。 - -1. 普通 `exec` 返回首尾片段,中部已知数据不在结果中,证明截断会丢失所需事实。 -2. 重新执行相同的纯读取命令,将标准输出重定向到文件。 -3. AgentRunner 使用 `read_file` 的行偏移分段读取,恢复全部四组数据;文件内容与源文本相同。 -4. 独立测试确认:会话完成且输出截断后,`write_stdin` 返回会话不存在,不能恢复遗漏部分。 - -这条恢复流程仍由脚本决策驱动,未验证真实模型会自主采用。只应重跑确定没有副作用的文档提取命令,不能把这一策略无条件应用于任意命令。 - -### 新发现的单行文本缺口 - -生成一个超过 200,000 字符且没有换行的提取结果 `.txt`。当前文本读取逻辑只会减少“行数”;只剩一行时仍返回超出 128,000 上限的结果,同时标记 `End of file`,没有续读偏移。 - -运行时可能将此结果落盘为大工具结果引用,但再次用相同的按行工具读取超长单行,仍会遇到同一预算问题。因此“先保存文件再读”对普通多行文本有效,对超长单行文本还不可靠。应补充文本字符级分页或可靠的输出文件分块能力,再单独验证。当前复查测试明确记录这一已知缺陷;该测试通过表示缺陷可复现,不表示缺陷已经修复。 - -## 3. 性能测量 - -环境:macOS、Node.js v24.19.0。每个规模在独立进程中运行一次。使用真实 DOCX/PDF 解析器,默认每段 32,000 UTF-16 单元;分段内容的 SHA-256 与完整提取结果一致。PDF 文本均置于页面可见范围内。 - -| 样例 | 提取长度 | 读取次数 | 首段耗时 | 全文续读耗时 | 单次完整提取耗时 | 进程峰值 RSS | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| DOCX,约 25 万字符 | 253,375 | 8 | 29.8 ms | 148.2 ms | 15.5 ms | 507 MiB | -| DOCX,约 100 万字符 | 1,012,695 | 32 | 86.5 ms | 1,910.0 ms | 56.2 ms | 1,103 MiB | -| PDF,20 页 | 304,233 | 10 | 227.3 ms | 691.3 ms | 50.6 ms | 660 MiB | -| PDF,96 页 | 1,464,677 | 46 | 418.3 ms | 11,494.3 ms | 247.0 ms | 1,041 MiB | - -注意: - -- 峰值为整个测试进程的高水位,包含模块加载、样例创建和解析;开始读取前 RSS 约 473–492 MiB。不能据此断言存在内存泄漏,或把全部内存归因于本次改动。 -- 单次提取在续读测量之后执行,是热运行参考,不是修复前版本的性能基线。 -- 样例为重复文本,DOCX 压缩文件较小,未覆盖大量图片、复杂表格或扫描 PDF。 -- 没有模型推理、网络往返或上下文压缩耗时;不能把此表当作真实会话的总耗时。 -- 未发生超时或内容缺失,但反复全量解析导致明显累积成本。若继续优化,优先考虑有容量上限、能检测文件变化的提取结果缓存,或 PDF 按所需页提取;保持单次输出预算。 - -## 复跑 - -在依赖安装及内部包构建完成后,从 `App/memmy-agent` 执行: - -```bash -MEMMY_AGENT_DATA_DIR=/private/tmp/memmy-debug-test-data \ - node node_modules/vitest/vitest.mjs run tests/core/agent-runtime/long-document-review.test.ts - -node --import tsx scripts/debug/benchmark-document-reading.ts docx 250000 -node --import tsx scripts/debug/benchmark-document-reading.ts docx 1000000 -node --import tsx scripts/debug/benchmark-document-reading.ts pdf 20 -node --import tsx scripts/debug/benchmark-document-reading.ts pdf 96 -``` - -生成样例位于临时目录,测试后自动清理。基准脚本输出 JSON 指标。原有 135 个相关测试和新增 5 个复查测试均已运行;类型检查与差异格式检查也纳入复查。 - -## 后续状态与本次范围 - -真实账户模型的三项合成场景复测已完成,详见后续报告。用户确认本次只修复长文档截断与续读,保持普通文本读取流程不变;超长单行文本和提取缓存优化单独记录,不作为本次关闭条件。 - -本次关闭前仍需完成提交、PR 与适用的 CI/审查,以及包含修复的桌面版本验收。现有性能数据可用于评审,但不是正式性能 SLA 验收。 From 32c3437472687d0790370a19573d7a61eb4aa94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Wed, 30 Sep 2026 14:57:20 +0800 Subject: [PATCH 04/13] fix: refresh installed agent hooks when the bundled revision changes App updates left previously installed Cursor, Claude Code, and Codex hooks on the first-install script. Startup now compares the stored hook fingerprint and reinstalls when it is missing or stale. Co-authored-by: Cursor --- .../tests/agent-sources-route.test.ts | 3 + .../skill-writer/claude-code/target.ts | 20 ++- .../claude-code/tests/target.test.ts | 20 +++ .../outbound/skill-writer/codex/target.ts | 20 ++- .../skill-writer/codex/tests/target.test.ts | 17 ++ .../outbound/skill-writer/cursor/target.ts | 18 +- .../skill-writer/cursor/tests/target.test.ts | 21 +++ .../skill-writer/hook-revision.test.ts | 13 ++ .../outbound/skill-writer/hook-revision.ts | 93 +++++++++++ .../adapters/outbound/skill-writer/types.ts | 1 + .../agent-source-auto-inject-service.ts | 52 ++++-- .../src/services/agent-source-service.ts | 5 + .../services/skill-distribution-service.ts | 7 + .../agent-source-auto-inject-service.test.ts | 154 ++++++++++++++++++ .../tests/agent-source-service.test.ts | 27 +++ .../tests/skill-distribution-service.test.ts | 25 ++- 16 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts create mode 100644 App/backend/src/adapters/outbound/skill-writer/hook-revision.ts diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts index f2b420568..3440ab189 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts @@ -1039,6 +1039,9 @@ function createFakeAgentSourceService(): AgentSourceService { async uninstallPlugin() { return undefined; }, + async isInstalledHookCurrent() { + return true; + }, async detectMemoryPluginConflicts() { return []; } diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts index fcf45fc11..da135bda9 100644 --- a/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { createNodeHookCommand } from "../hook-command.js"; +import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js"; import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js"; import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js"; import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js"; @@ -85,6 +86,19 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER); }, + async isInstalledHookCurrent() { + const root = await this.resolveRootDirectory(); + if (!root) return false; + const hookDirectory = join(root, HOOK_DIRECTORY_NAME); + return isInstalledMemmyHookCurrent({ + source: CLAUDE_CODE_TARGET_ID, + mode: "claude-code", + hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME), + bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), + configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME) + }); + }, + async installPlugin(_targetId) { const root = await this.resolveRootDirectory(); if (!root) { @@ -96,7 +110,11 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep await mkdir(hookDirectory, { recursive: true }); await writeFileAtomically( join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n` + `${JSON.stringify({ + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), + ...(await memmyHookRevisionField(CLAUDE_CODE_TARGET_ID, "claude-code")) + }, null, 2)}\n` ); await writeFileAtomically( hookScriptPath, diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts index 9ce85dbaf..4870c6a28 100644 --- a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts @@ -118,6 +118,26 @@ describe("claude code skill target", () => { } }); + it("marks a hook without a stored revision as outdated and refreshes it in place", async () => { + const { rootDirectory, memmyConfigPath } = createFixture(); + const target = createClaudeCodeSkillTarget({ rootDirectory, memmyConfigPath }); + const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json"); + + await target.installPlugin?.("claude_code"); + const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string }; + expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/)); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + + writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + rmSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs")); + writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + + await target.installPlugin?.("claude_code"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + }); + it("installs a UserPromptSubmit hook that blocks resume commands with top L1 candidates", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); let requestBody: Record | undefined; diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts index 388782fdf..63627fe63 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { resolveCodexHomeDirectory } from "../../agent-paths.js"; import { createNodeHookCommand } from "../hook-command.js"; +import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js"; import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js"; import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js"; import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js"; @@ -84,6 +85,19 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER); }, + async isInstalledHookCurrent() { + const root = await this.resolveRootDirectory(); + if (!root) return false; + const hookDirectory = join(root, HOOK_DIRECTORY_NAME); + return isInstalledMemmyHookCurrent({ + source: CODEX_TARGET_ID, + mode: "codex", + hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME), + bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), + configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME) + }); + }, + async installPlugin(_targetId) { const root = await this.resolveRootDirectory(); if (!root) { @@ -95,7 +109,11 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S await mkdir(hookDirectory, { recursive: true }); await writeFileAtomically( join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n` + `${JSON.stringify({ + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), + ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) + }, null, 2)}\n` ); await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); await writeFileAtomically( diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index bc31cdb17..595feee82 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -122,6 +122,23 @@ describe("codex skill target", () => { } }); + it("marks a hook without a stored revision as outdated and refreshes it in place", async () => { + const { rootDirectory, memmyConfigPath } = createFixture(); + const target = createCodexSkillTarget({ rootDirectory, memmyConfigPath, trustHooks: noOpTrustHooks }); + const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json"); + + await target.installPlugin?.("codex"); + const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string }; + expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/)); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + + writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + + await target.installPlugin?.("codex"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + }); + it("persists trust for the installed user-level hooks before installation completes", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); let trustOptions: TrustMemmyCodexHooksOptions | undefined; diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts index 08e9599b5..885754a13 100644 --- a/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { createNodeHookCommand } from "../hook-command.js"; +import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js"; import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js"; import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js"; import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js"; @@ -52,6 +53,17 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}): return (await readTextFile(join(cursorRootDirectory, "skills", "memmy-memory", "SKILL.md"))).includes("name: memmy-memory"); }, + async isInstalledHookCurrent() { + const hookDirectory = join(cursorRootDirectory, HOOK_DIRECTORY_NAME); + return isInstalledMemmyHookCurrent({ + source: CURSOR_TARGET_ID, + mode: "cursor", + hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME), + bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), + configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME) + }); + }, + async installPlugin(_targetId) { await mkdir(cursorRootDirectory, { recursive: true }); @@ -60,7 +72,11 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}): await mkdir(hookDirectory, { recursive: true }); await writeFileAtomically( join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n` + `${JSON.stringify({ + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), + ...(await memmyHookRevisionField(CURSOR_TARGET_ID, "cursor")) + }, null, 2)}\n` ); await writeFileAtomically( hookScriptPath, diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts index 1515e9bb7..b6606d7d6 100644 --- a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts @@ -100,6 +100,27 @@ describe("cursor skill target", () => { } }); + it("marks a hook without a stored revision as outdated and refreshes it in place", async () => { + const { rootDirectory, memmyConfigPath } = createFixture(); + const target = createCursorSkillTarget({ rootDirectory, memmyConfigPath }); + const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json"); + + await target.installPlugin?.("cursor"); + const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string }; + expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/)); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + + writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + + delete installed.hook_revision; + writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + + await target.installPlugin?.("cursor"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + }); + it("installs a beforeSubmitPrompt hook that blocks resume commands with top L1 candidates", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); let requestBody: Record | undefined; diff --git a/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts new file mode 100644 index 000000000..4cdeffa5c --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts @@ -0,0 +1,13 @@ +/** Hook revision tests. */ +import { describe, expect, it } from "vitest"; +import { hookRevision } from "./hook-revision.js"; + +describe("hook revision", () => { + it("changes when the hook script or workspace bridge changes", () => { + const current = hookRevision("hook script", "workspace bridge"); + + expect(hookRevision("hook script", "workspace bridge")).toBe(current); + expect(hookRevision("updated hook script", "workspace bridge")).not.toBe(current); + expect(hookRevision("hook script", "updated workspace bridge")).not.toBe(current); + }); +}); diff --git a/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts b/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts new file mode 100644 index 000000000..ab8ba1884 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts @@ -0,0 +1,93 @@ +/** Hook revision module. */ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { renderMemmyResumeHookScript, type MemmyResumeHookMode } from "./templates/memmy-resume-hook.js"; +import { loadMemmyWorkspaceBridgeRuntimeAsset } from "./workspace-bridge/runtime-loader.js"; + +export const HOOK_REVISION_FIELD = "hook_revision"; + +const currentRevisionByKey = new Map>(); + +/** Fingerprint of the hook script and workspace bridge this package would install. */ +export function hookRevision(script: string, bridge: string): string { + return createHash("sha256").update(script).update("\0").update(bridge).digest("hex"); +} + +/** Returns the cached fingerprint for one agent hook bundle. */ +export function currentMemmyHookRevision(source: string, mode: MemmyResumeHookMode): Promise { + const key = `${mode}\0${source}`; + const cached = currentRevisionByKey.get(key); + if (cached) return cached; + + const pending = (async () => { + const script = renderMemmyResumeHookScript({ source, mode }); + const bridge = await loadMemmyWorkspaceBridgeRuntimeAsset(); + return hookRevision(script, bridge); + })().catch((error: unknown) => { + currentRevisionByKey.delete(key); + throw error; + }); + currentRevisionByKey.set(key, pending); + return pending; +} + +/** Config field written beside an installed hook. */ +export async function memmyHookRevisionField( + source: string, + mode: MemmyResumeHookMode +): Promise> { + return { [HOOK_REVISION_FIELD]: await currentMemmyHookRevision(source, mode) }; +} + +export interface InstalledMemmyHookPaths { + source: string; + mode: MemmyResumeHookMode; + hookScriptPath: string; + bridgePath: string; + configPath: string; +} + +/** + * An installed hook is current when both files exist and the stored fingerprint + * matches the bundle in this process. A missing fingerprint is an older install. + */ +export async function isInstalledMemmyHookCurrent(input: InstalledMemmyHookPaths): Promise { + const [script, bridge, configText] = await Promise.all([ + readOptionalText(input.hookScriptPath), + readOptionalText(input.bridgePath), + readOptionalText(input.configPath) + ]); + if (!script.trim() || !bridge.trim()) return false; + const stored = readStoredHookRevision(configText); + if (!stored) return false; + return stored === await currentMemmyHookRevision(input.source, input.mode); +} + +function readStoredHookRevision(configText: string): string | null { + if (!configText.trim()) return null; + try { + const parsed = JSON.parse(configText) as unknown; + if (!isRecord(parsed)) return null; + const value = parsed[HOOK_REVISION_FIELD]; + return typeof value === "string" && value.trim() ? value : null; + } catch { + return null; + } +} + +async function readOptionalText(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return ""; + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/adapters/outbound/skill-writer/types.ts b/App/backend/src/adapters/outbound/skill-writer/types.ts index 9ec21586a..a1c8cd20e 100644 --- a/App/backend/src/adapters/outbound/skill-writer/types.ts +++ b/App/backend/src/adapters/outbound/skill-writer/types.ts @@ -25,5 +25,6 @@ export interface SkillTarget { isInstalled(targetId: string): Promise; installPlugin?(targetId: string): Promise; uninstallPlugin?(targetId: string): Promise; + isInstalledHookCurrent?(): Promise; detectMemoryPluginConflict?(): Promise; } diff --git a/App/backend/src/services/agent-source-auto-inject-service.ts b/App/backend/src/services/agent-source-auto-inject-service.ts index f5ac09fc4..c97eed893 100644 --- a/App/backend/src/services/agent-source-auto-inject-service.ts +++ b/App/backend/src/services/agent-source-auto-inject-service.ts @@ -1,5 +1,5 @@ /** Agent source auto inject service module. */ -import type { AgentSourceAutoInjectResult, ScanPreferences } from "@memmy/local-api-contracts"; +import type { AgentSourceAutoInjectResult, AgentSourceView, ScanPreferences } from "@memmy/local-api-contracts"; import type { PermissionManager } from "../permission/index.js"; import type { AgentSourceService } from "./agent-source-service.js"; @@ -16,13 +16,14 @@ const AUTO_INJECT_AGENT_SOURCE_IDS = new Set([ "qwenwork" ]); const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "deepseek_harness"]); +const UPDATABLE_HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex"]); export interface AgentSourceAutoInjectService { runOnce(): Promise; } export interface CreateAgentSourceAutoInjectServiceOptions { - agentSources: Pick; + agentSources: Pick; permissionManager: Pick; getScanPreferences: () => ScanPreferences; } @@ -45,23 +46,41 @@ export function createAgentSourceAutoInjectService( }; } - const preferences = options.getScanPreferences(); - if (!preferences.autoInjectSkill) { - return { - ok: true, - skipped: true, - reason: "auto_inject_disabled", - installed: [], - failed: [] - }; - } - running = true; try { const sources = await options.agentSources.list(); const installed: string[] = []; const failed: Array<{ sourceId: string; reason: string }> = []; + for (const source of sources) { + if (!isUpdatableInstalledHook(source)) continue; + if (!(await options.permissionManager.canWriteAgentSkill({ agentSourceId: source.sourceId }))) { + continue; + } + + try { + if (await options.agentSources.isInstalledHookCurrent(source.sourceId)) continue; + await options.agentSources.installPlugin(source.sourceId, { installType: "auto_inject" }); + installed.push(source.sourceId); + } catch (error) { + failed.push({ + sourceId: source.sourceId, + reason: error instanceof Error ? error.message : String(error) + }); + } + } + + const preferences = options.getScanPreferences(); + if (!preferences.autoInjectSkill) { + return { + ok: true, + skipped: true, + reason: "auto_inject_disabled", + installed, + failed + }; + } + for (const source of sources) { if (!AUTO_INJECT_AGENT_SOURCE_IDS.has(source.sourceId) || !source.builtin || !source.available || source.status !== "not_connected") { continue; @@ -98,3 +117,10 @@ export function createAgentSourceAutoInjectService( } }; } + +function isUpdatableInstalledHook(source: AgentSourceView): boolean { + return UPDATABLE_HOOK_AGENT_SOURCE_IDS.has(source.sourceId) + && source.builtin + && source.available + && source.status === "plugin_installed"; +} diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index 62bd1f76d..0d49b8785 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -94,6 +94,7 @@ export interface AgentSourceService { uninstallSkill(sourceId: string): Promise; installPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; uninstallPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; + isInstalledHookCurrent(sourceId: string): Promise; detectMemoryPluginConflicts(): Promise; } @@ -441,6 +442,10 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption } }, + async isInstalledHookCurrent(sourceId) { + return options.skillDistributionService.isInstalledHookCurrent?.(sourceId) ?? true; + }, + async uninstallPlugin(sourceId, action = {}) { const startedAt = Date.now(); const installType = normalizePluginInstallType(action.installType); diff --git a/App/backend/src/services/skill-distribution-service.ts b/App/backend/src/services/skill-distribution-service.ts index a849bc27e..1bb35d937 100644 --- a/App/backend/src/services/skill-distribution-service.ts +++ b/App/backend/src/services/skill-distribution-service.ts @@ -25,6 +25,7 @@ export interface SkillDistributionService { uninstall(sourceId: string): Promise; installPlugin(sourceId: string): Promise; uninstallPlugin(sourceId: string): Promise; + isInstalledHookCurrent?(sourceId: string): Promise; detectMemoryPluginConflicts?(): Promise; } @@ -101,6 +102,12 @@ export function createSkillDistributionService( await target.uninstall(sourceId); }, + async isInstalledHookCurrent(sourceId) { + const target = options.targetRegistry.get(sourceId); + if (!target?.isInstalledHookCurrent) return true; + return target.isInstalledHookCurrent(); + }, + async detectMemoryPluginConflicts() { const conflicts: MemoryPluginConflict[] = []; for (const target of options.targetRegistry.list()) { diff --git a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts index 04821d248..a79b18a5b 100644 --- a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts +++ b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts @@ -108,6 +108,157 @@ describe("agent source auto inject service", () => { releaseInstall(); await running; }); + + it("refreshes an outdated installed hook while auto inject is disabled", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [source("cursor", "plugin_installed", true)]; + }, + async isInstalledHookCurrent() { + return false; + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => ({ ...enabledPreferences, autoInjectSkill: false }) + }); + + await expect(service.runOnce()).resolves.toEqual({ + ok: true, + skipped: true, + reason: "auto_inject_disabled", + installed: ["cursor"], + failed: [] + }); + expect(calls).toEqual(["plugin:cursor:auto_inject"]); + }); + + it("leaves a current hook in place and still installs a newly found agent", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [ + source("cursor", "plugin_installed", true), + source("claude_code", "plugin_installed", true), + source("codex", "plugin_installed", true), + source("workbuddy", "not_connected", true) + ]; + }, + async isInstalledHookCurrent(sourceId) { + return sourceId !== "codex"; + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences + }); + + await expect(service.runOnce()).resolves.toEqual({ + ok: true, + skipped: false, + installed: ["codex", "workbuddy"], + failed: [] + }); + expect(calls).toEqual([ + "plugin:codex:auto_inject", + "skill:workbuddy" + ]); + }); + + it("does not refresh installed plugins that are not hooks", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [ + source("hermes", "plugin_installed", true), + source("opencode", "plugin_installed", true) + ]; + }, + async isInstalledHookCurrent() { + return false; + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences + }); + + await expect(service.runOnce()).resolves.toEqual({ + ok: true, + skipped: false, + installed: [], + failed: [] + }); + expect(calls).toEqual([]); + }); + + it("skips an outdated hook when the agent is unavailable or not writable", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [ + { ...source("cursor", "plugin_installed", true), available: false }, + source("claude_code", "plugin_installed", true) + ]; + }, + async isInstalledHookCurrent() { + return false; + } + }, + permissionManager: { + async canWriteAgentSkill(input: { agentSourceId: string }) { + return input.agentSourceId !== "claude_code"; + } + }, + getScanPreferences: () => ({ ...enabledPreferences, autoInjectSkill: false }) + }); + + await expect(service.runOnce()).resolves.toEqual({ + ok: true, + skipped: true, + reason: "auto_inject_disabled", + installed: [], + failed: [] + }); + expect(calls).toEqual([]); + }); + + it("records a hook refresh failure and continues with other agents", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [ + source("cursor", "plugin_installed", true), + source("pi", "not_connected", true) + ]; + }, + async isInstalledHookCurrent() { + return false; + }, + async installPlugin() { + throw new Error("install failed"); + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences + }); + + await expect(service.runOnce()).resolves.toEqual({ + ok: true, + skipped: false, + installed: ["pi"], + failed: [{ sourceId: "cursor", reason: "install failed" }] + }); + expect(calls).toEqual(["skill:pi"]); + }); }); function createAgentSources(calls: string[]) { @@ -130,6 +281,9 @@ function createAgentSources(calls: string[]) { }, async installPlugin(sourceId: string, action?: { installType?: string }) { calls.push(`plugin:${sourceId}:${action?.installType ?? "manual"}`); + }, + async isInstalledHookCurrent() { + return true; } }; } diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index 2b2d560f7..80478dbdb 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -1395,6 +1395,33 @@ describe("agent source service", () => { }); }); + it("reports whether an installed hook matches the bundled revision", async () => { + const fallback = createService(); + const service = createService({ + skillDistributionService: { + async install() { + return undefined; + }, + async uninstall() { + return undefined; + }, + async installPlugin() { + return undefined; + }, + async uninstallPlugin() { + return undefined; + }, + async isInstalledHookCurrent(sourceId) { + return sourceId !== "cursor"; + } + } + }); + + await expect(fallback.isInstalledHookCurrent("cursor")).resolves.toBe(true); + await expect(service.isInstalledHookCurrent("cursor")).resolves.toBe(false); + await expect(service.isInstalledHookCurrent("claude_code")).resolves.toBe(true); + }); + it("emits skill install analytics", async () => { const repository = createRepository(); repository.upsertSource({ diff --git a/App/backend/src/services/tests/skill-distribution-service.test.ts b/App/backend/src/services/tests/skill-distribution-service.test.ts index b5abf7768..7bd2fdd51 100644 --- a/App/backend/src/services/tests/skill-distribution-service.test.ts +++ b/App/backend/src/services/tests/skill-distribution-service.test.ts @@ -125,6 +125,27 @@ describe("skill distribution service", () => { expect(calls).toEqual(["cursor"]); }); + it("reads hook currency from targets that publish it", async () => { + const current = createSkillDistributionService({ + targetRegistry: createSkillTargetRegistry([ + createFakeTarget({ isInstalledHookCurrent: async () => true }) + ]) + }); + const outdated = createSkillDistributionService({ + targetRegistry: createSkillTargetRegistry([ + createFakeTarget({ isInstalledHookCurrent: async () => false }) + ]) + }); + const untouched = createSkillDistributionService({ + targetRegistry: createSkillTargetRegistry([createFakeTarget({})]) + }); + + await expect(current.isInstalledHookCurrent?.("cursor")).resolves.toBe(true); + await expect(outdated.isInstalledHookCurrent?.("cursor")).resolves.toBe(false); + await expect(untouched.isInstalledHookCurrent?.("cursor")).resolves.toBe(true); + await expect(untouched.isInstalledHookCurrent?.("missing")).resolves.toBe(true); + }); + it("delegates native plugin installation to plugin-capable targets", async () => { const calls: string[] = []; const service = createSkillDistributionService({ @@ -224,6 +245,7 @@ function createFakeTarget(overrides: { uninstall?: (targetId: string) => void; installPlugin?: (targetId: string) => void; uninstallPlugin?: (targetId: string) => void; + isInstalledHookCurrent?: () => Promise; detectMemoryPluginConflict?: () => MemoryPluginConflict | null; }): SkillTarget { return { @@ -253,6 +275,7 @@ function createFakeTarget(overrides: { : undefined, detectMemoryPluginConflict: overrides.detectMemoryPluginConflict ? async () => overrides.detectMemoryPluginConflict?.() ?? null - : undefined + : undefined, + isInstalledHookCurrent: overrides.isInstalledHookCurrent }; } From 478ae78c2393c23916d40d978ce22f23e9ced074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Wed, 30 Sep 2026 16:05:27 +0800 Subject: [PATCH 05/13] fix: deprioritize startup summary repairs --- Memory/src/storage/repositories.ts | 5 ++ .../service/worker/worker-runtime.test.ts | 62 ++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 7432606ab..ce5c29b69 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -7614,6 +7614,11 @@ function evolutionJobPrioritySql(): string { WHEN job_type IN ('trace_summary', 'import_summary', 'embedding') AND ${onboardingFirstReportTarget} THEN -100 WHEN json_extract(payload_json, '$.source') = 'memory.processing.manual_retry' THEN 0 + WHEN job_type IN ('trace_summary', 'import_summary') + AND json_extract(payload_json, '$.source') IN ( + 'startup.processing_repair', + 'startup.placeholder_summary_repair' + ) THEN 90 WHEN job_type = 'trace_summary' OR (job_type = 'embedding' AND ${interactiveL1Target}) THEN 1 WHEN job_type = 'import_summary' diff --git a/Memory/tests/service/worker/worker-runtime.test.ts b/Memory/tests/service/worker/worker-runtime.test.ts index 298557c54..73a01210a 100644 --- a/Memory/tests/service/worker/worker-runtime.test.ts +++ b/Memory/tests/service/worker/worker-runtime.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { Repositories } from "../../../src/storage/repositories.js"; -import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; +import { + addAgentSourceImport, + createMemoryServiceFixture +} from "../../fixtures/memory-service-fixture.js"; const { cleanup: cleanupMemoryServiceFixture, @@ -12,6 +15,63 @@ afterEach(() => { }); describe("MemoryService / worker / runtime", () => { + it("runs normal reflection before startup summary repair backlogs", () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "startup-repair-priority", + userId: "startup-repair-priority-user" + }; + const processingRepair = addAgentSourceImport( + service, + namespace, + "repair an interrupted import summary", + "startup-processing-repair" + ); + const placeholderRepair = addAgentSourceImport( + service, + namespace, + "repair a placeholder import summary", + "startup-placeholder-repair" + ); + db.db.prepare( + `UPDATE evolution_jobs + SET payload_json = json_set(payload_json, '$.source', ?) + WHERE target_memory_id = ?` + ).run("startup.processing_repair", processingRepair.id); + db.db.prepare( + `UPDATE evolution_jobs + SET payload_json = json_set(payload_json, '$.source', ?) + WHERE target_memory_id = ?` + ).run("startup.placeholder_summary_repair", placeholderRepair.id); + db.db.prepare( + `INSERT INTO evolution_jobs ( + id, job_type, status, user_id, episode_id, payload_json, + attempts, max_attempts, created_at, updated_at + ) VALUES (?, 'reflection', 'queued', ?, ?, '{}', 0, 3, ?, ?)` + ).run( + "job_normal_reflection_before_startup_repairs", + namespace.userId, + "episode_normal_reflection_before_startup_repairs", + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z" + ); + + const runtime = new Repositories(db.db).runtime; + const leased = runtime.leaseQueuedJobs(10, 60, undefined, true).map((job) => job.id); + + expect(leased).toEqual(["job_normal_reflection_before_startup_repairs"]); + runtime.completeJob("job_normal_reflection_before_startup_repairs"); + const repairs = runtime.leaseQueuedJobs(10, 60, undefined, true); + expect(repairs).toHaveLength(2); + expect(repairs.every((job) => job.jobType === "import_summary")).toBe(true); + expect(repairs.map((job) => job.payload.source).sort()).toEqual([ + "startup.placeholder_summary_repair", + "startup.processing_repair" + ]); + db.close(); + }); + it("leases L3 World Model updates FIFO per field while allowing different fields in parallel", () => { const { db } = createTestService(); const repos = new Repositories(db.db); From fb65ef0e86ced569d7f97e392c3c7cb437e41738 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 11:10:47 +0800 Subject: [PATCH 06/13] fix(dsh): use the current recall message source and assistant event DeepSeek Harness no longer accepts a generic plugin message source, and completed assistant turns are recorded as assistant/message. Co-authored-by: Cursor --- .../skill-writer/deepseek-harness/tests/target.test.ts | 2 +- .../skill-writer/templates/memmy-deepseek-harness-plugin.ts | 2 +- Memory/adapters/dsh/index.js | 6 +++--- .../integration/templates/memmy-deepseek-harness-plugin.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts index 068bdef41..2b6b62e43 100644 --- a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts @@ -361,7 +361,7 @@ describe("DeepSeek Harness skill target", () => { ) as { messages: Array<{ source: { kind: string }; content: Array<{ text: string }> }> }; expect(decision.messages[0]).toBe(userMessage); - expect(decision.messages[1]?.source.kind).toBe("plugin"); + expect(decision.messages[1]?.source.kind).toBe("memmy-memory"); expect(decision.messages[1]?.content[0]?.text).toContain("User prefers concise answers."); expect(decision.messages[1]?.content[0]?.text).toContain("\n检查 README"); expect(decision.messages[2]).toBe(runtimeContext); diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts index f0f71d635..3357fb471 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts @@ -76,7 +76,7 @@ export function apply(ctx, config = {}) { pendingL3.delete(String(payload.agent.session.id)); if (!markdown && !l3) return decision; const memory = createUserMessage({ - source: { kind: "plugin", plugin: name, form: "recall" }, + source: { kind: "memmy-memory", form: "recall" }, content: [{ type: "text", text: [l3, markdown ? renderMemoryPacket(markdown, "turn_start", query) : ""].filter(Boolean).join("\n\n") }] }); return { ...decision, messages: insertAfterUserMessage(decision.messages, memory) }; diff --git a/Memory/adapters/dsh/index.js b/Memory/adapters/dsh/index.js index 8a07a268b..15931e4fa 100644 --- a/Memory/adapters/dsh/index.js +++ b/Memory/adapters/dsh/index.js @@ -60,17 +60,17 @@ export async function apply(ctx, config) { if (query) { const started = await request("/turns/start", profileId, { sessionId, query }, config.recallTimeoutMs); turns.set(String(agent?.id || "default"), { sessionId, query, turnId: started.turnId }); - if (started.injectedContext && Array.isArray(payload?.messages)) payload.messages.push({ role: "user", content: [{ type: "text", text: started.injectedContext }], source: { kind: "plugin", plugin: name, form: "recall" } }); + if (started.injectedContext && Array.isArray(payload?.messages)) payload.messages.push({ role: "user", content: [{ type: "text", text: started.injectedContext }], source: { kind: "memmy-memory", form: "recall" } }); } } catch (error) { ctx.logger.warn(`memmy-memory recall unavailable: ${String(error)}`); } return next(); })); disposers.push(ctx.on("session/event", (session, event) => { - if (!config.captureEnabled || event?.type !== "assistant") return; + if (!config.captureEnabled || event?.type !== "assistant/message") return; const active = turns.get(String(session?.id || "default")); if (!active) return; turns.delete(String(session?.id || "default")); - const answer = String(event?.message?.content?.map?.((part) => part.text || "").join("\n") || event?.content || ""); + const answer = String(event?.data?.message?.content?.map?.((part) => part.text || "").join("\n") || ""); void request(`/turns/${encodeURIComponent(active.turnId)}/complete`, profileId, { sessionId: active.sessionId, query: active.query, answer, status: "succeeded" }, 10000).catch(() => undefined); })); disposers.push(ctx.on("session/disposed", (session) => { const key = String(session?.id || "default"); const id = sessions.get(key); sessions.delete(key); if (id) void request(`/sessions/${encodeURIComponent(id)}/close`, profileId, {}).catch(() => undefined); })); diff --git a/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts b/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts index f0f71d635..3357fb471 100644 --- a/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts +++ b/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts @@ -76,7 +76,7 @@ export function apply(ctx, config = {}) { pendingL3.delete(String(payload.agent.session.id)); if (!markdown && !l3) return decision; const memory = createUserMessage({ - source: { kind: "plugin", plugin: name, form: "recall" }, + source: { kind: "memmy-memory", form: "recall" }, content: [{ type: "text", text: [l3, markdown ? renderMemoryPacket(markdown, "turn_start", query) : ""].filter(Boolean).join("\n\n") }] }); return { ...decision, messages: insertAfterUserMessage(decision.messages, memory) }; From 8bb6d0de440f737aa33fabea4848f37daebb4423 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 16:33:18 +0800 Subject: [PATCH 07/13] fix(dsh): give the memory plugin a package version DeepSeek Harness rejects official model requests when an enabled plugin manifest has no version. Co-authored-by: Cursor --- .../outbound/skill-writer/deepseek-harness/tests/target.test.ts | 2 +- .../skill-writer/templates/memmy-deepseek-harness-plugin.ts | 1 + .../integration/templates/memmy-deepseek-harness-plugin.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts index 2b6b62e43..54f5e3561 100644 --- a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts @@ -44,6 +44,7 @@ describe("DeepSeek Harness skill target", () => { const packageManifest = JSON.parse(readFileSync(packagePath, "utf8")) as Record; expect(packageManifest).toMatchObject({ name: "@memmy/memmy-memory", + version: "1.0.0", type: "module", exports: { ".": "./index.mjs", @@ -51,7 +52,6 @@ describe("DeepSeek Harness skill target", () => { }, dsh: { client: { platform: "web" } } }); - expect(packageManifest).not.toHaveProperty("version"); expect(readFileSync(skillPath, "utf8")).toContain('memmy-memory search "query text" --source deepseek_harness'); expect(readFileSync(resumeSkillPath, "utf8")).toContain("--source deepseek_harness"); expect(patch).toContain("id: user-plugin"); diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts index 3357fb471..22f003bdc 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts @@ -653,6 +653,7 @@ export const DEEPSEEK_HARNESS_PLUGIN_CLIENT = String.raw`window.__ModuleLoader__ export function createDeepseekHarnessPluginPackageManifest(): Record { return { name: "@memmy/memmy-memory", + version: "1.0.0", private: true, type: "module", exports: { diff --git a/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts b/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts index 3357fb471..22f003bdc 100644 --- a/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts +++ b/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts @@ -653,6 +653,7 @@ export const DEEPSEEK_HARNESS_PLUGIN_CLIENT = String.raw`window.__ModuleLoader__ export function createDeepseekHarnessPluginPackageManifest(): Record { return { name: "@memmy/memmy-memory", + version: "1.0.0", private: true, type: "module", exports: { From 7e9ec791f99db88ed5493bb7d57c16e71ef52f2b Mon Sep 17 00:00:00 2001 From: jiang Date: Thu, 8 Oct 2026 10:59:50 +0800 Subject: [PATCH 08/13] fix(dsh): align standalone adapter with supported lifecycle --- Memory/adapters/dsh/index.js | 42 +++++++++++++--- .../tests/deepseek-harness-template.test.ts | 48 +++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 Memory/tests/deepseek-harness-template.test.ts diff --git a/Memory/adapters/dsh/index.js b/Memory/adapters/dsh/index.js index 15931e4fa..235c2630a 100644 --- a/Memory/adapters/dsh/index.js +++ b/Memory/adapters/dsh/index.js @@ -43,7 +43,7 @@ export async function apply(ctx, config) { const profileId = config.profileId || "default"; async function sessionFor(agent) { - const key = String(agent?.id || agent?.session?.id || "default"); + const key = agentSessionKey(agent); if (sessions.has(key)) return sessions.get(key); const opened = await request("/sessions/open", profileId, { sessionId: `dsh:${key}`, meta: { host: "dsh" } }); sessions.set(key, opened.sessionId); @@ -56,24 +56,26 @@ export async function apply(ctx, config) { try { const agent = payload?.agent; const sessionId = await sessionFor(agent); - const query = String(payload?.message?.content?.[0]?.text || payload?.message?.content || "").trim(); + const query = userQuery(payload); if (query) { const started = await request("/turns/start", profileId, { sessionId, query }, config.recallTimeoutMs); - turns.set(String(agent?.id || "default"), { sessionId, query, turnId: started.turnId }); - if (started.injectedContext && Array.isArray(payload?.messages)) payload.messages.push({ role: "user", content: [{ type: "text", text: started.injectedContext }], source: { kind: "memmy-memory", form: "recall" } }); + turns.set(agentSessionKey(agent), { sessionId, query, turnId: started.turnId }); + const injectedContext = contextText(started.injectedContext); + if (injectedContext && Array.isArray(payload?.messages)) payload.messages.push({ role: "user", content: [{ type: "text", text: injectedContext }], source: { kind: "memmy-memory", form: "recall" } }); } } catch (error) { ctx.logger.warn(`memmy-memory recall unavailable: ${String(error)}`); } return next(); })); disposers.push(ctx.on("session/event", (session, event) => { - if (!config.captureEnabled || event?.type !== "assistant/message") return; + if (!config.captureEnabled || (event?.type !== "assistant/message" && event?.type !== "assistant")) return; const active = turns.get(String(session?.id || "default")); if (!active) return; turns.delete(String(session?.id || "default")); - const answer = String(event?.data?.message?.content?.map?.((part) => part.text || "").join("\n") || ""); + const message = event?.data?.message || event?.message; + const answer = messageText(message) || contextText(event?.data?.content || event?.content); void request(`/turns/${encodeURIComponent(active.turnId)}/complete`, profileId, { sessionId: active.sessionId, query: active.query, answer, status: "succeeded" }, 10000).catch(() => undefined); })); - disposers.push(ctx.on("session/disposed", (session) => { const key = String(session?.id || "default"); const id = sessions.get(key); sessions.delete(key); if (id) void request(`/sessions/${encodeURIComponent(id)}/close`, profileId, {}).catch(() => undefined); })); + disposers.push(ctx.on("session/disposed", (session) => { const key = agentSessionKey({ session }); const id = sessions.get(key); sessions.delete(key); if (id) void request(`/sessions/${encodeURIComponent(id)}/close`, profileId, {}).catch(() => undefined); })); if (config.toolsEnabled) { const registrations = [ @@ -88,3 +90,29 @@ export async function apply(ctx, config) { } return async () => { for (const dispose of disposers.reverse()) dispose(); }; } + +function userQuery(payload) { + const messages = Array.isArray(payload?.messages) ? payload.messages : []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.source?.kind === "user" || message?.role === "user") return messageText(message); + } + return messageText(payload?.message); +} + +function agentSessionKey(agent) { + return String(agent?.session?.id || agent?.id || "default"); +} + +function messageText(message) { + return contextText(message?.content); +} + +function contextText(value) { + if (typeof value === "string") return value.trim(); + if (Array.isArray(value)) return value.map((part) => typeof part === "string" ? part : part?.text || "").join("\n").trim(); + if (value && typeof value === "object") { + return contextText(value.markdown || value.text || value.content || value.value); + } + return ""; +} diff --git a/Memory/tests/deepseek-harness-template.test.ts b/Memory/tests/deepseek-harness-template.test.ts new file mode 100644 index 000000000..472640d8d --- /dev/null +++ b/Memory/tests/deepseek-harness-template.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + createDeepseekHarnessPluginPackageManifest, + DEEPSEEK_HARNESS_PLUGIN_INDEX +} from "../src/agent-source/integration/templates/memmy-deepseek-harness-plugin.js"; + +describe("DeepSeek Harness plugin compatibility", () => { + it("marks recalled context as Memmy memory", () => { + expect(DEEPSEEK_HARNESS_PLUGIN_INDEX).toContain( + 'source: { kind: "memmy-memory", form: "recall" }' + ); + expect(DEEPSEEK_HARNESS_PLUGIN_INDEX).not.toContain( + 'source: { kind: "plugin", plugin: name, form: "recall" }' + ); + }); + + it("writes a package identity accepted by DSH request inventory", () => { + expect(createDeepseekHarnessPluginPackageManifest()).toMatchObject({ + name: "@memmy/memmy-memory", + version: "1.0.0", + type: "module" + }); + }); + + it.each([ + "dsh-v0.1.7-rc.2", + "dsh-v0.2.0-rc.2", + "dsh-v0.2.1-alpha.1" + ])("keeps the shared %s lifecycle envelope", (tag) => { + expect(DEEPSEEK_HARNESS_PLUGIN_INDEX).toContain("userQuery(payload.messages)"); + expect(DEEPSEEK_HARNESS_PLUGIN_INDEX).toContain('event.type === "assistant/message"'); + expect(tag).toMatch(/^dsh-v0\.(1\.7|2\.0|2\.1)-/); + }); + + it("keeps the standalone dsh adapter on the same current event envelope", () => { + const adapter = readFileSync( + resolve(import.meta.dirname, "../adapters/dsh/index.js"), + "utf8" + ); + expect(adapter).toContain("const query = userQuery(payload);"); + expect(adapter).toContain("function agentSessionKey(agent)"); + expect(adapter).toContain("turns.set(agentSessionKey(agent)"); + expect(adapter).toContain('event?.type !== "assistant/message"'); + expect(adapter).toContain('source: { kind: "memmy-memory", form: "recall" }'); + }); +}); From 7580c48256fbd3f2793f808798645b30a37ef1cb Mon Sep 17 00:00:00 2001 From: jiang Date: Thu, 8 Oct 2026 11:13:58 +0800 Subject: [PATCH 09/13] fix(dsh): align client manifest with supported tags --- .../deepseek-harness/tests/target.test.ts | 25 ++++++++++++++++++- .../memmy-deepseek-harness-plugin.ts | 8 +++--- .../memmy-deepseek-harness-plugin.ts | 8 +++--- .../tests/deepseek-harness-template.test.ts | 8 +++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts index 54f5e3561..9623d9a7c 100644 --- a/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/deepseek-harness/tests/target.test.ts @@ -50,7 +50,7 @@ describe("DeepSeek Harness skill target", () => { ".": "./index.mjs", "./client": "./client.js" }, - dsh: { client: { platform: "web" } } + dsh: { client: { platform: "web", inject: ["@deepseek-ai/dsh-client-ui-conversation"] } } }); expect(readFileSync(skillPath, "utf8")).toContain('memmy-memory search "query text" --source deepseek_harness'); expect(readFileSync(resumeSkillPath, "utf8")).toContain("--source deepseek_harness"); @@ -227,6 +227,29 @@ describe("DeepSeek Harness skill target", () => { expect(definition?.kind).toBe("memmy-optimistic-user"); }); + it("supports the transitional conversation.events registry", async () => { + const rootDirectory = createRoot(); + const target = createDeepseekHarnessSkillTarget({ rootDirectory }); + await target.installPlugin?.("deepseek_harness"); + const clientPath = join(installedPluginDirectory(rootDirectory), "client.js"); + let handoff: { id: string; factory(): Record } | undefined; + runInNewContext(readFileSync(clientPath, "utf8"), { + window: { __ModuleLoader__: { load: (value: typeof handoff) => { handoff = value; } } } + }); + + let registered = 0; + const client = handoff?.factory(); + client?.apply({ + get(name: string) { + return name === "conversation" + ? { events: { register: () => { registered += 1; } } } + : undefined; + } + }); + + expect(registered).toBe(1); + }); + it("fails clearly when neither conversation event API is available", async () => { const rootDirectory = createRoot(); const target = createDeepseekHarnessSkillTarget({ rootDirectory }); diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts index 22f003bdc..e89be3fb7 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts @@ -577,6 +577,9 @@ export const DEEPSEEK_HARNESS_PLUGIN_CLIENT = String.raw`window.__ModuleLoader__ const uiConversation = ctx.uiConversation || (typeof ctx.get === "function" ? ctx.get("uiConversation") : undefined); if (uiConversation && uiConversation.events) return uiConversation.events; + const conversation = ctx.conversation + || (typeof ctx.get === "function" ? ctx.get("conversation") : undefined); + if (conversation && conversation.events) return conversation.events; const conversationEvents = typeof ctx.get === "function" ? ctx.get("conversationEvents") : undefined; if (conversationEvents) return conversationEvents; throw new Error("memmy-memory requires uiConversation.events or conversationEvents"); @@ -664,10 +667,7 @@ export function createDeepseekHarnessPluginPackageManifest(): Record { expect(createDeepseekHarnessPluginPackageManifest()).toMatchObject({ name: "@memmy/memmy-memory", version: "1.0.0", - type: "module" + type: "module", + dsh: { + client: { + platform: "web", + inject: ["@deepseek-ai/dsh-client-ui-conversation"] + } + } }); }); From ceb5fa3e9d1d91c27a4b84f6e66bfe4f1df46189 Mon Sep 17 00:00:00 2001 From: jiang Date: Thu, 8 Oct 2026 17:19:37 +0800 Subject: [PATCH 10/13] fix: remove legacy memory embedding config --- .../desktop/tests/dev-cli-launcher.test.ts | 2 +- Memory/src/cli/legacy-migration.ts | 12 +-- Memory/src/cli/setup.ts | 44 ++++++++--- Memory/src/config/index.ts | 26 ++++--- Memory/tests/cli-setup.test.ts | 25 +++--- Memory/tests/config.test.ts | 45 +++++++++++ .../0001-remove-legacy-memory-embedding.ts | 76 +++++++++++++++++++ Migrations/src/registry.ts | 2 + ...001-remove-legacy-memory-embedding.test.ts | 71 +++++++++++++++++ Migrations/tests/runner.test.ts | 13 +++- 10 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 Migrations/src/migrations/v1.1.9/0001-remove-legacy-memory-embedding.ts create mode 100644 Migrations/tests/0001-remove-legacy-memory-embedding.test.ts diff --git a/App/shell/desktop/tests/dev-cli-launcher.test.ts b/App/shell/desktop/tests/dev-cli-launcher.test.ts index ea91c4f7c..5bbafd041 100644 --- a/App/shell/desktop/tests/dev-cli-launcher.test.ts +++ b/App/shell/desktop/tests/dev-cli-launcher.test.ts @@ -151,7 +151,7 @@ fi`; ); expect(init.status, init.stderr || init.stdout).toBe(0); const config = YAML.parse(readFileSync(configPath, "utf8")); - expect(config.memmyMemory.embedding).toEqual({ mode: "local", provider: "local" }); + expect(config.memmyMemory.embedding).toBeUndefined(); const validate = spawnSync( "node", diff --git a/Memory/src/cli/legacy-migration.ts b/Memory/src/cli/legacy-migration.ts index 76366f042..42b936bde 100644 --- a/Memory/src/cli/legacy-migration.ts +++ b/Memory/src/cli/legacy-migration.ts @@ -156,15 +156,16 @@ async function importLegacyConfig(source: LegacySource, targetPath: string): Pro const legacy = record(parsed); const llm = record(legacy.llm); const skillEvolver = record(legacy.skillEvolver); - const embedding = record(legacy.embedding); + const rawEmbedding = record(legacy.embedding); const algorithm = record(legacy.algorithm); const summary = mapLlm(llm); const evolution = mapLlm(Object.keys(skillEvolver).length ? skillEvolver : llm); + const embedding = mapEmbedding(rawEmbedding); await mutateMemoryConfig(targetPath, (root) => { const memory = record(root.memmyMemory); const hub = record(legacy.hub); const telemetry = record(legacy.telemetry); - const nextMemory = { + const nextMemory: Record = { ...memory, roleRouting: { ...record(memory.roleRouting), @@ -173,19 +174,20 @@ async function importLegacyConfig(source: LegacySource, targetPath: string): Pro }, summary, evolution, - embedding: mapEmbedding(embedding), + embedding, algorithm: mergeLegacyAlgorithm(record(memory.algorithm), algorithm), ...(Object.keys(telemetry).length ? { telemetry: { ...record(memory.telemetry), ...telemetry } } : {}), ...(Object.keys(hub).length ? { hub: { ...hub, migratedFrom: source.agent } } : {}), migratedFrom: source.agent }; - root.memmyMemory = nextMemory; syncMemoryModelCatalog(root, nextMemory, { roleRouting: nextMemory.roleRouting, summary, evolution, - embedding: nextMemory.embedding + embedding }); + delete nextMemory.embedding; + root.memmyMemory = nextMemory; }); } diff --git a/Memory/src/cli/setup.ts b/Memory/src/cli/setup.ts index dcc791f06..df8298243 100644 --- a/Memory/src/cli/setup.ts +++ b/Memory/src/cli/setup.ts @@ -1,3 +1,4 @@ +import { syncMemoryModelCatalog } from "../config/model-catalog.js"; import { mutateMemoryConfig } from "../config/writer.js"; import { existsSync, @@ -293,8 +294,8 @@ function setupMemoryConfig( ): void { const app = asRecord(config.app); const appUserId = optionalString(app.userId); - - config.memmyMemory = setupMemmyMemoryConfig(asRecord(config.memmyMemory), { + const existingMemory = asRecord(config.memmyMemory); + const memory = setupMemmyMemoryConfig(existingMemory, { appUserId, accountMode: app.userMode === "account", dbPath: options.dbPath, @@ -302,6 +303,13 @@ function setupMemoryConfig( token: options.token, generateTokenIfMissing: options.generateTokenIfMissing, }); + // Older installs stored embedding settings here. Normalize them into the + // shared model catalog while ensuring the retired runtime field is absent. + if (Object.prototype.hasOwnProperty.call(existingMemory, "embedding")) { + const embedding = setupEmbeddingForCatalog(existingMemory.embedding, app.userMode === "account"); + syncMemoryModelCatalog(config, { ...memory, embedding }, { embedding }); + } + config.memmyMemory = memory; } function setupMemmyMemoryConfig( @@ -316,7 +324,6 @@ function setupMemmyMemoryConfig( } ): Record { const roleRouting = asRecord(existing.roleRouting); - const embedding = asRecord(existing.embedding); const storage = asRecord(existing.storage); const algorithm = asRecord(existing.algorithm); const agentAccess = asRecord(existing.agentAccess); @@ -355,16 +362,35 @@ function setupMemmyMemoryConfig( watchFileChanges: optionalBoolean(agentAccess.watchFileChanges) ?? true, autoInjectSkill: optionalBoolean(agentAccess.autoInjectSkill) ?? false }, - embedding: Object.keys(embedding).length - ? embedding - : { - mode: options.accountMode ? "cloud" : "local", - ...(options.accountMode ? {} : { provider: "local" }) - } }; + delete memmyMemory.embedding; return memmyMemory; } +function setupEmbeddingForCatalog(value: unknown, accountMode: boolean): Record { + const embedding = asRecord(value); + const nested = asRecord(embedding.custom); + const configuredMode = optionalString(embedding.mode); + if (configuredMode === "local" || optionalString(embedding.provider) === "local") { + return { ...embedding, mode: "local" }; + } + if (configuredMode === "custom" || Object.keys(nested).length > 0 || hasEmbeddingConnection(embedding)) { + return { + ...embedding, + ...nested, + mode: "custom", + }; + } + return { + ...embedding, + mode: accountMode ? "cloud" : "local", + }; +} + +function hasEmbeddingConnection(value: Record): boolean { + return ["endpoint", "model", "apiKey", "extraHeaders", "extraBody"].some((key) => key in value); +} + function memoryRoleRouting(value: unknown): "follow" | "fixed" { return value === "fixed" ? "fixed" : "follow"; } diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 85a96a4f2..a1e0af3f9 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -947,19 +947,25 @@ function resolveMemoryEmbedding( hasCatalog: boolean ): Record { const embedding = asRecord(memory.embedding); - const configuredMode = optionalString(embedding.mode); - const embeddingMode = configuredMode === "cloud" - || configuredMode === "local" - || configuredMode === "custom" - ? configuredMode - : mode === "account" && hasCatalog - ? "cloud" - : DEFAULT_MEMMY_CONFIG.embedding.mode; const activeAssignment = mode ? asRecord(asRecord(rootConfig.modelAssignments)[mode]) : {}; const rawAssignedPreset = activeAssignment.embedding; const hasExplicitAssignment = rawAssignedPreset !== undefined && rawAssignedPreset !== null; + const resolved = resolveMemoryAssignment(rootConfig, mode, "embedding"); + const configuredMode = optionalString(embedding.mode); + const embeddingMode = configuredMode === "cloud" + || configuredMode === "local" + || configuredMode === "custom" + ? configuredMode + : rawAssignedPreset === BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID + || (mode === "byok" && hasCatalog && !hasExplicitAssignment) + ? "local" + : hasExplicitAssignment && resolved.ok && embeddingProtocolSupported(resolved.context.protocol) + ? resolved.context.source === "account" ? "cloud" : "custom" + : mode === "account" && hasCatalog + ? "cloud" + : DEFAULT_MEMMY_CONFIG.embedding.mode; if (rawAssignedPreset === BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID) { const assignmentOwner = optionalString(activeAssignment.ownerAccountId); const activeAccountId = optionalString(asRecord(rootConfig.app).userId); @@ -988,8 +994,6 @@ function resolveMemoryEmbedding( }; } - const resolved = resolveMemoryAssignment(rootConfig, mode, "embedding"); - if (mode === "byok" && hasCatalog && !hasExplicitAssignment) { return localEmbeddingConfig(embedding); } @@ -1006,7 +1010,7 @@ function resolveMemoryEmbedding( return localEmbeddingConfig(embedding); } - if (embeddingMode === "custom") { + if (embeddingMode === "custom" && (configuredMode === "custom" || !hasExplicitAssignment)) { const custom = asRecord(embedding.custom); return { ...embedding, diff --git a/Memory/tests/cli-setup.test.ts b/Memory/tests/cli-setup.test.ts index da0e89db4..05ac55649 100644 --- a/Memory/tests/cli-setup.test.ts +++ b/Memory/tests/cli-setup.test.ts @@ -70,12 +70,9 @@ describe("memmy-memory CLI setup commands", () => { enableMemorySearch: true, enableQueryRewrite: false }, - embedding: { - mode: "local", - provider: "local" - } } }); + expect(saved.memmyMemory.embedding).toBeUndefined(); expect(existsSync(dbPath)).toBe(false); }); @@ -429,11 +426,8 @@ describe("memmy-memory CLI setup commands", () => { enableMemorySearch: true, enableQueryRewrite: false }, - embedding: { - mode: "local", - provider: "local" - } }); + expect(saved.memmyMemory.embedding).toBeUndefined(); expect(existsSync(dbPath)).toBe(false); }); @@ -473,7 +467,7 @@ describe("memmy-memory CLI setup commands", () => { expect(saved.memmyMemory.summary.model).toBe("memmy-model"); }); - it("preserves embedding modes during setup", async () => { + it("removes legacy embedding modes during setup", async () => { for (const mode of ["cloud", "local", "custom"]) { const root = tempRoot(); const configPath = join(root, "config.yaml"); @@ -495,21 +489,23 @@ describe("memmy-memory CLI setup commands", () => { }); const saved = YAML.parse(readFileSync(configPath, "utf8")); - expect(saved.memmyMemory.embedding).toEqual({ mode }); + expect(saved.memmyMemory.embedding).toBeUndefined(); } }); - it("preserves embedding connections during setup", async () => { + it("moves embedding connections into the model catalog during setup", async () => { for (const embedding of [ { provider: "openai_compatible", - endpoint: "https://example.com/v1" + endpoint: "https://example.com/v1", + model: "text-embedding-3-small" }, { mode: "custom", custom: { provider: "openai_compatible", - endpoint: "https://example.com/v1" + endpoint: "https://example.com/v1", + model: "text-embedding-3-small" } } ]) { @@ -528,7 +524,8 @@ describe("memmy-memory CLI setup commands", () => { ] }); const saved = YAML.parse(readFileSync(configPath, "utf8")); - expect(saved.memmyMemory.embedding).toEqual(embedding); + expect(saved.memmyMemory.embedding).toBeUndefined(); + expect(saved.modelAssignments.byok.embedding).toEqual(expect.any(String)); } }); diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index 0afb417ea..be064876e 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -434,6 +434,51 @@ describe("memmy memory config", () => { expect(config.embedding.selectionError).toBeUndefined(); }); + it("resolves catalog embedding assignments when the legacy runtime field is absent", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: { + openai: { + apiKey: "sk-embedding", + endpoints: { + embeddings: { + apiBase: "https://embedding.example/v1", + protocol: "openai-embeddings" + } + } + } + }, + modelPresets: { + embeddings: { + provider: "openai", + endpoint: "embeddings", + model: "text-embedding-3-small", + source: "byok", + capabilities: ["embedding"] + } + }, + modelAssignments: { + byok: { embedding: "embeddings" }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: {} + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding).toMatchObject({ + mode: "custom", + provider: "openai_compatible", + sourceProvider: "openai", + endpoint: "https://embedding.example/v1", + model: "text-embedding-3-small", + apiKey: "sk-embedding" + }); + expect(config.embedding.selectionError).toBeUndefined(); + }); + it("rejects a built-in local Embedding assignment owned by another account", () => { const root = tempRoot(); const configPath = join(root, "config.yaml"); diff --git a/Migrations/src/migrations/v1.1.9/0001-remove-legacy-memory-embedding.ts b/Migrations/src/migrations/v1.1.9/0001-remove-legacy-memory-embedding.ts new file mode 100644 index 000000000..4c32f156a --- /dev/null +++ b/Migrations/src/migrations/v1.1.9/0001-remove-legacy-memory-embedding.ts @@ -0,0 +1,76 @@ +import { + mutateRuntimeConfig, + mutateRuntimeConfigLockHeld, + type RuntimeConfigDocument, +} from "../../runtime-config-writer.js"; +import { + MigrationError, + type AgentWorkspaceMigrationContext, + type MigrationDefinition, + type MigrationResult, +} from "../../types.js"; + +const MIGRATION_ID = "v1.1.9/0001-remove-legacy-memory-embedding"; + +function isObject(value: unknown): value is RuntimeConfigDocument { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function removeLegacyEmbedding(config: RuntimeConfigDocument): void { + // Memory setup used to write this field after the model-catalog migration + // had already recorded its completion, so it needs a follow-up cleanup. + const memory = isObject(config.memmyMemory) ? config.memmyMemory : null; + if (memory) delete memory.embedding; +} + +function wrapError(error: unknown): never { + if (error instanceof MigrationError) { + throw new MigrationError(error.code, error.message, { + migrationId: MIGRATION_ID, + scope: "runtime-config", + cause: error.cause, + }); + } + throw new MigrationError( + "migration_config_invalid", + "Unable to remove the legacy memory embedding configuration", + { + migrationId: MIGRATION_ID, + scope: "runtime-config", + cause: error, + }, + ); +} + +async function runMigration( + context: AgentWorkspaceMigrationContext, +): Promise { + try { + const options = { createIfMissing: false as const }; + const result = context.runtimeConfigLock + ? await mutateRuntimeConfigLockHeld(context.runtimeConfigLock, removeLegacyEmbedding, options) + : await mutateRuntimeConfig(context.runtimeConfigFile, removeLegacyEmbedding, options); + if (!result.sourceExists) { + return { scanned: 0, changed: 0, ignored: 0, deferred: true }; + } + return result.changed + ? { scanned: 1, changed: 1, ignored: 0 } + : { scanned: 1, changed: 0, ignored: 1 }; + } catch (error) { + wrapError(error); + } +} + +export const removeLegacyMemoryEmbeddingV119: MigrationDefinition = { + id: MIGRATION_ID, + introducedIn: "1.1.9", + scope: "runtime-config", + description: "Remove the legacy memmyMemory.embedding runtime configuration", + up: runMigration, +}; + +export function removeLegacyMemoryEmbeddingForTest( + context: AgentWorkspaceMigrationContext, +): Promise { + return runMigration(context); +} diff --git a/Migrations/src/registry.ts b/Migrations/src/registry.ts index 50d646e7a..8dc6d57b1 100644 --- a/Migrations/src/registry.ts +++ b/Migrations/src/registry.ts @@ -6,6 +6,7 @@ import { addGoalDagBoundaryV107 } from "./migrations/v1.0.7/0004-add-goal-dag-bo import { repairRuntimeModelCatalogV109 } from "./migrations/v1.0.9/0001-repair-runtime-model-catalog.js"; import { upgradeSummaryTimeoutV112 } from "./migrations/v1.1.2/0001-upgrade-summary-timeout.js"; import { addOpenComputerUseMcpV116 } from "./migrations/v1.1.6/0001-add-open-computer-use-mcp.js"; +import { removeLegacyMemoryEmbeddingV119 } from "./migrations/v1.1.9/0001-remove-legacy-memory-embedding.js"; import { MigrationError, type MigrationDefinition } from "./types.js"; const STABLE_SEMVER_PATTERN = @@ -28,6 +29,7 @@ export const migrations: readonly MigrationDefinition[] = [ repairRuntimeModelCatalogV109, upgradeSummaryTimeoutV112, addOpenComputerUseMcpV116, + removeLegacyMemoryEmbeddingV119, ]; function definitionError(message: string, migrationId: string | null = null): never { diff --git a/Migrations/tests/0001-remove-legacy-memory-embedding.test.ts b/Migrations/tests/0001-remove-legacy-memory-embedding.test.ts new file mode 100644 index 000000000..870b83461 --- /dev/null +++ b/Migrations/tests/0001-remove-legacy-memory-embedding.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { removeLegacyMemoryEmbeddingV119 } from "../src/migrations/v1.1.9/0001-remove-legacy-memory-embedding.js"; +import type { MigrationLogger } from "../src/types.js"; + +const temporaryDirectories: string[] = []; + +function logger(): MigrationLogger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +async function fixture(config: unknown) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "memmy-remove-embedding-migration-")); + temporaryDirectories.push(root); + const configPath = path.join(root, "config.yaml"); + await fs.writeFile(configPath, YAML.stringify(config), "utf8"); + return { root, configPath }; +} + +function context(root: string, configPath: string) { + return { + profileWorkspace: root, + sessionsDir: path.join(root, "sessions"), + runtimeConfigFile: configPath, + sessionDagDir: path.join(root, "session-dag"), + logger: logger(), + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + fs.rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe("v1.1.9/0001-remove-legacy-memory-embedding", () => { + it("removes memmyMemory.embedding while preserving the rest of the config", async () => { + const { root, configPath } = await fixture({ + app: { userMode: "byok" }, + memmyMemory: { + embedding: { mode: "local", provider: "local" }, + storage: { sqlitePath: "/tmp/memory.sqlite" }, + }, + }); + + await expect(removeLegacyMemoryEmbeddingV119.up(context(root, configPath))).resolves.toEqual({ + scanned: 1, + changed: 1, + ignored: 0, + }); + + const config = YAML.parse(await fs.readFile(configPath, "utf8")); + expect(config.memmyMemory.embedding).toBeUndefined(); + expect(config.memmyMemory.storage).toEqual({ sqlitePath: "/tmp/memory.sqlite" }); + }); + + it("is idempotent when the legacy field is already absent", async () => { + const { root, configPath } = await fixture({ memmyMemory: { enabled: true } }); + + await expect(removeLegacyMemoryEmbeddingV119.up(context(root, configPath))).resolves.toEqual({ + scanned: 1, + changed: 0, + ignored: 1, + }); + }); +}); diff --git a/Migrations/tests/runner.test.ts b/Migrations/tests/runner.test.ts index c14b02385..5bc2d9b83 100644 --- a/Migrations/tests/runner.test.ts +++ b/Migrations/tests/runner.test.ts @@ -113,9 +113,10 @@ describe("migration runner", () => { "v1.0.9/0001-repair-runtime-model-catalog", "v1.1.2/0001-upgrade-summary-timeout", "v1.1.6/0001-add-open-computer-use-mcp", + "v1.1.9/0001-remove-legacy-memory-embedding", ]); expect(first.deferred).toEqual(["v1.0.7/0002-import-legacy-app-state-model-config"]); - expect(first.results).toEqual({ scanned: 6, changed: 3, ignored: 3 }); + expect(first.results).toEqual({ scanned: 7, changed: 3, ignored: 4 }); expect(second).toEqual({ applied: [], skipped: [ @@ -126,6 +127,7 @@ describe("migration runner", () => { "v1.0.9/0001-repair-runtime-model-catalog", "v1.1.2/0001-upgrade-summary-timeout", "v1.1.6/0001-add-open-computer-use-mcp", + "v1.1.9/0001-remove-legacy-memory-embedding", ], deferred: ["v1.0.7/0002-import-legacy-app-state-model-config"], results: { scanned: 0, changed: 0, ignored: 0 }, @@ -194,6 +196,15 @@ describe("migration runner", () => { key: runtimeConfigTargetKey(configPath), }, }, + { + id: "v1.1.9/0001-remove-legacy-memory-embedding", + introducedIn: "1.1.9", + appliedAt: expect.stringMatching(/Z$/), + target: { + type: "runtime-config", + key: runtimeConfigTargetKey(configPath), + }, + }, ]); }); From 9da8ea8a4f485fcbe25ff8856f9a14962825ca17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Thu, 8 Oct 2026 18:10:09 +0800 Subject: [PATCH 11/13] fix: retry a hook install whose revision was written before the files A stored fingerprint no longer counts as current unless the on-disk script and bridge match it, and the fingerprint is recorded only after the rest of the install succeeds. Co-authored-by: Cursor --- .../skill-writer/claude-code/target.ts | 18 +++++----- .../claude-code/tests/target.test.ts | 4 +++ .../outbound/skill-writer/codex/target.ts | 18 +++++----- .../skill-writer/codex/tests/target.test.ts | 4 +++ .../outbound/skill-writer/cursor/target.ts | 18 +++++----- .../skill-writer/cursor/tests/target.test.ts | 4 +++ .../skill-writer/hook-revision.test.ts | 35 +++++++++++++++++-- .../outbound/skill-writer/hook-revision.ts | 8 +++-- 8 files changed, 80 insertions(+), 29 deletions(-) diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts index da135bda9..b114faa71 100644 --- a/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts @@ -107,15 +107,13 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep const hookDirectory = join(root, HOOK_DIRECTORY_NAME); const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME); + const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME); + const hookConfig = { + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }; await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically( - join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ - memmy_config_path: memmyConfigPath, - ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), - ...(await memmyHookRevisionField(CLAUDE_CODE_TARGET_ID, "claude-code")) - }, null, 2)}\n` - ); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); await writeFileAtomically( hookScriptPath, renderMemmyResumeHookScript({ source: CLAUDE_CODE_TARGET_ID, mode: "claude-code" }) @@ -136,6 +134,10 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep upsertMarkerBlock(await readTextFile(filePath), renderMemmySkillBootstrapManifest(manifest)) ); await replaceMemmySkillDirectory(root, manifest); + await writeFileAtomically( + hookConfigPath, + `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CLAUDE_CODE_TARGET_ID, "claude-code")) }, null, 2)}\n` + ); }, async uninstallPlugin(_targetId) { diff --git a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts index 1667ead6a..b33faf7f7 100644 --- a/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/claude-code/tests/target.test.ts @@ -131,6 +131,10 @@ describe("claude code skill target", () => { writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + + writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8"); + writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); rmSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs")); writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts index df9e40fe6..a292d4d33 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts @@ -113,15 +113,13 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S const hookDirectory = join(root, HOOK_DIRECTORY_NAME); const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME); + const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME); + const hookConfig = { + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }; await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically( - join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ - memmy_config_path: memmyConfigPath, - ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), - ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) - }, null, 2)}\n` - ); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); await writeFileAtomically( join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), @@ -145,6 +143,10 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S hooksFilePath, hookCommand }); + await writeFileAtomically( + hookConfigPath, + `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n` + ); }, async uninstallPlugin(_targetId) { diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 7af2b272b..5a3e6855e 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -140,6 +140,10 @@ describe("codex skill target", () => { writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8"); + writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + await target.installPlugin?.("codex"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); }); diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts index fc60c42c2..28e6e96aa 100644 --- a/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/cursor/target.ts @@ -76,15 +76,13 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}): const hookDirectory = join(cursorRootDirectory, HOOK_DIRECTORY_NAME); const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME); + const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME); + const hookConfig = { + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }; await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically( - join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ - memmy_config_path: memmyConfigPath, - ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), - ...(await memmyHookRevisionField(CURSOR_TARGET_ID, "cursor")) - }, null, 2)}\n` - ); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); await writeFileAtomically( hookScriptPath, renderMemmyResumeHookScript({ source: CURSOR_TARGET_ID, mode: "cursor" }) @@ -99,6 +97,10 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}): const manifest = renderMemmyPluginSkillManifest(_targetId); await replaceMemmySkillDirectory(cursorRootDirectory, manifest); await replaceMemmyResumeSkillDirectory(cursorRootDirectory, CURSOR_TARGET_ID); + await writeFileAtomically( + hookConfigPath, + `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CURSOR_TARGET_ID, "cursor")) }, null, 2)}\n` + ); }, async uninstallPlugin(_targetId) { diff --git a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts index 40d054971..b321c58dc 100644 --- a/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/cursor/tests/target.test.ts @@ -116,6 +116,10 @@ describe("cursor skill target", () => { writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8"); + writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); + delete installed.hook_revision; writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8"); await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false); diff --git a/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts index 4cdeffa5c..19f58e21b 100644 --- a/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts @@ -1,6 +1,18 @@ /** Hook revision tests. */ -import { describe, expect, it } from "vitest"; -import { hookRevision } from "./hook-revision.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { currentMemmyHookRevision, hookRevision, isInstalledMemmyHookCurrent } from "./hook-revision.js"; + +let tempDir: string | undefined; + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); describe("hook revision", () => { it("changes when the hook script or workspace bridge changes", () => { @@ -10,4 +22,23 @@ describe("hook revision", () => { expect(hookRevision("updated hook script", "workspace bridge")).not.toBe(current); expect(hookRevision("hook script", "updated workspace bridge")).not.toBe(current); }); + + it("treats a current fingerprint with leftover hook files as outdated", async () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-hook-revision-")); + const revision = await currentMemmyHookRevision("cursor", "cursor"); + const hookScriptPath = join(tempDir, "memmy-resume-hook.mjs"); + const bridgePath = join(tempDir, "memmy-workspace-bridge.mjs"); + const configPath = join(tempDir, "memmy-memory-config.json"); + writeFileSync(hookScriptPath, "old hook\n", "utf8"); + writeFileSync(bridgePath, "old bridge\n", "utf8"); + writeFileSync(configPath, `${JSON.stringify({ hook_revision: revision }, null, 2)}\n`, "utf8"); + + await expect(isInstalledMemmyHookCurrent({ + source: "cursor", + mode: "cursor", + hookScriptPath, + bridgePath, + configPath + })).resolves.toBe(false); + }); }); diff --git a/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts b/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts index ab8ba1884..5c9e4d218 100644 --- a/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts @@ -48,8 +48,9 @@ export interface InstalledMemmyHookPaths { } /** - * An installed hook is current when both files exist and the stored fingerprint - * matches the bundle in this process. A missing fingerprint is an older install. + * An installed hook is current only after the on-disk script and bridge match + * this bundle and the config records that same fingerprint. A fingerprint written + * before those files were replaced is an interrupted install. */ export async function isInstalledMemmyHookCurrent(input: InstalledMemmyHookPaths): Promise { const [script, bridge, configText] = await Promise.all([ @@ -60,7 +61,8 @@ export async function isInstalledMemmyHookCurrent(input: InstalledMemmyHookPaths if (!script.trim() || !bridge.trim()) return false; const stored = readStoredHookRevision(configText); if (!stored) return false; - return stored === await currentMemmyHookRevision(input.source, input.mode); + const current = await currentMemmyHookRevision(input.source, input.mode); + return stored === current && hookRevision(script, bridge) === current; } function readStoredHookRevision(configText: string): string | null { From fdb36179895b84e0af767de7ca5d3112371513ab Mon Sep 17 00:00:00 2001 From: antalike <> Date: Thu, 8 Oct 2026 20:09:24 +0800 Subject: [PATCH 12/13] fix(analytics): report each newly stored memory once Send account identity with plugin and scan events, and coerce boolean params so GA4 keeps the events. --- .../src/analytics/analytics-transport.ts | 6 +- .../src/analytics/memory-add-analytics.ts | 4 +- .../tests/memory-add-analytics.test.ts | 10 +- .../src/services/agent-source-service.ts | 63 ++++- App/backend/src/services/ingestion-service.ts | 23 ++ .../tests/agent-source-service.test.ts | 134 ++++++++++ .../services/tests/ingestion-service.test.ts | 48 ++++ .../desktop/src/analytics/cloud-analytics.ts | 6 +- .../src/analytics/cloud-analytics.ts | 6 +- Memory/src/agent-source/runtime.ts | 71 ++++- Memory/src/cli/analytics.ts | 6 +- .../src/server/desktop-analytics-identity.ts | 36 +++ Memory/src/server/http.ts | 38 ++- Memory/src/server/memory-add-analytics.ts | 4 +- Memory/src/server/plugin-runtime-analytics.ts | 184 +++++++++++-- Memory/tests/agent-source-runtime.test.ts | 56 ++++ Memory/tests/analytics-contract.test.ts | 2 +- .../tests/desktop-analytics-identity.test.ts | 127 +++++++++ Memory/tests/memory-add-analytics.test.ts | 10 +- ...ugin-runtime-source-turn-analytics.test.ts | 243 ++++++++++++++++++ 20 files changed, 1031 insertions(+), 46 deletions(-) create mode 100644 Memory/src/server/desktop-analytics-identity.ts create mode 100644 Memory/tests/desktop-analytics-identity.test.ts create mode 100644 Memory/tests/plugin-runtime-source-turn-analytics.test.ts diff --git a/App/backend/src/analytics/analytics-transport.ts b/App/backend/src/analytics/analytics-transport.ts index 115c00387..6708ea247 100644 --- a/App/backend/src/analytics/analytics-transport.ts +++ b/App/backend/src/analytics/analytics-transport.ts @@ -103,9 +103,13 @@ export function resolveAnalyticsEnvParams(options: { }; } +/** GA4 Measurement Protocol accepts only string and number param values. A boolean drops the whole event. */ export function compactAnalyticsParams(params: AnalyticsParams = {}): AnalyticsParams { return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""), + Object.entries(params).flatMap(([key, value]) => { + if (value === undefined || value === null || value === "") return []; + return [[key, typeof value === "boolean" ? (value ? 1 : 0) : value]]; + }), ) as AnalyticsParams; } diff --git a/App/backend/src/analytics/memory-add-analytics.ts b/App/backend/src/analytics/memory-add-analytics.ts index 5cd6a62be..47fec4d58 100644 --- a/App/backend/src/analytics/memory-add-analytics.ts +++ b/App/backend/src/analytics/memory-add-analytics.ts @@ -43,6 +43,8 @@ export type MemoryDesktopScanAddBaseInput = { adapterId: string; /** Present for agent-source scan/import paths; omitted when unavailable. */ scanMode?: MemoryDesktopAddScanMode; + /** Defaults to L1. Skill imports pass Skill so they are not counted as conversation memory. */ + layer?: "L1" | "Skill"; conversationId?: string | null; turnId?: string | null; }; @@ -60,7 +62,7 @@ export function buildMemoryDesktopScanAddParams(input: MemoryDesktopScanAddBaseI adapter_id: input.adapterId, storage_backend: MEMORY_DESKTOP_ADD_STORAGE_BACKEND, mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN, - layer: MEMORY_DESKTOP_ADD_LAYER_L1, + layer: input.layer ?? MEMORY_DESKTOP_ADD_LAYER_L1, ...(input.scanMode ? { scan_mode: input.scanMode } : {}), ...(sessionIdHash ? { session_id_hash: sessionIdHash } : {}), ...(turnIdHash ? { turn_id_hash: turnIdHash } : {}), diff --git a/App/backend/src/analytics/tests/memory-add-analytics.test.ts b/App/backend/src/analytics/tests/memory-add-analytics.test.ts index e4505c826..62fd84232 100644 --- a/App/backend/src/analytics/tests/memory-add-analytics.test.ts +++ b/App/backend/src/analytics/tests/memory-add-analytics.test.ts @@ -25,6 +25,12 @@ describe("memory-add-analytics", () => { session_id_hash: hashAnalyticsId("conv-1"), turn_id_hash: hashAnalyticsId("cursor:abc"), }); + expect(buildMemoryDesktopScanAddParams({ + adapterId: "agent-source:cursor", + layer: "Skill", + conversationId: "skill:review-code", + turnId: "skill:review-code:v2", + }).layer).toBe("Skill"); }); it("tracks started/succeeded/failed desktop add events", async () => { @@ -83,13 +89,13 @@ describe("memory-add-analytics", () => { adapter_id: "agent-source:cursor", }); expect(body.events[1]?.params).toMatchObject({ - success: true, + success: 1, stored_count: 1, duration_ms: 12, scan_mode: "full", }); expect(body.events[2]?.params).toMatchObject({ - success: false, + success: 0, error_code: "boom", duration_ms: 3, scan_mode: "incremental", diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index 521de8d24..4f0b4a0ec 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -729,7 +729,7 @@ async function ingestPersistentStagedSource( const ingestion = await ingestPersistentSource(options, store, sourceId, scanOptions, []); const scannedAt = now(); options.agentSourceRepository.setLastScannedAt(sourceId, scannedAt); - const skillResult = await ingestSourceSkills(options, sourceId, scanOptions, store); + const skillResult = await ingestSourceSkills(options, sourceId, scanOptions, store, mode); if (stage.errors.length === 0 && ingestion.errors.length === 0 && skillResult.errorCount === 0 && !ingestion.hasUncommittedSkips) { updatePersistentWatermark(options, sourceId, mode, ingestion.latestSeenAt, scannedAt, since); } @@ -907,6 +907,14 @@ async function ingestPersistentSource( } const scanMode = persistentScanMode(store.getSourceState(sourceId)?.mode ?? scanOptions.mode); if (hasStagedSourceTurn(turn.messages[0]) || sourceId === "codex") { + let addAnalyticsBase: { + adapterId: string; + conversationId: string; + turnId: string; + scanMode?: MemoryDesktopAddScanMode; + } | undefined; + let addStartedAt = 0; + let reportedAdd = false; try { const sourceTurn = sourceTurnFromMessages(turn.messages); if (!sourceTurn) { @@ -918,6 +926,13 @@ async function ingestPersistentSource( continue; } const legacyImportTurnId = legacyImportTurnIdFromMessages(sourceId, turn.conversationId, turn.messages); + addAnalyticsBase = { + adapterId: `agent-source:${sourceId}`, + conversationId: turn.conversationId, + turnId: sourceTurn.turnId, + ...(scanMode ? { scanMode } : {}) + }; + addStartedAt = Date.now(); const result = await options.memoryClient.completeSourceTurn({ ...buildSourceTurnRequest(sourceTurn, "agent_source_scan"), ...(legacyImportTurnId ? { legacyImportTurnId } : {}), @@ -935,6 +950,13 @@ async function ingestPersistentSource( if (result.status === "stored") { memoryIdCount += ids.length; memoryIds.push(...ids.slice(0, Math.max(0, 1000 - memoryIds.length))); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: Math.max(ids.length, 1) + }); + reportedAdd = true; } else { deduped += turn.messages.length; } @@ -945,6 +967,14 @@ async function ingestPersistentSource( const reason = error instanceof Error ? error.message : "native turn ingestion failed"; skipPersistentTurn(store, sourceId, turn.conversationId, reason); noteUncommittedSkip(reason); + if (addAnalyticsBase && !reportedAdd) { + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); + } } processed += turn.messages.length; emitAddProgress("Capturing conversation turns"); @@ -1241,7 +1271,7 @@ async function ingestCollectedSource( ) { updateScanWatermark(options, collected, scanOptions, scannedAt); } - errors.push(...(await ingestSourceSkills(options, collected.sourceId, scanOptions)).errors); + errors.push(...(await ingestSourceSkills(options, collected.sourceId, scanOptions, undefined, collected.scanMode ?? scanOptions.mode)).errors); return { result: { sourceId: collected.sourceId, @@ -1265,7 +1295,8 @@ async function ingestSourceSkills( options: CreateAgentSourceServiceOptions, sourceId: string, scanOptions: AgentSourceScanOptions, - store?: AppAgentSourceScanStore + store?: AppAgentSourceScanStore, + resolvedScanMode?: AgentSourceScanMode ): Promise { if (!options.skillDistributionService.listSkills) return { errors: [], errorCount: 0, memoryIdCount: 0 }; @@ -1281,8 +1312,18 @@ async function ingestSourceSkills( return { errors: [], errorCount: 0, memoryIdCount: 0 }; } + const scanMode = persistentScanMode(resolvedScanMode ?? scanOptions.mode); for (const skill of skills) { scanOptions.signal?.throwIfAborted(); + const turnId = `skill:${skill.sourceSkillId}:${skill.sourceSkillVersion}`; + const addAnalyticsBase = { + adapterId: `agent-source:${sourceId}`, + conversationId: `skill:${skill.sourceSkillId}`, + turnId, + layer: "Skill" as const, + ...(scanMode ? { scanMode } : {}) + }; + const addStartedAt = Date.now(); try { const added = await options.memoryClient.addMemory({ requestId: `agent-source-skill:${sourceId}:${skill.sourceSkillId}:${skill.sourceContentHash}`, @@ -1292,7 +1333,7 @@ async function ingestSourceSkills( title: skill.title, tags: ["agent-source", "cross-agent-skill", sourceId], source: sourceId, - turnId: `skill:${skill.sourceSkillId}:${skill.sourceSkillVersion}`, + turnId, createdAt: skill.updatedAt, sourceAgentId: sourceId, sourceSkillId: skill.sourceSkillId, @@ -1300,6 +1341,14 @@ async function ingestSourceSkills( sourceSkillVersion: skill.sourceSkillVersion, sourceContentHash: skill.sourceContentHash }); + if (added.duplicate !== true && added.status !== "deleted") { + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: 1 + }); + } memoryIdCount += 1; store?.saveResult({ sourceId, conversationId: `skill:${skill.sourceSkillId}`, memoryId: added.id }); } catch (error) { @@ -1309,6 +1358,12 @@ async function ingestSourceSkills( `skill:${skill.sourceSkillId}`, error instanceof Error ? error.message : "Agent Skill import failed" ); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); } } return { errors, errorCount, memoryIdCount }; diff --git a/App/backend/src/services/ingestion-service.ts b/App/backend/src/services/ingestion-service.ts index 827099691..169713030 100644 --- a/App/backend/src/services/ingestion-service.ts +++ b/App/backend/src/services/ingestion-service.ts @@ -318,6 +318,14 @@ async function processNativeConversation( emitIngestionProgress(ctx, stats); continue; } + const addAnalyticsBase = { + adapterId: `agent-source:${ctx.sourceId}`, + conversationId: turn.conversationId, + turnId: sourceTurn.turnId, + ...(ctx.scanMode ? { scanMode: ctx.scanMode } : {}) + }; + const addStartedAt = Date.now(); + let reportedAdd = false; try { const legacyImportTurnId = legacyImportTurnIdFromMessages(ctx.sourceId, turn.conversationId, turn.messages); const result = await options.memoryClient.completeSourceTurn({ @@ -338,6 +346,13 @@ async function processNativeConversation( stats.written += turn.messages.length; stats.writtenMemories += ids.length; stats.memoryIds.push(...ids); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: Math.max(ids.length, 1) + }); + reportedAdd = true; } else { stats.deduped += turn.messages.length; if (result.status === "existing") stats.dedupedMemories += 1; @@ -350,6 +365,14 @@ async function processNativeConversation( stats.failed += turn.messages.length; stats.failedMemories += 1; reportItemSkip(options, ctx, turn.conversationId, error instanceof Error ? error.message : "native turn ingestion failed"); + if (!reportedAdd) { + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); + } } emitIngestionProgress(ctx, stats); } diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index 977643968..50c4abf90 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -175,6 +175,64 @@ describe("agent source service", () => { ]); }); + it("emits add analytics only when a scanned skill is newly stored", async () => { + const events: Array<{ name: string; payload: Record }> = []; + const memoryClient = createMockMemoryClient(); + const service = createService({ + adapters: [createFakeAdapter("cursor", [])], + memoryClient: { + ...memoryClient, + async addMemory(input, context) { + if (input.sourceSkillId === "duplicate-skill") { + return { ...(await memoryClient.addMemory(input, context)), duplicate: true }; + } + if (input.sourceSkillId === "deleted-skill") { + return { ...(await memoryClient.addMemory(input, context)), status: "deleted" as const }; + } + if (input.sourceSkillId === "failing-skill") throw new Error("skill import failed"); + return memoryClient.addMemory(input, context); + } + }, + memoryAddAnalytics: createAddAnalyticsRecorder(events), + skillDistributionService: { + async listSkills() { + return ["fresh-skill", "duplicate-skill", "deleted-skill", "failing-skill"].map((sourceSkillId) => ({ + sourceAgentId: "cursor", + sourceSkillId, + sourceSkillPath: `/tmp/cursor/skills/${sourceSkillId}/SKILL.md`, + sourceSkillVersion: "v1", + sourceContentHash: `${sourceSkillId}-hash`, + title: sourceSkillId, + content: sourceSkillId, + updatedAt: "2026-05-28T09:00:00.000Z" + })); + }, + async install() {}, + async uninstall() {}, + async installPlugin() {}, + async uninstallPlugin() {} + } + }); + + await service.scanOne("cursor", { mode: "incremental" }); + + expect(events.map((event) => event.name)).toEqual(["started", "succeeded", "started", "failed"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:cursor", + conversationId: "skill:fresh-skill", + turnId: "skill:fresh-skill:v1", + layer: "Skill", + scanMode: "incremental", + storedCount: 1 + }); + expect(events[3]?.payload).toMatchObject({ + conversationId: "skill:failing-skill", + layer: "Skill", + scanMode: "incremental", + error: expect.any(Error) + }); + }); + it("completes the scan and advances checkpoints when every memory is skipped", async () => { const repository = createRepository(); const messages = createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"); @@ -1576,6 +1634,49 @@ describe("agent source service", () => { expect(events).toEqual([]); }); + it("emits add analytics for a newly stored native persistent scan turn", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-native-add-analytics-")); + const service = createService({ + adapters: [createFakeAdapter("cursor", stagedNativeMessages("cursor"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient: createReadyMemoryClient(), + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + const result = await service.scanOne("cursor", { scanJobId: "job-native-add", mode: "initial_subset" }); + + expect(result.errors).toEqual([]); + expect(events.map((event) => event.name)).toEqual(["started", "succeeded"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:cursor", + conversationId: "cursor-conv-1", + turnId: "native-turn", + scanMode: "initial_subset", + storedCount: 1 + }); + }); + + it("does not emit add analytics when a native persistent scan turn already exists", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-native-existing-analytics-")); + const memoryClient = createReadyMemoryClient(); + memoryClient.completeSourceTurn = vi.fn().mockResolvedValue({ + status: "existing", + result: { l1MemoryIds: ["l1-existing"] } + }); + const service = createService({ + adapters: [createFakeAdapter("cursor", stagedNativeMessages("cursor"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient, + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + await service.scanOne("cursor", { scanJobId: "job-native-existing", mode: "incremental" }); + + expect(events).toEqual([]); + }); + it("does not emit add analytics for already checkpointed persistent scan turns", async () => { const events: Array<{ name: string; payload: Record }> = []; tempDir = mkdtempSync(join(tmpdir(), "persistent-scan-skip-analytics-")); @@ -1820,6 +1921,39 @@ function createMessage(sourceId: string, index: number): ConversationMessage { }; } +function stagedNativeMessages(sourceId: string): ConversationMessage[] { + const sourceTurn = { + source: sourceId, + conversationId: `${sourceId}-conv-1`, + turnId: "native-turn", + sequence: 1, + startedAt: "2026-05-28T10:00:00.000Z", + completedAt: "2026-05-28T10:01:00.000Z", + completionEvidence: "task_complete:native-turn", + query: "Run tests", + answer: "Tests passed", + status: "succeeded", + toolCalls: [], + toolResults: [] + }; + return [ + { + ...createMessage(sourceId, 1), + role: "user", + content: sourceTurn.query, + createdAt: sourceTurn.startedAt, + rawMeta: { sourceTurnId: "native-turn", sourceTurnState: "complete" } + }, + { + ...createMessage(sourceId, 2), + role: "assistant", + content: sourceTurn.answer, + createdAt: sourceTurn.completedAt, + rawMeta: { sourceTurnId: "native-turn", sourceTurnState: "complete", sourceTurn } + } + ]; +} + function createCompleteMemoryMessages( sourceId: string, count: number, diff --git a/App/backend/src/services/tests/ingestion-service.test.ts b/App/backend/src/services/tests/ingestion-service.test.ts index b862844bc..3fc59ab1d 100644 --- a/App/backend/src/services/tests/ingestion-service.test.ts +++ b/App/backend/src/services/tests/ingestion-service.test.ts @@ -47,6 +47,54 @@ describe("native Codex ingestion", () => { expect(markSeen).toHaveBeenCalledTimes(2); }); + it("emits add analytics only when a native scan turn is newly stored", async () => { + const events: Array<{ name: string; payload: Record }> = []; + const analytics = { + trackAddStarted(input: Record) { + events.push({ name: "started", payload: { ...input } }); + }, + trackAddSucceeded(input: Record) { + events.push({ name: "succeeded", payload: { ...input } }); + }, + trackAddFailed(input: Record) { + events.push({ name: "failed", payload: { ...input } }); + } + }; + const stored = vi.fn().mockResolvedValue({ status: "stored", result: { l1MemoryIds: ["l1-a", "l1-b"] } }); + await createService({ completeSourceTurn: stored }, {}, undefined, analytics).ingest( + toAsyncIterable(nativeMessages()), + { sourceId: "codex", scanMode: "initial_subset" } + ); + expect(events.map((event) => event.name)).toEqual(["started", "succeeded"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:codex", + conversationId: "native-conversation", + turnId: "native-turn", + scanMode: "initial_subset", + storedCount: 2 + }); + + events.length = 0; + const existing = vi.fn().mockResolvedValue({ status: "existing", result: { l1MemoryIds: ["l1-a"] } }); + await createService({ completeSourceTurn: existing }, {}, undefined, analytics).ingest( + toAsyncIterable(nativeMessages()), + { sourceId: "codex", scanMode: "incremental" } + ); + expect(events).toEqual([]); + + const failed = vi.fn().mockRejectedValue(new Error("native write failed")); + await createService({ completeSourceTurn: failed }, {}, undefined, analytics).ingest( + toAsyncIterable(nativeMessages()), + { sourceId: "codex", scanMode: "full" } + ); + expect(events.map((event) => event.name)).toEqual(["started", "failed"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:codex", + turnId: "native-turn", + scanMode: "full" + }); + }); + it("asks a full scan to capture history that completed before activation", async () => { const completeSourceTurn = vi.fn().mockResolvedValue({ status: "stored", result: { l1MemoryIds: ["l1-historical"] } }); await createService({ completeSourceTurn }).ingest(toAsyncIterable(nativeMessages()), { sourceId: "codex", scanMode: "full" }); diff --git a/App/frontend/desktop/src/analytics/cloud-analytics.ts b/App/frontend/desktop/src/analytics/cloud-analytics.ts index b40f65a9b..70ae5dabe 100644 --- a/App/frontend/desktop/src/analytics/cloud-analytics.ts +++ b/App/frontend/desktop/src/analytics/cloud-analytics.ts @@ -145,7 +145,11 @@ function flushNow(): Promise { function compactParams(params: CloudAnalyticsParams): CloudAnalyticsParams { return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== "") + Object.entries(params).flatMap(([key, value]) => { + if (value === undefined || value === null || value === "") return []; + // GA4 Measurement Protocol accepts only string and number values. + return [[key, typeof value === "boolean" ? (value ? 1 : 0) : value]]; + }) ) as CloudAnalyticsParams; } diff --git a/App/memmy-agent/src/analytics/cloud-analytics.ts b/App/memmy-agent/src/analytics/cloud-analytics.ts index b8480729f..93437737a 100644 --- a/App/memmy-agent/src/analytics/cloud-analytics.ts +++ b/App/memmy-agent/src/analytics/cloud-analytics.ts @@ -111,9 +111,13 @@ export function resolveAnalyticsEnvParams(options: { }; } +/** GA4 Measurement Protocol accepts only string and number param values. A boolean drops the whole event. */ export function compactAnalyticsParams(params: AnalyticsParams = {}): AnalyticsParams { return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""), + Object.entries(params).flatMap(([key, value]) => { + if (value === undefined || value === null || value === "") return []; + return [[key, typeof value === "boolean" ? (value ? 1 : 0) : value]]; + }), ) as AnalyticsParams; } diff --git a/Memory/src/agent-source/runtime.ts b/Memory/src/agent-source/runtime.ts index d0bfa9028..755f27014 100644 --- a/Memory/src/agent-source/runtime.ts +++ b/Memory/src/agent-source/runtime.ts @@ -337,7 +337,9 @@ export function createAgentSourceExecutor(options: CreateAgentSourceExecutorOpti sourceId, store, options.resolveAgentSkillRoot, - options.scheduleWorker + options.scheduleWorker, + options.memoryAddAnalytics, + persistentScanMode(mode) ); const sourceErrorCount = stage.scanErrorCount + result.errorCount + skillResult.errorCount; failureCount += sourceErrorCount; @@ -829,6 +831,14 @@ async function ingestStagedMessages( continue; } if (hasStagedSourceTurn(turn.messages[0])) { + let addAnalyticsBase: { + adapterId: string; + conversationId: string; + turnId: string; + scanMode?: MemoryDesktopAddScanMode; + } | undefined; + let addStartedAt = 0; + let reportedAdd = false; try { const sourceTurn = sourceTurnFromMessages(turn.messages); if (!sourceTurn) { @@ -840,6 +850,14 @@ async function ingestStagedMessages( continue; } const legacyImportTurnId = legacyImportTurnIdFromMessages(sourceId, turn.conversationId, turn.messages); + const resolvedScanMode = persistentScanMode(store.getSourceState(sourceId)?.mode ?? scanMode); + addAnalyticsBase = { + adapterId: `agent-source:${sourceId}`, + conversationId: turn.conversationId, + turnId: sourceTurn.turnId, + ...(resolvedScanMode ? { scanMode: resolvedScanMode } : {}) + }; + addStartedAt = Date.now(); const result = service.completeSourceTurn({ ...buildSourceTurnRequest(sourceTurn, "agent_source_scan"), ...(legacyImportTurnId ? { legacyImportTurnId } : {}), @@ -861,7 +879,16 @@ async function ingestStagedMessages( continue; } const ids = result.result?.l1MemoryIds ?? []; - if (result.status === "stored") written += ids.length; + if (result.status === "stored") { + written += ids.length; + memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: Math.max(ids.length, 1) + }); + reportedAdd = true; + } if (ids.length === 0) store.saveResult({ sourceId, conversationId: turn.conversationId }); for (const memoryId of ids) store.saveResult({ sourceId, conversationId: turn.conversationId, memoryId }); messageCount += turn.messages.length; @@ -870,6 +897,14 @@ async function ingestStagedMessages( const reason = error instanceof Error ? error.message : "native turn ingestion failed"; recordScanItemSkip(store, sourceId, turn.conversationId, reason); noteUncommittedSkip(reason); + if (addAnalyticsBase && !reportedAdd) { + memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); + } } processed += turn.messages.length; onProgress({ sourceId, phase: "add", current: processed, total: store.count(sourceId), message: "Capturing conversation turns" }); @@ -1064,7 +1099,12 @@ async function ingestAgentSkills( sourceId: string, store: MemoryAgentSourceScanStore, resolveAgentSkillRoot: (sourceId: string) => string | null = agentRootDirectory, - scheduleWorker?: () => void + scheduleWorker?: () => void, + memoryAddAnalytics?: Pick< + MemoryDesktopAddAnalytics, + "trackAddStarted" | "trackAddSucceeded" | "trackAddFailed" + >, + scanMode?: MemoryDesktopAddScanMode ): Promise<{ written: number; memoryIdCount: number; errorCount: number; errors: string[] }> { const root = resolveAgentSkillRoot(sourceId); if (!root) return { written: 0, memoryIdCount: 0, errorCount: 0, errors: [] }; @@ -1085,6 +1125,15 @@ async function ingestAgentSkills( const sourceSkillId = relative(skillsRoot, dirname(filePath)).replaceAll("\\", "/"); const requestId = `agent-source-skill:${sourceId}:${sourceSkillId}:${contentHash}`; const fileStat = await stat(filePath); + const turnId = `skill:${sourceSkillId}:${contentHash}`; + const addAnalyticsBase = { + adapterId: `agent-source:${sourceId}`, + conversationId: `skill:${sourceSkillId}`, + turnId, + layer: "Skill" as const, + ...(scanMode ? { scanMode } : {}) + }; + const addStartedAt = Date.now(); try { const added = service.addMemory({ requestId, @@ -1094,7 +1143,7 @@ async function ingestAgentSkills( title: frontmatterValue(content, "name") ?? sourceSkillId, tags: ["agent-source", "cross-agent-skill", sourceId], source: sourceId, - turnId: `skill:${sourceSkillId}:${contentHash}`, + turnId, createdAt: fileStat.mtime.toISOString(), sourceAgentId: sourceId, sourceSkillId, @@ -1103,6 +1152,14 @@ async function ingestAgentSkills( sourceContentHash: contentHash, deferProcessing: true }); + if (added.duplicate !== true && added.status !== "deleted") { + memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: 1 + }); + } written += 1; memoryIdCount += 1; store.saveResult({ sourceId, conversationId: `skill:${sourceSkillId}`, memoryId: added.id }); @@ -1117,6 +1174,12 @@ async function ingestAgentSkills( `skill:${sourceSkillId}`, error instanceof Error ? error.message : String(error) ); + memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); } } flush(true); diff --git a/Memory/src/cli/analytics.ts b/Memory/src/cli/analytics.ts index 6a1a16c67..6479d5327 100644 --- a/Memory/src/cli/analytics.ts +++ b/Memory/src/cli/analytics.ts @@ -107,9 +107,13 @@ export function resolveAnalyticsEnvParams(options: { }; } +/** GA4 Measurement Protocol accepts only string and number param values. A boolean drops the whole event. */ export function compactAnalyticsParams(params: AnalyticsParams = {}): AnalyticsParams { return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""), + Object.entries(params).flatMap(([key, value]) => { + if (value === undefined || value === null || value === "") return []; + return [[key, typeof value === "boolean" ? (value ? 1 : 0) : value]]; + }), ) as AnalyticsParams; } diff --git a/Memory/src/server/desktop-analytics-identity.ts b/Memory/src/server/desktop-analytics-identity.ts new file mode 100644 index 000000000..6eff01564 --- /dev/null +++ b/Memory/src/server/desktop-analytics-identity.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from "node:fs"; +import { parse as parseYaml } from "yaml"; +import { normalizeAnalyticsUserId } from "../cli/analytics.js"; + +export type DesktopAnalyticsIdentity = { + userId: string | null; + userMode: "account" | "byok" | null; +}; + +/** Desktop login projected into `~/.memmy/config.yaml`. Read on each report so a mode switch is visible without restarting. */ +export function readDesktopAnalyticsIdentity(configPath?: string | null): DesktopAnalyticsIdentity { + const empty: DesktopAnalyticsIdentity = { userId: null, userMode: null }; + if (!configPath) return empty; + try { + if (!existsSync(configPath)) return empty; + const root = asRecord(parseYaml(readFileSync(configPath, "utf8"))); + const app = asRecord(root.app); + const mode = optionalString(app.userMode); + const userMode = mode === "account" || mode === "byok" ? mode : null; + const cloudUuid = optionalString(app.cloudUuid); + const userId = cloudUuid ? normalizeAnalyticsUserId(optionalString(app.userId)) : null; + return { userId, userMode }; + } catch { + return empty; + } +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index 1d2d0eecd..5e7ec4cc8 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -30,6 +30,7 @@ import { MemoryService } from "../service/memory-service.js"; import { MemoryServiceError, statusForCode } from "../utils/error.js"; import { stableHash } from "../utils/id.js"; import { resolveTimeZone } from "../utils/time.js"; +import { readDesktopAnalyticsIdentity } from "./desktop-analytics-identity.js"; import { createMemoryDesktopAddAnalytics, type MemoryDesktopAddAnalytics, @@ -38,9 +39,10 @@ import { createPluginRuntimeAnalytics, hitCountFromGetResponse, hitCountFromSearchResponse, - storedCountFromAddResponse, trackExternalHookCapture, trackExternalHookRecall, + trackStoredExternalMemoryAdd, + trackStoredExternalSourceTurnCapture, trackExternalToolCall, type PluginRuntimeAnalytics, } from "./plugin-runtime-analytics.js"; @@ -146,8 +148,15 @@ export function createMemoryHttpServer(options: MemoryHttpServerOptions): Server startupFallbackMs: options.workerStartupFallbackMs ?? DEFAULT_WORKER_STARTUP_FALLBACK_MS, postHealthDelayMs: options.workerPostHealthDelayMs ?? DEFAULT_WORKER_POST_HEALTH_DELAY_MS }); - const pluginRuntimeAnalytics = options.pluginRuntimeAnalytics ?? createPluginRuntimeAnalytics(); - const memoryAddAnalytics = options.memoryAddAnalytics ?? createMemoryDesktopAddAnalytics(); + const desktopIdentity = () => readDesktopAnalyticsIdentity(options.configPath); + const pluginRuntimeAnalytics = options.pluginRuntimeAnalytics ?? createPluginRuntimeAnalytics({ + getUserId: () => desktopIdentity().userId, + getUserMode: () => desktopIdentity().userMode, + }); + const memoryAddAnalytics = options.memoryAddAnalytics ?? createMemoryDesktopAddAnalytics({ + getUserId: () => desktopIdentity().userId, + getUserMode: () => desktopIdentity().userMode, + }); const agentSources = options.agentSourceExecutor ?? createAgentSourceExecutor({ service: options.service, configPath: options.configPath, @@ -689,7 +698,7 @@ async function routeRequest( const request = strictEnvelopeWithPrincipal({ ...input, namespace }, scopedPrincipal) as unknown as SourceTurnCompleteRequest; requireStringField(request, "query", "source-turn.complete"); requireStringField(request, "answer", "source-turn.complete"); - const result = service.completeSourceTurn({ + const completeSourceTurn = () => service.completeSourceTurn({ namespace: request.namespace, timeZone: request.timeZone, source: request.source, sourceTurn: request.sourceTurn, channel: request.channel, workspacePath: request.workspacePath, ...(request.captureLegacyHistory === true ? { captureLegacyHistory: true } : {}), @@ -702,6 +711,19 @@ async function routeRequest( sourceMemoryIds: request.sourceMemoryIds, usage: request.usage, status: request.status, tags: request.tags, userMemoryCorrection: request.userMemoryCorrection }); + const result = request.channel === "hook" + ? await trackStoredExternalSourceTurnCapture( + pluginRuntimeAnalytics, + { + source: request.source ?? request.sourceTurn?.source, + adapterId: request.adapterId, + namespace: request.namespace, + turnId: request.sourceTurn?.turnId, + }, + request, + completeSourceTurn, + ) + : completeSourceTurn(); if (result.result) scheduleAutoWorkerForEvolution(result.result, autoWorker); return result; } @@ -815,17 +837,13 @@ async function routeRequest( sourceContentHash: typeof request.sourceContentHash === "string" ? request.sourceContentHash : undefined }; const idempotency = memoryAddIdempotency(publicRequest, path); - const result = await trackExternalToolCall( + const result = await trackStoredExternalMemoryAdd( pluginRuntimeAnalytics, - { ...request, toolName: "memmy_memory_add" }, + { ...request, layer: publicRequest.layer }, () => service.idempotent("memory.add", idempotency.request, idempotency.fingerprint, () => service.addMemory(publicRequest) ), - (addResult) => ({ - stored_count: storedCountFromAddResponse(addResult), - ...(publicRequest.layer ? { layer: publicRequest.layer } : {}), - }), ); if (!publicRequest.deferProcessing) { autoWorker.schedule(); diff --git a/Memory/src/server/memory-add-analytics.ts b/Memory/src/server/memory-add-analytics.ts index 3c2c84e12..08468f47a 100644 --- a/Memory/src/server/memory-add-analytics.ts +++ b/Memory/src/server/memory-add-analytics.ts @@ -42,6 +42,8 @@ export type MemoryDesktopScanAddBaseInput = { adapterId: string; /** Present for agent-source scan/import paths; omitted when unavailable. */ scanMode?: MemoryDesktopAddScanMode; + /** Defaults to L1. Skill imports pass Skill so they are not counted as conversation memory. */ + layer?: "L1" | "Skill"; conversationId?: string | null; turnId?: string | null; }; @@ -59,7 +61,7 @@ export function buildMemoryDesktopScanAddParams(input: MemoryDesktopScanAddBaseI adapter_id: input.adapterId, storage_backend: MEMORY_DESKTOP_ADD_STORAGE_BACKEND, mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN, - layer: MEMORY_DESKTOP_ADD_LAYER_L1, + layer: input.layer ?? MEMORY_DESKTOP_ADD_LAYER_L1, ...(input.scanMode ? { scan_mode: input.scanMode } : {}), ...(sessionIdHash ? { session_id_hash: sessionIdHash } : {}), ...(turnIdHash ? { turn_id_hash: turnIdHash } : {}), diff --git a/Memory/src/server/plugin-runtime-analytics.ts b/Memory/src/server/plugin-runtime-analytics.ts index b46c82fed..a002d7a37 100644 --- a/Memory/src/server/plugin-runtime-analytics.ts +++ b/Memory/src/server/plugin-runtime-analytics.ts @@ -42,12 +42,14 @@ const EXTERNAL_AGENT_SOURCE_IDS = new Set([ "opencode", "openclaw", "hermes", + "deepseek_harness", ]); const HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex"]); -const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes"]); +const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes", "deepseek_harness"]); -const PLUGIN_RUNTIME_SOURCE = "memmy-memory"; +/** Ingest stores `memmy-agent`. `memmy-memory` is rejected before the row is written. */ +const PLUGIN_RUNTIME_SOURCE = "memmy-agent"; export type PluginRuntimeAnalytics = CliLifecycleAnalytics; @@ -58,6 +60,9 @@ export type PluginRuntimeBaseParams = AnalyticsParams & { export function createPluginRuntimeAnalytics(options: { getClientId?: () => string | null | undefined; + getInstallationId?: () => string | null | undefined; + getUserId?: () => string | null | undefined; + getUserMode?: () => string | null | undefined; fetchImpl?: typeof fetch; baseUrl?: string | null; } = {}): PluginRuntimeAnalytics { @@ -195,6 +200,14 @@ export function storedCountFromCompleteTurnResponse(response: unknown): number { return typeof record.l1MemoryId === "string" && record.l1MemoryId.trim() ? 1 : 0; } +/** Source-turn replies nest the turn result and repeat its ids on `existing`. */ +export function storedCountFromSourceTurnResponse(response: unknown): number { + if (!response || typeof response !== "object") return 0; + const record = response as { status?: unknown; result?: unknown }; + if (record.status !== "stored") return 0; + return storedCountFromCompleteTurnResponse(record.result); +} + export function toolCallCountFromCompleteTurnRequest(request: unknown): number | undefined { if (!request || typeof request !== "object") return undefined; const toolCalls = (request as { toolCalls?: unknown }).toolCalls; @@ -224,6 +237,16 @@ export function storedCountFromAddResponse(response: unknown): number { return typeof id === "string" && id.trim() ? 1 : 0; } +function isReplayCompleteTurn(response: unknown): boolean { + return Boolean(response && typeof response === "object" && (response as { duplicate?: unknown }).duplicate === true); +} + +function isReplayOrDeletedAdd(response: unknown): boolean { + if (!response || typeof response !== "object") return false; + const record = response as { duplicate?: unknown; status?: unknown }; + return record.duplicate === true || record.status === "deleted"; +} + export async function trackExternalHookRecall( analytics: PluginRuntimeAnalytics, input: PluginRuntimeAttribution, @@ -263,6 +286,11 @@ export async function trackExternalHookRecall( } } +/** + * Reports a legacy `POST /turns/:turnId/complete` write only after a new row is stored. + * Replays (`duplicate: true`) and empty captures emit nothing. + * `started` is emitted with `succeeded` so a replay does not leave an unmatched start. + */ export async function trackExternalHookCapture( analytics: PluginRuntimeAnalytics, input: PluginRuntimeAttribution, @@ -270,40 +298,162 @@ export async function trackExternalHookCapture( run: () => Promise | T, ): Promise { const agentSourceId = resolveExternalAgentSource(input); - if (!agentSourceId) return await run(); - - const baseParams = buildPluginRuntimeBaseParams(agentSourceId, input, "turn_complete"); - if (!baseParams) return await run(); - + const baseParams = agentSourceId + ? buildPluginRuntimeBaseParams(agentSourceId, input, "turn_complete") + : null; const fallbackMode = resolveCaptureFallbackMode(input.turnId); const startedAt = Date.now(); - analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, compactAnalyticsParams({ - ...baseParams, - status: "started", - ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), - })); try { const result = await run(); + const storedCount = isReplayCompleteTurn(result) ? 0 : storedCountFromCompleteTurnResponse(result); + if (!baseParams || storedCount <= 0) return result; const toolCallCount = toolCallCountFromCompleteTurnRequest(request); + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, compactAnalyticsParams({ + ...baseParams, + status: "started", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + })); analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureSucceeded, compactAnalyticsParams({ ...baseParams, status: "succeeded", ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), latency_ms: elapsedMs(startedAt), success: true, - stored_count: storedCountFromCompleteTurnResponse(result), + stored_count: storedCount, ...(typeof toolCallCount === "number" ? { tool_call_count: toolCallCount } : {}), })); return result; } catch (error) { - analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureFailed, compactAnalyticsParams({ + if (baseParams) { + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, compactAnalyticsParams({ + ...baseParams, + status: "started", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + })); + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureFailed, compactAnalyticsParams({ + ...baseParams, + status: "failed", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + latency_ms: elapsedMs(startedAt), + success: false, + error_code: errorCodeFromUnknown(error), + })); + } + throw error; + } +} + +/** + * Reports a hook or native-plugin source-turn write only after a new row is stored. + * Replays (`existing`), skips, and scan-channel calls must not reach this helper. + * `started` is emitted with `succeeded` so a replay does not leave an unmatched start. + */ +export async function trackStoredExternalSourceTurnCapture( + analytics: PluginRuntimeAnalytics, + input: PluginRuntimeAttribution, + request: unknown, + run: () => Promise | T, +): Promise { + const agentSourceId = resolveExternalAgentSource(input); + const baseParams = agentSourceId + ? buildPluginRuntimeBaseParams(agentSourceId, input, "turn_complete") + : null; + const fallbackMode = resolveCaptureFallbackMode(input.turnId); + const startedAt = Date.now(); + try { + const result = await run(); + const storedCount = storedCountFromSourceTurnResponse(result); + if (!baseParams || storedCount <= 0) return result; + const toolCallCount = toolCallCountFromCompleteTurnRequest(request); + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, compactAnalyticsParams({ ...baseParams, - status: "failed", + status: "started", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + })); + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureSucceeded, compactAnalyticsParams({ + ...baseParams, + status: "succeeded", ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), latency_ms: elapsedMs(startedAt), - success: false, - error_code: errorCodeFromUnknown(error), + success: true, + stored_count: storedCount, + ...(typeof toolCallCount === "number" ? { tool_call_count: toolCallCount } : {}), })); + return result; + } catch (error) { + if (baseParams) { + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, compactAnalyticsParams({ + ...baseParams, + status: "started", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + })); + analytics.track(PLUGIN_RUNTIME_EVENTS.hookCaptureFailed, compactAnalyticsParams({ + ...baseParams, + status: "failed", + ...(fallbackMode ? { fallback_mode: fallbackMode } : {}), + latency_ms: elapsedMs(startedAt), + success: false, + error_code: errorCodeFromUnknown(error), + })); + } + throw error; + } +} + +/** + * Reports a native-plugin `memmy_memory_add` only after a new memory is stored. + * Duplicate and deleted echoes still return the existing id, and emit nothing. + */ +export async function trackStoredExternalMemoryAdd( + analytics: PluginRuntimeAnalytics, + input: PluginRuntimeAttribution & { layer?: string }, + run: () => Promise | T, +): Promise { + const toolName = "memmy_memory_add"; + const agentSourceId = resolveExternalPluginToolCallSource(input); + const baseParams = agentSourceId + ? buildPluginRuntimeBaseParams(agentSourceId, input, toolName) + : null; + const layer = stringValue(input.layer); + const startedAt = Date.now(); + try { + const result = await run(); + const storedCount = isReplayOrDeletedAdd(result) ? 0 : storedCountFromAddResponse(result); + if (!baseParams || storedCount <= 0) return result; + analytics.track(PLUGIN_RUNTIME_EVENTS.toolCallStarted, compactAnalyticsParams({ + ...baseParams, + status: "started", + tool_name: toolName, + ...(layer ? { layer } : {}), + })); + analytics.track(PLUGIN_RUNTIME_EVENTS.toolCallSucceeded, compactAnalyticsParams({ + ...baseParams, + status: "succeeded", + tool_name: toolName, + ...(layer ? { layer } : {}), + latency_ms: elapsedMs(startedAt), + success: true, + stored_count: storedCount, + })); + return result; + } catch (error) { + if (baseParams) { + analytics.track(PLUGIN_RUNTIME_EVENTS.toolCallStarted, compactAnalyticsParams({ + ...baseParams, + status: "started", + tool_name: toolName, + ...(layer ? { layer } : {}), + })); + analytics.track(PLUGIN_RUNTIME_EVENTS.toolCallFailed, compactAnalyticsParams({ + ...baseParams, + status: "failed", + tool_name: toolName, + ...(layer ? { layer } : {}), + latency_ms: elapsedMs(startedAt), + success: false, + error_code: errorCodeFromUnknown(error), + })); + } throw error; } } diff --git a/Memory/tests/agent-source-runtime.test.ts b/Memory/tests/agent-source-runtime.test.ts index c0ec084fb..4474711f7 100644 --- a/Memory/tests/agent-source-runtime.test.ts +++ b/Memory/tests/agent-source-runtime.test.ts @@ -610,6 +610,62 @@ describe("standalone Agent source executor", () => { })); }); + it("emits add analytics only when a scanned skill is newly stored", async () => { + const root = tempRoot(); + const codexRoot = join(root, ".codex"); + for (const sourceSkillId of ["deleted-skill", "duplicate-skill", "failing-skill", "fresh-skill"]) { + const skillPath = join(codexRoot, "skills", sourceSkillId, "SKILL.md"); + mkdirSync(join(skillPath, ".."), { recursive: true }); + writeFileSync(skillPath, `---\nname: ${sourceSkillId}\nversion: 1\n---\n\n${sourceSkillId}\n`); + } + vi.stubEnv("CODEX_HOME", codexRoot); + const events: Array<{ name: string; payload: Record }> = []; + const service = { + addMemory: vi.fn((input: { sourceSkillId?: string }) => { + if (input.sourceSkillId === "duplicate-skill") return { id: "dup", duplicate: true, status: "activated" }; + if (input.sourceSkillId === "deleted-skill") return { id: "gone", status: "deleted" }; + if (input.sourceSkillId === "failing-skill") throw new Error("skill import failed"); + return { id: "fresh", status: "activated" }; + }), + enqueuePendingImportSummaries: vi.fn() + } as unknown as MemoryService; + const adapter: SourceAdapter = { + descriptor: { sourceId: "codex", displayName: "Codex", builtin: true, dataPath: codexRoot }, + detect: async () => true, + async *scan() {} + }; + const executor = createAgentSourceExecutor({ + service, + configPath: join(root, "config.yaml"), + statePath: join(root, "agent-sources.json"), + sourceRegistry: createSourceRegistry([adapter]), + memoryAddAnalytics: { + trackAddStarted(input) { events.push({ name: "started", payload: { ...input } }); }, + trackAddSucceeded(input) { events.push({ name: "succeeded", payload: { ...input } }); }, + trackAddFailed(input) { events.push({ name: "failed", payload: { ...input } }); } + } + }); + + await executor.startScan({ sourceId: "codex", mode: "incremental" }); + await waitForScan(executor); + + expect(events.map((event) => event.name)).toEqual(["started", "failed", "started", "succeeded"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:codex", + conversationId: "skill:failing-skill", + layer: "Skill", + scanMode: "incremental" + }); + expect(events[3]?.payload).toMatchObject({ + adapterId: "agent-source:codex", + conversationId: "skill:fresh-skill", + layer: "Skill", + scanMode: "incremental", + storedCount: 1 + }); + await executor.dispose(); + }); + it("installs and removes a real Cursor Hook without Memmy Desktop", async () => { const root = tempRoot(); const cursorRoot = join(root, ".cursor"); diff --git a/Memory/tests/analytics-contract.test.ts b/Memory/tests/analytics-contract.test.ts index 60b7168e9..953259f65 100644 --- a/Memory/tests/analytics-contract.test.ts +++ b/Memory/tests/analytics-contract.test.ts @@ -31,7 +31,7 @@ describe("CLI analytics event contract", () => { eventName: "memmy_cli_completed", params: expect.objectContaining({ engagement_time_msec: 100, - success: true, + success: 1, user_mode: "byok", source: "memmy-memory", app_env: "prod", diff --git a/Memory/tests/desktop-analytics-identity.test.ts b/Memory/tests/desktop-analytics-identity.test.ts new file mode 100644 index 000000000..b5b8cd5f7 --- /dev/null +++ b/Memory/tests/desktop-analytics-identity.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { readDesktopAnalyticsIdentity } from "../src/server/desktop-analytics-identity.js"; +import { createMemoryDesktopAddAnalytics } from "../src/server/memory-add-analytics.js"; +import { + PLUGIN_RUNTIME_EVENTS, + createPluginRuntimeAnalytics, +} from "../src/server/plugin-runtime-analytics.js"; + +describe("desktop analytics identity", () => { + it("reads the logged-in account from the config file on each call", async () => { + const configPath = writeConfig({ + userMode: "account", + userId: "account-user", + cloudUuid: "cloud-1", + }); + expect(readDesktopAnalyticsIdentity(configPath)).toEqual({ + userId: "account-user", + userMode: "account", + }); + + writeConfig({ + userMode: "byok", + userId: "account-user", + cloudUuid: "cloud-1", + }, configPath); + expect(readDesktopAnalyticsIdentity(configPath)).toEqual({ + userId: "account-user", + userMode: "byok", + }); + }); + + it("omits the account id unless cloudUuid is present", () => { + const configPath = writeConfig({ + userMode: "account", + userId: "account-user", + }); + expect(readDesktopAnalyticsIdentity(configPath)).toEqual({ + userId: null, + userMode: "account", + }); + expect(readDesktopAnalyticsIdentity(join(tmpdir(), "missing-memmy-config.yaml"))).toEqual({ + userId: null, + userMode: null, + }); + }); + + it("does not report local-user as an account id", () => { + const configPath = writeConfig({ + userMode: "byok", + userId: "local-user", + cloudUuid: "cloud-1", + }); + expect(readDesktopAnalyticsIdentity(configPath).userId).toBeNull(); + }); + + it("posts hook and scan events with the identity currently in the config", async () => { + const configPath = writeConfig({ + userMode: "account", + userId: "account-user", + cloudUuid: "cloud-1", + }); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ code: 0 }), { status: 200 })); + const identity = () => readDesktopAnalyticsIdentity(configPath); + const shared = { + fetchImpl, + baseUrl: "https://cloud.example.com", + getClientId: () => "1234567890.1234567890", + getInstallationId: () => "install-1", + getUserId: () => identity().userId, + getUserMode: () => identity().userMode, + }; + const plugin = createPluginRuntimeAnalytics(shared); + const scan = createMemoryDesktopAddAnalytics(shared); + + await plugin.trackAwait(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, { source_id: "cursor" }); + scan.trackAddStarted({ adapterId: "agent-source:cursor", scanMode: "incremental" }); + await scan.flush(); + + expect(postedIdentity(fetchImpl, 0)).toMatchObject({ + userId: "account-user", + user_id: "account-user", + user_mode: "account", + }); + expect(postedIdentity(fetchImpl, 1)).toMatchObject({ + userId: "account-user", + user_id: "account-user", + user_mode: "account", + }); + + writeConfig({ userMode: "byok" }, configPath); + await plugin.trackAwait(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, { source_id: "cursor" }); + expect(postedIdentity(fetchImpl, 2)).toMatchObject({ user_mode: "byok" }); + expect(postedIdentity(fetchImpl, 2).userId).toBeUndefined(); + }); +}); + +function writeConfig( + app: { userMode?: string; userId?: string; cloudUuid?: string }, + configPath = join(mkdtempSync(join(tmpdir(), "memmy-analytics-")), "config.yaml"), +): string { + const lines = ["app:"]; + if (app.userMode) lines.push(` userMode: ${app.userMode}`); + if (app.userId) lines.push(` userId: ${app.userId}`); + if (app.cloudUuid) lines.push(` cloudUuid: ${app.cloudUuid}`); + writeFileSync(configPath, `${lines.join("\n")}\n`); + return configPath; +} + +function postedIdentity(fetchImpl: { mock: { calls: unknown[][] } }, call: number): { + userId?: string; + user_id?: string; + user_mode?: string; +} { + const init = fetchImpl.mock.calls[call]?.[1] as { body?: string } | undefined; + const body = JSON.parse(String(init?.body)) as { + userId?: string; + events: Array<{ params: { user_id?: string; user_mode?: string } }>; + }; + return { + userId: body.userId, + user_id: body.events[0]?.params.user_id, + user_mode: body.events[0]?.params.user_mode, + }; +} diff --git a/Memory/tests/memory-add-analytics.test.ts b/Memory/tests/memory-add-analytics.test.ts index c49c38b62..2e9e9f4cf 100644 --- a/Memory/tests/memory-add-analytics.test.ts +++ b/Memory/tests/memory-add-analytics.test.ts @@ -25,6 +25,12 @@ describe("memory-add-analytics", () => { session_id_hash: hashAnalyticsId("conv-1"), turn_id_hash: hashAnalyticsId("cursor:abc"), }); + expect(buildMemoryDesktopScanAddParams({ + adapterId: "agent-source:cursor", + layer: "Skill", + conversationId: "skill:review-code", + turnId: "skill:review-code:v2", + }).layer).toBe("Skill"); }); it("tracks started/succeeded/failed desktop add events", async () => { @@ -82,13 +88,13 @@ describe("memory-add-analytics", () => { adapter_id: "agent-source:cursor", }); expect(body.events[1]?.params).toMatchObject({ - success: true, + success: 1, stored_count: 1, duration_ms: 12, scan_mode: "full", }); expect(body.events[2]?.params).toMatchObject({ - success: false, + success: 0, error_code: "boom", duration_ms: 3, scan_mode: "incremental", diff --git a/Memory/tests/plugin-runtime-source-turn-analytics.test.ts b/Memory/tests/plugin-runtime-source-turn-analytics.test.ts new file mode 100644 index 000000000..f95983ecd --- /dev/null +++ b/Memory/tests/plugin-runtime-source-turn-analytics.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PLUGIN_RUNTIME_EVENTS, + createPluginRuntimeAnalytics, + storedCountFromSourceTurnResponse, + trackExternalHookCapture, + trackStoredExternalMemoryAdd, + trackStoredExternalSourceTurnCapture, + type PluginRuntimeAnalytics, +} from "../src/server/plugin-runtime-analytics.js"; + +describe("stored source-turn capture analytics", () => { + it("emits hook capture only after a new source turn is stored", async () => { + const events: Array<{ name: string; params: Record }> = []; + const stored = { + status: "stored", + result: { l1MemoryIds: ["l1-a", "l1-b"], l1MemoryId: "l1-a" }, + }; + const result = await trackStoredExternalSourceTurnCapture( + recorder(events), + { source: "cursor", adapterId: "memmy-cursor-hook", turnId: "turn-1" }, + { toolCalls: [{ id: "call-1" }] }, + () => stored, + ); + + expect(result).toBe(stored); + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, + PLUGIN_RUNTIME_EVENTS.hookCaptureSucceeded, + ]); + expect(events[1]?.params).toMatchObject({ + source_id: "cursor", + source_kind: "hook", + adapter_id: "memmy-cursor-hook", + hook_name: "turn_complete", + success: 1, + stored_count: 2, + tool_call_count: 1, + }); + }); + + it("does not emit when the source turn already exists", async () => { + const events: Array<{ name: string; params: Record }> = []; + const existing = { + status: "existing", + result: { l1MemoryIds: ["l1-a"], duplicate: true }, + }; + + await trackStoredExternalSourceTurnCapture( + recorder(events), + { source: "opencode", adapterId: "memmy-opencode-plugin", turnId: "turn-1" }, + {}, + () => existing, + ); + + expect(storedCountFromSourceTurnResponse(existing)).toBe(0); + expect(events).toEqual([]); + }); + + it("emits capture failure only when the write throws", async () => { + const events: Array<{ name: string; params: Record }> = []; + + await expect(trackStoredExternalSourceTurnCapture( + recorder(events), + { source: "deepseek_harness", adapterId: "memmy-deepseek-harness-plugin", turnId: "turn-1" }, + {}, + () => { + throw new Error("capture failed"); + }, + )).rejects.toThrow("capture failed"); + + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, + PLUGIN_RUNTIME_EVENTS.hookCaptureFailed, + ]); + expect(events[1]?.params).toMatchObject({ + source_id: "deepseek_harness", + source_kind: "native_plugin", + success: 0, + error_code: "capture failed", + }); + }); +}); + +describe("legacy turn complete capture analytics", () => { + it("emits hook capture only after a new turn stores memories", async () => { + const events: Array<{ name: string; params: Record }> = []; + const stored = { l1MemoryIds: ["l1-a"], l1MemoryId: "l1-a" }; + + const result = await trackExternalHookCapture( + recorder(events), + { source: "cursor", adapterId: "memmy-cursor-hook", turnId: "turn-1" }, + { toolCalls: [{ id: "call-1" }] }, + () => stored, + ); + + expect(result).toBe(stored); + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, + PLUGIN_RUNTIME_EVENTS.hookCaptureSucceeded, + ]); + expect(events[1]?.params).toMatchObject({ + source_id: "cursor", + success: 1, + stored_count: 1, + tool_call_count: 1, + }); + }); + + it("does not emit when the completed turn is a replay", async () => { + const events: Array<{ name: string; params: Record }> = []; + + await trackExternalHookCapture( + recorder(events), + { source: "cursor", adapterId: "memmy-cursor-hook", turnId: "turn-1" }, + {}, + () => ({ l1MemoryIds: ["l1-a"], l1MemoryId: "l1-a", duplicate: true }), + ); + + expect(events).toEqual([]); + }); + + it("emits capture failure only when the legacy complete throws", async () => { + const events: Array<{ name: string; params: Record }> = []; + + await expect(trackExternalHookCapture( + recorder(events), + { source: "codex", adapterId: "memmy-codex-hook", turnId: "turn-1" }, + {}, + () => { + throw new Error("complete failed"); + }, + )).rejects.toThrow("complete failed"); + + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, + PLUGIN_RUNTIME_EVENTS.hookCaptureFailed, + ]); + expect(events[1]?.params).toMatchObject({ + success: 0, + error_code: "complete failed", + }); + }); +}); + +describe("native plugin memory add analytics", () => { + it("emits tool call analytics only after a new memory is stored", async () => { + const events: Array<{ name: string; params: Record }> = []; + const stored = { id: "mem-1", status: "activated" }; + + const result = await trackStoredExternalMemoryAdd( + recorder(events), + { source: "opencode", adapterId: "memmy-opencode-plugin", layer: "L1" }, + () => stored, + ); + + expect(result).toBe(stored); + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.toolCallStarted, + PLUGIN_RUNTIME_EVENTS.toolCallSucceeded, + ]); + expect(events[1]?.params).toMatchObject({ + source_id: "opencode", + source_kind: "native_plugin", + tool_name: "memmy_memory_add", + layer: "L1", + success: 1, + stored_count: 1, + }); + }); + + it("does not emit when the added memory already exists or was deleted", async () => { + const events: Array<{ name: string; params: Record }> = []; + + await trackStoredExternalMemoryAdd( + recorder(events), + { source: "hermes", adapterId: "memmy-hermes-plugin" }, + () => ({ id: "mem-1", duplicate: true }), + ); + await trackStoredExternalMemoryAdd( + recorder(events), + { source: "hermes", adapterId: "memmy-hermes-plugin" }, + () => ({ id: "skill-1", status: "deleted" }), + ); + + expect(events).toEqual([]); + }); + + it("emits tool call failure only when the add throws", async () => { + const events: Array<{ name: string; params: Record }> = []; + + await expect(trackStoredExternalMemoryAdd( + recorder(events), + { source: "openclaw", adapterId: "memmy-openclaw-plugin" }, + () => { + throw new Error("add failed"); + }, + )).rejects.toThrow("add failed"); + + expect(events.map((event) => event.name)).toEqual([ + PLUGIN_RUNTIME_EVENTS.toolCallStarted, + PLUGIN_RUNTIME_EVENTS.toolCallFailed, + ]); + expect(events[1]?.params).toMatchObject({ + success: 0, + error_code: "add failed", + }); + }); +}); + +describe("plugin runtime analytics source", () => { + it("posts hook writes with the source ingest stores", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ code: 0 }), { status: 200 })); + const analytics = createPluginRuntimeAnalytics({ + fetchImpl, + baseUrl: "https://cloud.example.com", + getClientId: () => "1234567890.1234567890", + }); + + await analytics.trackAwait(PLUGIN_RUNTIME_EVENTS.hookCaptureStarted, { + source_id: "cursor", + source_kind: "hook", + status: "started", + }); + + const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)) as { + events: Array<{ params: { source?: string } }>; + }; + expect(body.events[0]?.params.source).toBe("memmy-agent"); + }); +}); + +function recorder(events: Array<{ name: string; params: Record }>): PluginRuntimeAnalytics { + return { + track(eventName, params = {}) { + events.push({ name: eventName, params }); + }, + async trackAwait(eventName, params = {}) { + events.push({ name: eventName, params }); + }, + async flush() {}, + }; +} From 8f9c428b2e14a10ed51587dbc52ca753c7b900d9 Mon Sep 17 00:00:00 2001 From: xrzhu Date: Fri, 9 Oct 2026 17:18:01 +0800 Subject: [PATCH 13/13] fix(chat): keep copy actions on completed historical replies --- .../src/pages/agent-thread-messages.tsx | 22 ++-- .../agent-thread-copy.interaction.test.tsx | 100 ++++++++++++++++++ 2 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index 29588a66c..d5da86ea4 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -136,7 +136,7 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen () => buildAgentDisplayUnits(props.messages, { chatScopeKey: props.chatScopeKey, retryWaitStatus: props.retryWaitStatus ?? null }), [props.chatScopeKey, props.messages, props.retryWaitStatus] ); - const finalAssistantAnswerIndex = useMemo(() => findFinalAssistantAnswerUnitIndex(units, { isSending: props.isSending }), [props.isSending, units]); + const finalAssistantAnswerIndices = useMemo(() => findFinalAssistantAnswerUnitIndices(units, { isSending: props.isSending }), [props.isSending, units]); const recallEvidenceAnchors = useMemo( () => findRecallEvidenceUserAnchors(units, { isSending: props.isSending }), [props.isSending, units] @@ -217,7 +217,7 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen artifactClient={props.artifactClient} chatScopeKey={props.chatScopeKey} unitIndex={index} - isFinalAssistantAnswer={index === finalAssistantAnswerIndex} + isFinalAssistantAnswer={finalAssistantAnswerIndices.has(index)} forceMessageActions={unit.message.id === (props.forceMessageActionsForMessageId ?? props.afterMessageId)} deferContentRender={shouldDeferAgentMessageContent(unit, index, units.length)} deferredRevealDelayMs={deferredAgentMessageRevealDelay(index, units.length)} @@ -361,16 +361,23 @@ function isActivityAutoOpenRunning(unit: Extract { + const indices = new Set(); const lastUserUnitIndex = findLastUserUnitIndex(units); + let foundAnswerInTurn = false; for (let index = units.length - 1; index >= 0; index -= 1) { const unit = units[index]; if (unit?.type !== "single") continue; + if (unit.message.role === "user") { + foundAnswerInTurn = false; + continue; + } + if (foundAnswerInTurn) continue; if (options.isSending && index > lastUserUnitIndex) continue; const message = unit.message; if (message.role === "assistant" @@ -378,10 +385,11 @@ function findFinalAssistantAnswerUnitIndex(units: AgentDisplayUnit[], options: { && message.kind !== "narration" && message.kind !== "context_compaction" && message.content.trim().length > 0) { - return index; + indices.add(index); + foundAnswerInTurn = true; } } - return -1; + return indices; } function findLastUserUnitIndex(units: AgentDisplayUnit[]): number { diff --git a/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx new file mode 100644 index 000000000..87dad7cf4 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import type { AgentChatMessage } from "../../state/agent-chat-slice.js"; +import { AgentThreadMessages } from "../agent-thread-messages.js"; + +describe("AgentThreadMessages copy actions across turns", () => { + let container: HTMLDivElement; + let root: Root; + const completedMessages: AgentChatMessage[] = [ + { id: "question-1", role: "user", content: "First question" }, + { id: "answer-1", role: "assistant", content: "First **answer**" }, + { id: "question-2", role: "user", content: "Second question" }, + { id: "answer-2", role: "assistant", content: "Second `answer`" }, + ]; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + async function render(messages: AgentChatMessage[], isSending = false) { + await act(async () => { + root.render( + + + + ); + }); + } + + function copyButtons() { + return [...container.querySelectorAll(".agent-message-copy-button--left")]; + } + + it("keeps the first answer copyable after a second turn and copies each answer's raw text", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + await render(completedMessages.slice(0, 2)); + expect(copyButtons()).toHaveLength(1); + + await render(completedMessages); + const buttons = copyButtons(); + expect(buttons).toHaveLength(2); + await act(async () => buttons[0]!.click()); + await act(async () => buttons[1]!.click()); + expect(writeText.mock.calls).toEqual([["First **answer**"], ["Second `answer`"]]); + }); + + it.each(["waiting", "streaming", "reasoning", "tools"] as const)( + "keeps both completed turns copyable while the third turn is %s", + async (phase) => { + const messages: AgentChatMessage[] = [ + ...completedMessages, + { id: "question-3", role: "user", content: "Third question" }, + ]; + if (phase !== "waiting") { + messages.push({ + id: "answer-3", role: "assistant", content: "Third draft", + isStreaming: phase === "streaming", + reasoningStreaming: phase === "reasoning", + }); + } + if (phase === "tools") { + messages.push({ id: "tool-3", role: "tool", kind: "trace", content: "read_file()", isStreaming: true }); + } + await render(messages, true); + expect(copyButtons()).toHaveLength(2); + expect(copyButtons().map((button) => button.closest(".agent-chat-bubble-frame")?.textContent)) + .toEqual([expect.stringContaining("First answer"), expect.stringContaining("Second answer")]); + }, + ); + + it("only exposes the final answer within each completed turn", async () => { + await render([ + { id: "question-1", role: "user", content: "First question" }, + { id: "continuation-1", role: "assistant", content: "Checking first" }, + { id: "tool-1", role: "tool", kind: "trace", content: "read_file()" }, + { id: "answer-1", role: "assistant", content: "First final" }, + { id: "question-2", role: "user", content: "Second question" }, + { id: "continuation-2", role: "assistant", content: "Checking second" }, + { id: "narration-2", role: "assistant", kind: "narration", content: "Still checking" }, + { id: "answer-2", role: "assistant", content: "Second final", stoppedByUser: true }, + { id: "empty-2", role: "assistant", content: " " }, + ]); + expect(copyButtons()).toHaveLength(2); + expect(copyButtons().map((button) => button.closest(".agent-chat-bubble-frame")?.textContent)) + .toEqual([expect.stringContaining("First final"), expect.stringContaining("Second final")]); + }); +});