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 @@ 54 MCP tools 12 auto hooks 0 external DBs - 1,428+ tests passing + 1,596+ tests passing

@@ -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`:

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 @@ -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" }