Skip to main content
Glama
README.md
# agent-memory

[![Version](https://img.shields.io/badge/version-v0.5.0-blue.svg)](CHANGELOG.md)

Persistent memory for AI coding agents — a v1 replica of
[rohitg00/agentmemory](https://github.com/rohitg00/agentmemory) rebuilt on
[HelixDB](https://docs.helix-db.com) instead of iii-engine + SQLite. One engine
provides **graph + vector + full-text (BM25) + temporal** storage with
traversal-scoped prefiltering, so hybrid retrieval (vector, keyword, concept
graph) lives in a single query layer — no external search service, no API keys,
no model downloads. It ships a dependency-light REST server, a stdio MCP server
with 11 tools, and a zero-dependency capture hook, all backed by the same store.

The frozen spec this repo implements is [`docs/CONTRACT.md`](docs/CONTRACT.md).

## How it works

### Data model

Three node labels, two edge types (CONTRACT §1):

```
Session  sessionId (unique), project, startedAt, updatedAt
Memory   memoryId (unique), content, project, sessionId, origin,
         importance (0..1), createdAt, embedding (f32[384]),
         dedupKey (unique, sha256 of project + normalized content)
Concept  name (unique), project

BELONGS_TO   Memory ──▶ Session
HAS_CONCEPT  Memory ──▶ Concept
```

- **`Memory.embedding`** — 384-dim vector, cosine distance, indexed with
  `project` as the tenant key.
- **`Memory.content`** — BM25 full-text index, also scoped by `project`.
- **`Memory.dedupKey`** — write-time dedup key (v1.1): saving the same fact
  twice returns the existing id (`deduped: true`) instead of a second row;
  the hash never leaves the store as raw content. A dedup hit creates no
  `Session` node — sessions materialize only on novel writes (contract §3).
- **`project`** is the tenant/scope for every vector and text index; search
  routes always pass it.

`bootstrapIndexes()` ensures all 8 indexes: 4 unique (`Memory.memoryId`,
`Session.sessionId`, `Concept.name`, `Memory.dedupKey`), 2 equality
(`Memory.sessionId`, `Memory.project`), 1 vector (`Memory.embedding`, 384-dim
cosine, tenant `project`), 1 text (`Memory.content`, tenant `project`).

Writes anchor narrow: `saveMemory()` is a single `writeBatch` that upserts the
Session (create or bump `updatedAt`), creates the Memory, links `BELONGS_TO`,
then upserts each Concept and links `HAS_CONCEPT` — an empty `concepts` array is
safe.

### Retrieval

`POST /memory/smart-search` runs up to three independent sources:

1. **vector** — query embedded to 384 dims, `vectorSearchWith` prefiltered by
   `project`,
2. **text** — BM25 `textSearchWith` on `content`, scoped by `project`,
3. **graph** — only when `concepts` are provided: `Concept` where `name` in
   `concepts` → `.in("HAS_CONCEPT")` → `Memory`, scoped by `project`.

They are fused in the app layer (`src/search.ts`) with **Reciprocal Rank
Fusion**:

```
score(doc) = Σ 1 / (60 + rank_i)      over every source that returned it
```

Ranks are 1-based. Ties break by **decayed-then-recall-boosted** `importance`
(desc): first `importance · e^(−λ·ageDays)` when `AGENT_MEMORY_DECAY_LAMBDA`
is set (older, never-recalled rows sink; λ unset/invalid/≤0 → plain
importance), then a recall boost `+ 0.2·n/(n+1)` where `n` is how many times
this process returned the row (in-process recall ledger, cap 10k, resets on
restart) — then `createdAt` (desc, newest first), then `memoryId` (asc) so
output is fully deterministic. The returned `importance` field is always the
stored value; decay and the recall boost are ranking-only.

Both search paths also honor `AGENT_MEMORY_TTL_DAYS` (v1.1): rows older than the
TTL are hidden and reported as `signals: ["ttl: hidden N expired rows"]` — explicit
degradation, never silent thinning. Both knobs are **off by default**.

Failures degrade instead of exploding: each source runs independently, a source
error is caught and recorded in a `signals` list, and the remaining sources
still contribute rows. Even an all-sources-down search returns **200** with
empty `results` + `signals` — never a 500. `POST /memory/search` is the
BM25-only path with the same degradation rule.

## Quick start

Requires **Node 20+** and a running Helix dev instance (Docker/Podman):

```bash
helix start dev --disk --persist   # durable default: persists storage mode into helix.toml

npm install
npm run bootstrap               # create the 8 indexes, poll until ready
npm run demo                    # seed 3 sessions, run keyword/semantic/hybrid searches
npm run dev                     # REST server on http://127.0.0.1:3111
npm run verify                  # end-to-end verification against the running server
npm run verify-lifecycle        # pure dedupKey/decay/TTL/concepts/confidence/merge checks (no server)
npm run verify-capture          # 7-event hook E2E vs a local counting server (no Helix)
npm run verify-skills           # structural + live round-trip of the 8 skills (running server)
npm run eval                    # retrieval scorecard -> docs/benchmarks/SCORECARD.md (running server)
```

Run `bootstrap` **before the first write**: the dedup lookup, the
consolidation probe, and both searches all depend on the 8 indexes — writes
fail closed (500) while an index is missing.

`--disk --persist` writes `storage = "disk"` into `helix.toml`, and that key —
not the flag — is what decides persistence. This repo's `helix.toml` already
sets it, so a plain `helix start dev` keeps data across restarts. A project
whose `helix.toml` has no `storage = "disk"` key runs memory storage, and
every restart wipes it.

Scripts (from `package.json`): `bootstrap`, `dev`, `demo`, `verify`,
`verify-env`, `verify-lifecycle`, `verify-capture`, `verify-skills`, `eval`,
`purge`, `typecheck`.

## REST API

All routes live under `/memory`, JSON in/out. If `AGENT_MEMORY_SECRET` is
set, add `-H "Authorization: Bearer $AGENT_MEMORY_SECRET"` to every call except
`livez` (see [Authentication](#authentication)).

| Method | Route | Body / query | Success |
|---|---|---|---|
| GET | `/memory/livez` | — | 200 `{"status":"ok"}` |
| GET | `/memory/health` | `?project=` | 200 `{"status":"ok","counts":{…}}` |
| POST | `/memory/remember` | `{content, concepts?, project?, sessionId?, origin?, importance?}` | 201 `{id, sessionId, project, concepts, deduped, consolidated}` |
| POST | `/memory/search` | `{query, project?, limit?}` | 200 `{mode:"bm25", results:[…], signals:[…]}` |
| POST | `/memory/smart-search` | `{query, concepts?, project?, limit?}` | 200 `{mode:"hybrid", results:[…], signals:[…]}` |
| GET | `/memory/sessions` | `?project=&limit=` | 200 `{sessions:[…]}` |
| GET | `/memory/sessions/:sessionId/memories` | `?project=&limit=` | 200 `{memories:[…]}` |
| POST | `/memory/forget` | `{memoryId}` | 200 `{forgotten:true}` / 404 |
| POST | `/memory/recap` | `{project?, sessionId?, limit?}` | 200 `{recap, sessionId, count, signals}` |
| POST | `/memory/handoff` | `{project?, sessionId?, limit?}` | 200 `{handoff, sessionId, counts, signals}` |
| POST | `/memory/lesson` | `{content, concepts?, project?, sessionId?, importance?}` (no `origin`) | 201 `{id, sessionId, project, concepts, deduped, consolidated}` |
| POST | `/memory/delete` | `{memoryId, reason}` (`reason` required) | 200 `{deleted:true, receipt:{memoryId, deletedAt}}` / 404 |

Defaults: `project="default"`, `limit=10`, `origin="rest"`, `sessionId`
auto-generated (`crypto.randomUUID()`) when absent. `importance` (v1.2): an
explicit 0..1 value is stored as-is; when omitted the store **derives** it
from provenance + structure — base lesson 0.75 / `hook:*` 0.55 / else 0.5,
+ 0.025·min(concepts,8), clamp01 (the old `0.5` default is retired; see
[`docs/CONTRACT.md`](docs/CONTRACT.md) §3). Every REST
body is a strict zod object: **unknown keys are rejected with 400** — so
`lesson` never accepts `origin` (sending it → 400; the row is always stored
with `origin="lesson"`) and `delete` requires `reason`. MCP input schemas are
SDK-mediated instead: unknown keys are **stripped, not rejected**, and `origin`
is server-forced either way (`memory_lesson` stores `origin="lesson"` no
matter what the caller sends).

### Examples

```bash
# liveness
curl -s http://127.0.0.1:3111/memory/livez
# {"status":"ok"}

# health with counts
curl -s 'http://127.0.0.1:3111/memory/health?project=readme'
# {"status":"ok","counts":{"memories":2,"sessions":1}}
```

**remember** — real captured request/response:

```bash
curl -s -X POST http://127.0.0.1:3111/memory/remember \
  -H 'content-type: application/json' \
  -d '{
    "content": "Implemented JWT auth in src/middleware/auth.ts: HS256 signing, 15-minute expiry, httpOnly cookie on login.",
    "concepts": ["auth", "jwt"],
    "project": "readme",
    "sessionId": "readme-example"
  }'
# 201
# {"id":"0d850e3b-6ec5-49bb-bd94-42a1973912fa","sessionId":"readme-example","project":"readme","concepts":["auth","jwt"],"deduped":false}
```

Send the same `content` again (same project, any casing/whitespace) and the
server returns the SAME `id` with `"deduped":true` — no second row. Omit
`concepts` entirely and they're derived for you (top-8 terms of the content),
so plain saves still feed the concept-graph branch of hybrid search.

**search** (BM25 only):

```bash
curl -s -X POST http://127.0.0.1:3111/memory/search \
  -H 'content-type: application/json' \
  -d '{"query": "jwt token expiry", "project": "readme", "limit": 5}'
# {"mode":"bm25","results":[{"id":"…","memoryId":"…","content":"…","score":2.54,…,"source":"text","signals":[]}],"signals":[]}
```

**smart-search** (hybrid RRF) — real captured request/response:

```bash
curl -s -X POST http://127.0.0.1:3111/memory/smart-search \
  -H 'content-type: application/json' \
  -d '{"query":"dashboard query latency","concepts":["performance"],"project":"readme","limit":5}'
# 200
# {"mode":"hybrid","results":[
#   {"id":"109","memoryId":"0a6b1c4f-bf37-4586-9477-cacb3e7b3ad5",
#    "content":"Fixed the dashboard N+1 query by batching user lookups into one IN query; p95 latency dropped from 820ms to 45ms.",
#    "sessionId":"readme-example","origin":"rest","importance":0.5,
#    "createdAt":"2026-09-22T13:30:39.062Z","score":0.04918032786885246,
#    "source":"vector","signals":[]},
#   {"id":"108","memoryId":"0d850e3b-6ec5-49bb-bd94-42a1973912fa",
#    "content":"Implemented JWT auth in src/middleware/auth.ts: HS256 signing, 15-minute expiry, httpOnly cookie on login.",
#    "sessionId":"readme-example","origin":"rest","importance":0.5,
#    "createdAt":"2026-09-22T13:30:38.921Z","score":0.016129032258064516,
#    "source":"vector","signals":[]}],
#  "signals":[]}
```

Each result row carries `source` (`"vector" | "text" | "graph"`); fused rows
add a per-row `signals` array, and the envelope carries top-level `signals`
(empty when every attempted source succeeded).

**sessions / session memories / forget:**

```bash
# list sessions
curl -s 'http://127.0.0.1:3111/memory/sessions?project=readme&limit=5'
# {"sessions":[{"sessionId":"readme-example","project":"readme","startedAt":"2026-09-22T13:30:38.921Z","updatedAt":"2026-09-22T13:30:39.066Z"}]}

# one session's memories (ordered by node insertion, newest first — see Known limitations)
curl -s 'http://127.0.0.1:3111/memory/sessions/readme-example/memories?project=readme&limit=5'
# {"memories":[{"id":"109","memoryId":"0a6b1c4f-…","content":"Fixed the dashboard N+1 query…","sessionId":"readme-example","origin":"rest","importance":0.5,"createdAt":"2026-09-22T13:30:39.062Z"}, …]}

# hard-delete one memory (incident edges go with it)
curl -s -X POST http://127.0.0.1:3111/memory/forget \
  -H 'content-type: application/json' \
  -d '{"memoryId":"0d850e3b-6ec5-49bb-bd94-42a1973912fa"}'
# {"forgotten":true}   (404 {"error":"not_found"} when the id does not exist)
```

**lesson → governed delete** (P3.1 — reason is required, receipt is auditable):

```bash
# store a lesson (strict body: origin is rejected; row gets origin="lesson")
curl -s -X POST http://127.0.0.1:3111/memory/lesson \
  -H 'content-type: application/json' \
  -d '{"content":"Always pass an explicit reason on deletes: audit trails depend on it.","concepts":["governance"],"project":"readme","sessionId":"readme-example"}'
# 201
# {"id":"…","sessionId":"readme-example","project":"readme","concepts":["governance"]}

# governed delete with the required reason
curl -s -X POST http://127.0.0.1:3111/memory/delete \
  -H 'content-type: application/json' \
  -d '{"memoryId":"0d850e3b-6ec5-49bb-bd94-42a1973912fa","reason":"superseded by docs/CONTRACT.md"}'
# {"deleted":true,"receipt":{"memoryId":"0d850e3b-6ec5-49bb-bd94-42a1973912fa","deletedAt":"2026-09-22T13:31:02.114Z"}}
# 400 when reason is missing; 404 {"error":"not_found"} for an unknown id
```

## MCP server

`src/mcp.ts` runs over **stdio** with the official `@modelcontextprotocol/sdk`,
backed by the same `MemoryStore` as the REST server. Handshake exposes exactly
**11 tools**:

| Tool | Purpose |
|---|---|
| `memory_save` | Persist one memory (content + optional concepts — derived when omitted; duplicate content returns the existing id with `deduped:true`) |
| `memory_search` | Keyword (BM25) search within a project |
| `memory_smart_search` | Hybrid search: vector + BM25 + optional concept graph, RRF-fused |
| `memory_sessions` | List sessions of a project |
| `memory_session_memories` | List memories of one `sessionId` |
| `memory_forget` | Hard-delete one memory by id |
| `memory_health` | Liveness + memory/session counts |
| `memory_recap` | Text recap of one session's (or the project's) recent memories |
| `memory_handoff` | Project handoff digest for the next agent session |
| `memory_lesson` | Persist a lesson (stored with `origin="lesson"`; unknown input keys are stripped by the SDK, not rejected) |
| `memory_delete` | Governed delete: `memoryId` + required `reason`, returns a receipt |

### OpenCode

Top-level `mcp` key, command as an array (run from the repo root, or use the
absolute path to `src/mcp.ts`):

```json
{
  "mcp": {
    "agent-memory": {
      "type": "local",
      "command": ["npx", "tsx", "src/mcp.ts"],
      "enabled": true
    }
  }
}
```

### Claude Code

`mcpServers` shape (e.g. in `.mcp.json` or `~/.claude.json`):

```json
{
  "mcpServers": {
    "agent-memory": {
      "command": "npx",
      "args": ["tsx", "src/mcp.ts"],
      "env": {
        "HELIX_URL": "http://localhost:6969"
      }
    }
  }
}
```

When `AGENT_MEMORY_SECRET` is set, every tool call must carry
`_meta.authorization = "Bearer <secret>"` (stdio has no HTTP headers, so the
bearer rides in the request's `_meta`); mismatch is an MCP `unauthorized`
error. Never commit a real secret — set it in the server's environment.

## Hooks

`hooks/capture.mjs` is plain Node ESM with **zero dependencies**. It reads the
host's hook JSON on stdin, takes the event name from `argv[2]` (supported:
`SessionStart`, `PostToolUse`, `Stop`, `PostToolUseFailure`, `PreCompact`,
`SessionEnd`, `UserPromptSubmit` — 7 events), and POSTs one small observation to
`/memory/remember` with `origin="hook:<event>"`.

Wiring example (Claude Code `settings.json` hooks shape):

```json
{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs SessionStart" }] }
    ],
    "PostToolUse": [
      { "matcher": "*", "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs PostToolUse" }] }
    ],
    "PostToolUseFailure": [
      { "matcher": "*", "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs PostToolUseFailure" }] }
    ],
    "PreCompact": [
      { "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs PreCompact" }] }
    ],
    "SessionEnd": [
      { "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs SessionEnd" }] }
    ],
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs UserPromptSubmit" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "node /path/to/agent-memory/hooks/capture.mjs Stop" }] }
    ]
  }
}
```

Any host that can run a command with JSON on stdin works the same way:
`node hooks/capture.mjs <EventName>`.

Guarantees (verified):

- **Always exits 0 with zero output** — even when the memory server is down,
  with malformed stdin, or with an unsupported event. A dead memory server must
  never block the coding agent.
- Stores a valid observation when the server is up; all 7 events work
  (`SessionStart` / `PostToolUse` / `Stop` / `PostToolUseFailure` /
  `PreCompact` / `SessionEnd` / `UserPromptSubmit`).
- **Only an allowlisted, host-agnostic summary is stored**: `agent session
  started`, `tool used: <tool-name>`, `tool failed: <tool-name>`, `agent
  session stopped`, `context compaction requested`, `agent session ended`, or
  `user prompt submitted`. Hook payloads, file paths, command output, and the
  user's prompt text are deliberately NOT captured — a planted file path
  (`/tmp/secret-should-not-be-captured.txt`) and a prompt canary were both
  confirmed **not** stored (privacy, Ley 172-13).
- Never prints memory content, the hook payload, or the secret.
- `AGENT_MEMORY_URL` defaults to `http://127.0.0.1:3111` (the REST service, not
  the raw Helix port); `project` derives from the workspace directory name,
  overridable via `AGENT_MEMORY_PROJECT`.
- A 2s fetch timeout keeps a hung server from hanging the agent.

The OpenCode plugin mirrors this with a 5th hook, `tool.execute.before` — a
fire-and-forget `tool started: <tool-name>` observation (own `memory*` tools
skipped; never awaited, so the tool hot path pays nothing).

## Authentication

- Set `AGENT_MEMORY_SECRET` to a non-empty value to arm the guard: every
  `/memory/*` route **except `livez`** then requires
  `Authorization: Bearer <secret>`; mismatch → `401` with body
  `{"error":"unauthorized"}`.
- **Unset `AGENT_MEMORY_SECRET` → open localhost** (matches the upstream
  default).
- The secret value is never logged, echoed, or included in error text; the
  access log records method, path, status, and duration only.
- The delete governance line (`memoryId`, `reason`, `at`) exists for
  **operational audit of destructive deletes only**: it goes to process
  stdout/stderr and nowhere else (this repo keeps no durable store for it),
  is retained per the host's log retention/rotation, and is deleted by log
  rotation or process exit — and the delete receipt's `deletedAt` is server
  time captured immediately after the store confirms the delete, with the
  receipt omitting `reason` by design (reason lives only in that log line).
  Its fields are a strict **allowlist** (`memoryId`, normalized `reason`,
  `at` — never memory content, embeddings, or headers): don't put PII or
  secrets in `reason` (caller responsibility, bearer-auth + length bound), and
  apply standard log masking/retention on the host that streams it.
- The MCP server applies the same rule over `_meta.authorization`.
- Never commit or print real secret values.

Verified auth matrix: `livez` exempt → 200; no header → 401; wrong bearer →
401; correct bearer → 200/201; secret appears 0 times in server logs; auth off
when `AGENT_MEMORY_SECRET` is unset.

## Configuration

| Variable | Default | Used by | Purpose |
|---|---|---|---|
| `AGENT_MEMORY_PORT` | `3111` | REST server | Listen port |
| `AGENT_MEMORY_URL` | `http://127.0.0.1:3111` | hooks, `verify`, plugin | Base URL of the REST service |
| `AGENT_MEMORY_SECRET` | *(unset = open)* | REST + MCP + hooks + plugin | Bearer secret; non-empty arms the guard |
| `HELIX_URL` | `http://localhost:6969` | store, bootstrap, purge | HelixDB instance endpoint |
| `AGENT_MEMORY_PROJECT` | *(workspace dir name)* | hooks, plugin | Tenant/scope value for captured observations |
| `AGENT_MEMORY_HOST` | `127.0.0.1` | REST server | Bind address |
| `AGENT_MEMORY_INJECT` | `true` | OpenCode plugin | Auto-inject recalled memories into the system prompt; `false` skips the search and the context block (the static compaction reminder still lands) |
| `AGENT_MEMORY_INJECT_LIMIT` | `8` | OpenCode plugin | Max rows injected per block and per auto-recall query (`1`–`20`) |
| `AGENT_MEMORY_INJECT_TTL_MS` | `45000` | OpenCode plugin | Auto-recall cache TTL in ms, bounding network cost inside the request hot path (`1000`–`600000`) |
| `AGENT_MEMORY_TTL_DAYS` | *(unset = off)* | REST + MCP searches, store | Hide memories older than N days from search results (reported as a `ttl:` signal); must parse to a finite number > 0, else OFF |
| `AGENT_MEMORY_DECAY_LAMBDA` | *(unset = off)* | REST + MCP searches, store | Per-day decay rate λ for the fused-row tie-break: weight = `importance · e^(−λ·ageDays)`; invalid/≤ 0 → decay OFF; stored `importance` is never modified |
| `AGENT_MEMORY_MERGE_JACCARD` | `0.9` | REST + MCP `remember`, store | Tier-1 consolidation: near-duplicates with `Jaccard(tokens) ≥ threshold` merge into one survivor (content concatenated, never discarded); parseable in (0,1) selects the threshold, anything else (≤0, ≥1, garbage) → consolidation OFF (fail-closed) |
| `EVAL_MODE` | `rest` | eval harness only (`scripts/eval.ts`) | Adapter selector for the pluggable `EvalClient`; unknown mode fails closed (exit 1). Never read by the server |

The three plugin rows are read with `options` > env > default, so a matching
`inject` / `injectLimit` / `injectTtlMs` key on the plugin itself wins over the
environment variable.

## Known limitations

Stated plainly — these are real, not hypothetical:

1. **Port conflict with upstream agentmemory.** Port `3111` is **our default**,
   chosen deliberately for drop-in parity with upstream. Ports
   `3111/3112/3113` may be held by the real upstream `agentmemory`
   (`npx` → `node …/bin/agentmemory` → `iii`; verified live on this machine).
   When `3111` is occupied, start ours on `3151` and point **every HTTP
   client — hooks, plugin, and `verify`** — at it via
   `AGENT_MEMORY_URL=http://127.0.0.1:3151` (the MCP server needs no reroute:
   it is stdio and talks to HelixDB directly via `HELIX_URL`):

   ```bash
   AGENT_MEMORY_PORT=3151 npm run dev
   AGENT_MEMORY_URL=http://127.0.0.1:3151 npm run verify
   ```

   Starting ours on `3151` does **not** move the clients: hooks, the plugin,
   and `verify` still default to `http://127.0.0.1:3111` — which upstream may
   hold — so **every client process must set
   `AGENT_MEMORY_URL=http://127.0.0.1:3151` explicitly**. Skip it and captures
   and recalls are silently aimed at whatever occupies `3111`.

   The server prints this exact reroute hint on `EADDRINUSE`.
   **Never kill or displace the upstream instance.**

2. **Persistence is the default; in-memory is opt-in.** Persistence is decided
   by the `storage = "disk"` key in `helix.toml`, not by any start flag: the
   Quick start's `--disk --persist` is what writes that key, and this repo's
   `helix.toml` already carries it — so a plain `helix start dev` keeps data
   across restarts. A project whose `helix.toml` lacks the key runs memory
   storage and loses everything on restart.

   **Durability & recovery.** Data survives `helix restart dev` on the Docker
   volume while `storage = "disk"` is set. After a **host reboot** the
   container does **not** auto-start (no restart policy is configured) — run
   `helix start dev` to bring it back. The failure mode is symptom-free:
   captures keep exiting `0` silently and auto-recall is simply skipped, so
   the memory stack goes dark with no error anywhere. If recall suddenly
   returns nothing, check `helix status` first.

3. **Listings are ordered by node `$id` descending (insertion order), not by
   timestamp.** The engine cannot correctly sort `dateTime` properties —
   `orderBy` on DateTime was verified non-monotone across 8 sessions in the
   build probe — so `listSessions` and `sessionMemories` order by `$id` desc
   (deterministic newest-created-first) instead. This is an upstream engine
   limitation, **not** our choice.

4. **Embeddings are a deterministic, hash-based 384-dim embedder**
   (`src/embed.ts`: FNV-1a token hashing + TF weighting + L2 normalization — no
   model download, no API key, no LLM). It is good for structural/rank parity
   with the upstream, but it does **not** understand true semantic synonyms.
   Swap `src/embed.ts` for a real embedding model when semantics matter.

5. **Out of v1** per [`docs/CONTRACT.md` §4](docs/CONTRACT.md): consolidation
   tiers 2–4 (upstream's full 4-tier model), LLM auto-compress, viewer UI,
   session replay, JSONL import, multi-agent adapters (20 upstream), and the
   full 54-tool MCP surface.
   (Read-time decay + TTL + `purge.ts` shipped in v0.4.0 — "corte A";
   tier-1 near-duplicate consolidation, derived confidence and the eval
   harness shipped in v0.5.0 — the remaining tiers stay out of scope.)

6. **`demo` appends on every run — it is not idempotent.** Each invocation
   seeds 3 more sessions into project `demo`, so re-running it produces
   duplicate rows in later results. Storage is durable by default
   (`storage = "disk"`), so a restart no longer clears them — seed into a
   fresh `project` for a clean demonstration. Data persists now; what is still
   pending is the P4 hardening story (backup/DR, data-dir control), not an
   in-memory reset.

7. **Vector hits carry `distance`, BM25 hits carry `score`.** Vector rows are
   projected as `$distance` (cosine, lower = closer), so a raw vector hit's
   `score` is `0` until RRF fusion assigns one — read `distance` when ranking
   or displaying vector results, `score` for BM25/RRF. Both are surfaced on
   the REST row.

8. **Concurrent distinct-variant merges can lose one append — accepted
   residual (owner: engineering).** The consolidation FIFO lock is keyed by
   content hash, so it serializes *identical* content only: two concurrent
   saves of *different* near-dup variants that pick the same survivor both
   read the pre-merge content, last writer wins, and both callers still get
   `consolidated:true`. Recoverable — the caller keeps its text and
   re-saving re-merges. Declared in
   [`docs/CONTRACT.md`](docs/CONTRACT.md) §3 tier-1 (a); tracked in
   [`ROADMAP.md`](ROADMAP.md) §1.3 with expiry **2026-12-31 or the start of
   P4.3 multi-instance work, whichever first** — at P4.3 survivor-level
   serialization becomes mandatory. Gate P1R-P32 / RL-001.

9. **With tier-1 ON, an unhealthy text index fails WRITES too.** Every novel
   `remember` runs the near-dupe probe (`textSearchWith`), so
   `index_not_found` surfaces as **500s on writes**, not just degraded
   search — `npx tsx scripts/bootstrap.ts` restores writes (idempotent;
   run it *before* the first write). The probe sends the full incoming
   content (≤200 kB) as the BM25 query, bounded by the 15 s per-operation
   timeout. Fail-closed is the documented posture (gate P1R-P32 / RL-003).

## Verification

All run clean:

- `npm run typecheck` (`tsc --noEmit`) — zero errors; no `any`, no
  `@ts-ignore`, no TODO anywhere in the source.
- `npm run verify` (`scripts/verify.ts`) — **`214 passed, 0 failed` →
  `VERIFY PASS`** (identity guard → health → remember with concepts → BM25 hits
  → smart-search hits → sessions list → session memories → forget → gone →
  counts reflect it, plus embedder determinism, defaults, boundary validation,
  the P3.1 round-trip: lesson → search hits with `origin:"lesson"` →
  recap (every bullet session-scoped) → handoff → governed delete with receipt
  → gone → second delete 404 → counts, the v1.1 P1.3/P1.6 sections:
  derived default concepts ≤8 → graph-branch proof (fused score == 3/61) →
  content-hash dedup round-trip: same id + `deduped:true` + counts stable,
  cross-project distinct, concurrent race → same id → dedup × hook first-wins:
  same fixed hook content in a NEW session → same id, no new row, no Session
  node for that session, and the v1.2 sections: derived importance
  (no-caller-value == `deriveWriteImportance(origin, concepts.length)`, explicit
  wins, recall-lift ordering) + tier-1 consolidation (3 near-dup variants →
  1 row with `consolidated:true`, each variant's wording recalls it,
  healthCount +1, merged-text re-save → exact-dedup loop guard) + the MCP
  adapter pass-through (`InMemoryTransport`: save without `importance` → the
  store sees `undefined`, explicit value wins). Before the
  first write it probes
  `POST /memory/recap` and aborts (exit 1, no writes) unless the target
  answers 200 — so when `3111` is occupied by the upstream `agentmemory`, run
  it against ours: `AGENT_MEMORY_PORT=3151 npm run dev` then
  `AGENT_MEMORY_URL=http://127.0.0.1:3151 npm run verify` (README conflict
  procedure).
- `npm run verify-lifecycle` (`scripts/verify-lifecycle.ts`) — **`104 passed` →
  `VERIFY PASS`**: pure dedupKey/hash golden vectors, decay math (λ=0 → 1,
  half-life exact, monotonic, clamp), TTL filter (OFF/boundary/purity),
  concept extraction determinism + bounds, `oneLine` CWE-117 render guard
  (collapses `\n`/`\r`/tabs to single spaces, idempotent, non-corrupting
  for names/digits/ISO/booleans), plus §F derived confidence
  (deriveWriteImportance goldens, confidenceBoost monotonic/clamp, recall
  ledger), §F-bis the decay-THEN-boost order golden (λ on, discriminating),
  §G consolidation (jaccard, threshold fail-closed OFF, substring
  guard), §H hand-computed eval-metric goldens (R@5/R@10/MRR/nDCG/aggregate),
  and §I fail-closed near-dupe probe + TTL×expired-survivor guard + plugin
  no-default source checks. No Helix, no server — CI-runnable.
- `npm run verify-capture` (`scripts/verify-capture.ts`) — **`115 checks` →
  `ALL PASS`**: all 7 hook events × exact payload/origin/exit-0/stdout+stderr
  silence, prompt-text privacy canary, negatives (unsupported event, malformed
  /empty stdin, dead server), Authorization header, and the plugin
  `captureToolStart` helper (incl. `memory*` skip + dead-backend fail-soft).
  Spawns `capture.mjs` against a local counting server — no Helix, CI-runnable.
- `npm run bootstrap` — `bootstrapIndexes: OK (8 indexes ensured)` then
  `READY — searchByText responding`.
- `npm run verify-env` (`scripts/verify-env.ts`) — **`21 passed`**: legacy
  `AGENTMEMORY_*` migration guards + hook silence.
- `npx tsx scripts/verify-injection.ts` — **`ALL PASS` (73)**: marker
  idempotency, block size budget, cache TTL/LRU, fail-soft recall.
- `npx tsx scripts/probe3.ts` — **`OVERALL: GREEN`**: live-instance proof for
  dedup lookup round-trip, application-side (non-)uniqueness, and `ltParam`
  strict older-than on `dateTime` (feeds `purge.ts`).
- `npx tsx scripts/probe4.ts` — **`12 passed`**: live proof that
  `updateMemoryContent`'s `setProperty` refreshes BOTH the text and vector
  indexes (verdict A — the tier-1 merge ships in-place, survivor id stable).
- `npm run verify-skills` (`scripts/verify-skills.ts`) — **`119 checks` →
  `VERIFY SKILLS PASS`**: 73 structural checks across the 8
  `skills/*/SKILL.md` (frontmatter, name == dir, contract route + MCP tool
  per skill, index links, secret patterns) + 46 live round-trips exercising
  every skill's frozen route under project `verify-skills`, behind the same
  identity guard as `verify`. `--structural` runs only the 73 checks with
  **no server** — that mode is what CI executes.
- `npm run eval` (`scripts/eval.ts`) — **`EVAL PASS`**: seeds the in-repo
  corpus (`eval/corpus.ts`, 40 docs / 15 queries, project
  `agent-memory-eval`, idempotent via dedup) and writes our own R@5 / R@10 /
  MRR@10 / nDCG@10 for bm25 + hybrid to
  [`docs/benchmarks/SCORECARD.md`](docs/benchmarks/SCORECARD.md) — upstream's
  published numbers are never claimed as ours.
- `npx tsx scripts/purge.ts --dry-run` — prints would-delete count + ids and
  deletes nothing; missing `--days` → usage + exit 2 (fail closed).
- `npm run demo` — `demo OK`: BM25 hits at scores 2.54 / 1.59 / 0.88, vector
  hits ranked by cosine distance (e.g. `d=0.2972 < 0.3251 < 0.4151` — verified
  discriminating, not tied), and hybrid RRF hits mixing `source: vector` and
  `source: graph`.
- Auth matrix: `livez` exempt 200; no header 401; wrong bearer 401; correct
  bearer 200/201; 401 body `{"error":"unauthorized"}`; secret appears 0 times
  in server logs; auth off when `AGENT_MEMORY_SECRET` is unset.
- MCP stdio handshake → exactly the 11 tools listed above; live `tools/call`
  round-trips confirmed.
- Hook guarantees: exit code 0 and zero output with the server DOWN, with
  malformed stdin, and with an unsupported event; valid observation stored when
  up; all 7 events work; only the allowlisted summary is stored (planted path
  `/tmp/secret-should-not-be-captured.txt` and a prompt canary both confirmed
  NOT stored).

## Skills

Invocable agent skills live under [`skills/`](skills/), each mapping one job to
the frozen REST + MCP surface (the frontmatter `description` is what an agent
matches on — see [`skills/memory/SKILL.md`](skills/memory/SKILL.md) for the
index): `recall` (hybrid/BM25 recall), `remember` (save + dedup/consolidation
semantics), `recap`, `handoff`, `forget` (permanent delete) / governance
delete, `lesson` (origin-forced), `commit-context` (capture durable state
before a commit), and `session-history` (sessions → memories → recap).
`npm run verify-skills` structurally validates all 8 and live round-trips
every route against a running server.

## Contributing & security

- [`CONTRIBUTING.md`](CONTRIBUTING.md) — prerequisites, exact dev-setup
  commands, Conventional Commits, the PR/evidence bar, strict-TS rules, and
  the never-kill-upstream coexistence rule.
- [`SECURITY.md`](SECURITY.md) — supported versions, private reporting via
  GitHub Security Advisories (never a public issue), scope, the
  `AGENT_MEMORY_SECRET` policy, and response expectations.
- [`CHANGELOG.md`](CHANGELOG.md) — release history.

## Specification

The frozen contract this implementation follows — labels, routes, query
surface, MCP tools, scope, and §4 out-of-scope list — is
[`docs/CONTRACT.md`](docs/CONTRACT.md). Release history lives in
[`CHANGELOG.md`](CHANGELOG.md); per-release notes in
[`docs/specs/30_delivery/RELEASE_NOTES.md`](docs/specs/30_delivery/RELEASE_NOTES.md).

---

License: Apache-2.0