From 39b1e67af62ec5ad23b2003f2f043fde936aa65b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 2 Aug 2026 19:32:06 +0100 Subject: [PATCH 1/8] chore(release): v0.9.29 with project-scope parity across surfaces Version trio + plugin manifests + supportedVersions + ExportData union bumped to 0.9.29; CHANGELOG entry covering everything since v0.9.28 with upgrade notes for the four visible behavior changes. Fixes the endpoint-count drift on main (130 registered routes vs docs saying 129 after #1132 landed in parallel with #1136). Project-scope parity: OpenCode plugin, Hermes plugin, Pi extension, and JSONL replay now resolve project the same way the hooks do (env override, git toplevel basename, cwd basename) instead of sending raw filesystem paths, closing #903 and #1135 and pre-empting the same bug in pi. The filesystem watcher accepts AGENTMEMORY_PROJECT_NAME with the old AGENTMEMORY_PROJECT kept as a deprecated alias, replay handles Windows-recorded paths, and OpenCode file enrichment matches the agent's lowercase tool names (the capitalized set never matched). Tests: opencode fallback expectations updated to basenames per the canonicalization, git-toplevel resolution covered with a fixture repo, new project-scope-parity suite for replay and fs-watcher. --- AGENTS.md | 2 +- CHANGELOG.md | 43 +++++++++++++ README.md | 2 +- integrations/filesystem-watcher/watcher.mjs | 4 +- integrations/hermes/__init__.py | 31 +++++++++- integrations/pi/index.ts | 35 +++++++++-- package.json | 2 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/.codex-plugin/plugin.json | 2 +- plugin/opencode/agentmemory-capture.ts | 48 +++++++++++---- plugin/plugin.json | 2 +- .../skills/agentmemory-rest-api/REFERENCE.md | 2 +- src/functions/export-import.ts | 2 +- src/index.ts | 2 +- src/replay/jsonl-parser.ts | 4 +- src/types.ts | 2 +- src/version.ts | 2 +- test/export-import.test.ts | 3 +- test/opencode-auto-context.test.ts | 55 ++++++++++++++--- test/project-scope-parity.test.ts | 60 +++++++++++++++++++ 20 files changed, 264 insertions(+), 41 deletions(-) create mode 100644 test/project-scope-parity.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/CHANGELOG.md b/CHANGELOG.md index 24a1722e8..8914491dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.9.29] — 2026-08-02 + +Patch release: the `.env` file now actually applies everywhere, imports become searchable, consolidation runs on session stop, twelve MCP-only agents get activated on connect, and every capture surface finally agrees on what "project" means. No breaking changes; read the upgrade notes below for four behavior changes you will notice. + +### Upgrade notes + +- `~/.agentmemory/.env` values that were silently ignored by most modules now take effect on boot. If that file has stale entries from past experiments, review it before upgrading. +- `agentmemory connect ` now writes a short memory-usage guideline into the agent's native rules file (Cursor, Cline, Continue, Zed, Warp, Kiro, Gemini CLI, Qwen, OpenCode, Droid, Copilot CLI, Antigravity) so MCP-only agents actually call the memory tools. Pass `--no-guidelines` to opt out. +- Installs with an LLM key now run consolidation and crystallization on session stop (previously they never fired), debounced to once per 5 minutes (`AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS`). +- Local embeddings re-download once after the `@huggingface/transformers` migration (different model cache directory). Model IDs are unchanged. + +### Added + +- `--data-dir` flag and `AGENTMEMORY_DATA_DIR` so iii-engine state lives outside repositories, with gated legacy `./data` adoption and Docker-volume preservation (#314) +- Native hooks adapter for Droid via `~/.factory/hooks.json`, reusing the bundled hook scripts (#1130) +- `mem::graph::import-graphify` and `POST /agentmemory/graph/import-graphify`: merge graphify's `graph.json` into the knowledge graph with confidence tags carried over as edge weights (#1136) +- Connector guideline activation for twelve hook-less agents, with every rules-file path verified against the agent's official documentation (#1136) +- Honest `memory_forget` reporting plus a real lesson delete path (`mem::lesson-delete`, `DELETE`-style REST route, MCP tool) (#1132) +- `AGENTMEMORY_PROJECT_NAME` override in the OpenCode plugin (#1125) +- Provider fetches retry 429/503 honoring `Retry-After` under a total-elapsed budget capped below the iii invocation timeout (#1136) + +### Fixed + +- Boot hydrates `~/.agentmemory/.env` into `process.env`, closing the class of "env var in .env is ignored" bugs (#1136) +- Imported and replayed observations are indexed into BM25 and the vector index, so imports are searchable (#1072, via #1136) +- Snapshot timer actually runs, non-positive intervals clamp to the default, and snapshot creation is serialized across timer, REST, and MCP (#1006, via #1136) +- CJK-aware dedup with NFC normalization and an exact-match fallback for short memories (#1021, via #1136) +- OpenRouter embeddings no longer hardcode 1536 dimensions (#1002, via #1136) +- Viewer decodes multibyte request bodies correctly (#930, via #1136) +- Session-stop consolidation is debounced and no longer double-fires from the client hook; eviction recovery is bounded to one consolidation pass (#1087, #1131 class, via #1136) +- `/agentmemory/sessions` no longer deadlocks on large session counts (#1100, via #1136) +- Filesystem watcher validates roots before `fs.watch`, fixing Node 24/26 on Linux (#1136) +- Claude bridge writes `MEMORY.md` under the `memory/` subdirectory Claude Code actually reads (#1134) +- Hook project-resolution tests no longer depend on the checkout directory name (#1137, #1138) +- Project-scope parity: the OpenCode plugin, Hermes plugin, Pi extension, and JSONL replay now resolve `project` the same way the hooks do (env override, git toplevel basename, cwd basename) instead of sending raw filesystem paths, so the same repository shares one memory bucket across agents (#903, #1135); the filesystem watcher accepts `AGENTMEMORY_PROJECT_NAME` with the old `AGENTMEMORY_PROJECT` kept as a deprecated alias; replay handles Windows-recorded paths +- OpenCode file enrichment matches the agent's lowercase tool names, which the previous capitalized set never did +- Viewer surfaces health status from non-2xx health responses (#1046) +- Documented REST endpoint count matches the registered routes again (130) + +### Changed + +- Local embeddings migrate from `@xenova/transformers` to `@huggingface/transformers` v4 with Node 22+ support; CI now tests Node 20, 22, 24, and 26 (#479, #1096) + ## [0.9.28] — 2026-07-19 Patch release: hardens the hook runner against malformed payloads and closes a cross-agent context leak. No breaking changes; drop-in upgrade. 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/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index a73d178f3..5e203305e 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -319,7 +319,9 @@ export function configFromEnv(env = process.env) { roots, baseUrl: env.AGENTMEMORY_URL, secret: env.AGENTMEMORY_SECRET, - project: env.AGENTMEMORY_PROJECT || null, + // AGENTMEMORY_PROJECT_NAME is the canonical override (matches the hooks); + // AGENTMEMORY_PROJECT stays as a deprecated alias for existing setups. + project: env.AGENTMEMORY_PROJECT_NAME || env.AGENTMEMORY_PROJECT || null, sessionId: env.AGENTMEMORY_SESSION_ID || null, ignorePatterns: extraIgnore, allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1", diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 2933632d0..79ab21889 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -13,6 +13,30 @@ import os import sys import threading +import subprocess +from pathlib import PurePath + + +def _resolve_project(cwd: str) -> str: + """Canonical project scope, matching the hooks' resolveProject order: + AGENTMEMORY_PROJECT_NAME env override, git toplevel basename, cwd basename. + Keeps Hermes sessions in the same project bucket as every other agent.""" + explicit = os.environ.get("AGENTMEMORY_PROJECT_NAME", "").strip() + if explicit: + return explicit + try: + top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=cwd, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + if top: + return PurePath(top).name + except Exception: + pass + return PurePath(cwd).name or cwd import time from pathlib import Path from typing import Any, Callable @@ -188,14 +212,15 @@ def is_available(self) -> bool: def initialize(self, session_id: str, **kwargs: Any) -> None: self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL) self._session_id = session_id - self._project = kwargs.get("cwd", os.getcwd()) + self._cwd = kwargs.get("cwd", os.getcwd()) + self._project = _resolve_project(self._cwd) if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1": _check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", "")) _api(self._base, "session/start", { "sessionId": session_id, "project": self._project, - "cwd": self._project, + "cwd": self._cwd, }) def get_config_schema(self) -> list[dict]: @@ -348,7 +373,7 @@ def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: "hookType": "post_tool_use", "sessionId": kwargs.get("session_id", self._session_id), "project": self._project, - "cwd": self._project, + "cwd": self._cwd, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "data": { "tool_name": "conversation", diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts index 9c6cfc702..e6ad648de 100644 --- a/integrations/pi/index.ts +++ b/integrations/pi/index.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import path from "node:path"; import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; import { createPlaintextBearerAuthGuard } from "./security.js"; type TextBlock = { type?: string; text?: string }; @@ -120,7 +121,31 @@ export default function agentmemoryExtension(pi: ExtensionAPI) { ); } let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`; - let currentProject = process.cwd(); + // Canonical project scope, matching the hooks' resolveProject order (env + // override, git toplevel basename, cwd basename) so Pi sessions share a + // project bucket with every other agent instead of scoping on a raw path. + const projectCache = new Map(); + function resolveProjectName(dir: string): string { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]?.trim(); + if (explicit) return explicit; + const cached = projectCache.get(dir); + if (cached) return cached; + let name = path.basename(dir) || dir; + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim(); + if (top) name = path.basename(top); + } catch { + // not a git repo + } + projectCache.set(dir, name); + return name; + } + let currentCwd = process.cwd(); + let currentProject = resolveProjectName(currentCwd); let lastPrompt = ""; let lastHealthOk = false; @@ -227,12 +252,14 @@ export default function agentmemoryExtension(pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const sessionFile = ctx.sessionManager.getSessionFile(); sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`; - currentProject = process.cwd(); + currentCwd = process.cwd(); + currentProject = resolveProjectName(currentCwd); await refreshStatus(ctx); }); pi.on("before_agent_start", async (event, ctx) => { - currentProject = event.systemPromptOptions.cwd || process.cwd(); + currentCwd = event.systemPromptOptions.cwd || process.cwd(); + currentProject = resolveProjectName(currentCwd); lastPrompt = event.prompt?.trim() || ""; if (!lastPrompt) return; @@ -262,7 +289,7 @@ export default function agentmemoryExtension(pi: ExtensionAPI) { hookType: "post_tool_use", sessionId, project: currentProject, - cwd: currentProject, + cwd: currentCwd, timestamp: new Date().toISOString(), data: { tool_name: "conversation", diff --git a/package.json b/package.json index 77185ad5f..79b716c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentmemory/agentmemory", - "version": "0.9.28", + "version": "0.9.29", "description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives", "type": "module", "main": "dist/index.mjs", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 27bdc81eb..52fc79280 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentmemory", - "version": "0.9.28", + "version": "0.9.29", "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 54 MCP tools, 8 skills, real-time viewer.", "author": { "name": "Rohit Ghumare", diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index ad262621d..cbcd5731a 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentmemory", - "version": "0.9.28", + "version": "0.9.29", "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 6 hooks, 54 MCP tools, 8 skills, real-time viewer.", "author": { "name": "Rohit Ghumare", diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index 46419ef8c..1a1d04268 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -1,7 +1,12 @@ import type { Plugin } from "@opencode-ai/plugin"; +import { execFileSync } from "node:child_process"; +import { basename } from "node:path"; const API = process.env.AGENTMEMORY_URL || "http://localhost:3111"; -const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]); +// OpenCode reports tool names in lowercase ("read", "edit", ...); matching is +// case-insensitive at the call site so a future casing change cannot silently +// kill file enrichment again. +const FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep"]); const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"]; const MAX_STASHED_FILES = 20; @@ -50,8 +55,8 @@ async function observe( await post("/observe", { hookType, sessionId, - project: projectPath, - cwd: projectPath, + project: projectName, + cwd: projectCwd, timestamp: new Date().toISOString(), data, }); @@ -59,7 +64,28 @@ async function observe( let activeSessionId: string | null = null; let pendingConfig: Record | null = null; -let projectPath: string | null = null; +// projectName is the canonical scope (same resolution order as the hooks' +// resolveProject: env override, git toplevel basename, cwd basename) so +// OpenCode sessions land in the same project bucket as every other agent on +// the repo. projectCwd keeps the full path for the cwd field. +let projectName: string | null = null; +let projectCwd: string | null = null; + +function resolveProjectName(dir: string): string { + const explicit = process.env.AGENTMEMORY_PROJECT_NAME?.trim(); + if (explicit) return explicit; + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim(); + if (top) return basename(top); + } catch { + // not a git repo, fall through + } + return basename(dir) || dir; +} const stashedFiles = new Map>(); const seenSubtaskIds = new Map>(); const seenToolCallIds = new Map>(); @@ -168,8 +194,8 @@ function extractErrorMessage(err: unknown): string { } export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { - const explicitProject = process.env.AGENTMEMORY_PROJECT_NAME?.trim(); - projectPath = explicitProject || ctx.worktree || ctx.project?.id || process.cwd(); + projectCwd = ctx.worktree || ctx.project?.id || process.cwd(); + projectName = resolveProjectName(projectCwd); return { event: async ({ event }) => { @@ -194,8 +220,8 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { title: info?.title ?? null, parentID: info?.parentID ?? null, version: info?.version ?? null, - project: projectPath, - cwd: projectPath, + project: projectName, + cwd: projectCwd, }); // cache the context returned at session/start so the // chat.system.transform hook injects it without a second fetch. @@ -582,7 +608,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { // ── tool.execute.before ── "tool.execute.before": async (input, output) => { - if (!FILE_TOOLS.has(input.tool)) return; + if (!FILE_TOOLS.has(String(input.tool ?? "").toLowerCase())) return; const sid = input.sessionID || activeSessionId; if (!sid) return; const args = output.args as Record | undefined; @@ -613,7 +639,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { if (typeof ctx !== "string" || ctx.length === 0) { const result = await postJson("/context", { sessionId: sid, - project: projectPath, + project: projectName, }); ctx = (result as any)?.context; } else { @@ -651,7 +677,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { const result = await postJson("/context", { sessionId: sid, - project: projectPath, + project: projectName, }); const ctx = (result as any)?.context; if (typeof ctx === "string" && ctx.length > 0) { diff --git a/plugin/plugin.json b/plugin/plugin.json index 90d248c58..ad8025aec 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentmemory", - "version": "0.9.28", + "version": "0.9.29", "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 54 MCP tools, 15 skills, real-time viewer.", "author": { "name": "Rohit Ghumare", 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/src/functions/export-import.ts b/src/functions/export-import.ts index 2e30e070f..740037aab 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -200,7 +200,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { const strategy = data.strategy || "merge"; const importData = data.exportData; - const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7", "0.9.8", "0.9.9", "0.9.10", "0.9.11", "0.9.12", "0.9.13", "0.9.14", "0.9.15", "0.9.16", "0.9.17", "0.9.18", "0.9.19", "0.9.20", "0.9.21", "0.9.22", "0.9.23", "0.9.24", "0.9.25", "0.9.26", "0.9.27", "0.9.28"]); + const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7", "0.9.8", "0.9.9", "0.9.10", "0.9.11", "0.9.12", "0.9.13", "0.9.14", "0.9.15", "0.9.16", "0.9.17", "0.9.18", "0.9.19", "0.9.20", "0.9.21", "0.9.22", "0.9.23", "0.9.24", "0.9.25", "0.9.26", "0.9.27", "0.9.28", "0.9.29"]); if (!supportedVersions.has(importData.version)) { return { success: false, 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/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts index 5060c3451..fc4b0811b 100644 --- a/src/replay/jsonl-parser.ts +++ b/src/replay/jsonl-parser.ts @@ -26,7 +26,9 @@ export interface ParsedTranscript { function deriveProject(cwd: string): string { if (!cwd) return "unknown"; - const parts = cwd.split("/").filter(Boolean); + // Split on both separators so a Windows-recorded cwd yields its basename + // instead of the whole raw path becoming the project scope. + const parts = cwd.split(/[\\/]+/).filter(Boolean); return parts[parts.length - 1] || "unknown"; } diff --git a/src/types.ts b/src/types.ts index 7cda80ffb..2f3f0285f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -307,7 +307,7 @@ export interface ExportPagination { } export interface ExportData { - version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7" | "0.9.8" | "0.9.9" | "0.9.10" | "0.9.11" | "0.9.12" | "0.9.13" | "0.9.14" | "0.9.15" | "0.9.16" | "0.9.17" | "0.9.18" | "0.9.19" | "0.9.20" | "0.9.21" | "0.9.22" | "0.9.23" | "0.9.24" | "0.9.25" | "0.9.26" | "0.9.27" | "0.9.28"; + version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7" | "0.9.8" | "0.9.9" | "0.9.10" | "0.9.11" | "0.9.12" | "0.9.13" | "0.9.14" | "0.9.15" | "0.9.16" | "0.9.17" | "0.9.18" | "0.9.19" | "0.9.20" | "0.9.21" | "0.9.22" | "0.9.23" | "0.9.24" | "0.9.25" | "0.9.26" | "0.9.27" | "0.9.28" | "0.9.29"; exportedAt: string; sessions: Session[]; observations: Record; diff --git a/src/version.ts b/src/version.ts index 6d09f4f4a..84d83bdcb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.9.28"; +export const VERSION = "0.9.29"; diff --git a/test/export-import.test.ts b/test/export-import.test.ts index a345ca3d5..d33aacb1e 100644 --- a/test/export-import.test.ts +++ b/test/export-import.test.ts @@ -5,6 +5,7 @@ vi.mock("../src/logger.js", () => ({ })); import { registerExportImportFunction } from "../src/functions/export-import.js"; +import { VERSION } from "../src/version.js"; import { getSearchIndex } from "../src/functions/search.js"; import type { Session, @@ -124,7 +125,7 @@ describe("Export/Import Functions", () => { it("export produces valid ExportData structure", async () => { const result = (await sdk.trigger("mem::export", {})) as ExportData; - expect(result.version).toBe("0.9.28"); + expect(result.version).toBe(VERSION); expect(result.exportedAt).toBeDefined(); expect(result.sessions.length).toBe(1); expect(result.sessions[0].id).toBe("ses_1"); diff --git a/test/opencode-auto-context.test.ts b/test/opencode-auto-context.test.ts index 2691e0965..dd59b0411 100644 --- a/test/opencode-auto-context.test.ts +++ b/test/opencode-auto-context.test.ts @@ -53,7 +53,9 @@ describe("OpenCode plugin project name resolution", () => { else process.env.AGENTMEMORY_PROJECT_NAME = savedProjectName; }); - async function projectFor(ctx: Record): Promise { + async function startPayloadFor( + ctx: Record, + ): Promise<{ project: unknown; cwd: unknown }> { const { AgentmemoryCapturePlugin } = await import( "../plugin/opencode/agentmemory-capture.ts" ); @@ -67,7 +69,12 @@ describe("OpenCode plugin project name resolution", () => { (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("/session/start"), ); if (!startCall) throw new Error("no /session/start call captured"); - return JSON.parse((startCall[1] as { body: string }).body).project; + const body = JSON.parse((startCall[1] as { body: string }).body); + return { project: body.project, cwd: body.cwd }; + } + + async function projectFor(ctx: Record): Promise { + return (await startPayloadFor(ctx)).project; } it("uses trimmed AGENTMEMORY_PROJECT_NAME when set", async () => { @@ -75,20 +82,50 @@ describe("OpenCode plugin project name resolution", () => { expect(await projectFor({ worktree: "/should/be/ignored" })).toBe("my-proj"); }); - it("treats whitespace-only env value as unset and falls back", async () => { + it("treats whitespace-only env value as unset and falls back to the basename", async () => { process.env.AGENTMEMORY_PROJECT_NAME = " "; - expect(await projectFor({ worktree: "/repo/alpha" })).toBe("/repo/alpha"); + expect(await projectFor({ worktree: "/repo/alpha" })).toBe("alpha"); }); - it("falls back to ctx.worktree when env is unset", async () => { - expect(await projectFor({ worktree: "/repo/alpha" })).toBe("/repo/alpha"); + // Canonicalization: project is the git-toplevel/cwd BASENAME (matching the + // hooks' resolveProject), while cwd keeps the full path. A nonexistent dir + // cannot be a git repo, so these exercise the basename fallback. + it("sends the basename as project and the full path as cwd", async () => { + const payload = await startPayloadFor({ worktree: "/repo/alpha" }); + expect(payload.project).toBe("alpha"); + expect(payload.cwd).toBe("/repo/alpha"); }); it("falls back to ctx.project.id when worktree is absent", async () => { - expect(await projectFor({ project: { id: "/repo/beta" } })).toBe("/repo/beta"); + expect(await projectFor({ project: { id: "/repo/beta" } })).toBe("beta"); }); - it("falls back to process.cwd() when no ctx field is present", async () => { - expect(await projectFor({})).toBe(process.cwd()); + it("resolves the git toplevel basename inside a real repository", async () => { + const { mkdtempSync, mkdirSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { execFileSync } = await import("node:child_process"); + const root = mkdtempSync(join(tmpdir(), "amem-oc-")); + const repo = join(root, "oc-fixture-repo"); + const nested = join(repo, "src", "deep"); + mkdirSync(nested, { recursive: true }); + execFileSync("git", ["init", "--quiet"], { cwd: repo, stdio: "ignore" }); + try { + // Subdirectory of the repo still resolves to the repo basename. + expect(await projectFor({ worktree: nested })).toBe("oc-fixture-repo"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("OpenCode plugin file-tool matching", () => { + const plugin = readFileSync("plugin/opencode/agentmemory-capture.ts", "utf-8"); + + it("matches OpenCode's lowercase tool names case-insensitively", () => { + // OpenCode reports "read"/"edit"/... in lowercase; the old capitalized + // set never matched, silently disabling file enrichment. + expect(plugin).toContain('FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep"])'); + expect(plugin).toContain('FILE_TOOLS.has(String(input.tool ?? "").toLowerCase())'); }); }); diff --git a/test/project-scope-parity.test.ts b/test/project-scope-parity.test.ts new file mode 100644 index 000000000..28ef8b89f --- /dev/null +++ b/test/project-scope-parity.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { parseJsonlText } from "../src/replay/jsonl-parser.js"; +// @ts-expect-error plain .mjs module without type declarations +import { configFromEnv } from "../integrations/filesystem-watcher/watcher.mjs"; + +// Project-scope parity: every capture surface must resolve `project` the same +// way the hooks do (env override, git toplevel basename, cwd basename), or the +// same repo fragments into per-agent memory buckets that never cross-recall. + +function transcriptLine(cwd: string): string { + return JSON.stringify({ + type: "user", + uuid: "u1", + sessionId: "sess-parity", + timestamp: "2026-08-01T10:00:00.000Z", + cwd, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }); +} + +describe("replay deriveProject (via parseJsonlText)", () => { + it("uses the basename of a posix cwd", () => { + const parsed = parseJsonlText(transcriptLine("/home/dev/myrepo")); + expect(parsed.project).toBe("myrepo"); + }); + + it("uses the basename of a Windows cwd instead of the whole raw path", () => { + const parsed = parseJsonlText(transcriptLine("C:\\Users\\dev\\myrepo")); + expect(parsed.project).toBe("myrepo"); + }); + + it("handles mixed separators", () => { + const parsed = parseJsonlText(transcriptLine("C:\\Users\\dev/myrepo")); + expect(parsed.project).toBe("myrepo"); + }); +}); + +describe("fs-watcher configFromEnv project override", () => { + it("prefers the canonical AGENTMEMORY_PROJECT_NAME", () => { + const cfg = configFromEnv({ + AGENTMEMORY_FS_WATCH: "/tmp", + AGENTMEMORY_PROJECT_NAME: "canonical-name", + AGENTMEMORY_PROJECT: "legacy-name", + }); + expect(cfg.project).toBe("canonical-name"); + }); + + it("falls back to the deprecated AGENTMEMORY_PROJECT alias", () => { + const cfg = configFromEnv({ + AGENTMEMORY_FS_WATCH: "/tmp", + AGENTMEMORY_PROJECT: "legacy-name", + }); + expect(cfg.project).toBe("legacy-name"); + }); + + it("is null when neither is set (watcher derives from the root basename)", () => { + const cfg = configFromEnv({ AGENTMEMORY_FS_WATCH: "/tmp" }); + expect(cfg.project).toBeNull(); + }); +}); From a8a6d1a87e8cc2b3975b8cebc2d194d8fc48acc6 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 2 Aug 2026 20:51:25 +0100 Subject: [PATCH 2/8] fix(release): review findings, git-toplevel parity, doc counts - skills generator dedupes routes on method plus path, so the REST reference lists all 130 registered routes instead of hiding the second method on ten dual-method paths (header said 119) - fs-watcher trims AGENTMEMORY_PROJECT_NAME and the deprecated alias, treating whitespace as unset, and derives the git toplevel basename when watching a subdirectory - replay resolves the git toplevel basename when the recorded cwd still exists locally (memoized per cwd), keeping the basename fallback for historical or cross-platform paths; no env override here since a bulk import spans many projects - parity tests for replay git-root resolution, watcher git-root and trim behavior - stat-tests badge updated from 1428+ to 1550+ passing --- assets/tags/light/stat-tests.svg | 4 +- assets/tags/stat-tests.svg | 4 +- integrations/filesystem-watcher/watcher.mjs | 26 +++++- .../skills/agentmemory-rest-api/REFERENCE.md | 13 ++- scripts/skills/generate.ts | 10 ++- src/replay/jsonl-parser.ts | 37 ++++++++- test/project-scope-parity.test.ts | 79 ++++++++++++++++++- 7 files changed, 159 insertions(+), 14 deletions(-) diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg index a309d2c74..f161f25f4 100644 --- a/assets/tags/light/stat-tests.svg +++ b/assets/tags/light/stat-tests.svg @@ -1,5 +1,5 @@ - + - 1428+ + 1550+ TESTS PASSING diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg index 4b2dfe07c..35eb29b73 100644 --- a/assets/tags/stat-tests.svg +++ b/assets/tags/stat-tests.svg @@ -1,5 +1,5 @@ - + - 1428+ + 1550+ TESTS PASSING diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index 5e203305e..3527ceb2c 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -1,6 +1,24 @@ import { watch, promises as fsp, statSync } from "node:fs"; import { resolve, relative, join, extname, sep, basename } from "node:path"; import { randomBytes } from "node:crypto"; +import { execFileSync } from "node:child_process"; + +// Same resolution order as the hooks' resolveProject (git toplevel basename, +// then directory basename) so a watched subdirectory scopes to the repository +// name instead of the subdirectory name. +function deriveProjectName(dir) { + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim(); + if (top) return basename(top); + } catch { + // not a git repo + } + return basename(dir); +} const TEXT_EXTENSIONS = new Set([ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", @@ -123,7 +141,7 @@ export class FilesystemWatcher { this.secret = config.secret; this.project = config.project || - (this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher"); + (this.roots[0] ? deriveProjectName(this.roots[0]) : "filesystem-watcher"); this.sessionId = config.sessionId || `fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; @@ -321,7 +339,11 @@ export function configFromEnv(env = process.env) { secret: env.AGENTMEMORY_SECRET, // AGENTMEMORY_PROJECT_NAME is the canonical override (matches the hooks); // AGENTMEMORY_PROJECT stays as a deprecated alias for existing setups. - project: env.AGENTMEMORY_PROJECT_NAME || env.AGENTMEMORY_PROJECT || null, + // Trimmed, with whitespace-only treated as unset, same as resolveProject. + project: + (env.AGENTMEMORY_PROJECT_NAME || "").trim() || + (env.AGENTMEMORY_PROJECT || "").trim() || + null, sessionId: env.AGENTMEMORY_SESSION_ID || null, ignorePatterns: extraIgnore, allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1", diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md index b92e35a9e..a176863c5 100644 --- a/plugin/skills/agentmemory-rest-api/REFERENCE.md +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -5,10 +5,11 @@ 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. -119 registered endpoints: +130 registered endpoints: | Method | Path | | --- | --- | +| GET | `/agentmemory/actions` | | POST | `/agentmemory/actions` | | POST | `/agentmemory/actions/edges` | | GET | `/agentmemory/actions/get` | @@ -19,6 +20,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | GET | `/agentmemory/branch/sessions` | | GET | `/agentmemory/branch/worktrees` | | POST | `/agentmemory/cascade-update` | +| GET | `/agentmemory/checkpoints` | | POST | `/agentmemory/checkpoints` | | POST | `/agentmemory/checkpoints/resolve` | | GET | `/agentmemory/claude-bridge/read` | @@ -39,6 +41,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | POST | `/agentmemory/evict` | | POST | `/agentmemory/evolve` | | GET | `/agentmemory/export` | +| GET | `/agentmemory/facets` | | POST | `/agentmemory/facets` | | POST | `/agentmemory/facets/query` | | POST | `/agentmemory/facets/remove` | @@ -64,6 +67,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | POST | `/agentmemory/leases/acquire` | | POST | `/agentmemory/leases/release` | | POST | `/agentmemory/leases/renew` | +| GET | `/agentmemory/lessons` | | POST | `/agentmemory/lessons` | | POST | `/agentmemory/lessons/delete` | | POST | `/agentmemory/lessons/search` | @@ -72,6 +76,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | GET | `/agentmemory/memories` | | GET | `/agentmemory/memories/:id` | | GET | `/agentmemory/mesh/export` | +| GET | `/agentmemory/mesh/peers` | | POST | `/agentmemory/mesh/peers` | | POST | `/agentmemory/mesh/receive` | | POST | `/agentmemory/mesh/sync` | @@ -84,16 +89,19 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | GET | `/agentmemory/procedural` | | GET | `/agentmemory/profile` | | POST | `/agentmemory/reflect` | +| GET | `/agentmemory/relations` | | POST | `/agentmemory/relations` | | POST | `/agentmemory/remember` | | POST | `/agentmemory/replay/import-jsonl` | | GET | `/agentmemory/replay/load` | | GET | `/agentmemory/replay/sessions` | +| GET | `/agentmemory/routines` | | POST | `/agentmemory/routines` | | POST | `/agentmemory/routines/run` | | GET | `/agentmemory/routines/status` | | POST | `/agentmemory/search` | | GET | `/agentmemory/semantic` | +| GET | `/agentmemory/sentinels` | | POST | `/agentmemory/sentinels` | | POST | `/agentmemory/sentinels/cancel` | | POST | `/agentmemory/sentinels/check` | @@ -105,12 +113,15 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | GET | `/agentmemory/sessions` | | GET | `/agentmemory/signals` | | POST | `/agentmemory/signals/send` | +| GET | `/agentmemory/sketches` | | POST | `/agentmemory/sketches` | | POST | `/agentmemory/sketches/add` | | POST | `/agentmemory/sketches/discard` | | POST | `/agentmemory/sketches/gc` | | POST | `/agentmemory/sketches/promote` | +| DELETE | `/agentmemory/slot` | | GET | `/agentmemory/slot` | +| POST | `/agentmemory/slot` | | POST | `/agentmemory/slot/append` | | POST | `/agentmemory/slot/reflect` | | POST | `/agentmemory/slot/replace` | diff --git a/scripts/skills/generate.ts b/scripts/skills/generate.ts index 44ccf941a..33e14bf4a 100644 --- a/scripts/skills/generate.ts +++ b/scripts/skills/generate.ts @@ -95,10 +95,16 @@ function rest(): string { const mm = /http_method:\s*"([A-Z]+)"/.exec(win); found.push({ path, method: mm ? mm[1] : "POST" }); } + // Dedupe on method+path, not path alone: ten paths register both GET and + // POST, and a path-only dedupe hid the second method and undercounted the + // surface (119 listed vs 130 registered). const seen = new Set(); const rows = found - .filter((e) => (seen.has(e.path) ? false : (seen.add(e.path), true))) - .sort((a, b) => a.path.localeCompare(b.path)); + .filter((e) => { + const key = `${e.method} ${e.path}`; + return seen.has(key) ? false : (seen.add(key), true); + }) + .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); const lines = [ `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.`, "", diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts index fc4b0811b..ae12b1f10 100644 --- a/src/replay/jsonl-parser.ts +++ b/src/replay/jsonl-parser.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import type { HookType, RawObservation } from "../types.js"; import { generateId } from "../state/schema.js"; @@ -24,12 +26,39 @@ export interface ParsedTranscript { observations: RawObservation[]; } +// Memoized per import run: transcripts repeat the same cwd on every line. +const projectByCwd = new Map(); + function deriveProject(cwd: string): string { if (!cwd) return "unknown"; - // Split on both separators so a Windows-recorded cwd yields its basename - // instead of the whole raw path becoming the project scope. - const parts = cwd.split(/[\\/]+/).filter(Boolean); - return parts[parts.length - 1] || "unknown"; + const cached = projectByCwd.get(cwd); + if (cached) return cached; + let name = ""; + // When the recorded cwd still exists on this machine, resolve the git + // toplevel basename so a subdirectory session scopes to the repository + // name, matching the hooks' resolveProject. Historical or cross-platform + // paths fall back to the basename below. No env override here: a bulk + // import spans many projects, so a global name would mislabel them all. + if (existsSync(cwd)) { + try { + const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim(); + if (top) name = top.split(/[\\/]+/).filter(Boolean).pop() ?? ""; + } catch { + // not a git repo + } + } + if (!name) { + // Split on both separators so a Windows-recorded cwd yields its basename + // instead of the whole raw path becoming the project scope. + const parts = cwd.split(/[\\/]+/).filter(Boolean); + name = parts[parts.length - 1] || "unknown"; + } + projectByCwd.set(cwd, name); + return name; } function toText(content: unknown): string { diff --git a/test/project-scope-parity.test.ts b/test/project-scope-parity.test.ts index 28ef8b89f..4ad3150d2 100644 --- a/test/project-scope-parity.test.ts +++ b/test/project-scope-parity.test.ts @@ -1,4 +1,10 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +// @ts-expect-error plain .mjs module without type declarations +import { FilesystemWatcher } from "../integrations/filesystem-watcher/watcher.mjs"; import { parseJsonlText } from "../src/replay/jsonl-parser.js"; // @ts-expect-error plain .mjs module without type declarations import { configFromEnv } from "../integrations/filesystem-watcher/watcher.mjs"; @@ -35,6 +41,54 @@ describe("replay deriveProject (via parseJsonlText)", () => { }); }); +describe("git-toplevel resolution parity", () => { + let tmpRoot: string; + let repoDir: string; + let nestedDir: string; + + beforeAll(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "amem-parity-")); + repoDir = join(tmpRoot, "parity-fixture-repo"); + nestedDir = join(repoDir, "packages", "core"); + mkdirSync(nestedDir, { recursive: true }); + execFileSync("git", ["init", "--quiet"], { cwd: repoDir, stdio: "ignore" }); + }); + + afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("replay resolves a locally-present subdirectory cwd to the repo basename", () => { + const parsed = parseJsonlText(transcriptLine(nestedDir)); + expect(parsed.project).toBe("parity-fixture-repo"); + }); + + it("replay falls back to the basename for a cwd that no longer exists", () => { + const parsed = parseJsonlText(transcriptLine(join(tmpRoot, "gone", "old-checkout"))); + expect(parsed.project).toBe("old-checkout"); + }); + + it("watcher derives the repo basename when watching a subdirectory", () => { + const w = new FilesystemWatcher({ + roots: [nestedDir], + baseUrl: "http://localhost:3111", + logger: {}, + }); + expect(w.project).toBe("parity-fixture-repo"); + }); + + it("watcher falls back to the root basename outside a repository", () => { + const plain = join(tmpRoot, "plain-dir"); + mkdirSync(plain, { recursive: true }); + const w = new FilesystemWatcher({ + roots: [plain], + baseUrl: "http://localhost:3111", + logger: {}, + }); + expect(w.project).toBe("plain-dir"); + }); +}); + describe("fs-watcher configFromEnv project override", () => { it("prefers the canonical AGENTMEMORY_PROJECT_NAME", () => { const cfg = configFromEnv({ @@ -57,4 +111,27 @@ describe("fs-watcher configFromEnv project override", () => { const cfg = configFromEnv({ AGENTMEMORY_FS_WATCH: "/tmp" }); expect(cfg.project).toBeNull(); }); + + it("trims values and treats whitespace-only as unset, like resolveProject", () => { + expect( + configFromEnv({ + AGENTMEMORY_FS_WATCH: "/tmp", + AGENTMEMORY_PROJECT_NAME: " padded ", + }).project, + ).toBe("padded"); + expect( + configFromEnv({ + AGENTMEMORY_FS_WATCH: "/tmp", + AGENTMEMORY_PROJECT_NAME: " ", + AGENTMEMORY_PROJECT: "legacy-name", + }).project, + ).toBe("legacy-name"); + expect( + configFromEnv({ + AGENTMEMORY_FS_WATCH: "/tmp", + AGENTMEMORY_PROJECT_NAME: " ", + AGENTMEMORY_PROJECT: " ", + }).project, + ).toBeNull(); + }); }); From a99ff2f4542255f500e1eb6541ca15ab2873f9a5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 2 Aug 2026 21:32:06 +0100 Subject: [PATCH 3/8] fix(cli): refuse second-instance boot over a live daemon Closes the class behind issue 1140: agentmemory consolidate (or any unrecognized word) fell through the command table into the full server boot, registering a duplicate worker on the running engine; on iii 0.11.2 the second instance's shutdown tears down the daemon's HTTP trigger routing until a full engine restart. Unknown subcommands now error with the supported list, and main() probes livez on the resolved port and refuses to boot over a live daemon, so multi-instance setups on other ports are unaffected. Verified behaviorally against the built CLI: both paths refuse with exit 1. Also from review: the watcher stamps each event with its own root's project via a per-root map (an explicit config.project still overrides for every root), and replay only accepts a non-empty string cwd from parsed JSONL so malformed entries cannot reach the filesystem probe. --- integrations/filesystem-watcher/watcher.mjs | 8 ++++- src/cli.ts | 33 ++++++++++++++++++++- src/replay/jsonl-parser.ts | 2 +- test/cli-second-instance-guard.test.ts | 29 ++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 test/cli-second-instance-guard.test.ts diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs index 3527ceb2c..27fb4f022 100644 --- a/integrations/filesystem-watcher/watcher.mjs +++ b/integrations/filesystem-watcher/watcher.mjs @@ -142,6 +142,12 @@ export class FilesystemWatcher { this.project = config.project || (this.roots[0] ? deriveProjectName(this.roots[0]) : "filesystem-watcher"); + // Per-root scope: a multi-root watcher must stamp each event with the + // project of the root that produced it, not the first root's project. + // An explicit config.project overrides for every root. + this.projectByRoot = new Map( + this.roots.map((r) => [r, config.project || deriveProjectName(r)]), + ); this.sessionId = config.sessionId || `fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; @@ -232,7 +238,7 @@ export class FilesystemWatcher { const payload = { hookType: "post_tool_use", sessionId: this.sessionId, - project: this.project, + project: this.projectByRoot.get(rootDir) ?? this.project, cwd: rootDir, timestamp: new Date().toISOString(), data: { diff --git a/src/cli.ts b/src/cli.ts index 2ae20f08b..918e011cd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1255,6 +1255,26 @@ function printReadyHint(consoleState: IiiConsoleState): void { } async function main() { + // Booting a second instance next to a live daemon registers a duplicate + // worker on the running engine, and on iii 0.11.2 the second instance's + // shutdown tears down the daemon's HTTP trigger routing (every + // /agentmemory/* route 404s until a full engine restart). Refuse instead. + // A different --instance resolves to a different port, so multi-instance + // setups are unaffected. + try { + const probe = await fetch(`${getBaseUrl()}/agentmemory/livez`, { + signal: AbortSignal.timeout(1500), + }); + if (probe.ok) { + p.log.error( + `agentmemory is already running on port ${getRestPort()}. Starting a second instance here would corrupt the running daemon's REST routing. Use the REST API (or the MCP tools) against the running instance, run a different --instance, or stop it first with \`agentmemory stop\`.`, + ); + process.exit(1); + } + } catch { + // no live daemon on this port; boot normally + } + // `--reset` wipes preferences before anything else so the onboarding // flow below always runs fresh. if (IS_RESET) { @@ -3049,7 +3069,18 @@ const commands: Record Promise> = { "import-jsonl": runImportJsonl, }; -const handler = commands[args[0] ?? ""] ?? main; +const first = args[0] ?? ""; +async function unknownCommand(): Promise { + p.log.error( + `Unknown command: ${first}. Supported: ${Object.keys(commands).join(", ")}. Run \`agentmemory\` with no arguments to start the memory server, or \`agentmemory --help\` for usage.`, + ); + process.exit(1); +} +// Only a bare invocation or flag-style args boot the server; an unrecognized +// word is an error. Previously any typo (or a guessed subcommand like +// `agentmemory consolidate`) fell through to the full server boot and could +// break a running daemon. +const handler = commands[first] ?? (first && !first.startsWith("-") ? unknownCommand : main); handler().catch((err) => { p.log.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts index ae12b1f10..ec2f33d1d 100644 --- a/src/replay/jsonl-parser.ts +++ b/src/replay/jsonl-parser.ts @@ -130,7 +130,7 @@ export function parseJsonlText(text: string, fallbackSessionId?: string): Parsed for (const entry of entries) { if (entry.sessionId && !sessionId) sessionId = entry.sessionId; - if (entry.cwd && !cwd) cwd = entry.cwd; + if (typeof entry.cwd === "string" && entry.cwd.trim() && !cwd) cwd = entry.cwd; const ts = entry.timestamp || new Date().toISOString(); if (!firstTs) firstTs = ts; lastTs = ts; diff --git a/test/cli-second-instance-guard.test.ts b/test/cli-second-instance-guard.test.ts new file mode 100644 index 000000000..176734157 --- /dev/null +++ b/test/cli-second-instance-guard.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// A second full instance next to a live daemon registers a duplicate worker +// on the running engine, and on iii 0.11.2 its shutdown tears down the +// daemon's HTTP trigger routing (every /agentmemory/* route 404s until a full +// engine restart). Two guards prevent that: unknown subcommands error instead +// of falling through to the server boot, and the boot path probes livez and +// refuses when a live daemon already answers on the resolved port. +describe("CLI second-instance guards (#1140)", () => { + const src = readFileSync("src/cli.ts", "utf-8"); + + it("unknown subcommands do not fall through to the server boot", () => { + expect(src).toContain("async function unknownCommand()"); + expect(src).toMatch( + /const handler = commands\[first\] \?\? \(first && !first\.startsWith\("-"\) \? unknownCommand : main\)/, + ); + }); + + it("main() probes livez and refuses to boot over a live daemon", () => { + const mainBody = src.slice(src.indexOf("async function main()")); + const probeIdx = mainBody.indexOf("/agentmemory/livez"); + expect(probeIdx).toBeGreaterThan(-1); + // The probe must run before the engine/worker boot path. + const bootIdx = mainBody.indexOf("startEngine"); + expect(probeIdx).toBeLessThan(bootIdx); + expect(mainBody).toContain("already running on port"); + }); +}); From 98403e164490532713cdba337afd35a027cc0fef Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 2 Aug 2026 21:32:44 +0100 Subject: [PATCH 4/8] test(watcher): two-repository flush events scope to their own project --- test/project-scope-parity.test.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/project-scope-parity.test.ts b/test/project-scope-parity.test.ts index 4ad3150d2..408e4531b 100644 --- a/test/project-scope-parity.test.ts +++ b/test/project-scope-parity.test.ts @@ -77,6 +77,38 @@ describe("git-toplevel resolution parity", () => { expect(w.project).toBe("parity-fixture-repo"); }); + it("multi-root watcher stamps each event with its own root's project", async () => { + const { writeFileSync } = await import("node:fs"); + const repoB = join(tmpRoot, "second-fixture-repo"); + mkdirSync(repoB, { recursive: true }); + execFileSync("git", ["init", "--quiet"], { cwd: repoB, stdio: "ignore" }); + writeFileSync(join(repoDir, "a.txt"), "alpha", "utf8"); + writeFileSync(join(repoB, "b.txt"), "beta", "utf8"); + + const calls: Array<{ project: unknown; cwd: unknown }> = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => { + const body = JSON.parse(init?.body ?? "{}"); + calls.push({ project: body.project, cwd: body.cwd }); + return { ok: true, json: async () => ({}) } as Response; + }) as typeof fetch; + try { + const w = new FilesystemWatcher({ + roots: [repoDir, repoB], + baseUrl: "http://localhost:3111", + logger: {}, + }); + await w.flush(w.roots[0], "a.txt"); + await w.flush(w.roots[1], "b.txt"); + } finally { + globalThis.fetch = realFetch; + } + + expect(calls).toHaveLength(2); + expect(calls[0].project).toBe("parity-fixture-repo"); + expect(calls[1].project).toBe("second-fixture-repo"); + }); + it("watcher falls back to the root basename outside a repository", () => { const plain = join(tmpRoot, "plain-dir"); mkdirSync(plain, { recursive: true }); From e8e81b35bedc50fc51626fb525aacf489618a5b6 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 2 Aug 2026 21:45:53 +0100 Subject: [PATCH 5/8] chore(release): bump packages/mcp, guard it, refresh CONTRIBUTING packages/mcp was still 0.9.28 after the release bump because nothing guarded it; a consistency test now pins it to package.json. CONTRIBUTING release list corrected to the files a bump actually touches (no tracked lockfile, the two extra plugin manifests, the export test derives from VERSION now), and the subsystems table gains src/cli, integrations/pi, and the generated-manifest note. --- CONTRIBUTING.md | 23 +++++++++++++---------- packages/mcp/package.json | 2 +- test/consistency.test.ts | 9 +++++++++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39d38d6fc..d865ecb9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,10 +68,11 @@ PRs with commits lacking sign-off will not merge. | `src/mcp/` | Standalone MCP server (`@agentmemory/mcp`), tools registry, transport, in-memory KV. | | `src/functions/` | Core memory operations — observe, compress, consolidate, retention, forget, graph, smart-search, export-import, governance. | | `src/hooks/` | The 12 auto-hooks that capture sessions in agents. | +| `src/cli/` | The `agentmemory` CLI, including `connect/` adapters for 18 agents and the guideline writer for hook-less agents. | | `src/health/` | Liveness + readiness + alert thresholds. | | `src/state/` | KV schema, keyed mutex, access log. | -| `integrations/` | First-party plugins: `hermes/`, `openclaw/`, `filesystem-watcher/`. | -| `plugin/` | Claude Code plugin (`agentmemory@agentmemory`). | +| `integrations/` | First-party plugins: `hermes/`, `openclaw/`, `pi/`, `filesystem-watcher/`. | +| `plugin/` | Agent plugin bundle: Claude Code plugin, hook manifests for Codex/Copilot/Droid, the OpenCode capture plugin, and the skills. Hook manifests and skill REFERENCE files are partly generated; run `npm run skills:gen` after touching registered endpoints or env vars. | | `website/` | Marketing site (Next.js 16). | | `test/` | Vitest test suite. | @@ -92,18 +93,20 @@ PRs with commits lacking sign-off will not merge. ## Release process -Maintainers cut releases. Every bump touches 8 files in lockstep: +Maintainers cut releases. Every bump touches these files in lockstep (the consistency tests fail if the trio of doc counts or any version drifts): 1. `package.json` -2. `package-lock.json` (top + `packages[""].version`) +2. `src/version.ts` 3. `plugin/.claude-plugin/plugin.json` -4. `packages/mcp/package.json` (self + `~x.y.z` pin on the main package) -5. `src/version.ts` (extend the union, assign) -6. `src/types.ts` (`ExportData.version` union) -7. `src/functions/export-import.ts` (`supportedVersions` Set) -8. `test/export-import.test.ts` (assertion) +4. `plugin/plugin.json` +5. `plugin/.codex-plugin/plugin.json` +6. `packages/mcp/package.json` +7. `src/types.ts` (`ExportData.version` union) +8. `src/functions/export-import.ts` (`supportedVersions` Set) -Then: CHANGELOG section, PR, merge, tag, GitHub release. The `Publish to npm` workflow picks up the release trigger and publishes `@agentmemory/agentmemory`, `@agentmemory/mcp`, and `@agentmemory/fs-watcher` to npm with provenance. +No lockfiles are committed. `test/export-import.test.ts` asserts against the `VERSION` constant, so it needs no per-release edit. Run `npm run skills:gen` if the endpoint or env surface changed. + +Then: CHANGELOG section, PR, merge, tag, GitHub release. The `Publish to npm` workflow picks up the release trigger and publishes `@agentmemory/agentmemory`, `@agentmemory/mcp`, and `@agentmemory/fs-watcher` to npm with provenance (`@agentmemory/fs-watcher` versions independently from `integrations/filesystem-watcher/package.json`). ## Security issues diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bdc312034..c88f9c89e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@agentmemory/mcp", - "version": "0.9.28", + "version": "0.9.29", "description": "Standalone MCP server for agentmemory — thin shim that re-exposes @agentmemory/agentmemory's MCP entrypoint", "type": "module", "bin": { diff --git a/test/consistency.test.ts b/test/consistency.test.ts index e0871cbf8..9e2cc53fa 100644 --- a/test/consistency.test.ts +++ b/test/consistency.test.ts @@ -35,6 +35,15 @@ describe("Consistency checks", () => { expect(plugin.version).toBe(pkg.version); }); + it("packages/mcp version matches package.json", () => { + // The mcp package publishes in lockstep with the main package but its + // version lives in its own manifest; without this guard a release bump + // can silently ship a stale @agentmemory/mcp (it slipped in 0.9.29). + const pkg = JSON.parse(readText("package.json")); + const mcp = JSON.parse(readText("packages/mcp/package.json")); + expect(mcp.version).toBe(pkg.version); + }); + it("export-import.ts supports current version", () => { const src = readText("src/functions/export-import.ts"); expect(src).toContain(`"${VERSION}"`); From 22adc0dcd34b2e25b26ddf4178cab043f3370dfe Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 3 Aug 2026 11:27:52 +0100 Subject: [PATCH 6/8] fix(export): refuse over-frame export instead of dropping the worker Closes the availability bug in issue 1142: GET /agentmemory/export assembles the full store and returns it through sdk.trigger, so a store whose serialized export passes the engine's 16 MiB WebSocket frame (tungstenite max_frame_size, not raisable under the 0.11.2 pin) dies on the worker->engine hop, drops the worker, and 404s every endpoint for ~1s. The session collections page on maxSessions/offset but ~18 others do not, so a large store hits this at any parameter combination. A shared frame-guard measures the serialized size before returning: mem::export returns a small oversized error instead of the giant object, and api::mesh-export returns 413 (same dead-end as #890). Either way the over-frame payload never crosses the boundary, so the daemon stays up and the failure is one clean request with a hint to narrow the range. Full pagination of the non-session collections is a follow-up. Layer 1 of the fix; verified with a synthetic oversized export returning the error object (tiny) rather than the payload. --- CHANGELOG.md | 1 + src/functions/export-import.ts | 16 ++++ src/state/frame-guard.ts | 52 ++++++++++++ src/triggers/api.ts | 11 +++ test/frame-guard.test.ts | 139 +++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/state/frame-guard.ts create mode 100644 test/frame-guard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8914491dd..14d01e56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Patch release: the `.env` file now actually applies everywhere, imports become s - Session-stop consolidation is debounced and no longer double-fires from the client hook; eviction recovery is bounded to one consolidation pass (#1087, #1131 class, via #1136) - `/agentmemory/sessions` no longer deadlocks on large session counts (#1100, via #1136) - Filesystem watcher validates roots before `fs.watch`, fixing Node 24/26 on Linux (#1136) +- `GET /agentmemory/export` and `/agentmemory/mesh/export` refuse an over-frame response instead of shipping it: a payload past the engine 16 MiB transport frame used to drop the worker and 404 every endpoint for ~1s. They now fail that one request (413 for mesh, an `oversized` error for export) with a hint to narrow the range, keeping the daemon up (#1142, #890). Full pagination of the non-session collections is a follow-up. - Claude bridge writes `MEMORY.md` under the `memory/` subdirectory Claude Code actually reads (#1134) - Hook project-resolution tests no longer depend on the checkout directory name (#1137, #1138) - Project-scope parity: the OpenCode plugin, Hermes plugin, Pi extension, and JSONL replay now resolve `project` the same way the hooks do (env override, git toplevel basename, cwd basename) instead of sending raw filesystem paths, so the same repository shares one memory bucket across agents (#903, #1135); the filesystem watcher accepts `AGENTMEMORY_PROJECT_NAME` with the old `AGENTMEMORY_PROJECT` kept as a deprecated alias; replay handles Windows-recorded paths diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index 740037aab..0385064c5 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -26,6 +26,7 @@ import type { } from "../types.js"; import { normalizeAccessLog } from "./access-tracker.js"; import { KV } from "../state/schema.js"; +import { checkPayloadFrameSize } from "../state/frame-guard.js"; import { StateKV } from "../state/kv.js"; import { VERSION } from "../version.js"; import { recordAudit } from "./audit.js"; @@ -181,6 +182,21 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { summaries: summaries.length, }); + // Refuse an over-frame payload here rather than let it die on the + // worker->engine hop and drop the whole daemon (#1142). The session + // collections page on ?maxSessions/?offset, but the other collections + // do not, so a large store can exceed the cap even at ?maxSessions=1. + const oversized = checkPayloadFrameSize( + exportData, + "narrow the range with ?maxSessions / ?offset, or export fewer collections; the non-session collections (memories, graph, semantic, actions, lessons, ...) are not yet paginated", + ); + if (oversized) { + logger.warn("Export exceeds transport frame limit", { + bytes: oversized.bytes, + }); + return oversized; + } + return exportData; }, ); diff --git a/src/state/frame-guard.ts b/src/state/frame-guard.ts new file mode 100644 index 000000000..e9a1f76c6 --- /dev/null +++ b/src/state/frame-guard.ts @@ -0,0 +1,52 @@ +// The pinned iii engine (0.11.2) refuses any WebSocket frame larger than +// tungstenite's 16 MiB default (max_frame_size). A function result or HTTP +// response body that serializes past this dies on the worker->engine hop, +// which drops and re-registers the worker and 404s every endpoint for ~1s +// (issue #1142, and the mesh/export twin #890). We cannot raise the engine +// limit without moving off the pin, so oversized payloads must be refused as +// one clean request instead of being shipped and taking the daemon down. +// +// The cap sits below the frame limit to leave headroom for the SDK's own +// framing/serialization overhead on top of the raw JSON we measure here. +const FRAME_LIMIT_BYTES = 16 * 1024 * 1024; +export const SAFE_PAYLOAD_BYTES = 15 * 1024 * 1024; + +export type OversizedPayload = { + success: false; + error: string; + oversized: true; + bytes: number; + limitBytes: number; +}; + +export function payloadByteLength(payload: unknown): number { + return Buffer.byteLength(JSON.stringify(payload) ?? "", "utf8"); +} + +export function oversizedPayloadError( + bytes: number, + hint: string, +): OversizedPayload { + const mib = (bytes / (1024 * 1024)).toFixed(1); + return { + success: false, + error: `Response is ${mib} MiB, over the ~${SAFE_PAYLOAD_BYTES / (1024 * 1024)} MiB engine transport frame limit; ${hint}`, + oversized: true, + bytes, + limitBytes: SAFE_PAYLOAD_BYTES, + }; +} + +// Returns the oversized error when the payload would exceed the safe cap, +// otherwise null. Serializes once; callers that also return the payload pay a +// second serialization, which is acceptable on these cold export paths. +export function checkPayloadFrameSize( + payload: unknown, + hint: string, +): OversizedPayload | null { + const bytes = payloadByteLength(payload); + if (bytes <= SAFE_PAYLOAD_BYTES) return null; + return oversizedPayloadError(bytes, hint); +} + +export const FRAME_LIMIT_BYTES_FOR_TEST = FRAME_LIMIT_BYTES; diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 701e87374..5a3e001b0 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -2,6 +2,7 @@ import { TriggerAction, type ISdk, type ApiRequest } from "iii-sdk"; import type { Session, CompressedObservation, HookPayload, CommitLink, SessionSummary } from "../types.js"; import { withKeyedLock } from "../state/keyed-mutex.js"; import { KV } from "../state/schema.js"; +import { checkPayloadFrameSize } from "../state/frame-guard.js"; import { StateKV } from "../state/kv.js"; import { getLatestHealth } from "../health/monitor.js"; import type { MetricsStore } from "../eval/metrics-store.js"; @@ -2789,6 +2790,16 @@ export function registerApiTriggers( ); body.graphEdges = df(graphEdges, "createdAt"); } + // Same 16 MiB transport-frame ceiling as #1142: a full mesh export past + // the frame limit would drop the worker (#890). Fail this one request + // with 413 instead of taking the daemon down. + const oversized = checkPayloadFrameSize( + body, + "use ?since to fetch only changes after a timestamp, or ?project to scope the export", + ); + if (oversized) { + return { status_code: 413, body: oversized }; + } return { status_code: 200, body }; }, ); diff --git a/test/frame-guard.test.ts b/test/frame-guard.test.ts new file mode 100644 index 000000000..5072817e6 --- /dev/null +++ b/test/frame-guard.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + checkPayloadFrameSize, + oversizedPayloadError, + payloadByteLength, + SAFE_PAYLOAD_BYTES, + FRAME_LIMIT_BYTES_FOR_TEST, +} from "../src/state/frame-guard.js"; +import { registerExportImportFunction } from "../src/functions/export-import.js"; +import { KV } from "../src/state/schema.js"; +import type { Session } from "../src/types.js"; + +// The pinned iii engine refuses a WebSocket frame over 16 MiB; a function +// result that serializes larger dies on the worker->engine hop and drops the +// worker, 404ing every endpoint (#1142). The guard must catch it BEFORE the +// return so the oversized frame is never shipped. + +describe("frame-guard", () => { + it("keeps the safe cap under the 16 MiB frame limit with headroom", () => { + expect(SAFE_PAYLOAD_BYTES).toBeLessThan(FRAME_LIMIT_BYTES_FOR_TEST); + expect(FRAME_LIMIT_BYTES_FOR_TEST - SAFE_PAYLOAD_BYTES).toBeGreaterThanOrEqual( + 1024 * 1024, + ); + }); + + it("passes payloads at or under the cap", () => { + expect(checkPayloadFrameSize({ ok: true }, "hint")).toBeNull(); + // A string just under the cap (account for JSON quotes). + const almost = "x".repeat(SAFE_PAYLOAD_BYTES - 2); + expect(payloadByteLength(almost)).toBeLessThanOrEqual(SAFE_PAYLOAD_BYTES); + expect(checkPayloadFrameSize(almost, "hint")).toBeNull(); + }); + + it("flags payloads over the cap with byte count and hint", () => { + const big = "x".repeat(SAFE_PAYLOAD_BYTES + 1024); + const res = checkPayloadFrameSize(big, "narrow the range"); + expect(res).not.toBeNull(); + expect(res!.oversized).toBe(true); + expect(res!.success).toBe(false); + expect(res!.bytes).toBeGreaterThan(SAFE_PAYLOAD_BYTES); + expect(res!.limitBytes).toBe(SAFE_PAYLOAD_BYTES); + expect(res!.error).toContain("narrow the range"); + expect(res!.error).toMatch(/MiB/); + }); + + it("reports the size in MiB", () => { + const err = oversizedPayloadError(20 * 1024 * 1024, "do X"); + expect(err.error).toContain("20.0 MiB"); + }); +}); + +function mockKV(store = new Map>()) { + return { + get: async () => null, + set: async (s: string, k: string, d: T) => { + if (!store.has(s)) store.set(s, new Map()); + store.get(s)!.set(k, d); + return d; + }, + delete: async () => {}, + update: async () => {}, + list: async (scope: string): Promise => + Array.from(store.get(scope)?.values() ?? []) as T[], + _store: store, + }; +} + +function mockSdk(kv: ReturnType) { + const fns = new Map(); + return { + registerFunction: (id: string, h: Function) => fns.set(id, h), + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload?: unknown }) => + fns.get(input.function_id)?.(input.payload), + _fns: fns, + _kv: kv, + } as never; +} + +describe("mem::export frame guard (#1142)", () => { + it("returns the export object when it fits under the frame limit", async () => { + const kv = mockKV(); + await kv.set(KV.sessions, "s1", { + id: "s1", + project: "p", + cwd: "/p", + startedAt: "2026-08-01T00:00:00Z", + status: "completed", + observationCount: 0, + } as Session); + const sdk = mockSdk(kv); + registerExportImportFunction(sdk, kv as never); + const result = (await (sdk as any).trigger({ + function_id: "mem::export", + payload: {}, + })) as { version?: string; oversized?: boolean }; + expect(result.oversized).toBeUndefined(); + expect(result.version).toBeDefined(); + }); + + it("returns a clean oversized error (not the object) when the export exceeds the cap", async () => { + const kv = mockKV(); + // One memory whose content alone pushes the serialized export past the cap. + const huge = "z".repeat(SAFE_PAYLOAD_BYTES + 4096); + await kv.set(KV.memories, "m1", { + id: "m1", + type: "pattern", + title: "big", + content: huge, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }); + const sdk = mockSdk(kv); + registerExportImportFunction(sdk, kv as never); + const result = (await (sdk as any).trigger({ + function_id: "mem::export", + payload: {}, + })) as { oversized?: boolean; success?: boolean; bytes?: number; version?: string }; + + // The giant object is never returned; a small error object is. + expect(result.oversized).toBe(true); + expect(result.success).toBe(false); + expect(result.bytes).toBeGreaterThan(SAFE_PAYLOAD_BYTES); + expect(result.version).toBeUndefined(); + // The error object itself is tiny (would never blow the frame). + expect(payloadByteLength(result)).toBeLessThan(2048); + }); +}); From 3ae4df9bbe7c7b27ca49036e3144d956cfd1d271 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 3 Aug 2026 11:47:58 +0100 Subject: [PATCH 7/8] ci: collapse to a single npm install to fix Node 24/26 CI The two-step install (npm install --package-lock-only then npm ci) failed only on the Node 24/26 matrix rows: their stricter npm rejects rolldown's optional platform bindings (@rolldown/binding-android-arm64) that a --package-lock-only pass does not fully enumerate. Lockfiles are gitignored, so npm ci re-validation buys no reproducibility here. A single lenient npm install resolves and installs in one pass. --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b60a7874..cbedfaa6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,11 +60,13 @@ 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. - - run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund - - run: npm ci --legacy-peer-deps --no-audit --no-fund + # Lockfiles are gitignored, so `npm ci` (which strictly re-validates a + # committed lockfile) buys no reproducibility here — and Node 24+'s + # stricter npm rejects rolldown's optional platform bindings that a + # `--package-lock-only` pass doesn't fully enumerate, failing the matrix + # on 24/26 only. A single lenient `npm install` resolves and installs + # in one pass. + - run: npm install --legacy-peer-deps --no-audit --no-fund - run: npm run build - run: npm run skills:check - run: npm test From 811da6fe668ef35cbbb7e6e3af851c91246d0210 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 3 Aug 2026 11:48:06 +0100 Subject: [PATCH 8/8] fix(mesh): scope exported memories by project like actions api::mesh-export filtered actions by ?project but returned every project's memories. On a mesh instance federating one project to a peer, the peer pulled other projects' memories (cross-project leak), and those extras could push the payload past the 16 MiB transport frame into a 413 even when the requested project's own slice fit. Memories carry the same optional project field as actions, so filter both before the frame-size guard runs. Adds a regression test asserting a project-scoped export excludes other projects' memories and that an oversized memory in another project no longer 413s the scoped request. --- src/triggers/api.ts | 3 +- test/mesh-export-project-scope.test.ts | 133 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/mesh-export-project-scope.test.ts diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 5a3e001b0..943910b29 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -2767,9 +2767,10 @@ export function registerApiTriggers( const sinceTime = since ? new Date(since).getTime() : 0; const df = (items: T[], field: "updatedAt" | "createdAt") => items.filter((i) => new Date((i as Record)[field] as string).getTime() > sinceTime); - const memories = await kv.list(KV.memories); + let memories = await kv.list(KV.memories); let actions = await kv.list(KV.actions); if (project) { + memories = memories.filter((m) => m.project === project); actions = actions.filter((a) => a.project === project); } const body: Record = { diff --git a/test/mesh-export-project-scope.test.ts b/test/mesh-export-project-scope.test.ts new file mode 100644 index 000000000..b64e06bcc --- /dev/null +++ b/test/mesh-export-project-scope.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerApiTriggers } from "../src/triggers/api.js"; +import { KV } from "../src/state/schema.js"; +import { SAFE_PAYLOAD_BYTES } from "../src/state/frame-guard.js"; +import type { Memory } from "../src/types.js"; + +// api::mesh-export filters `actions` by ?project but historically returned +// ALL memories regardless of project. On a mesh instance federating one +// project to a peer that only shares that project, the peer pulled every +// other project's memories too (cross-project leak), and those extra memories +// could push the payload past the 16 MiB transport frame into a 413 (#1142/#890) +// even when the requested project's own slice fit fine. Memories carry the same +// optional `project` field as actions, so the scoped export must filter both. + +const SECRET = "mesh-test-secret"; + +function mockKV(store = new Map>()) { + return { + get: async () => null, + set: async (s: string, k: string, d: T) => { + if (!store.has(s)) store.set(s, new Map()); + store.get(s)!.set(k, d); + return d; + }, + delete: async () => {}, + update: async () => {}, + list: async (scope: string): Promise => + Array.from(store.get(scope)?.values() ?? []) as T[], + _store: store, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + registerFunction: (id: string, h: Function) => fns.set(id, h), + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload?: unknown }) => + fns.get(input.function_id)?.(input.payload), + _fns: fns, + }; +} + +function memory(id: string, project: string, content = "x"): Memory { + return { + id, + type: "pattern", + title: id, + content, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + project, + }; +} + +async function meshExport( + sdk: ReturnType, + project?: string, +): Promise<{ status_code: number; body: Record }> { + const handler = sdk._fns.get("api::mesh-export")!; + return handler({ + headers: { authorization: `Bearer ${SECRET}` }, + query_params: project ? { project } : {}, + }); +} + +describe("api::mesh-export project scoping (#1142/#890)", () => { + it("excludes other projects' memories from a project-scoped export", async () => { + const kv = mockKV(); + await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha")); + await kv.set(KV.memories, "m-beta", memory("m-beta", "beta")); + const sdk = mockSdk(); + registerApiTriggers(sdk as never, kv as never, SECRET); + + const res = await meshExport(sdk, "alpha"); + + expect(res.status_code).toBe(200); + const memories = res.body.memories as Memory[]; + expect(memories.map((m) => m.id)).toEqual(["m-alpha"]); + expect(memories.some((m) => m.project === "beta")).toBe(false); + }); + + it("returns all memories when no project is provided", async () => { + const kv = mockKV(); + await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha")); + await kv.set(KV.memories, "m-beta", memory("m-beta", "beta")); + const sdk = mockSdk(); + registerApiTriggers(sdk as never, kv as never, SECRET); + + const res = await meshExport(sdk); + + expect(res.status_code).toBe(200); + const memories = res.body.memories as Memory[]; + expect(memories.map((m) => m.id).sort()).toEqual(["m-alpha", "m-beta"]); + }); + + it("avoids the 413 when only another project's memory is oversized", async () => { + const kv = mockKV(); + // A single beta memory alone blows the frame; alpha's slice is tiny. + await kv.set( + KV.memories, + "m-beta-huge", + memory("m-beta-huge", "beta", "z".repeat(SAFE_PAYLOAD_BYTES + 4096)), + ); + await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha")); + const sdk = mockSdk(); + registerApiTriggers(sdk as never, kv as never, SECRET); + + // Scoped to alpha: the huge beta memory is filtered out before the frame + // guard runs, so the request succeeds instead of 413-ing. + const scoped = await meshExport(sdk, "alpha"); + expect(scoped.status_code).toBe(200); + expect((scoped.body.memories as Memory[]).map((m) => m.id)).toEqual([ + "m-alpha", + ]); + + // Unscoped: the oversized memory is included, so the guard fires (413). + const unscoped = await meshExport(sdk); + expect(unscoped.status_code).toBe(413); + expect((unscoped.body as { oversized?: boolean }).oversized).toBe(true); + }); +});