Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@ Create `~/.agentmemory/.env`:

<h2 id="api"><picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/section-api.svg"><img src="assets/tags/section-api.svg" alt="API" height="32" /></picture></h2>

129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` 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 <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.

<details>
<summary>Key endpoints</summary>
Expand Down
11 changes: 7 additions & 4 deletions integrations/filesystem-watcher/bin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
46 changes: 39 additions & 7 deletions integrations/filesystem-watcher/watcher.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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-----/;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/agentmemory-rest-api/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run `
<!-- AUTOGEN:rest START - generated by scripts/skills/generate.ts, do not edit by hand -->
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 |
| --- | --- |
Expand Down
30 changes: 30 additions & 0 deletions scripts/copy-build-assets.mjs
Original file line number Diff line number Diff line change
@@ -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),
);
}
8 changes: 7 additions & 1 deletion scripts/skills/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!-- AUTOGEN:${key} START - generated by scripts/skills/generate.ts, do not edit by hand -->`;
const close = `<!-- AUTOGEN:${key} END -->`;
Expand All @@ -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);
Expand Down
23 changes: 13 additions & 10 deletions src/functions/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,34 +355,37 @@ 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 {
checks.push({
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,
});
}
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/_project.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
26 changes: 25 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 } });
Expand All @@ -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<string, unknown> = { label };
Expand All @@ -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" } };
Expand All @@ -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" } };
Expand All @@ -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 } });
Expand Down
Loading