diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b60a78..cbedfaa 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
diff --git a/AGENTS.md b/AGENTS.md
index 6f64946..46dc678 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -109,16 +109,16 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import).
## Testing
-- All tests must pass before PR: `npm test` (1,428+ tests)
+- All tests must pass before PR: `npm test` (1,596+ tests)
- Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list`
- Test files go in `test/` with `.test.ts` extension
- Follow existing patterns in `test/crystallize.test.ts` for function tests
-## Current Stats (v0.9.28)
+## Current Stats (v0.9.29)
- 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
-- 1,428+ tests
+- 1,596+ tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24a1722..e877b7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,51 @@ 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)
+- Native hooks adapter for Antigravity CLI (agy) via a stdin bridge that normalizes agy's hook payloads onto the bundled hook scripts, with an explicit PreToolUse allow decision (#1146, thanks @berthojoris)
+- `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)
+- `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
+- 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.
@@ -66,6 +111,7 @@ Wave release closing several breaking regressions reported against v0.9.26, plus
- `/agentmemory:forget` skill still calls `memory_governance_delete` which only touches `KV.memories` and never observations ([#833](https://github.com/rohitg00/agentmemory/issues/833)). Skill rewrite + new `memory_forget` MCP tool tracked separately.
- `crypto.randomUUID()` global-only on Node <19 ([#715](https://github.com/rohitg00/agentmemory/issues/715)). Drop-in import fix tracked.
+[0.9.29]: https://github.com/rohitg00/agentmemory/compare/v0.9.28...v0.9.29
[0.9.28]: https://github.com/rohitg00/agentmemory/compare/v0.9.27...v0.9.28
[0.9.27]: https://github.com/rohitg00/agentmemory/compare/v0.9.26...v0.9.27
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 39d38d6..d865ecb 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/README.md b/README.md
index eb6bd1f..a716d8f 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@
-
+
@@ -1209,7 +1209,7 @@ Full registry: [workers.iii.dev](https://workers.iii.dev). Every worker there co
| Prometheus / Grafana | iii OTEL + health monitor |
| Custom plugin systems | `iii worker add ` |
-**175 source files · ~39,200 LOC · 1,428+ tests · 261 functions · 52 KV scopes** — all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself.
+**175 source files · ~39,200 LOC · 1,596+ tests · 261 functions · 52 KV scopes** — all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself.
---
@@ -1499,7 +1499,7 @@ Create `~/.agentmemory/.env`:
-129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
+130 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints
@@ -1533,7 +1533,7 @@ Full endpoint list: [`src/triggers/api.ts`](src/triggers/api.ts)
```bash
npm run dev # Hot reload
npm run build # Production build
-npm test # 1,428+ tests
+npm test # 1,596+ tests
npm run test:integration # API tests (requires running services)
```
diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg
index a309d2c..b8f386d 100644
--- a/assets/tags/light/stat-tests.svg
+++ b/assets/tags/light/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1428+
+ 1596+
TESTS PASSING
diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg
index 4b2dfe0..8a4637d 100644
--- a/assets/tags/stat-tests.svg
+++ b/assets/tags/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1428+
+ 1596+
TESTS PASSING
diff --git a/deploy/coolify/Dockerfile b/deploy/coolify/Dockerfile
index e95bd70..c0a6bb6 100644
--- a/deploy/coolify/Dockerfile
+++ b/deploy/coolify/Dockerfile
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
FROM node:22-slim
-ARG AGENTMEMORY_VERSION=0.9.28
+ARG AGENTMEMORY_VERSION=0.9.29
ARG III_VERSION=0.11.2
ARG III_SDK_VERSION=0.11.2
diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml
index c2f93ab..b34823d 100644
--- a/deploy/coolify/docker-compose.yml
+++ b/deploy/coolify/docker-compose.yml
@@ -4,7 +4,7 @@ services:
context: .
dockerfile: Dockerfile
args:
- AGENTMEMORY_VERSION: "0.9.28"
+ AGENTMEMORY_VERSION: "0.9.29"
III_VERSION: "0.11.2"
III_SDK_VERSION: "0.11.2"
restart: unless-stopped
diff --git a/deploy/fly/Dockerfile b/deploy/fly/Dockerfile
index 51da03a..e094699 100644
--- a/deploy/fly/Dockerfile
+++ b/deploy/fly/Dockerfile
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
FROM node:22-slim
-ARG AGENTMEMORY_VERSION=0.9.28
+ARG AGENTMEMORY_VERSION=0.9.29
ARG III_VERSION=0.11.2
ARG III_SDK_VERSION=0.11.2
diff --git a/deploy/railway/Dockerfile b/deploy/railway/Dockerfile
index 51da03a..e094699 100644
--- a/deploy/railway/Dockerfile
+++ b/deploy/railway/Dockerfile
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
FROM node:22-slim
-ARG AGENTMEMORY_VERSION=0.9.28
+ARG AGENTMEMORY_VERSION=0.9.29
ARG III_VERSION=0.11.2
ARG III_SDK_VERSION=0.11.2
diff --git a/deploy/render/Dockerfile b/deploy/render/Dockerfile
index 51da03a..e094699 100644
--- a/deploy/render/Dockerfile
+++ b/deploy/render/Dockerfile
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
FROM node:22-slim
-ARG AGENTMEMORY_VERSION=0.9.28
+ARG AGENTMEMORY_VERSION=0.9.29
ARG III_VERSION=0.11.2
ARG III_SDK_VERSION=0.11.2
diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml
index b233380..d1871d5 100644
--- a/deploy/render/render.yaml
+++ b/deploy/render/render.yaml
@@ -15,7 +15,7 @@ services:
- key: PORT
value: "3111"
- key: AGENTMEMORY_VERSION
- value: "0.9.28"
+ value: "0.9.29"
- key: III_VERSION
value: "0.11.2"
- key: III_SDK_VERSION
diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs
index a73d178..27fb4f0 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,13 @@ 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");
+ // 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")}`;
@@ -214,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: {
@@ -319,7 +343,13 @@ 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.
+ // 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/integrations/hermes/__init__.py b/integrations/hermes/__init__.py
index 2933632..79ab218 100644
--- a/integrations/hermes/__init__.py
+++ b/integrations/hermes/__init__.py
@@ -13,6 +13,30 @@ import json
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 @@ class AgentMemoryProvider(MemoryProvider):
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 @@ class AgentMemoryProvider(MemoryProvider):
"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 9c6cfc7..e6ad648 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 77185ad..79b716c 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/packages/mcp/package.json b/packages/mcp/package.json
index bdc3120..c88f9c8 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/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index 27bdc81..52fc792 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 ad26262..cbcd573 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 46419ef..1a1d042 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/opencode/plugin.json b/plugin/opencode/plugin.json
index 1472752..cf06cee 100644
--- a/plugin/opencode/plugin.json
+++ b/plugin/opencode/plugin.json
@@ -1,6 +1,6 @@
{
"name": "agentmemory-capture",
- "version": "0.9.20",
+ "version": "0.9.29",
"description": "OpenCode plugin for agentmemory — full Claude Code hook parity: session lifecycle (create/idle/status/compacted/update/diff/delete/error), messages & prompts (chat.message, message.updated user+assistant, message.removed), tool lifecycle (ToolPart states with timing), part tracking (subtask, step-finish, reasoning, file, patch, compaction, agent, retry), file enrichment pipeline, permissions, task tracking (w/ priority), commands, config & model tracking. 22 hooks, 2 slash commands.",
"author": {
"name": "Rohit Ghumare",
diff --git a/plugin/plugin.json b/plugin/plugin.json
index 90d248c..ad8025a 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/scripts/antigravity-bridge.mjs b/plugin/scripts/antigravity-bridge.mjs
old mode 100644
new mode 100755
diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md
index b92e35a..a176863 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 44ccf94..33e14bf 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/cli.ts b/src/cli.ts
index 2ae20f0..918e011 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/functions/export-import.ts b/src/functions/export-import.ts
index 2e30e07..23854a9 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,19 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
summaries: summaries.length,
});
+ // Only session collections page on ?maxSessions/?offset, so a large
+ // store can exceed the transport 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;
},
);
@@ -200,7 +214,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 5f66d76..198a6dc 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 5060c34..ec2f33d 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,10 +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";
- 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 {
@@ -99,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/src/state/frame-guard.ts b/src/state/frame-guard.ts
new file mode 100644
index 0000000..8651b38
--- /dev/null
+++ b/src/state/frame-guard.ts
@@ -0,0 +1,45 @@
+// The pinned engine rejects WebSocket frames over 16 MiB; an oversized
+// function result drops the worker and 404s every endpoint. Refuse the
+// payload as one clean error instead. The cap sits under the frame limit
+// to leave headroom for the SDK's framing overhead.
+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,
+ };
+}
+
+// Serializes once; callers that also return the payload pay a second
+// serialization, 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 701e873..7560e87 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";
@@ -2766,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 = {
@@ -2789,6 +2791,14 @@ export function registerApiTriggers(
);
body.graphEdges = df(graphEdges, "createdAt");
}
+ // Fail an over-frame export with 413 instead of dropping the worker.
+ 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/src/types.ts b/src/types.ts
index 7cda80f..2f3f028 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 6d09f4f..84d83bd 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/cli-second-instance-guard.test.ts b/test/cli-second-instance-guard.test.ts
new file mode 100644
index 0000000..1767341
--- /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");
+ });
+});
diff --git a/test/consistency.test.ts b/test/consistency.test.ts
index e0871cb..9e2cc53 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}"`);
diff --git a/test/export-import.test.ts b/test/export-import.test.ts
index a345ca3..d33aacb 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/frame-guard.test.ts b/test/frame-guard.test.ts
new file mode 100644
index 0000000..fbb7346
--- /dev/null
+++ b/test/frame-guard.test.ts
@@ -0,0 +1,137 @@
+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 guard must catch an oversized payload before the return so the frame
+// that would drop the worker 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", () => {
+ 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);
+ });
+});
diff --git a/test/mesh-export-project-scope.test.ts b/test/mesh-export-project-scope.test.ts
new file mode 100644
index 0000000..7ff72c7
--- /dev/null
+++ b/test/mesh-export-project-scope.test.ts
@@ -0,0 +1,129 @@
+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";
+
+// A project-scoped mesh export must filter memories like actions: unscoped
+// memories leak across projects and can push the payload past the transport
+// frame limit even when the requested project's own slice fits.
+
+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", () => {
+ 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);
+ });
+});
diff --git a/test/opencode-auto-context.test.ts b/test/opencode-auto-context.test.ts
index 2691e09..dd59b04 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 0000000..408e453
--- /dev/null
+++ b/test/project-scope-parity.test.ts
@@ -0,0 +1,169 @@
+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";
+
+// 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("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("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 });
+ 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({
+ 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();
+ });
+
+ 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();
+ });
+});
diff --git a/website/lib/generated-meta.json b/website/lib/generated-meta.json
index 06792f8..8ebf1fd 100644
--- a/website/lib/generated-meta.json
+++ b/website/lib/generated-meta.json
@@ -1,8 +1,8 @@
{
- "version": "0.9.28",
- "mcpTools": 53,
+ "version": "0.9.29",
+ "mcpTools": 54,
"hooks": 12,
- "restEndpoints": 128,
- "testsPassing": 1428,
- "generatedAt": "2026-07-19T10:24:26.108Z"
+ "restEndpoints": 130,
+ "testsPassing": 1610,
+ "generatedAt": "2026-08-09T09:15:51.690Z"
}