@staix/agent-mem 0.13.0 → 0.15.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
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.13.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.15.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -300,7 +300,7 @@ Prune apply still requires a sealed JSON plan. Save `amem prune --dry-run --cuto
300
300
 
301
301
  Intake runs at the end of each Codex, Kimi, Pi and Qwen session once `amem setup <cli> --apply` has registered it. Sync is off unless you ask for it: amem registers no sync hook and runs nothing between sessions. To sync on a schedule you have authorized, call `amem sync --if-due` from the chosen shell or scheduler; it has no TTY gate. This release registers no sync schedule or hook, and the configured privacy and remote-visibility checks still apply. It syncs at most hourly, backs off 15 minutes doubling to a day after failures, stops after 30 seconds, and does nothing without a remote set by `amem sync --remote <private git url>`. `--force` skips the schedule and the backoff.
302
302
 
303
- Split-home sync durably holds verified foreign-generation prunes with a "held, not applied" warning while unrelated sync continues; targets can remain visible, and full cross-generation removal remains a separate ceiling (see [the sync contract](docs/contracts.md#per-shard-private-sync-15-t3)).
303
+ V1 split-home sync durably holds verified foreign-generation prunes with a "held, not applied" warning. The opt-in v2 path below applies eligible complete same-realm public prunes across generations after durable proof and root-map commitment. Unsupported historical lineage and multi-realm translations remain held; targets can remain visible and agent-mem#15 AC4 stays open (see [the sync contract](docs/contracts.md#per-shard-private-sync-15-t3)).
304
304
 
305
305
  ## Native transcript intake
306
306
 
@@ -481,3 +481,22 @@ Run `amem consolidate [--project <current-key>]` (another project is refused) to
481
481
  The CLI runs the deterministic pass only. A library host may explicitly enable an injected model callback for the person; no provider is built in. It has batch, byte, deadline and persistent daily limits. Model answers remain marked machine-local candidates that cannot be accepted; `amem note add` is the separate path for an independently authored note. A non-answer is never saved.
482
482
 
483
483
  Agents use `amem_evidence` with a question to get cited, live curated entries, not an answer. The complete JSON bundle is byte-capped (`maxBytes`, default 4096, range 256..8192); excerpted entries retain an exact `mem:` reference for fetching the full note. Capture, repository-only rows, pending candidates and model answers are excluded. amem makes no model call for this tool. Rendering a citation in the bundle records only `rendered` after successful output, never `delivered` or `cited`.
484
+
485
+ ### Public prune view and v2 sync (opt-in)
486
+
487
+ On an active curated split, the person can preview and enable the forward-only v2 reader floor. This keeps the receiving generation and native logs/seals, builds a separate `index-v2.db`, and preserves every native/local mask. Once activated, explicit sync uses the v2 transport and can install complete generation-neutral public-prune proof; historical hold receipts alone never authorize a mask.
488
+
489
+ ```sh
490
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --dry-run
491
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --apply
492
+ # Finish an interrupted activation, using its exact recorded forward state.
493
+ AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --resume
494
+ ```
495
+
496
+ Dry-run writes nothing and reports mask building as deferred. Close cooperating reader handles for initial v1-to-v2 activation or its resume, and install compatible hosts first: older readers reject the new markers and transport tip. Initial activation preserves all native/local masks and currently performs ten old/new comparison rebuilds per realm across five verification passes, plus snapshot/index work. Deactivation is refused because v1 would omit foreign masks. Unannounced historical readers can still inspect unchanged old bytes; this is logical masking, not erasure.
497
+
498
+ The first explicit v2 `amem sync` primes a private create-once publisher boundary for each selected shard/current machine. Only complete own prune rows appended after that boundary can receive new `post-seal` lineage; all earlier rows and unsupported historical lineage stay ineligible. Sync never infers a native-generation claim or rewrites native seals/rows. Both machines need compatible v2 readers and explicit sync. Stable exact project/user refs make a one-way v1-to-v2 history transition; returning a ref to v1 is refused.
499
+
500
+ Sync validates all selected history and complete raw Git proof, and checks each realm's aggregate input/archive capacity before atomic publication. It then fsyncs immutable inputs and commits the root map before ordinary/native imports, preventing an unmasked first import. Already-v2 map advances preserve generation/format and monotone ancestry, use a forward-only intent and rebind existing readers without requiring them to close. A reader waits at most 3000 ms for a v2 writer; an unresolved intent, incompatible marker pair or missing committed proof reports unavailable. The same `--resume` command completes an interrupted advance forward. Read and capture never activate, prime a boundary or repair a pending operation.
501
+
502
+ `SyncResult.pruneApplication` reports transport durability, complete-proof count, neutral projection activity, native application, held count and phase separately. Transport durability alone does not establish completed local application. Historical multi-realm translations and older unsupported lineage retain "held, not applied" warnings, so agent-mem#15 AC4 remains open. The [full contract](docs/generation-neutral-prune-witness.md) states the owned-source integrity and resource ceilings; this source behavior does not establish release or live-host verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -18,7 +18,8 @@
18
18
  "@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0",
19
19
  "@staix/agent-mem-pre-vault": "npm:@staix/agent-mem@0.3.1",
20
20
  "@staix/agent-mem-v011": "npm:@staix/agent-mem@0.11.0",
21
- "@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0"
21
+ "@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0",
22
+ "@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0"
22
23
  },
23
24
  "exports": {
24
25
  ".": "./src/index.ts",
@@ -6,6 +6,16 @@ import type { ParsedCommand } from "./commands-table.ts";
6
6
 
7
7
  const UNAVAILABLE = "Memory operation unavailable or refused; no partial result is shown.";
8
8
  const RESULT_BYTES = 262_144;
9
+ const AGENT_READS = "Agent reads: search, list, show, brief, graph, vault-candidates, capacity.";
10
+
11
+ /** A refusal decided from the parsed command alone, before any store or file is opened: safe to show as is. */
12
+ export class AgentUsageError extends Error {
13
+ constructor(message: string) { super(message); this.name = "AgentUsageError"; }
14
+ }
15
+ const accepts = (command: string, allowed: readonly string[]) => new AgentUsageError(allowed.length
16
+ ? `agent-marked ${command} accepts only ${allowed.length === 1 ? allowed[0] : `${allowed.slice(0, -1).join(", ")} and ${allowed.at(-1)}`}.`
17
+ : `agent-marked ${command} accepts no options.`);
18
+
9
19
  type ReadCall = { tool: keyof ReturnType<typeof createMemoryMcp>["tools"]; args: Record<string, unknown> };
10
20
 
11
21
  function values(argv: readonly string[], name: string): string[] {
@@ -29,50 +39,54 @@ function positiveLimit(value: string | boolean | undefined): number | undefined
29
39
  function route(parsed: ParsedCommand, argv: readonly string[]): ReadCall | undefined {
30
40
  const { path, options, positionals, definition } = parsed;
31
41
 
32
- if (path[0] === "machine" && options["--label"] === undefined) throw new Error(UNAVAILABLE);
42
+ if (path[0] === "machine" && options["--label"] === undefined) throw new AgentUsageError(`amem machine without --label is not available to an agent-marked session. ${AGENT_READS}`);
33
43
  // Write/preview/confirmation paths continue through the guarded administrative handlers.
34
44
  if (definition.mutates !== undefined) return undefined;
35
- if (path[0] === "project" && path[1] === "export") throw new Error(UNAVAILABLE);
45
+ if (path[0] === "project" && path[1] === "export") throw new AgentUsageError(`amem project export is not available to an agent-marked session. ${AGENT_READS}`);
36
46
  if (path[0] === "setup") return undefined;
37
47
 
38
48
  const flags = Object.keys(options).filter(key => key !== "--json" && key !== "--policy-free");
39
49
  const only = (...allowed: string[]) => flags.every(flag => allowed.includes(flag));
40
50
  const limit = positiveLimit(options["--limit"]);
41
- if (options["--limit"] !== undefined && limit === undefined) throw new Error(UNAVAILABLE);
51
+ if (options["--limit"] !== undefined && limit === undefined) throw new AgentUsageError("--limit takes a whole number from 1 to 100.");
42
52
 
43
53
  if (path[0] === "search") {
44
- if (!only("--limit", "--since", "--explain") || !positionals.join(" ").trim()) throw new Error(UNAVAILABLE);
54
+ if (!only("--limit", "--since", "--explain")) throw accepts("search", ["--limit", "--since", "--explain"]);
55
+ if (!positionals.join(" ").trim()) throw new AgentUsageError("agent-marked search needs a query.");
45
56
  return { tool: "amem_search", args: { query: positionals.join(" "), ...(limit === undefined ? {} : { limit }), ...(options["--since"] !== undefined ? { since: options["--since"] } : {}), ...(options["--explain"] === true ? { explain: true } : {}) } };
46
57
  }
47
58
  if (path[0] === "list") {
48
- if (!only("--limit", "--since")) throw new Error(UNAVAILABLE);
59
+ if (!only("--limit", "--since")) throw accepts("list", ["--limit", "--since"]);
49
60
  return { tool: "amem_timeline", args: { ...(limit === undefined ? {} : { limit }), ...(options["--since"] !== undefined ? { since: options["--since"] } : {}) } };
50
61
  }
51
62
  if (path[0] === "show") {
52
- if (!only() || positionals.length !== 1 || !positionals[0]!.trim()) throw new Error(UNAVAILABLE);
63
+ if (!only()) throw accepts("show", []);
64
+ if (positionals.length !== 1 || !positionals[0]!.trim()) throw new AgentUsageError("agent-marked show takes exactly one id.");
53
65
  return { tool: "amem_show", args: { id: positionals[0] } };
54
66
  }
55
67
  if (path[0] === "brief") {
56
- if (!only("--for", "--paths", "--symbols", "--bytes") || typeof options["--for"] !== "string") throw new Error(UNAVAILABLE);
68
+ if (!only("--for", "--paths", "--symbols", "--bytes")) throw accepts("brief", ["--for", "--paths", "--symbols", "--bytes"]);
69
+ if (typeof options["--for"] !== "string") throw new AgentUsageError("agent-marked brief needs --for.");
57
70
  const rawBytes = options["--bytes"];
58
- if (rawBytes !== undefined && !/^[1-9][0-9]*$/.test(String(rawBytes))) throw new Error(UNAVAILABLE);
71
+ if (rawBytes !== undefined && !/^[1-9][0-9]*$/.test(String(rawBytes))) throw new AgentUsageError("--bytes takes a positive whole number.");
59
72
  return { tool: "amem_brief", args: { intent: options["--for"], paths: values(argv, "--paths"), symbols: values(argv, "--symbols"), ...(rawBytes === undefined ? {} : { maxBytes: Number(rawBytes) }) } };
60
73
  }
61
74
  if (path[0] === "graph") {
62
- if (!only("--hops") || positionals.length !== 1) throw new Error(UNAVAILABLE);
75
+ if (!only("--hops")) throw accepts("graph", ["--hops"]);
76
+ if (positionals.length !== 1) throw new AgentUsageError("agent-marked graph takes exactly one start id.");
63
77
  const hops = options["--hops"] === undefined ? 1 : Number(options["--hops"]);
64
- if (hops !== 1 && hops !== 2) throw new Error(UNAVAILABLE);
78
+ if (hops !== 1 && hops !== 2) throw new AgentUsageError("--hops takes 1 or 2.");
65
79
  return { tool: "amem_graph", args: { start: positionals[0], ...(options["--hops"] === undefined ? {} : { hops }) } };
66
80
  }
67
81
  if (path[0] === "vault-candidates") {
68
- if (!only("--limit") || options["--all-projects"] !== undefined || options["--project"] !== undefined) throw new Error(UNAVAILABLE);
82
+ if (!only("--limit") || options["--all-projects"] !== undefined || options["--project"] !== undefined) throw accepts("vault-candidates", ["--limit"]);
69
83
  return { tool: "amem_vault_candidates", args: limit === undefined ? {} : { limit } };
70
84
  }
71
85
  if (path[0] === "capacity" && path.length === 1) {
72
- if (!only()) throw new Error(UNAVAILABLE);
86
+ if (!only()) throw accepts("capacity", []);
73
87
  return { tool: "amem_capacity", args: {} };
74
88
  }
75
- throw new Error(UNAVAILABLE);
89
+ throw new AgentUsageError(`amem ${path.join(" ")} is not available to an agent-marked session. ${AGENT_READS}`);
76
90
  }
77
91
 
78
92
  /** Route marked-agent read commands through the same startup-bound MCP policy and handlers. */
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { agentCliRead } from "./cli-agent-reads.ts";
1
+ import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
2
+ import { AgentUsageError, agentCliRead } from "./cli-agent-reads.ts";
2
3
  import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import type { Writable } from "node:stream";
@@ -211,6 +212,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
211
212
  if (!readable) throw new CliPartialApplyError(error.applied.length, error.jsonOutput);
212
213
  const safe = new DecayApplyError(error.report, undefined); safe.jsonOutput = error.jsonOutput; throw safe;
213
214
  }
215
+ // Data-free errors (decided before any store or file is opened) keep their own message.
216
+ if (marked && error instanceof AgentUsageError) throw error;
217
+ if (marked) {
218
+ // parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
219
+ let usage: Error | undefined;
220
+ try { parseCommand(argv); } catch (parseError) { usage = parseError as Error; }
221
+ if (usage) throw Object.assign(new Error(usage.message), (error as { exitCode?: number }).exitCode === undefined ? {} : { exitCode: (error as { exitCode?: number }).exitCode });
222
+ }
214
223
  // Marked administrative failures must not reveal a target's private state or native errors.
215
224
  if (marked) throw Error("Memory operation unavailable or refused; no partial result is shown.");
216
225
  throw error;
@@ -222,14 +231,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
222
231
  await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
223
232
  return "";
224
233
  }
225
- if (argv[0] === "hook") {
234
+ if (argv[0] === "hook" && !argv.includes("--help")) {
226
235
  const { hookMain } = await import("./hooks.ts");
227
236
  return hookMain(argv.slice(1));
228
237
  }
229
238
  if (argv[0] === "ingest") {
230
239
  // Capture-only native intake: no project/home override, policy bypass or curated write capability.
231
240
  const parsed = parseCommand(argv);
232
- if (parsed.options["--help"] === true) return commandHelp();
241
+ if (parsed.options["--help"] === true) return commandHelp(argv);
233
242
  const kind = parsed.positionals[0];
234
243
  if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi", "qwen"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi|qwen [--since ISO] [--json]");
235
244
  const { ingestTranscript } = await import("./ingest.ts");
@@ -243,7 +252,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
243
252
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
244
253
  if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
245
254
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
246
- if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
255
+ if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp(argv);
247
256
  let parsed;
248
257
  try {
249
258
  parsed = parseCommand(argv);
@@ -344,6 +353,12 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
344
353
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
345
354
  const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
346
355
  const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
356
+ if (options["--public-prune-view"] === true) {
357
+ if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
358
+ if (actor === "agent" && mode !== "dry-run") administrative({ command: "migrate --public-prune-view", mode });
359
+ const result = await activatePublicPruneView({ memoryDir: memoryHome(), mode: mode as "dry-run" | "apply" | "resume", authorizedPerson: true, tty: true });
360
+ return options["--json"] ? JSON.stringify(result, null, 2) : `public prune view: ${result.mode}; generation ${result.generation}; mask build ${result.maskBuildCheck}; forward-only, no deactivation`;
361
+ }
347
362
  const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
348
363
  if (shardRequested) {
349
364
  if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
@@ -882,7 +897,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
882
897
  const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
883
898
  try {
884
899
  output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
885
- sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
900
+ sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
886
901
  onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
887
902
  } catch (error) {
888
903
  if (sub === "sync") stampSync((error as Error).message, stop?.cause());
@@ -107,6 +107,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
107
107
  ...json, "--apply": flag("Apply the previewed migration"), "--dry-run": flag("Read-only migration receipt (default; kept as an alias)"), "--generation": text("Explicit 32-hex generation ID"),
108
108
  "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
109
109
  "--shards": flag("Migrate the curated home into project and user shards"),
110
+ "--public-prune-view": flag("Enable the forward-only v2 public prune view on an active split"),
110
111
  } },
111
112
  status: { description: "Report shard migration and pending old-layout appends", options: json },
112
113
  doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
@@ -166,7 +167,7 @@ export function parseCommand(argv: readonly string[]): ParsedCommand {
166
167
  options[token] = value;
167
168
  } else options[token] = true;
168
169
  } else if (definition.commands) {
169
- const child: CommandDefinition | undefined = definition.commands[token];
170
+ const child: CommandDefinition | undefined = Object.hasOwn(definition.commands, token) ? definition.commands[token] : undefined;
170
171
  if (!child) throw new Error(`unknown command ${[...path, token].join(" ")}`);
171
172
  commandIndexes.add(i);
172
173
  definition = { ...definition, ...child }; path.push(token);
@@ -193,9 +194,36 @@ export function parseCommand(argv: readonly string[]): ParsedCommand {
193
194
  return { path, definition, options, positionals, rawArgs };
194
195
  }
195
196
 
196
- export function commandHelp(): string {
197
- return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
198
- const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
199
- return ` ${names}${Object.entries(row.options).map(([name, option]) => ` [${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}]`).join("")}`;
200
- }), "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "Agent-marked reads use the MCP privacy ceiling. Explicit apply/confirm flags do not require a terminal; review walk and the note editor remain interactive."].join("\n");
197
+ const optionLabel = (name: string, option: OptionDefinition) =>
198
+ `${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}`;
199
+ const usageRow = (row: CompletionDefinition) =>
200
+ `${[...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ")}${Object.entries(row.options).map(([name, option]) => ` [${optionLabel(name, option)}]`).join("")}`;
201
+ const described = (rows: [string, string][]) => {
202
+ // Long enum labels (review --kind) would push every description far right; they overflow instead.
203
+ const width = Math.min(24, Math.max(...rows.map(([label]) => label.length)));
204
+ return rows.map(([label, description]) => ` ${label.padEnd(width)} ${description}`);
205
+ };
206
+
207
+ /** Full command list, or one command's help when argv names a command path (defaultCommand is not applied). */
208
+ export function commandHelp(argv: readonly string[] = []): string {
209
+ const path: string[] = [], rows = completionDefinitions();
210
+ let commands: Record<string, CommandDefinition> | undefined = COMMANDS, command: CommandDefinition | undefined, row = rows[0]!;
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const token = argv[i]!;
213
+ if (token.startsWith("--")) {
214
+ // An option's value is never a command name.
215
+ if (!token.includes("=") && row.options[token]?.value !== undefined) i++;
216
+ continue;
217
+ }
218
+ if (!commands || !Object.hasOwn(commands, token)) break;
219
+ command = commands[token]!; path.push(token); commands = command.commands;
220
+ row = rows.find(candidate => candidate.path.join(" ") === path.join(" "))!;
221
+ }
222
+ if (command && path[0] !== "help") {
223
+ const lines = [`usage: amem ${usageRow(row)}`, "", command.description, "", "options:", ...described(Object.entries(row.options).map(([name, option]) => [optionLabel(name, option), option.description]))];
224
+ if (command.commands) lines.push("", "commands:", ...described(Object.entries(command.commands).map(([name, child]) => [name, child.description])));
225
+ return lines.join("\n");
226
+ }
227
+ return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => ` ${usageRow(row)}`),
228
+ "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "Agent-marked reads use the MCP privacy ceiling. Explicit apply/confirm flags do not require a terminal; review walk and the note editor remain interactive."].join("\n");
201
229
  }
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
- import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
5
+ import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
6
6
  import { readProfileJSON, validGeneration } from "./generation.ts";
7
7
  import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
8
  import { openShardStore } from "./store.ts";
@@ -36,7 +36,7 @@ const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key,
36
36
  function capturedSelection(base: string): boolean {
37
37
  const value = readProfileJSON(join(base, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
38
38
  if (value === undefined) return false;
39
- if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || (value.phase !== "captured" && value.phase !== "active") || typeof value.selectorRevision !== "string") throw new Error("curated shard selection is unavailable or invalid");
39
+ if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || (value.phase !== "captured" && value.phase !== "active") || typeof value.selectorRevision !== "string") throw new Error("curated shard selection is unavailable or invalid");
40
40
  return value.phase === "captured";
41
41
  }
42
42
 
@@ -48,10 +48,10 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
48
48
  if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
49
49
  const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
50
50
  try {
51
- const db = new Database(join(dir, "index.db"), { readonly: true });
51
+ const db = new Database(join(dir, store.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), { readonly: true });
52
52
  try {
53
53
  db.run("PRAGMA busy_timeout = 0");
54
- const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?").get(key) as { n: number; last: string | null };
54
+ const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
55
55
  return { entries: row.n, lastAt: row.last };
56
56
  } finally { db.close(); }
57
57
  } finally { store.close(); }
@@ -197,6 +197,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
197
197
  const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
198
198
  if (json) return JSON.stringify(r, null, 2);
199
199
  if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
200
+ if (r.pruneApplication) {
201
+ const a = r.pruneApplication;
202
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
203
+ }
200
204
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
201
205
  }
202
206
  case "dedupe": {
@@ -1,6 +1,6 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { sessionStartBrief } from "../session-brief.ts";
6
6
  import { pendingSummary } from "./candidate-pending.ts";
@@ -87,8 +87,10 @@ class TooBig extends Error {}
87
87
  function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
88
88
  mkdirSync(to, { recursive: true, mode: 0o700 });
89
89
  for (const name of readdirSync(from)) {
90
- if (SKIP.test(name) || skipNames.has(name)) continue;
90
+ const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
91
+ if (SKIP.test(name) && !proofObject || skipNames.has(name)) continue;
91
92
  const source = join(from, name), stat = lstatSync(source);
93
+ if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
92
94
  if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
93
95
  if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
94
96
  else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
@@ -103,6 +105,14 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
103
105
  const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
104
106
  copyFileSync(join(home, name), join(scratch, name));
105
107
  }
108
+ const selected = readSelector(home).selector;
109
+ if (selected?.schema === "agent-mem.profile/v2") {
110
+ const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
111
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
112
+ budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
113
+ const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
114
+ copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
115
+ }
106
116
  if (layout === "split") {
107
117
  for (const key of [options.project, ...options.aliases]) {
108
118
  const dir = shardDir(home, key);
@@ -1,16 +1,20 @@
1
- import { SHARDED_SCHEMA } from "./shard.ts";
1
+ import { createPublicPruneReplayCache, type PublicPruneProjection } from "./public-prune-inputs.ts";
2
+ import { readPublicPruneView, readPublicPruneViewForActivation } from "./public-prune-view.ts";
3
+ import { SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
2
4
  import { readShardLegacySnapshot } from "./shard-bridge.ts";
3
5
  import type { Database } from "bun:sqlite";
4
6
  import { createHash } from "node:crypto";
5
7
  import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
6
8
  import { join } from "node:path";
7
- import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
9
+ import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink, rebindPublicPruneProfile } from "./generation.ts";
8
10
  import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
9
11
  import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
10
12
  import { PROMOTED_KINDS, type Entry } from "./store.ts";
11
13
 
14
+ const FOREIGN_TABLES = ["generation_foreign_inputs", "generation_foreign_rows", "generation_foreign_targets"] as const;
12
15
  const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
13
16
  const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
17
+ class PublicPruneViewChanged extends Error { constructor() { super("public prune view changed repeatedly; retry the read"); } }
14
18
  export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
15
19
  export interface GenerationIndexOptions {
16
20
  profile: BoundProfile; db: Database; now: () => number;
@@ -65,9 +69,17 @@ export class GenerationIndex {
65
69
  private fullCapture = false;
66
70
  private retainedTail = false;
67
71
  private replayProvenance = false;
72
+ private foreignStamp?: string;
73
+ private foreignCache?: { set: string; load: () => PublicPruneProjection };
68
74
  constructor(private readonly options: GenerationIndexOptions) {
69
75
  this.profile = options.profile;
70
76
  const db = options.db;
77
+ if (this.profile.foreignPruneFormat) {
78
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_inputs (id TEXT PRIMARY KEY)");
79
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_rows (id TEXT PRIMARY KEY, hash TEXT NOT NULL)");
80
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_targets (id TEXT PRIMARY KEY)");
81
+ this.replayProvenance = true; // persistent SQLite never supplies foreign membership
82
+ }
71
83
  db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
72
84
  if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
73
85
  db.run("CREATE TABLE IF NOT EXISTS generation_vault_links (id TEXT PRIMARY KEY, target TEXT NOT NULL, row_hash TEXT NOT NULL, project TEXT NOT NULL, path TEXT NOT NULL, at TEXT NOT NULL)");
@@ -93,9 +105,14 @@ export class GenerationIndex {
93
105
  /** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
94
106
  private shardFrontier(): string | undefined {
95
107
  if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
96
- const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
97
- if (!value || value.schema !== SHARDED_SCHEMA || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
98
- return hashJSON(value);
108
+ for (let attempt = 0; attempt < 6; attempt++) {
109
+ rebindPublicPruneProfile(this.profile);
110
+ const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
111
+ if (selectorRevision(this.profile.home) !== this.profile.selectorRevision) { if (this.profile.foreignPruneFormat) continue; return fail(); }
112
+ if (!value || value.schema !== (this.profile.foreignPruneFormat ? SHARDED_V2_SCHEMA : SHARDED_SCHEMA) || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
113
+ return hashJSON(value);
114
+ }
115
+ return fail();
99
116
  }
100
117
  private capture(reader: SegmentReader = this.reader): { segments: LegacySegment[]; commit?: string } {
101
118
  const shardBefore = this.shardFrontier();
@@ -119,7 +136,7 @@ export class GenerationIndex {
119
136
  const segment = own.find(s => s.path === old.path);
120
137
  if (!segment || segment.offset < old.offset) fail();
121
138
  }
122
- if (this.shardFrontier() !== shardBefore) return fail();
139
+ if (this.shardFrontier() !== shardBefore) { if (this.profile.foreignPruneFormat) throw new PublicPruneViewChanged(); return fail(); }
123
140
  return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
124
141
  }
125
142
  private index(captured: { segments: LegacySegment[]; commit?: string }): void {
@@ -161,7 +178,35 @@ export class GenerationIndex {
161
178
  const reduced = reducePruneRows(prunes, snapshot, this.profile.generation!);
162
179
  db.run("DELETE FROM generation_targets");
163
180
  for (const id of [...reduced.targets].sort()) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
164
- this.pruned = reduced.targets;
181
+ this.applyForeign(true);
182
+ this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
183
+ }
184
+ private applyForeign(validateRows = false): void {
185
+ if (!this.profile.foreignPruneFormat) return;
186
+ const profile = this.profile;
187
+ if (!profile.pruneInputSetHash || !profile.generation || !profile.sharded) return fail();
188
+ const view = (this.options.limits !== undefined ? readPublicPruneViewForActivation : readPublicPruneView)(profile.home, profile.pruneInputSetHash, profile.generation);
189
+ const key = profile.shardUser ? "user" : `project:${profile.shardKey}`, set = view.realms[key];
190
+ const db = this.options.db;
191
+ if (!set) {
192
+ if ((db.query("SELECT COUNT(*) AS n FROM generation_foreign_inputs").get() as {n:number}).n) return fail();
193
+ return;
194
+ }
195
+ if (!this.foreignCache || this.foreignCache.set !== set) this.foreignCache = { set, load: createPublicPruneReplayCache({ dir: profile.dir,
196
+ realm: profile.shardUser ? { scope: "user" } : { scope: "project", key: profile.shardKey! }, receivingGeneration: profile.generation, inputSetHash: set }) };
197
+ const proof = this.foreignCache.load();
198
+ if (validateRows || this.foreignStamp !== set) for (const item of db.query("SELECT id,line FROM generation_rows").all() as {id:string;line:string}[]) {
199
+ const expected = proof.rowHashes.get(item.id); if (expected && hashJSON(JSON.parse(item.line)) !== expected) return fail();
200
+ }
201
+ if (validateRows || this.foreignStamp !== set) {
202
+ for (const table of FOREIGN_TABLES) db.run(`DELETE FROM ${table}`);
203
+ for (const id of proof.inputs) db.query("INSERT INTO generation_foreign_inputs VALUES (?)").run(id);
204
+ for (const [id, hash] of proof.rowHashes) db.query("INSERT INTO generation_foreign_rows VALUES (?,?)").run(id, hash);
205
+ for (const id of proof.targets) db.query("INSERT INTO generation_foreign_targets VALUES (?)").run(id);
206
+ this.foreignStamp = set;
207
+ }
208
+ const current = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
209
+ for (const id of proof.targets) if (!current.has(id)) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
165
210
  }
166
211
  private recordSkipped(segment: LegacySegment): void {
167
212
  this.options.db.query("INSERT INTO generation_state VALUES (?,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT)").run(`skipped:${segment.source}:${segment.path}`);
@@ -183,12 +228,12 @@ export class GenerationIndex {
183
228
  const before = this.frontier(), db = this.options.db;
184
229
  let input: ReturnType<GenerationIndex["capture"]>;
185
230
  try { input = this.capture(reader); }
186
- catch (error) { if (attempt < 5 && this.frontier() !== before) return this.refresh(attempt + 1, reader); throw error; }
231
+ catch (error) { if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.refresh(attempt + 1, reader); throw error; }
187
232
  if (this.frontier() !== before) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
188
233
  const known = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments").all() as { source: string; path: string; offset: number; hash: string; commit_id: string | null; stamp: string | null }[];
189
234
  const currentCommit = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value;
190
235
  const unchanged = known.length === input.segments.length && currentCommit === input.commit && input.segments.every(segment => known.some(row => row.source === segment.source && row.path === segment.path && row.offset === segment.offset && row.hash === segment.hash && row.commit_id === (segment.commit ?? null) && row.stamp === (segment.stamp ?? null)));
191
- if (unchanged) { this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
236
+ if (unchanged) { if (this.profile.foreignPruneFormat) db.transaction(() => this.applyForeign()).deferred(); this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
192
237
  const committed = db.transaction(() => { if (this.frontier() !== before) return false; this.index(input); return true; }).immediate();
193
238
  if (!committed) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
194
239
  this.retainedTail = input.segments.some(segment => segment.tailBytes > 0);
@@ -197,11 +242,11 @@ export class GenerationIndex {
197
242
  const before = this.frontier();
198
243
  let input: ReturnType<GenerationIndex["capture"]>;
199
244
  try { this.fullCapture = true; input = this.capture(reader); }
200
- catch (error) { this.fullCapture = false; if (attempt < 5 && this.frontier() !== before) return this.rebuild(attempt + 1, reader); throw error; }
245
+ catch (error) { this.fullCapture = false; if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.rebuild(attempt + 1, reader); throw error; }
201
246
  finally { this.fullCapture = false; }
202
247
  const committed = this.options.db.transaction(() => {
203
248
  if (this.frontier() !== before) return false;
204
- for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES]) this.options.db.run(`DELETE FROM ${table}`);
249
+ for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES, ...(this.profile.foreignPruneFormat ? FOREIGN_TABLES : [])]) this.options.db.run(`DELETE FROM ${table}`);
205
250
  this.index(input); return true;
206
251
  }).immediate();
207
252
  if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1, reader); return fail(); }