From 31776a78f871fe4215fee7648c7dd535ccd4122c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80=20=D0=90=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D0=BF=D0=BE=D0=B2?= Date: Mon, 3 Aug 2026 19:31:44 +1100 Subject: [PATCH 01/15] Fix slot MCP errors and Windows tests (#1148) --- AGENTS.md | 2 +- README.md | 2 +- src/functions/diagnostics.ts | 23 +++++++++-------- src/index.ts | 2 +- src/mcp/server.ts | 26 ++++++++++++++++++- test/cli-remove.test.ts | 10 +++++--- test/compress-file.test.ts | 30 ++++++++++++---------- test/copilot-plugin.test.ts | 2 +- test/diagnostics.test.ts | 29 ++++++++++++++++++++++ test/mcp-slots-disabled.test.ts | 44 +++++++++++++++++++++++++++++++++ test/obsidian-export.test.ts | 28 ++++++++++----------- test/slots-flag-gate.test.ts | 27 ++++++++++++++------ 12 files changed, 172 insertions(+), 53 deletions(-) create mode 100644 test/mcp-slots-disabled.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6f64946fc..9a5a55467 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.9.28) - 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 129 REST endpoints +- 130 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 15 skills - 260+ iii functions diff --git a/README.md b/README.md index 332e2010c..655347994 100644 --- a/README.md +++ b/README.md @@ -1498,7 +1498,7 @@ Create `~/.agentmemory/.env`:

API

-129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers. +130 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints diff --git a/src/functions/diagnostics.ts b/src/functions/diagnostics.ts index cc982883f..5d0485aca 100644 --- a/src/functions/diagnostics.ts +++ b/src/functions/diagnostics.ts @@ -355,26 +355,29 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void { } } - // Project-coverage check: unscoped memories (no project field) will - // appear in every project's context and search results until the - // infer-memory-projects migration runs. Surface a count so operators - // know the backfill is still pending and can trigger it explicitly. const latestMemories = memories.filter((m) => m.isLatest); - const unscopedCount = latestMemories.filter((m) => !m.project).length; - if (unscopedCount === 0) { + const unscopedMemories = latestMemories.filter((m) => !m.project); + const sessionLinkedUnscoped = unscopedMemories.filter( + (m) => m.sessionIds.length > 0, + ); + const globalCount = unscopedMemories.length - sessionLinkedUnscoped.length; + const migratableCount = sessionLinkedUnscoped.length; + if (migratableCount === 0) { checks.push({ name: "memory-project-coverage", category: "memories", status: "pass", - message: `All ${latestMemories.length} latest memories have a project scope`, + message: globalCount === 0 + ? `All ${latestMemories.length} latest memories have a project scope` + : `${globalCount} sessionless latest memories are intentionally global; all session-linked memories have a project scope`, fixable: false, }); - } else if (unscopedCount <= 10) { + } else if (migratableCount <= 10) { checks.push({ name: "memory-project-coverage", category: "memories", status: "warn", - message: `${unscopedCount} of ${latestMemories.length} latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, + message: `${migratableCount} of ${latestMemories.length} session-linked latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, fixable: true, }); } else { @@ -382,7 +385,7 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void { name: "memory-project-coverage", category: "memories", status: "fail", - message: `${unscopedCount} of ${latestMemories.length} latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, + message: `${migratableCount} of ${latestMemories.length} session-linked latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, fixable: true, }); } diff --git a/src/index.ts b/src/index.ts index 5f66d76c9..198a6dc3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -540,7 +540,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 129 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 130 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 13240003b..d54377f60 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,7 +10,7 @@ import type { } from "../types.js"; import { getVisibleTools } from "./tools-registry.js"; import { timingSafeCompare } from "../auth.js"; -import { getAgentId, isAgentScopeIsolated } from "../config.js"; +import { getAgentId, getEnvVar, isAgentScopeIsolated } from "../config.js"; type McpResponse = { status_code: number; @@ -40,6 +40,24 @@ function parseCsvList(value: unknown): string[] { return []; } +function slotsDisabledMcpResponse(): McpResponse { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Memory slots not enabled. Set AGENTMEMORY_SLOTS=true and restart AgentMemory.", + }, + ], + }, + }; +} + +function isSlotsFeatureEnabled(): boolean { + return getEnvVar("AGENTMEMORY_SLOTS") === "true"; +} + export function registerMcpEndpoints( sdk: ISdk, kv: StateKV, @@ -1161,6 +1179,7 @@ export function registerMcpEndpoints( } case "memory_slot_list": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const result = await sdk.trigger({ function_id: "mem::slot-list", payload: {} }); return { status_code: 200, @@ -1169,6 +1188,7 @@ export function registerMcpEndpoints( } case "memory_slot_get": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const label = asNonEmptyString(args.label); if (!label) return { status_code: 400, body: { error: "label required" } }; const result = await sdk.trigger({ function_id: "mem::slot-get", payload: { label } }); @@ -1179,6 +1199,7 @@ export function registerMcpEndpoints( } case "memory_slot_create": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const label = asNonEmptyString(args.label); if (!label) return { status_code: 400, body: { error: "label required" } }; const payload: Record = { label }; @@ -1198,6 +1219,7 @@ export function registerMcpEndpoints( } case "memory_slot_append": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const label = asNonEmptyString(args.label); const text = typeof args.text === "string" ? args.text : null; if (!label || !text) return { status_code: 400, body: { error: "label and text required" } }; @@ -1209,6 +1231,7 @@ export function registerMcpEndpoints( } case "memory_slot_replace": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const label = asNonEmptyString(args.label); if (!label || typeof args.content !== "string") { return { status_code: 400, body: { error: "label and content (string) required" } }; @@ -1221,6 +1244,7 @@ export function registerMcpEndpoints( } case "memory_slot_delete": { + if (!isSlotsFeatureEnabled()) return slotsDisabledMcpResponse(); const label = asNonEmptyString(args.label); if (!label) return { status_code: 400, body: { error: "label required" } }; const result = await sdk.trigger({ function_id: "mem::slot-delete", payload: { label } }); diff --git a/test/cli-remove.test.ts b/test/cli-remove.test.ts index 2484d4f88..bd1d2015f 100644 --- a/test/cli-remove.test.ts +++ b/test/cli-remove.test.ts @@ -37,6 +37,10 @@ function mkdir(relPath: string): void { mkdirSync(join(sandbox, relPath), { recursive: true }); } +function iiiBinaryName(): string { + return process.platform === "win32" ? "iii.exe" : "iii"; +} + beforeEach(() => { sandbox = mkdtempSync(join(tmpdir(), "agentmemory-remove-")); }); @@ -110,7 +114,7 @@ describe("buildRemovePlan", () => { }); it("local-bin/iii is alwaysAsk when version does not match", () => { - touch(".local/bin/iii", "fakebin"); + touch(join(".local", "bin", iiiBinaryName()), "fakebin"); const plan = buildRemovePlan( ctx({ localBinIiiVersion: "9.9.9" }), { force: false, keepData: false }, @@ -121,7 +125,7 @@ describe("buildRemovePlan", () => { }); it("local-bin/iii is auto-fixable when version matches pinned", () => { - touch(".local/bin/iii", "fakebin"); + touch(join(".local", "bin", iiiBinaryName()), "fakebin"); const plan = buildRemovePlan( ctx({ localBinIiiVersion: "0.11.2" }), { force: false, keepData: false }, @@ -139,7 +143,7 @@ describe("buildRemovePlan", () => { }); it("private ~/.agentmemory/bin/iii is removed without prompt", () => { - touch(".agentmemory/bin/iii", "fakebin"); + touch(join(".agentmemory", "bin", iiiBinaryName()), "fakebin"); const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); const item = plan.find((p) => p.id === "private-bin-iii")!; expect(item).toBeDefined(); diff --git a/test/compress-file.test.ts b/test/compress-file.test.ts index 9b6820b3e..9d4638526 100644 --- a/test/compress-file.test.ts +++ b/test/compress-file.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolve } from "node:path"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -107,9 +108,10 @@ describe("mem::compress-file", () => { }); it("rejects symlinks", async () => { - symlinkPaths.add("/tmp/notes.md"); + const path = resolve("/tmp/notes.md"); + symlinkPaths.add(path); const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/notes.md", + filePath: path, })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain("symlink"); @@ -118,7 +120,7 @@ describe("mem::compress-file", () => { }); it("rejects TOCTOU symlink swap at write time via O_NOFOLLOW", async () => { - const path = "/tmp/notes.md"; + const path = resolve("/tmp/notes.md"); fileStore.set( path, "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nContent.", @@ -137,7 +139,7 @@ describe("mem::compress-file", () => { it("rejects non-markdown paths", async () => { const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/readme.txt", + filePath: resolve("/tmp/readme.txt"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain(".md"); @@ -145,14 +147,14 @@ describe("mem::compress-file", () => { it("returns file not found for missing paths", async () => { const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/nonexistent.md", + filePath: resolve("/tmp/nonexistent.md"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain("not found"); }); it("compresses markdown and writes .original.md backup", async () => { - const path = "/tmp/notes.md"; + const path = resolve("/tmp/notes.md"); fileStore.set( path, "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nSome long explanation.", @@ -172,14 +174,15 @@ describe("mem::compress-file", () => { }; expect(result.success).toBe(true); - expect(result.backupPath).toBe("/tmp/notes.original.md"); - expect(fileStore.get("/tmp/notes.original.md")).toContain("Some long explanation."); + const backupPath = resolve("/tmp/notes.original.md"); + expect(result.backupPath).toBe(backupPath); + expect(fileStore.get(backupPath)).toContain("Some long explanation."); expect(fileStore.get(path)).toContain("Short explanation."); expect(result.compressedChars).toBeLessThan(result.originalChars); }); it("fails validation when URLs change", async () => { - const path = "/tmp/guide.md"; + const path = resolve("/tmp/guide.md"); fileStore.set(path, "# Guide\n\nhttps://example.com\n"); summarize.mockResolvedValue("# Guide\n\nhttps://different.example.com\n"); @@ -190,11 +193,11 @@ describe("mem::compress-file", () => { expect(result.success).toBe(false); expect(result.error).toContain("validation"); expect(result.details.some((d) => d.includes("url"))).toBe(true); - expect(fileStore.get("/tmp/guide.original.md")).toBeUndefined(); + expect(fileStore.get(resolve("/tmp/guide.original.md"))).toBeUndefined(); }); it("uses a distinct backup path for *.original.md inputs", async () => { - const path = "/tmp/notes.original.md"; + const path = resolve("/tmp/notes.original.md"); fileStore.set(path, "# Title\n\nLong original body."); summarize.mockResolvedValue("# Title\n\nShort body."); @@ -203,8 +206,9 @@ describe("mem::compress-file", () => { })) as { success: boolean; backupPath: string }; expect(result.success).toBe(true); - expect(result.backupPath).toBe("/tmp/notes.original.backup.md"); - expect(fileStore.get("/tmp/notes.original.backup.md")).toBe( + const backupPath = resolve("/tmp/notes.original.backup.md"); + expect(result.backupPath).toBe(backupPath); + expect(fileStore.get(backupPath)).toBe( "# Title\n\nLong original body.", ); expect(fileStore.get(path)).toBe("# Title\n\nShort body."); diff --git a/test/copilot-plugin.test.ts b/test/copilot-plugin.test.ts index cd01b2d87..29b186bfc 100644 --- a/test/copilot-plugin.test.ts +++ b/test/copilot-plugin.test.ts @@ -295,7 +295,7 @@ describe("Copilot hook scripts", () => { expect(result.requests[0]?.path).toBe("/agentmemory/session/start"); expect(result.requests[0]?.body).toMatchObject({ sessionId: "copilot-session", - project: "C:\\repo", + project: "repo", cwd: "C:\\repo", }); }); diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 1e168767a..d6c186d35 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -439,6 +439,35 @@ describe("Diagnostics Functions", () => { expect(check!.fixable).toBe(false); }); + it("treats sessionless memories as intentionally global", async () => { + const memory = makeMemory({ project: undefined, sessionIds: [] }); + await kv.set(KV.memories, memory.id, memory); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["memories"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => c.name === "memory-project-coverage"); + expect(check).toMatchObject({ status: "pass", fixable: false }); + expect(check!.message).toContain("intentionally global"); + }); + + it("warns for session-linked memories without a project", async () => { + const memory = makeMemory({ + project: undefined, + sessionIds: ["ses_legacy"], + }); + await kv.set(KV.memories, memory.id, memory); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["memories"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => c.name === "memory-project-coverage"); + expect(check).toMatchObject({ status: "warn", fixable: true }); + expect(check!.message).toContain("infer-memory-projects"); + }); + it("stale mesh peer produces warn", async () => { const peer = makePeer({ lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), diff --git a/test/mcp-slots-disabled.test.ts b/test/mcp-slots-disabled.test.ts new file mode 100644 index 000000000..9088442ca --- /dev/null +++ b/test/mcp-slots-disabled.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; + +import { registerMcpEndpoints } from "../src/mcp/server.js"; + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => functions.set(id, handler), + registerTrigger: () => {}, + trigger: vi.fn(async () => { + throw new Error("slot function must not be called"); + }), + getFunction: (id: string) => functions.get(id), + }; +} + +describe("MCP slot feature gate", () => { + it("returns a useful response instead of triggering a missing function", async () => { + const original = process.env["AGENTMEMORY_SLOTS"]; + process.env["AGENTMEMORY_SLOTS"] = "false"; + const sdk = mockSdk(); + const kv = { list: vi.fn(), get: vi.fn() }; + registerMcpEndpoints(sdk as never, kv as never); + + try { + const call = sdk.getFunction("mcp::tools::call")!; + const result = (await call({ + body: { name: "memory_slot_list", arguments: {} }, + headers: {}, + query_params: {}, + })) as { + status_code: number; + body: { content: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.content[0].text).toContain("AGENTMEMORY_SLOTS=true"); + expect(sdk.trigger).not.toHaveBeenCalled(); + } finally { + if (original === undefined) delete process.env["AGENTMEMORY_SLOTS"]; + else process.env["AGENTMEMORY_SLOTS"] = original; + } + }); +}); diff --git a/test/obsidian-export.test.ts b/test/obsidian-export.test.ts index 31394bca9..65f80f195 100644 --- a/test/obsidian-export.test.ts +++ b/test/obsidian-export.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { join, resolve } from "node:path"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -121,7 +122,7 @@ function makeSession(id: string): Session { describe("Obsidian Export", () => { let sdk: ReturnType; let kv: ReturnType; - const exportRoot = "/tmp/agentmemory-export-root"; + const exportRoot = resolve("/tmp/agentmemory-export-root"); beforeEach(() => { process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot; @@ -164,7 +165,7 @@ describe("Obsidian Export", () => { expect(result.exported.memories).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_001.md"), + k.includes(join("memories", "mem_001.md")), ); expect(memFile).toBeDefined(); const content = memFile![1]; @@ -186,7 +187,7 @@ describe("Obsidian Export", () => { expect(result.exported.lessons).toBe(1); const lsnFile = [...writtenFiles.entries()].find(([k]) => - k.includes("lessons/lsn_001.md"), + k.includes(join("lessons", "lsn_001.md")), ); expect(lsnFile).toBeDefined(); const content = lsnFile![1]; @@ -202,7 +203,7 @@ describe("Obsidian Export", () => { await sdk.trigger("mem::obsidian-export", {}); const crysFile = [...writtenFiles.entries()].find(([k]) => - k.includes("crystals/crys_001.md"), + k.includes(join("crystals", "crys_001.md")), ); expect(crysFile).toBeDefined(); expect(crysFile![1]).toContain("[[act_1]]"); @@ -222,19 +223,18 @@ describe("Obsidian Export", () => { }); it("respects custom vaultDir", async () => { - await sdk.trigger("mem::obsidian-export", { - vaultDir: "/tmp/agentmemory-export-root/test-vault", - }); + const customVault = join(exportRoot, "test-vault"); + await sdk.trigger("mem::obsidian-export", { vaultDir: customVault }); const hasCustomPath = [...createdDirs].some((d) => - d.startsWith("/tmp/agentmemory-export-root/test-vault"), + d.startsWith(customVault), ); expect(hasCustomPath).toBe(true); }); it("rejects vaultDir outside the export root", async () => { const result = (await sdk.trigger("mem::obsidian-export", { - vaultDir: "/tmp/outside-root", + vaultDir: resolve("/tmp/outside-root"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); @@ -323,7 +323,7 @@ describe("Obsidian Export", () => { expect(result.exported.sessions).toBe(1); expect(result.errors).toBeUndefined(); expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false); - expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true); + expect([...writtenFiles.keys()].some((path) => path.includes(join("sessions", "ses_valid.md")))).toBe(true); }); it("tolerates malformed startedAt timestamps when sorting sessions", async () => { @@ -359,7 +359,7 @@ describe("Obsidian Export", () => { expect(result.exported.memories).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_incomplete.md"), + k.includes(join("memories", "mem_incomplete.md")), ); expect(memFile).toBeDefined(); const content = memFile![1]; @@ -394,17 +394,17 @@ describe("Obsidian Export", () => { expect(result.exported.crystals).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_no_title.md"), + k.includes(join("memories", "mem_no_title.md")), ); expect(memFile![1]).toContain("# mem_no_title"); const lsnFile = [...writtenFiles.entries()].find(([k]) => - k.includes("lessons/lsn_no_content.md"), + k.includes(join("lessons", "lsn_no_content.md")), ); expect(lsnFile![1]).toContain("# Lesson: lsn_no_content"); const crysFile = [...writtenFiles.entries()].find(([k]) => - k.includes("crystals/crys_no_narr.md"), + k.includes(join("crystals", "crys_no_narr.md")), ); expect(crysFile![1]).toContain("# Crystal: crys_no_narr"); }); diff --git a/test/slots-flag-gate.test.ts b/test/slots-flag-gate.test.ts index 287bcbd89..e9e994bf2 100644 --- a/test/slots-flag-gate.test.ts +++ b/test/slots-flag-gate.test.ts @@ -3,6 +3,21 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +function setTestHome(home: string): void { + process.env["HOME"] = home; + process.env["USERPROFILE"] = home; +} + +function restoreTestHome(): void { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; +} + // Regression tests for #678: // - isSlotsEnabled / isReflectEnabled must read from ~/.agentmemory/.env // (not only process.env), so users who set AGENTMEMORY_SLOTS in the @@ -12,21 +27,19 @@ import { join } from "node:path"; describe("isSlotsEnabled — reads merged env (#678)", () => { let home: string; - let ORIG_HOME: string | undefined; let ORIG_FLAG: string | undefined; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "am-slots-flag-")); mkdirSync(join(home, ".agentmemory"), { recursive: true }); - ORIG_HOME = process.env["HOME"]; ORIG_FLAG = process.env["AGENTMEMORY_SLOTS"]; - process.env["HOME"] = home; + setTestHome(home); delete process.env["AGENTMEMORY_SLOTS"]; vi.resetModules(); }); afterEach(() => { - if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + restoreTestHome(); if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_SLOTS"] = ORIG_FLAG; else delete process.env["AGENTMEMORY_SLOTS"]; rmSync(home, { recursive: true, force: true }); @@ -59,21 +72,19 @@ describe("isSlotsEnabled — reads merged env (#678)", () => { describe("isReflectEnabled — reads merged env (#678)", () => { let home: string; - let ORIG_HOME: string | undefined; let ORIG_FLAG: string | undefined; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "am-reflect-flag-")); mkdirSync(join(home, ".agentmemory"), { recursive: true }); - ORIG_HOME = process.env["HOME"]; ORIG_FLAG = process.env["AGENTMEMORY_REFLECT"]; - process.env["HOME"] = home; + setTestHome(home); delete process.env["AGENTMEMORY_REFLECT"]; vi.resetModules(); }); afterEach(() => { - if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + restoreTestHome(); if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_REFLECT"] = ORIG_FLAG; else delete process.env["AGENTMEMORY_REFLECT"]; rmSync(home, { recursive: true, force: true }); From 1bf9ff0e25f7627cca950d0b7a31c9f7405ee5fa Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 20:38:31 +1100 Subject: [PATCH 02/15] ci: restore Windows matrix for AgentMemory fixes --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b60a7874..e216aba4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,11 +47,9 @@ jobs: # whether a flake is platform-specific or universal). fail-fast: false matrix: - # Windows held back: test/obsidian-export.test.ts has hardcoded - # POSIX paths (`/tmp/...`) that fail on D:\ drive runners. - # src/functions/obsidian-export.ts needs os.tmpdir() + path.join - # rework before Windows can be added back. Tracked as follow-up. - os: [ubuntu-latest, macos-latest] + # Windows is covered again after the path-sensitive test fixtures + # were normalized with node:path and Windows binary naming. + os: [ubuntu-latest, macos-latest, windows-latest] node-version: [20, 22, 24, 26] steps: - uses: actions/checkout@v6 From 5f4ce6a618cd68451161a3214842f1966dca69dd Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 20:59:17 +1100 Subject: [PATCH 03/15] ci: name validation steps for failure diagnostics --- .github/workflows/ci.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e216aba4e..de8eb8fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,17 +52,24 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [20, 22, 24, 26] steps: - - uses: actions/checkout@v6 + - name: Check out repository + uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-node@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} # Two-step install: generate a lockfile in-runner with # --package-lock-only, then install from it with `npm ci`. # Lockfiles are gitignored at the repo level. - - run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund - - run: npm ci --legacy-peer-deps --no-audit --no-fund - - run: npm run build - - run: npm run skills:check - - run: npm test + - name: Generate dependency lockfile + run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund + - name: Install dependencies + run: npm ci --legacy-peer-deps --no-audit --no-fund + - name: Build TypeScript package + run: npm run build + - name: Check generated skills + run: npm run skills:check + - name: Run test suite + run: npm test From 3383a41aea5fb673462e3a547709aecfe009b7d8 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:00:35 +1100 Subject: [PATCH 04/15] ci: install dependencies without ephemeral npm ci lockfile --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8eb8fff..b0d994e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,13 +60,12 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - # Two-step install: generate a lockfile in-runner with - # --package-lock-only, then install from it with `npm ci`. - # Lockfiles are gitignored at the repo level. - - name: Generate dependency lockfile - run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund + # The repository intentionally does not commit package-lock.json. + # Generating an ephemeral lockfile and immediately running npm ci is + # unreliable with platform-specific optional dependencies, so CI uses + # one clean resolver/install pass instead. - name: Install dependencies - run: npm ci --legacy-peer-deps --no-audit --no-fund + run: npm install --legacy-peer-deps --no-audit --no-fund - name: Build TypeScript package run: npm run build - name: Check generated skills From 6acd2649ac9a6e91a5e972cc440af18e50487b0e Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:02:23 +1100 Subject: [PATCH 05/15] ci: expose generated REST skill reference drift --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d994e49..f439afe8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,11 @@ jobs: run: npm install --legacy-peer-deps --no-audit --no-fund - name: Build TypeScript package run: npm run build - - name: Check generated skills - run: npm run skills:check + - name: Show generated skill drift + shell: bash + run: | + npm run skills:gen + git diff -- plugin/skills/agentmemory-rest-api/REFERENCE.md + git diff --exit-code -- plugin/skills/agentmemory-rest-api/REFERENCE.md - name: Run test suite run: npm test From 6e33c8343507c5e28b87636e70657c004022e32d Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:04:02 +1100 Subject: [PATCH 06/15] docs(skills): regenerate REST endpoint count --- plugin/skills/agentmemory-rest-api/REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md index d12ff4610..b92e35a9e 100644 --- a/plugin/skills/agentmemory-rest-api/REFERENCE.md +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run ` The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). When `AGENTMEMORY_SECRET` is set, send `Authorization: Bearer $AGENTMEMORY_SECRET`; localhost is otherwise open. -118 registered endpoints: +119 registered endpoints: | Method | Path | | --- | --- | From ae36708ea1f1fea28a84939ca2d941673d57640c Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:04:22 +1100 Subject: [PATCH 07/15] ci: restore generated skills validation --- .github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f439afe8f..b0d994e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,11 +68,7 @@ jobs: run: npm install --legacy-peer-deps --no-audit --no-fund - name: Build TypeScript package run: npm run build - - name: Show generated skill drift - shell: bash - run: | - npm run skills:gen - git diff -- plugin/skills/agentmemory-rest-api/REFERENCE.md - git diff --exit-code -- plugin/skills/agentmemory-rest-api/REFERENCE.md + - name: Check generated skills + run: npm run skills:check - name: Run test suite run: npm test From ec288be0c3a8eb4345bb099cbc9afa92a8b988c8 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:07:54 +1100 Subject: [PATCH 08/15] build: copy package assets with a cross-platform Node script --- scripts/copy-build-assets.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 scripts/copy-build-assets.mjs diff --git a/scripts/copy-build-assets.mjs b/scripts/copy-build-assets.mjs new file mode 100644 index 000000000..794cc0842 --- /dev/null +++ b/scripts/copy-build-assets.mjs @@ -0,0 +1,30 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = resolve(repoRoot, "dist"); + +mkdirSync(distDir, { recursive: true }); + +for (const relativePath of [ + "iii-config.yaml", + "iii-config.docker.yaml", + "docker-compose.yml", + ".env.example", +]) { + const source = resolve(repoRoot, relativePath); + if (existsSync(source)) { + copyFileSync(source, resolve(distDir, relativePath)); + } +} + +const viewerDir = resolve(distDir, "viewer"); +mkdirSync(viewerDir, { recursive: true }); + +for (const fileName of ["index.html", "favicon.svg"]) { + copyFileSync( + resolve(repoRoot, "src", "viewer", fileName), + resolve(viewerDir, fileName), + ); +} From 718478ee80414d989a2bac0cc3bbc5d808d20893 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:08:13 +1100 Subject: [PATCH 09/15] build: use the cross-platform asset copy script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77185ad5f..a270922c5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "agentmemory": "dist/cli.mjs" }, "scripts": { - "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/", + "build": "tsdown && node scripts/copy-build-assets.mjs", "dev": "tsx src/index.ts", "start": "node dist/cli.mjs", "migrate": "node dist/functions/migrate.js", From b007a24e2e58c7170b8b7b5831d5e7286248bc01 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:08:45 +1100 Subject: [PATCH 10/15] fix(hooks): resolve project names from Windows paths on every OS --- src/hooks/_project.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/_project.ts b/src/hooks/_project.ts index 35364ea3b..61b61b755 100644 --- a/src/hooks/_project.ts +++ b/src/hooks/_project.ts @@ -1,6 +1,10 @@ import { execSync } from "node:child_process"; import { basename } from "node:path"; +function projectBasename(path: string): string { + return basename(path.replace(/\\/g, "/")); +} + // Resolution order: AGENTMEMORY_PROJECT_NAME env → git toplevel basename → cwd basename. export function resolveProject(cwd?: string): string { const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; @@ -14,7 +18,7 @@ export function resolveProject(cwd?: string): string { }) .toString() .trim(); - if (top) return basename(top); + if (top) return projectBasename(top); } catch {} - return basename(dir); + return projectBasename(dir); } From af626f84c1954ab8ecb52173246ec3dc8fcc3d29 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:11:09 +1100 Subject: [PATCH 11/15] fix(skills): normalize line endings before drift checks --- scripts/skills/generate.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/skills/generate.ts b/scripts/skills/generate.ts index 44ccf941a..9850b06b9 100644 --- a/scripts/skills/generate.ts +++ b/scripts/skills/generate.ts @@ -15,6 +15,10 @@ function clean(s: string): string { return s.replace(/\s*[—–]\s*/g, ", "); } +function normalizeNewlines(s: string): string { + return s.replace(/\r\n?/g, "\n"); +} + function block(key: string, body: string): { open: string; close: string; full: string } { const open = ``; const close = ``; @@ -23,7 +27,9 @@ function block(key: string, body: string): { open: string; close: string; full: function applyBlock(file: string, key: string, body: string): void { const { open, close, full } = block(key, body); - const existing = existsSync(file) ? readFileSync(file, "utf8") : ""; + const existing = existsSync(file) + ? normalizeNewlines(readFileSync(file, "utf8")) + : ""; let next: string; if (existing.includes(open) && existing.includes(close)) { const start = existing.indexOf(open); From d4d3c3518d15b172d480f67cee4022f0d84fb816 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:16:58 +1100 Subject: [PATCH 12/15] fix(fs-watcher): await native watcher close before teardown --- integrations/filesystem-watcher/watcher.mjs | 35 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index a73d178f3..68b5d4f6e 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -28,6 +28,7 @@ const DEFAULT_IGNORE = [ const MAX_PREVIEW_BYTES = 4096; const DEBOUNCE_MS = 500; +const WATCHER_CLOSE_TIMEOUT_MS = 1000; const REDACTED = "[REDACTED]"; const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/; const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/; @@ -291,16 +292,40 @@ export class FilesystemWatcher { } stop() { - for (const w of this.watchers) { - try { - w.close(); - } catch {} - } + const watchers = this.watchers; this.watchers = []; + for (const { timer } of this.pendingByPath.values()) { clearTimeout(timer); } this.pendingByPath.clear(); + + return Promise.all( + watchers.map( + (watcher) => + new Promise((resolveClose) => { + let settled = false; + let timeout; + const finish = () => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + watcher.removeListener("close", finish); + resolveClose(); + }; + + watcher.once("close", finish); + timeout = setTimeout(finish, WATCHER_CLOSE_TIMEOUT_MS); + timeout.unref?.(); + + try { + watcher.close(); + } catch { + finish(); + } + }), + ), + ).then(() => undefined); } } From 832ad987e6154b8bf02daa34d03dd8b8cc8710ea Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:17:21 +1100 Subject: [PATCH 13/15] fix(fs-watcher): await watcher shutdown before process exit --- integrations/filesystem-watcher/bin.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/integrations/filesystem-watcher/bin.mjs b/integrations/filesystem-watcher/bin.mjs index 34ebed722..fcbab2466 100755 --- a/integrations/filesystem-watcher/bin.mjs +++ b/integrations/filesystem-watcher/bin.mjs @@ -20,9 +20,12 @@ process.stderr.write( `[fs-watcher] emitting to ${envCfg.baseUrl || "http://localhost:3111"}\n`, ); -const shutdown = () => { - watcher.stop(); +let shuttingDown = false; +const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + await watcher.stop(); process.exit(0); }; -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); From 8528c5de3a77fb65455ca108a92819e7bf07e3de Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:18:34 +1100 Subject: [PATCH 14/15] test(fs-watcher): await native watcher shutdown before deleting roots --- test/fs-watcher.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/fs-watcher.test.ts b/test/fs-watcher.test.ts index 8375c6864..e0d226d01 100644 --- a/test/fs-watcher.test.ts +++ b/test/fs-watcher.test.ts @@ -73,7 +73,7 @@ describe("FilesystemWatcher", { retry: 2 }, () => { expect(body.data.files).toContain("notes.md"); expect(body.data.content).toContain("hello world"); } finally { - w.stop(); + await w.stop(); } }); @@ -93,7 +93,7 @@ describe("FilesystemWatcher", { retry: 2 }, () => { ); expect(deletes.length).toBeGreaterThanOrEqual(1); } finally { - w.stop(); + await w.stop(); } }); @@ -137,7 +137,7 @@ describe("FilesystemWatcher", { retry: 2 }, () => { ); expect(matches).toHaveLength(0); } finally { - w.stop(); + await w.stop(); } }); @@ -156,7 +156,7 @@ describe("FilesystemWatcher", { retry: 2 }, () => { const headers = captured[captured.length - 1].headers as Record; expect(headers.authorization).toBe("Bearer shhh"); } finally { - w.stop(); + await w.stop(); } }); @@ -362,7 +362,7 @@ describe("FilesystemWatcher", { retry: 2 }, () => { ); expect(hits.length).toBeLessThanOrEqual(2); } finally { - w.stop(); + await w.stop(); } }); }); From ca8ba72785602e2ca124ddf61fb8c4c13010bac6 Mon Sep 17 00:00:00 2001 From: arkhipowiktor-dev Date: Mon, 3 Aug 2026 21:24:04 +1100 Subject: [PATCH 15/15] fix(fs-watcher): canonicalize Windows roots before native watch --- integrations/filesystem-watcher/watcher.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index 68b5d4f6e..9fcb2291b 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -1,4 +1,4 @@ -import { watch, promises as fsp, statSync } from "node:fs"; +import { watch, promises as fsp, statSync, realpathSync } from "node:fs"; import { resolve, relative, join, extname, sep, basename } from "node:path"; import { randomBytes } from "node:crypto"; @@ -261,8 +261,15 @@ export class FilesystemWatcher { if (!st.isDirectory()) { throw new Error("not a directory"); } + + // libuv versions currently bundled by Node 24.16+ and early Node 26 + // can abort on Windows when fs.watch receives an 8.3 short path but + // ReadDirectoryChangesW later reports the same directory in long form. + // Watch the canonical native path while preserving the configured root + // in emitted payloads. This avoids the upstream prefix-mismatch crash. + const watchRoot = realpathSync.native(root); const handle = watch( - root, + watchRoot, { recursive: true, persistent: true }, (_eventType, filename) => { if (!filename) return;