diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b60a7874..b0d994e49 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,24 +47,28 @@ 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
+ - 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
+ # 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 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: Run test suite
+ run: npm test
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`:

-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/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());
diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs
index a73d178f3..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";
@@ -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-----/;
@@ -260,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;
@@ -291,16 +299,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);
}
}
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",
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 |
| --- | --- |
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),
+ );
+}
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);
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/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);
}
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/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();
}
});
});
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 });