@staix/agent-mem 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.3.1. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.4.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Install
8
8
 
@@ -32,11 +32,11 @@ amem goal new "Ship a release" --milestone "Review changes"
32
32
 
33
33
  `amem goal link` requires the host task boundary; use `ahub goal link` for board links.
34
34
 
35
- Memory commands: `list`, `search`, `show`, `graph`, `suggestions`, `confirm`, `reject`, `promote`, `demote`, `forget`, `supersede`, `rebuild`, `dedupe`, `sync`, `machine`, and `import`. Use `--json` where supported by the corresponding `ahub memory` command. `dedupe` previews candidates; `dedupe --apply` appends reconciliation operations.
35
+ Memory commands: `list`, `search`, `show`, `graph`, `suggestions`, `confirm`, `reject`, `promote`, `demote`, `forget`, `supersede`, `rebuild`, `dedupe`, `sync`, `machine`, and `import`. Use `--json` where supported by the corresponding `ahub memory` command. `dedupe` previews candidates; `dedupe --apply` appends reconciliation operations. `amem alias add|list` manages person-reviewed project key aliases; `amem capacity` reports and proposes capture bounds (see below).
36
36
 
37
- `amem` is for the person. Every command is refused when an agent-session marker is present, including `AGENTHUB_PEER_ID`, `CLAUDECODE`, and `CODEX_THREAD_ID`. Agents read memory through the hub's `hub_memory_*` tools. Environment-marker detection is an honest-session guard, not authentication. Mutating commands also require a TTY on stdin.
37
+ Ordinary `amem` commands are for the person and refuse agent-session markers, including `AGENTHUB_PEER_ID`, `CLAUDECODE`, and `CODEX_THREAD_ID`. `amem mcp` is the sole intentional session agent entrypoint; it supplies scoped reads and bounded candidate/work-list writes, never direct curated-note writes. Hosted agents can also use the hub's `hub_memory_*` read tools. Environment-marker detection is an honest-session guard, not authentication. Ordinary CLI mutations remain person-only and require a TTY on stdin.
38
38
 
39
- This package does not ship the host's privacy policy. Person reads show stored data without screening. Import and promotion retain the built-in secret screen. Commands that cross the privacy boundary require the person's explicit `--policy-free` choice:
39
+ This package does not ship the host's privacy policy. Ordinary person reads show stored data without screening; the vault-candidate listing uses its current policy. Import and promotion retain the built-in secret screen. Commands that cross the privacy boundary require the person's explicit `--policy-free` choice:
40
40
 
41
41
  ```sh
42
42
  amem promote <id> --policy-free
@@ -131,6 +131,18 @@ These interactive checks remain unrun until a person checks them in each install
131
131
 
132
132
  The JSONL segments are authoritative. `index.db` is derived and can be rebuilt. Entries are immutable; changes append operations. The compatibility schema remains `agent-hub.memory/v1` because it participates in entry IDs.
133
133
 
134
+ ## Capture tier and full-text search
135
+
136
+ From issue #13 (phase P1), the package carries a second, machine-local tier beside the curated store, born sharded (#15 T1): one capture shard per project key at `projects/<key-hash>/machine/<machine-id>/` under the memory home, with its own locks so two projects never contend. The capture tier records deterministic tool-event rows (project, agent, event, tool, paths, exit status and a bounded, secret-screened excerpt) in the shard's `capture.db`. It is never synced, never read as curated evidence, and never writes a curated log, so its maintenance can never evict notes, goals or work lists. Capture is bounded by a soft size cap and a retention window (defaults 512 MB per shard, 90 days), resolved at every invocation from the library options, then the shard's `config.json`, then the home default `capture-config.json`; eviction removes the oldest capture rows first in bounded batches. The 2026-10-17 owner decision keeps the P1 cap per shard; the machine-wide total is #15 T5. On lock contention a writer appends to an append-only spool and exits fast; the next uncontended writer replays the spool idempotently. The capture path makes no network or model call. The pre-T1 machine-wide `capture/` (an unreleased layout of this branch) is migrated per project on open and removed once empty.
137
+
138
+ Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and the main checkout of one repository share its key; two unrelated same-named repositories without a remote get distinct path-hash keys, flagged `local` in the shard's `project.json` and the catalog. A no-remote key hashes the main working root (using the actual top-level for a separate git dir), so keys written from the repository root keep their pre-#15 value. Known limit: a linked worktree of a no-remote separate-git-dir repository gets its own key, because git does not record that repository's main working root (probe: `alpha` and `alpha/sub` share `alpha@<hash>`, `alpha-wt` gets `alpha-wt@<hash>`); the key stays as is for root-key compatibility, and a reviewed alias reconciles its capture. Keys written by older builds from a subdirectory or worktree still used that invocation's cwd hash; a person-reviewed alias maps such a key to its canonical one for capture, work lists and the CLI and MCP project binding. Curated rows written under the old key keep it and are not remapped, so the CLI and MCP stop showing that key's project-scoped notes, goals and candidates (the library still reads them by the raw key; shared rows stay visible). An alias can be retargeted but not removed, so add one only after checking the old key's private notes; curated row reconciliation is a later spec change. `amem alias add <from-key> <to-key>` appends an op to `aliases.jsonl` (latest wins, never chained, never automatic), `amem alias list` shows the ops. `catalog.db` in the memory home (#15 D3's storage part) is derived only from the shards and the alias log and rebuilds to the same rows; the D3 management commands are #15 T5 and not built.
139
+
140
+ `store.search(query, filter)` searches the curated tier and the current project's capture shard with SQLite FTS5 and BM25 plus tier/recency weighting: natural multi-word queries match partial term sets (the substring filter required every word), and a curated row outranks the same text in the capture tier. Prefix terms match inflections and Korean particles in either direction by retaining the original query term and adding a common-particle-stripped alternative. The FTS copy of the curated rows lives in its own derived `search.db` (`index.db`'s table set is a frozen contract and gains nothing); it is written from the same apply path as the index, and a prune, crash or rebuild is detected and resynced, so the incremental FTS copy equals a full resync. Where FTS5 is unavailable the engine falls back to the substring filter and reports `engine: "substring"`; capture recall needs FTS5 and is skipped in the fallback. The library entry exports `CaptureStore`, `captureSearch`, `readCaptureConfig`, `writeCaptureConfig`, `SearchIndex`, `fts5Available`, the shard helpers (`resolveAlias`, `appendAlias`, `listAliases`, `shardKeyHash`), the catalog (`rebuildCatalog`, `readCatalog`, `scanCatalog`) and the capacity advisor (`capacityReport`).
141
+
142
+ The R7 capacity advisor reports this machine's load and proposes capture bounds: `amem capacity [--json]` shows capture size and growth, disk, memory, the rolling `metrics.jsonl` readings and the proposal with its binding constraint; `amem capacity apply [--cap-mb N] [--retention-days N]` saves a preview, and only a matching later `--confirm` writes the home default config. Applying is person-only (agent markers refused, a terminal required); an agent may read the proposal through the library, never apply it. The 2026-10-17 owner decision keeps P1 shrink-only; growth proposals are a follow-up after the agent-hub release, while explicit person-directed growth remains available through `apply --cap-mb N`. Benchmark samples stay in the measuring script's synthetic home; only real hook and search invocations feed the advisor's rolling metrics.
143
+
144
+ Resource budgets are acceptance criteria, measured by `bun scripts/measure-budget.ts` in CI on Ubuntu and macOS: a hook-like append (including the spool-and-exit path under contention) must stay under 150 ms p95 wall time and 100 MB peak RSS (R2), the search path under 100 MB idle and 200 MB peak RSS (R3), and no amem process may remain after exit (R1). The script prints a machine-readable verdict per budget and its CI log is also the FTS5 availability evidence for Linux (R5). The AC4 retrieval re-check of the frozen P0 query set is in [docs/p1-ac4-recheck.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/p1-ac4-recheck.md).
145
+
134
146
  ## Library
135
147
 
136
148
  ```ts
@@ -151,7 +163,7 @@ The entry point exports the store, types and identity helpers, sync and claude-m
151
163
 
152
164
  ## Contracts
153
165
 
154
- Shared segments retain the `agent-hub.memory/v1` profile. `OP_CONTRACTS` and `V1_OPS` keep the shared-v1 operation contract; both shared append paths refuse unknown or truth-changing operations. The separate `GENERATION_OP_CONTRACTS` enables only `prune` after an explicit generation selection. `correct`, `lift`, `consolidate` and `group` remain blocked. Reader announcements cannot authorize a new operation or relax generation isolation.
166
+ Shared segments retain the `agent-hub.memory/v1` profile. `OP_CONTRACTS` and `V1_OPS` keep the shared-v1 operation contract; both shared append paths refuse unknown or truth-changing operations. The separate `GENERATION_OP_CONTRACTS` enables `prune` and local exact-entry `vault_link` after an explicit generation selection. `correct`, `lift`, `consolidate` and `group` remain blocked. Reader announcements cannot authorize a new operation or relax generation isolation.
155
167
 
156
168
  The shared-v1 contract suites run the published 0.1.1 reader and compare all six v1 derived tables after replay and rebuild; generation fixtures cover their expanded derived state separately. Privacy and evidence matrices test the core's actual callbacks and sidecar boundaries; model-facing reads, PII turns, staleness checks and capture remain the host's responsibility. Direct person reads remain unscreened. Expiry after a wall-clock rollback retains the existing C05 ceiling: a rebuild can restore a previously expired row until its deadline passes again. A generation can persist exact-entry prune; shared-v1 and unpruned entries retain their existing expiry limitations.
157
169
 
@@ -192,10 +204,66 @@ Only explicit serialized expiry is eligible: a note's `expires`, or an exact dem
192
204
 
193
205
  Standalone CLI prune is local-only, even over a synced target. No `--policy-free` escape enables public prune. A later policy-aware host integration must authenticate intent and attest the exact public payload and current policy revision; only verified public target/basis provenance may sync. Local masks, plans, journals, caches and sidecars never sync. Generation sync refuses public or unknown remote visibility. An interrupted apply resumes the same durable row IDs and exact partial tail; it never rewrites complete lines.
194
206
 
195
- Candidate/counter sidecars are not migrated. `amem review` currently refuses a selected generation rather than using an unbound legacy sidecar. A profile-aware host integration must handle that context explicitly.
207
+ Candidate/counter sidecars are not migrated. `amem review list --kind memory` lists the new machine-local memory candidates under a selected generation; the default legacy review list retains its generation refusal. General memory candidates can be accepted by the person with `review accept --kind memory` into the current project store after current standalone screening; older correction/fail candidates still require profile-aware host integration for generation acceptance.
196
208
 
197
209
  Real migration remains blocked until the separately reviewed generation-capable amem release and exact-pinned agent-hub integration are installed and their running stores prove the same home/G/selector revision. Synthetic cross-writes and exact prune readbacks precede separately confirmed real migration; known local handles must be quiesced/closed and reopened through the supported session-preserving lifecycle. Release, deployment, real migration, host evidence/capture receipts and real multi-machine sync are not established by these source changes. Deactivation omits generation-only notes/prunes from the v1 view and is not a lossless rollback.
198
210
 
211
+ ## Curated shard migration (#15 T2)
212
+
213
+ `amem migrate --shards` moves the curated single home into `projects/<key-hash>/` and `user/` through a resumable generation journal. The original single-home log bytes are never rewritten. A populated selected generation is supported and keeps its generation ID. Each shard keeps private legacy inputs in read-only `legacy/`, public-main inputs in read-only `legacy-main/`, and generation-born rows in its own `log/` and `local/`. It has its own `index.db`, `search.db` and mutation lock; original IDs, hashes, placement and own/local/public provenance are preserved. The source generation bytes stay unchanged. Immutable prune proofs are validated before effects; a multi-shard prune is translated into one deterministic row per shard, with a receipt naming the original row. Existing capture and `work/` directories are not migration inputs and are left untouched. `catalog.db` remains derived and rebuildable, now including curated entry counts and last use. Per-shard sync refs are T3 and are not built.
214
+
215
+ Before splitting a live home, every installed host must open it through `openShardStore({ project })` or the explicit user shard. Plain `new MemoryStore()` refuses any split marker, including an interrupted split, so it cannot silently keep writing the old layout. No host capability registry currently exists; migration preflight output states that limitation. Do not release T2 for agent-hub use until its memory factory is integrated with `openShardStore` and that host version is installed. Existing unannounced older builds remain the documented unchanged-source ceiling.
216
+
217
+ Use the same synthetic-home and person-authority boundaries as generation migration:
218
+
219
+ ```sh
220
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --shards --dry-run
221
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --shards
222
+ AMEM_HOME=/path/to/synthetic-memory amem status
223
+ # Later old-layout appends are reported by status and carried in idempotently:
224
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --resume
225
+ ```
226
+
227
+ Every ordinary build that reads the single-home layout holds a cooperating lease in `readers/<pid>-<start-signature>.json`, renewed within 30 seconds and removed on close. Apply and resume refuse a live or unverifiable lease and remove only leases whose pid is confirmed gone. Removed lease filenames and times are recorded in the migration journal and status; registration also refuses while the profile lock is held. Published builds without leases cannot be detected: no process scan proves their absence, and their safety rests only on unchanged legacy bytes. Old-format writers may still append after the split; `amem status` reports those rows and resume carries them into the proper shards. A resume with pending work marks the split incomplete before copying; reads and writes through new or existing shard handles refuse until complete activation. `amem status` then reports the captured phase and pending rows, and `migrate --resume` finishes it idempotently.
228
+
229
+ Library callers open an already sealed shard directly with `openShardStore({ home, project })` or `openShardStore({ home, user: true })`; these opens do not scan the legacy home or walk the catalog. A project handle reads its own shard plus the user overlay, merging lifecycle metadata before applying visibility; it never reads another project's private text. Shared notes and goals written through a project handle go to `user/`, including their lifecycle operations. Project entries accept only their own project key, while the user shard accepts shared entries. `store.dir` names the selected shard; `store.profile.home` remains the base memory directory. Library callers with a direct base path can use `memoryDir` instead of the historical parent `home` option. Two project shards have independent writer locks, and each incremental index equals its rebuild. `bun scripts/measure-shard-ac8.ts` builds 20 projects and 200,000 entries, poisons the other 19 indexes, and measures open plus ten searches on one shard against the R3 idle/peak RSS limits.
230
+
231
+ Dry run reports v1, generation-owned/local, prune and translated-prune counts, plus refused relations. A private-to-private replacement across projects refuses before effects. A shared entry superseded by a private entry also refuses: the dry run reports `shared superseded by private: N` and entry IDs only. Un-supersede it, or promote the replacement to shared, then rerun. `ponytail:` supporting that direction requires a body-free lifecycle witness; revisit when a read-only dry run on a copy of the person's home reports a nonzero count, never by probing or migrating the live home here. Private-to-shared replacements remain supported. See [the T2 contract](docs/curated-shard-migration.md).
232
+
233
+ After a split, ordinary generation `amem migrate` modes (including `--deactivate`) refuse without changing the selector. Use `amem migrate --shards --resume` to finish the split. General memory candidates can be accepted with `review accept --kind memory` into the selected project shard after standalone screening; correction/fail candidates still refuse pending shard-aware host integration. Default `prune` selects the current project's shard; use the person-only `amem prune --user --cutoff <UTC> --dry-run` and `amem prune --user --apply --plan <file>` for shared/user entries. The actor and current-project authorization checks are unchanged.
234
+
235
+ A native shard crash tail is left to its original writer's recovery. Migration checks planned copy tails and translation-receipt tails before changing activation; matching copies finish append-only and mismatches give an inspection hint. Dangling lifecycle/suggestion metadata is carried as ids-only user data and counted, so a target arriving later retains its intent. Cross-private replacements have their own ids-only count and the un-supersede/promote-to-shared remedy. During interruption the catalog keeps capture information available while `curatedEntries` is `null` and `curatedAvailable` is `false`; its cached capture counts may still lag.
236
+
237
+ ## Session MCP and work lists (#13 P2)
238
+
239
+ Run `amem mcp` as a stdio child of the agent CLI. It binds the working directory, canonical project key, exact memory home and selected profile once, accepts no project/home overrides in tool arguments, and closes all stores on stdin EOF. It has no listener, daemon, watcher or model call. Ordinary person commands retain their agent-marker refusal and mutation TTY checks; `mcp` is the sole agent entry point.
240
+
241
+ The frozen nine-tool contract for dotfiles#255 and maru#458:
242
+
243
+ | Tool | Purpose |
244
+ | --- | --- |
245
+ | `amem_search` | Screened project search, FTS5/BM25 with tier/recency ranking |
246
+ | `amem_show` | Exact or unique-prefix curated note; explicit `capture:<id>` recall |
247
+ | `amem_timeline` | Screened notes and labelled capture near an id or time |
248
+ | `amem_graph` | Screened curated note neighbourhood, excluding capture and candidate data |
249
+ | `amem_work_state_read` | Project work lists, optional closed lists/items |
250
+ | `amem_work_state_write` | Append scalar fields, null clears, task selects an item |
251
+ | `amem_remember` | General memory candidate only, awaiting person acceptance |
252
+ | `amem_vault_candidates` | Read-only screened durable curated notes |
253
+ | `amem_capacity` | Numeric machine capacity proposal; no apply capability |
254
+
255
+ Search/show/timeline label capture as recall with `evidence: false`; capture never enters the curated graph. Policy screens complete rows before selection, limits or graph construction. The standalone secret screen, private flags, PII patterns and deny paths compose with a host's stricter policy through `createMemoryMcp({cwd, base, policy})`. Screening errors fail closed. `base` is the exact memory root; historical library `home` options still name its parent. Scope and profile changes refuse further access instead of silently reopening another store. Reads refuse above 5,000 rows per curated/capture tier; work-list reads use the same 5,000-entry scan ceiling. Count-first admission refuses an oversized work-list corpus before loading or parsing its fields, rather than returning an incomplete screened result. These are operational ceilings, not exposed counts of unscreened data. Graphs cover curated-note relationships, not goal/suggestion tables.
256
+
257
+ `WorkListStore` writes `projects/<key-hash>/work/` with its own append log, derived index and lock. Scalar fields reduce by latest value, null clears, list status `done` closes, and item status is `todo|doing|done|dropped`. Work lists are outside generation selection and capture eviction. `amem import claude-mem` additionally imports `work_state_entries` only through persisted person-reviewed D2 aliases, preserving source DB bytes and stable replay receipts; JSON keeps observation fields and adds `work` counts/cursor.
258
+
259
+ `amem vault-candidates --json` and the MCP read tool select durable long-term notes under current policy, excluding task/review/PR/CI progress and already-linked entries. The deterministic status filter covers English status phrases and can miss paraphrased or other-language status text; candidates require person review. No vault is opened, indexed or written. Library `recordVaultLink` records an exact-entry hash and relative Markdown path in a generation-only local append op; it survives index rebuild and shard migration, stays machine-local, and does not change shared-v1 schemas or sync semantics. There is no MCP link writer.
260
+
261
+ Do not record vault links on a home shared with a host using published agent-mem 0.3.1, including agent-hub while its exact pin stays at 0.3.1. That reader can keep listing from a shared derived index already advanced by the P2 writer, but its next full rebuild refuses the unknown operation. An independent unadvanced index refuses on refresh too. The compatibility fixture omits derived indexes and therefore forces this rebuild refusal on open. Upgrade the sharing host to a pin that includes P2 vault-link support before recording the first link; an ordinary successful list is not rollout readiness. See [the vault-link compatibility contract](docs/contracts.md#generation-only-vault-links).
262
+
263
+ General memory candidates are separate from curated evidence. `amem review accept <id> --kind memory --policy-free` remains person-only and rechecks the current standalone privacy policy before the accepted note append. Machine capacity reports numeric aggregate use across shards, as R7 requires; it does not return foreign project names or text.
264
+
265
+ `bun scripts/measure-mcp-budget.ts` exercises an actual stdio session with 200 curated notes, 2,000 capture rows and 2,000 work-list operations reduced into 100 items. It checks the R3 idle RSS limit of 100 MB, peak RSS limit of 200 MB across searches and work-list reads, and child reaping after EOF. CI runs the harness on macOS and Linux. All harness data is synthetic; live P6 measurements remain separate.
266
+
199
267
  ## Development
200
268
 
201
269
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,14 +15,15 @@
15
15
  "@types/bun": "^1.3.0",
16
16
  "typescript": "^5.8.0",
17
17
  "@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
18
- "@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0"
18
+ "@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0",
19
+ "@staix/agent-mem-pre-vault": "npm:@staix/agent-mem@0.3.1"
19
20
  },
20
21
  "exports": {
21
22
  ".": "./src/index.ts",
22
23
  "./platform": "./src/memory/platform.ts"
23
24
  },
24
25
  "bin": {
25
- "amem": "./bin/amem.js"
26
+ "amem": "bin/amem.js"
26
27
  },
27
28
  "files": [
28
29
  "src/",
package/src/cli.ts CHANGED
@@ -1,14 +1,24 @@
1
- import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
- import { MemoryStore, looksSecret } from "./memory/store.ts";
1
+ import { createMemoryMcp } from "./mcp-memory.ts";
2
+ import { serveMcp } from "./mcp.ts";
3
+ import { createStandalonePolicy } from "./memory/policy.ts";
4
+ import { vaultCandidates } from "./memory/vault.ts";
5
+ import { importClaudeMemWorkState } from "./memory/import.ts";
6
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
3
9
  import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
4
10
  import { memoryProjectKey } from "./memory/project.ts";
5
11
  import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
12
+ import { appendAlias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
13
+ import { capacityReport, capacityText } from "./memory/capacity.ts";
14
+ import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
6
15
 
7
16
  import { commandHelp, parseCommand } from "./commands-table.ts";
8
17
  import { completionScript } from "./completion.ts";
9
18
  import { memoryHome } from "./memory/home.ts";
10
19
  import { resolveProfile } from "./memory/generation.ts";
11
20
  import { migrate } from "./memory/migrate.ts";
21
+ import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
12
22
  import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
13
23
 
14
24
  export const PERSON_ONLY = "amem is for the person; agents read memory through the hub's hub_memory_* tools";
@@ -55,7 +65,25 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
55
65
  return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
56
66
  }
57
67
 
68
+ function shardStatePresent(): boolean {
69
+ try { lstatSync(join(memoryHome(), "shards.json")); return true; }
70
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
71
+ }
72
+
73
+ function curatedStore(project: string, opts: StoreOptions): MemoryStore {
74
+ if (shardStatePresent()) {
75
+ readShardedSelection(memoryHome());
76
+ return openShardStore({ ...opts, project });
77
+ }
78
+ return new MemoryStore(opts);
79
+ }
80
+
58
81
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
82
+ if (argv.length === 1 && argv[0] === "mcp") {
83
+ const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
84
+ await serveMcp({ tools: memory.tools, close: memory.close, version: VERSION });
85
+ return "";
86
+ }
59
87
  if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
60
88
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
61
89
  if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
@@ -79,30 +107,48 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
79
107
  if (definition.policyFree && !policyFree) {
80
108
  throw new Error(review ? "review accept requires --policy-free; use ahub memory to apply the host's privacy policy" : `${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
81
109
  }
110
+ let canonicalProject: string | undefined;
111
+ const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
82
112
  if (sub === "migrate") {
83
113
  const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
84
114
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
85
115
  const mode = options["--dry-run"] ? "dry-run" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "apply";
86
- return JSON.stringify(await migrate({ memoryDir: memoryHome(), mode, ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true }), null, 2);
116
+ const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
117
+ const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
118
+ if (shardRequested) {
119
+ if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
120
+ return JSON.stringify(await migrateShards({ ...common, mode }), null, 2);
121
+ }
122
+ return JSON.stringify(await migrate({ ...common, mode }), null, 2);
87
123
  }
124
+ if (sub === "status") return JSON.stringify(shardMigrationStatus(memoryHome()), null, 2);
88
125
  if (sub === "prune") {
89
126
  const applying = options["--apply"] === true;
90
- if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune --cutoff <UTC> --dry-run | --apply --plan <file>");
127
+ if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune [--user] --cutoff <UTC> --dry-run | [--user] --apply --plan <file>");
91
128
  let input: string | undefined;
92
129
  if (applying) {
93
130
  input = readPrunePlanFile(String(options["--plan"]));
94
131
  }
95
- const store = new MemoryStore({ deferPromotions: true });
132
+ const project = boundProject();
133
+ if (options["--user"] === true) {
134
+ if (!shardStatePresent()) throw new Error("prune --user requires an active curated shard migration; the default prune command selects the current project");
135
+ readShardedSelection(memoryHome());
136
+ }
137
+ const store = options["--user"] === true
138
+ ? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
139
+ : curatedStore(project, { deferPromotions: true });
96
140
  try {
97
- const auth = { project: memoryProjectKey(context.cwd ?? process.cwd()), actor: "person" as const, authorize: () => true };
141
+ const auth = { project, actor: "person" as const, authorize: () => true };
98
142
  return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
99
143
  } finally { store.close(); }
100
144
  }
101
145
  if (review) {
102
- if (resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use the profile-aware host after its integration");
146
+ if (sub === "accept" && options["--kind"] !== "memory" && shardStatePresent()) throw new Error("candidate acceptance requires shard-aware review integration; use --kind memory only for a general memory candidate");
147
+ if (sub === "accept" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use --kind memory only for a general memory candidate");
148
+ if (sub === "list" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; select --kind memory for the standalone memory candidate surface");
103
149
  const id = parsed.positionals[0];
104
150
  if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
105
- const project = memoryProjectKey(context.cwd ?? process.cwd()), candidates = new CandidateStore();
151
+ const project = boundProject(), candidates = new CandidateStore();
106
152
  try {
107
153
  if (sub === "list") {
108
154
  const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
@@ -115,34 +161,89 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
115
161
  }
116
162
  const candidate = candidates.get(id!, project);
117
163
  if (!candidate) throw new Error("candidate unavailable");
164
+ if (options["--kind"] === "memory" && candidate.input.kind !== "memory") throw new Error("--kind memory accepts only a general memory candidate");
118
165
  if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use ahub memory demote or forget after review");
166
+ if (candidate.input.kind !== "memory" && (shardStatePresent() || resolveProfile(memoryHome()).kind === "generation")) throw new Error("candidate acceptance requires shard-aware and profile-aware host integration for this candidate kind; the candidate remains pending");
167
+ const standalone = createStandalonePolicy();
168
+ if (!await standalone.screen(candidate.input)) throw new Error("candidate refused by current host policy; current standalone privacy policy declined it");
119
169
  // Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
120
- const store = new MemoryStore({ deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
170
+ const store = curatedStore(project, { deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
121
171
  try {
122
172
  const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
123
173
  policy: (proposal, _target, note) => !looksSecret(JSON.stringify(proposal.input)) && !looksSecret(JSON.stringify(note)),
124
174
  });
125
- return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary v1 note ${result.noteId}`;
175
+ return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary note ${result.noteId}`;
126
176
  } finally { store.close(); }
127
177
  } finally { candidates.close(); }
128
178
  }
179
+ if (sub === "vault-candidates") {
180
+ const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
181
+ try {
182
+ const result = await vaultCandidates(store, { project, policy: createStandalonePolicy(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
183
+ return JSON.stringify(result, null, 2);
184
+ } finally { store.close(); }
185
+ }
186
+ if (sub === "capacity") {
187
+ const share = options["--disk-share"];
188
+ if (share !== undefined && (!Number.isFinite(Number(share)) || Number(share) <= 0 || Number(share) > 1)) throw new Error("--disk-share must be a number in (0, 1]");
189
+ const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
190
+ if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
191
+ // Person-only (agent marker and TTY are refused above): preview first, --confirm writes the home default config.
192
+ const file = join(memoryHome(), "capture-config.json");
193
+ const target: CaptureConfig = {
194
+ capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
195
+ retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
196
+ };
197
+ if (!Number.isFinite(target.capMb) || target.capMb <= 0 || !Number.isFinite(target.retentionDays) || target.retentionDays <= 0) throw new Error("capacity apply needs positive --cap-mb and --retention-days");
198
+ const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
199
+ const previewFile = join(memoryHome(), "capture-config.preview.json");
200
+ if (options["--confirm"] !== true) {
201
+ writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
202
+ return JSON.stringify({ preview, confirm: "re-run with --confirm to write" }, null, 2);
203
+ }
204
+ let approved: { file?: string; current?: unknown; target?: CaptureConfig };
205
+ try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
206
+ catch { throw new Error("capacity apply needs a preview first; re-run without --confirm"); }
207
+ if (approved.file !== file || JSON.stringify(approved.current) !== JSON.stringify(preview.current)
208
+ || approved.target?.capMb !== target.capMb || approved.target?.retentionDays !== target.retentionDays) {
209
+ throw new Error("capacity preview changed; re-run without --confirm");
210
+ }
211
+ const written = writeCaptureConfigFile(file, target);
212
+ rmSync(previewFile, { force: true });
213
+ return JSON.stringify({ preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } }, null, 2);
214
+ }
215
+ if (sub === "alias") {
216
+ if (parsed.path[1] === "add") {
217
+ const [from, to] = parsed.positionals;
218
+ if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
219
+ const op = appendAlias(memoryHome(), { alias: from, key: to, actor: "person", machine: machineId(memoryHome()).id });
220
+ return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
221
+ }
222
+ const ops = listAliases(memoryHome());
223
+ return options["--json"] ? JSON.stringify(ops, null, 2) : ops.length ? ops.map((op) => `${op.at} ${op.alias} -> ${op.key} (${op.actor})`).join("\n") : "no aliases recorded";
224
+ }
129
225
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
130
226
  if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
131
227
  const crosses = definition.policyFree === true && policyFree;
132
- // Reads can trigger held-note reconciliation. Defer it unless this terminal explicitly authorized crossing policy.
133
- const open = () => new MemoryStore({ screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
134
228
  const cwd = context.cwd ?? process.cwd();
229
+ // Reads can trigger held-note reconciliation. Defer it unless this terminal explicitly authorized crossing policy.
230
+ const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
135
231
  // A person's reads are unscreened. Writes crossing the boundary remain secret-screened without host policy.
136
- if (goal) return rawGoalCommand(args, cwd, { open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) });
232
+ if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) });
137
233
  const receipt: { bridgeStatus?: { skippedRows: number } } = {};
138
- const output = await rawMemoryCommand(args, cwd, { open, readable: notes => notes, importPolicy: () => () => false,
234
+ const output = await rawMemoryCommand(args, cwd, { project: boundProject(), open, readable: notes => notes, importPolicy: () => () => false,
139
235
  onBridgeStatus: status => { receipt.bridgeStatus = status; } });
236
+ if (sub === "import" && parsed.path[1] === "claude-mem") {
237
+ const policy = createStandalonePolicy();
238
+ const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...(options["--db"] ? { db: String(options["--db"]) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}), restart: options["--restart"] === true });
239
+ return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work }, null, 2) : `${output}\nwork lists: ${JSON.stringify(work)}`;
240
+ }
140
241
  return !options["--json"] && receipt.bridgeStatus
141
242
  ? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
142
243
  }
143
244
 
144
245
  export async function main(argv = process.argv.slice(2)): Promise<void> {
145
- try { console.log(await runCli(argv)); }
246
+ try { const output = await runCli(argv); if (output) console.log(output); }
146
247
  catch (error) { console.error(`amem: ${(error as Error).message}`); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; }
147
248
  }
148
249
 
@@ -21,6 +21,8 @@ const filters = { ...json, "--tier": { description: "Memory tier", value: ["shor
21
21
  export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
22
22
  export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
23
23
  export const COMMANDS: Record<string, CommandDefinition> = {
24
+ mcp: { description: "Run the project-bound MCP stdio server for this session" },
25
+ "vault-candidates": { description: "List screened durable curated vault candidates", options: { ...json, "--limit": text("Maximum candidates (1..100)") } },
24
26
  list: { description: "List memory", options: filters },
25
27
  search: { description: "Search memory", options: filters, args: [{ name: "words", variadic: true }] },
26
28
  show: { description: "Show a note", options: json, args: [{ name: "id" }] },
@@ -50,19 +52,30 @@ export const COMMANDS: Record<string, CommandDefinition> = {
50
52
  review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
51
53
  list: { description: "List review candidates", options: {
52
54
  "--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
53
- "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay"] }, "--limit": text("Maximum candidates (1..500)"),
55
+ "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory"] }, "--limit": text("Maximum candidates (1..500)"),
54
56
  } },
55
- accept: { description: "Accept one candidate through the current memory policy", options: policy, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
57
+ accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Explicit standalone candidate kind for selected profiles", value: ["memory"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
56
58
  reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
57
59
  } },
58
60
  migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
59
61
  ...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
60
62
  "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
63
+ "--shards": flag("Migrate the curated home into project and user shards"),
61
64
  } },
65
+ status: { description: "Report shard migration and pending old-layout appends", options: json },
62
66
  prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: {
63
67
  ...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"),
68
+ "--user": flag("Select the user shard explicitly after a curated split (default: current project)"),
64
69
  "--apply": flag("Apply the exact local plan"), "--plan": { description: "Sealed local plan JSON file", value: "path" },
65
70
  } },
71
+ capacity: { description: "Report capture use and propose R6 bounds (R7)", options: { ...json, "--disk-share": text("Disk-share bound for the proposal (default 0.02)") }, commands: {
72
+ apply: { description: "Write the proposed (or given) bounds to the home capture config", mutates: true, options: {
73
+ ...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview") } },
74
+ } },
75
+ alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
76
+ list: { description: "List alias ops" },
77
+ add: { description: "Map one key onto its canonical key (append-only op)", args: [{ name: "from-key" }, { name: "to-key" }], mutates: true },
78
+ } },
66
79
  completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
67
80
  help: { description: "Show help" }, version: { description: "Show version" },
68
81
  };
package/src/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export * from "./memory/store.ts";
2
+ export * from "./memory/capture.ts";
3
+ export { fts5Available, ftsTerms, SearchIndex, captureSearch } from "./memory/search-fts.ts";
4
+ export type { CuratedDoc, RankedRef } from "./memory/search-fts.ts";
5
+ export * from "./memory/shard.ts";
6
+ export * from "./memory/catalog.ts";
7
+ export * from "./memory/capacity.ts";
2
8
  export * from "./memory/sync.ts";
3
9
  export * from "./memory/import.ts";
4
10
  export * from "./memory/project.ts";
@@ -10,3 +16,10 @@ export * from "./memory/ops-contract.ts";
10
16
  export * from "./memory/generation.ts";
11
17
  export * from "./memory/migrate.ts";
12
18
  export * from "./memory/prune.ts";
19
+ export * from "./memory/shard-migration.ts";
20
+
21
+ export * from "./mcp.ts";
22
+ export * from "./mcp-memory.ts";
23
+ export * from "./memory/policy.ts";
24
+ export * from "./memory/work-lists.ts";
25
+ export * from "./memory/vault.ts";
@@ -0,0 +1,151 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { McpHandlers } from "./mcp.ts";
4
+ import { CandidateStore } from "./memory/candidates.ts";
5
+ import { capacityReport } from "./memory/capacity.ts";
6
+ import { CaptureStore } from "./memory/capture.ts";
7
+ import { readSelector } from "./memory/generation.ts";
8
+ import { memoryHome } from "./memory/home.ts";
9
+ import { canonicalPath, realPath } from "./memory/platform.ts";
10
+ import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
11
+ import { memoryProjectKey } from "./memory/project.ts";
12
+ import { registerReaderLease } from "./memory/reader-lease.ts";
13
+ import { SearchIndex } from "./memory/search-fts.ts";
14
+ import { readShardedSelection, resolveAlias } from "./memory/shard.ts";
15
+ import { MemoryStore, openShardStore, type Graph, type View } from "./memory/store.ts";
16
+ import { vaultCandidates } from "./memory/vault.ts";
17
+ import { WorkListStore, type WorkListFields } from "./memory/work-lists.ts";
18
+
19
+ // ponytail: 5000 rows per tier per operation; above this ceiling refuse rather than select on an incomplete privacy-filtered corpus. Upgrade to screened streaming pagination.
20
+ export const MCP_SCAN_LIMIT = 5000;
21
+ export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions }
22
+ export interface MemoryMcp { tools: McpHandlers; close(): void }
23
+ const unavailable = (): never => { throw new Error("memory operation unavailable"); };
24
+ const bound = (args: Record<string, unknown>, keys: string[]) => { if (Object.keys(args).some(key => !keys.includes(key))) unavailable(); };
25
+ const limitOf = (args: Record<string, unknown>): number => args.limit === undefined ? 20 : Number.isSafeInteger(args.limit) && Number(args.limit) >= 1 && Number(args.limit) <= 100 ? Number(args.limit) : unavailable();
26
+ const textOf = (args: Record<string, unknown>, key: string, optional = false): string | undefined => {
27
+ const value = args[key]; if (value === undefined && optional) return undefined;
28
+ return typeof value === "string" && value.trim() && !value.includes("\0") ? value : unavailable();
29
+ };
30
+ const sinceOf = (args: Record<string, unknown>): string | undefined => { const since = textOf(args, "since", true); if (since !== undefined && !Number.isFinite(Date.parse(since))) unavailable(); return since; };
31
+
32
+ /** All identities are resolved once. Per-call arguments cannot change projects, homes, profiles or policy. */
33
+ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
34
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base ?? memoryHome());
35
+ const requested = options.project ?? memoryProjectKey(cwd), project = resolveAlias(base, requested);
36
+ const policy = createStandalonePolicy(options.policy), release = registerReaderLease(base);
37
+ const closers: (() => void)[] = [release]; let closed = false;
38
+ const close = () => { if (closed) return; closed = true; let failure: unknown; for (const closer of closers.reverse()) { try { closer(); } catch (error) { failure ??= error; } } if (failure) throw failure; };
39
+ try {
40
+ const selection = readSelector(base).revision;
41
+ const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
42
+ const shardSelection = sharded ? JSON.stringify(readShardedSelection(base)) : undefined;
43
+ const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true }) : new MemoryStore({ memoryDir: base, deferPromotions: true });
44
+ closers.push(() => store.close());
45
+ const capture = new CaptureStore({ memoryDir: base, project }); closers.push(() => capture.close());
46
+ const candidates = new CandidateStore({ memoryDir: base }); closers.push(() => candidates.close());
47
+ const work = new WorkListStore({ base, project, policy: async row => { const permitted = await policy.screen(row); guard(); return permitted; } }); closers.push(() => work.close());
48
+ const guard = () => {
49
+ if (closed) unavailable();
50
+ release.verify();
51
+ if (resolveAlias(base, requested) !== project || readSelector(base).revision !== selection) unavailable();
52
+ const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
53
+ if (split !== sharded || split && JSON.stringify(readShardedSelection(base)) !== shardSelection) unavailable();
54
+ };
55
+ const screen = async <T>(rows: T[]): Promise<T[]> => {
56
+ if (rows.length > MCP_SCAN_LIMIT) unavailable();
57
+ const publicRows: T[] = []; for (const row of rows) if (await policy.screen(row)) publicRows.push(row); return publicRows;
58
+ };
59
+ const curated = async (superseded = false) => {
60
+ guard();
61
+ // list() includes shared rows: remove other projects before screening or selection.
62
+ const rows = store.list({ project, includeSuperseded: superseded, limit: MCP_SCAN_LIMIT + 1 });
63
+ if (rows.length > MCP_SCAN_LIMIT) unavailable();
64
+ return screen(rows.filter(row => row.project === project));
65
+ };
66
+ const captures = async () => screen(capture.rows({ project, limit: MCP_SCAN_LIMIT + 1 }));
67
+ const find = (rows: View[], id: string): View | null => { const found = rows.filter(row => row.id === id || row.id.startsWith(id.replace(/^mem:/, ""))); return found.length === 1 ? found[0]! : null; };
68
+ const tools: McpHandlers = {
69
+ async amem_search(args) {
70
+ bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
71
+ if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
72
+ if (!await policy.screen({ query })) unavailable();
73
+ const notes = await curated(), recalls = args.includeCapture === false ? [] : await captures();
74
+ const hits = [...notes.map(view => ({ source: "curated" as const, evidence: true, view, ref: view.id, at: view.at, tier: view.tier, body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })),
75
+ ...recalls.map(row => ({ source: "capture" as const, evidence: false, capture: row, ref: `capture:${row.id}`, at: row.at, tier: "capture", body: `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}` }))].filter(hit => !since || Date.parse(hit.at) >= Date.parse(since));
76
+ const index = new SearchIndex(":memory:");
77
+ try {
78
+ index.resync(() => hits.map(hit => ({ id: hit.ref, at: hit.at, project, tier: hit.tier, scope: "project", body: hit.body })));
79
+ if (index.available && index.needsResync(hits.length)) unavailable();
80
+ const ranks = new Map(index.query(query, MCP_SCAN_LIMIT * 2, project).map(row => [row.ref, row.rank]));
81
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
82
+ const selected = hits.flatMap(hit => {
83
+ const rank = ranks.get(hit.ref);
84
+ if (index.available ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
85
+ const weight = hit.tier === "long" ? 1 : hit.tier === "capture" ? 0.5 : 0.9;
86
+ const score = -(rank ?? -1) * weight / (1 + Math.max(0, Date.now() - Date.parse(hit.at)) / (30 * 86400_000));
87
+ return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "curated" ? { view: hit.view } : { capture: hit.capture }), score }];
88
+ }).sort((a, b) => b.score - a.score).slice(0, limit);
89
+ guard(); return { engine: index.available ? "fts5" : "substring", hits: selected };
90
+ } finally { index.close(); }
91
+ },
92
+ async amem_show(args) {
93
+ bound(args, ["id"]); guard(); const id = textOf(args, "id")!;
94
+ if (/^capture:[1-9][0-9]*$/.test(id)) { const row = (await captures()).find(row => `capture:${row.id}` === id); guard(); return row ? { source: "capture", evidence: false, capture: row } : null; }
95
+ const result = find(await curated(true), id); guard(); return result;
96
+ },
97
+ async amem_timeline(args) {
98
+ bound(args, ["id", "since", "limit"]); const rows = await curated(true), recalls = await captures(), id = textOf(args, "id", true), since = sinceOf(args), limit = limitOf(args);
99
+ const entries = [...rows.map(row => ({ at: row.at, value: row as unknown })), ...recalls.map(row => ({ at: row.at, value: { source: "capture", evidence: false, capture: row } as unknown }))];
100
+ let selected = entries.filter(row => !since || Date.parse(row.at) >= Date.parse(since));
101
+ if (id) {
102
+ const anchor = id.startsWith("capture:") ? recalls.find(row => `capture:${row.id}` === id) : find(rows, id);
103
+ if (!anchor) return []; selected = selected.sort((a, b) => Math.abs(Date.parse(a.at) - Date.parse(anchor.at)) - Math.abs(Date.parse(b.at) - Date.parse(anchor.at)));
104
+ } else selected.sort((a, b) => b.at.localeCompare(a.at));
105
+ guard(); return selected.slice(0, limit).map(row => row.value);
106
+ },
107
+ async amem_graph(args) {
108
+ bound(args, ["start", "hops", "limit"]); const start = textOf(args, "start")!, cap = limitOf(args), hops = args.hops ?? 1;
109
+ if (hops !== 1 && hops !== 2) unavailable();
110
+ const rows = await curated(true); const result = screenedGraph(rows, start, Number(hops), cap); guard(); return result;
111
+ },
112
+ async amem_work_state_read(args) {
113
+ bound(args, ["list", "includeClosed"]); guard();
114
+ if (args.includeClosed !== undefined && typeof args.includeClosed !== "boolean") unavailable();
115
+ // Reduce complete views before policy: individually public appends can compose into private state.
116
+ const result = await work.read(textOf(args, "list", true), true);
117
+ const permitted: typeof result = [];
118
+ for (const view of result) if (await policy.screen(view)) permitted.push(view);
119
+ guard();
120
+ return args.includeClosed === true ? permitted : permitted.filter(view => !view.closed).map(view => ({ ...view, items: view.items.filter(item => item.fields.status !== "done" && item.fields.status !== "dropped") }));
121
+ },
122
+ async amem_work_state_write(args) { bound(args, ["list", "fields"]); guard(); const result = await work.write(textOf(args, "list")!, args.fields as WorkListFields); guard(); return result; },
123
+ async amem_remember(args) {
124
+ bound(args, ["text", "title", "kind"]); guard();
125
+ const kind = args.kind ?? "finding"; if (!["decision", "finding", "contract", "fail"].includes(String(kind))) unavailable();
126
+ const input = { project, kind: "memory" as const, text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" };
127
+ if (!await policy.screen(input)) unavailable(); guard(); return { id: candidates.put(input, "public") };
128
+ },
129
+ async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy, limit: limitOf(args) }); guard(); return result; },
130
+ async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; },
131
+ };
132
+ return { tools, close };
133
+ } catch (error) { close(); throw error; }
134
+ }
135
+
136
+ /** Build only from screened project rows; even an absent starting node does not leak its label. */
137
+ function screenedGraph(rows: View[], start: string, hops: number, cap: number): Graph {
138
+ const nodes = new Map<string, Graph["nodes"][number]>(), edges: Graph["edges"] = [];
139
+ const views = new Map(rows.map(row => [`mem:${row.id}`, row]));
140
+ for (const [id, row] of views) {
141
+ nodes.set(id, { id, type: "memory", label: (row.title ?? row.text).slice(0, 60), kind: row.kind, tier: row.tier });
142
+ for (const [other, rel] of [...row.about.map(node => [node, row.kind === "fail" ? "rules_out" : "about"] as const), [`peer:${row.peer}`, "by"], [`machine:${row.machine}`, "by"], [`project:${row.project}`, "in"]]) {
143
+ nodes.set(other!, { id: other!, type: other!.split(":")[0]!, label: other!.slice(other!.indexOf(":") + 1) }); edges.push({ from: id, to: other!, rel: rel! });
144
+ }
145
+ for (const old of row.supersedes) if (views.has(`mem:${old}`)) edges.push({ from: id, to: `mem:${old}`, rel: "supersedes" });
146
+ }
147
+ if (!nodes.has(start)) return { nodes: [], edges: [] };
148
+ const selected = new Set([start]); let frontier = [start];
149
+ for (let hop = 0; hop < hops; hop++) { const next: string[] = []; for (const id of frontier) for (const edge of edges) { const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined; if (other && !selected.has(other) && selected.size < cap) { selected.add(other); next.push(other); } } frontier = next; }
150
+ return { nodes: [...selected].map(id => nodes.get(id)!), edges: edges.filter(edge => selected.has(edge.from) && selected.has(edge.to)) };
151
+ }