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..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 @@ -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) { @@ -93,11 +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)) }, null, 2)}\n` - ); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); await writeFileAtomically( hookScriptPath, renderMemmyResumeHookScript({ source: CLAUDE_CODE_TARGET_ID, mode: "claude-code" }) @@ -118,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 ee697e306..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 @@ -119,6 +119,30 @@ 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); + + 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); + + 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 54e75627c..a292d4d33 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 { removeMemmyResumeSkillDirectory, @@ -91,6 +92,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) { @@ -99,11 +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)) }, 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), @@ -127,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 5ee80fafe..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 @@ -127,6 +127,27 @@ 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); + + 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); + }); + 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 3620be095..28e6e96aa 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 { removeMemmyResumeSkillDirectory, @@ -59,16 +60,29 @@ 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 }); 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)) }, null, 2)}\n` - ); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); await writeFileAtomically( hookScriptPath, renderMemmyResumeHookScript({ source: CURSOR_TARGET_ID, mode: "cursor" }) @@ -83,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 13adeca05..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 @@ -103,6 +103,31 @@ 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); + + 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); + + await target.installPlugin?.("cursor"); + await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true); + }); + it("installs a resume Skill and reads the current memmyMemory storage instead of legacy storage", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); let requestBody: Record | undefined; 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..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 @@ -44,14 +44,14 @@ 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", "./client": "./client.js" }, - dsh: { client: { platform: "web" } } + dsh: { client: { platform: "web", inject: ["@deepseek-ai/dsh-client-ui-conversation"] } } }); - 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"); @@ -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 }); @@ -361,7 +384,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/hook-revision.test.ts b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts new file mode 100644 index 000000000..19f58e21b --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.test.ts @@ -0,0 +1,44 @@ +/** Hook revision tests. */ +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", () => { + 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); + }); + + 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 new file mode 100644 index 000000000..5c9e4d218 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/hook-revision.ts @@ -0,0 +1,95 @@ +/** 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 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([ + readOptionalText(input.hookScriptPath), + readOptionalText(input.bridgePath), + readOptionalText(input.configPath) + ]); + if (!script.trim() || !bridge.trim()) return false; + const stored = readStoredHookRevision(configText); + if (!stored) return false; + const current = await currentMemmyHookRevision(input.source, input.mode); + return stored === current && hookRevision(script, bridge) === current; +} + +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/templates/memmy-deepseek-harness-plugin.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-deepseek-harness-plugin.ts index f0f71d635..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 @@ -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) }; @@ -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"); @@ -653,6 +656,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: { @@ -663,10 +667,7 @@ export function createDeepseekHarnessPluginPackageManifest(): Record; installPlugin?(targetId: string): Promise; uninstallPlugin?(targetId: string): Promise; + isInstalledHookCurrent?(): Promise; detectMemoryPluginConflict?(): Promise; } 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-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 521de8d24..cbecc0dfe 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -103,6 +103,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; } @@ -452,6 +453,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); @@ -729,7 +734,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 +912,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 +931,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 +955,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 +972,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 +1276,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 +1300,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 +1317,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 +1338,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 +1346,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 +1363,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/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 977643968..42a22ed6c 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"); @@ -1404,6 +1462,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({ @@ -1576,6 +1661,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 +1948,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/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 }; } 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/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")]); + }); +}); 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/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts index 671a0564b..b80a31c1f 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"); @@ -83,37 +91,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 +594,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 +608,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 +629,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 +646,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"; @@ -638,7 +661,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; @@ -665,12 +688,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/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"); 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/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/adapters/dsh/index.js b/Memory/adapters/dsh/index.js index 8a07a268b..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: "plugin", plugin: name, 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") 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?.message?.content?.map?.((part) => part.text || "").join("\n") || event?.content || ""); + 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/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts b/Memory/src/agent-source/integration/templates/memmy-deepseek-harness-plugin.ts index f0f71d635..e89be3fb7 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) }; @@ -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"); @@ -653,6 +656,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: { @@ -663,10 +667,7 @@ export function createDeepseekHarnessPluginPackageManifest(): Record 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/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/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/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index cb60646b1..dee1bb81f 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -8053,6 +8053,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/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/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/Memory/tests/deepseek-harness-template.test.ts b/Memory/tests/deepseek-harness-template.test.ts new file mode 100644 index 000000000..ab0802d09 --- /dev/null +++ b/Memory/tests/deepseek-harness-template.test.ts @@ -0,0 +1,54 @@ +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", + dsh: { + client: { + platform: "web", + inject: ["@deepseek-ai/dsh-client-ui-conversation"] + } + } + }); + }); + + 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" }'); + }); +}); 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() {}, + }; +} diff --git a/Memory/tests/service/worker/worker-runtime.test.ts b/Memory/tests/service/worker/worker-runtime.test.ts index 8629dbf42..8cde81de3 100644 --- a/Memory/tests/service/worker/worker-runtime.test.ts +++ b/Memory/tests/service/worker/worker-runtime.test.ts @@ -4,7 +4,12 @@ import { DEFAULT_MEMMY_CONFIG } from "../../../src/config/index.js"; import { MEMORY_BYOK_BUDGET_KV_KEY } from "../../../src/service/memory-token-budget-ledger.js"; import { evolutionJobDedupeKey } from "../../../src/service/worker/job-handlers.js"; import { Repositories } from "../../../src/storage/repositories.js"; -import { accountRuntimeConfig, byokRuntimeConfig, createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; +import { + accountRuntimeConfig, + addAgentSourceImport, + byokRuntimeConfig, + createMemoryServiceFixture +} from "../../fixtures/memory-service-fixture.js"; const { cleanup: cleanupMemoryServiceFixture, @@ -16,6 +21,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.each([ [{ repairId: "repair-1" }, "episode-1", "decision_repair:repair-1"], [{ feedbackId: "feedback-1" }, "episode-1", "decision_repair:feedback-1"], 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), + }, + }, ]); });