@staix/agent-mem 0.12.0 → 0.14.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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +70 -44
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +105 -0
  5. package/src/cli.ts +219 -56
  6. package/src/commands-table.ts +10 -9
  7. package/src/index.ts +1 -0
  8. package/src/ingest.ts +21 -4
  9. package/src/mcp.ts +8 -8
  10. package/src/memory/candidates.ts +15 -13
  11. package/src/memory/capacity-split.ts +4 -1
  12. package/src/memory/capacity.ts +48 -4
  13. package/src/memory/catalog.ts +4 -4
  14. package/src/memory/cli-attribution.ts +23 -0
  15. package/src/memory/commands.ts +63 -11
  16. package/src/memory/consolidate.ts +3 -2
  17. package/src/memory/decay-review.ts +4 -4
  18. package/src/memory/doctor.ts +36 -7
  19. package/src/memory/generation-index.ts +56 -11
  20. package/src/memory/generation.ts +73 -7
  21. package/src/memory/metrics.ts +20 -8
  22. package/src/memory/migrate.ts +31 -0
  23. package/src/memory/mutation.ts +24 -4
  24. package/src/memory/note-add.ts +4 -4
  25. package/src/memory/prune-publisher-lineage.ts +139 -0
  26. package/src/memory/prune.ts +28 -1
  27. package/src/memory/public-prune-activation.ts +260 -0
  28. package/src/memory/public-prune-advance.ts +149 -0
  29. package/src/memory/public-prune-input-chain.ts +73 -0
  30. package/src/memory/public-prune-inputs.ts +455 -0
  31. package/src/memory/public-prune-view.ts +139 -0
  32. package/src/memory/public-prune-witness.ts +168 -0
  33. package/src/memory/reader-lease.ts +46 -34
  34. package/src/memory/shard-migration.ts +42 -3
  35. package/src/memory/shard-sync-format.ts +40 -2
  36. package/src/memory/shard-sync-graph.ts +87 -0
  37. package/src/memory/shard-sync.ts +341 -53
  38. package/src/memory/shard.ts +50 -9
  39. package/src/memory/store.ts +51 -12
  40. package/src/memory/style.ts +3 -0
  41. package/src/memory/sync.ts +141 -5
  42. package/src/memory/vault.ts +31 -0
  43. package/src/setup.ts +59 -31
@@ -24,8 +24,8 @@ const filters = { ...json, "--tier": { description: "Memory tier", value: ["shor
24
24
  export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
25
25
  export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
26
26
  export const COMMANDS: Record<string, CommandDefinition> = {
27
- consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
28
- setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo are person-only", options: { ...json, "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
27
+ consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after explicit acceptance", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
28
+ setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
29
29
  mcp: { description: "Run the project-bound MCP stdio server for this session" },
30
30
  hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
31
31
  ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi", "qwen"] }] },
@@ -64,7 +64,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
64
64
  } },
65
65
  demote: { description: "Demote a note", options: json, args: [{ name: "id" }], mutates: true },
66
66
  forget: { description: "Forget a note", options: json, args: [{ name: "id" }], mutates: true },
67
- note: { description: "Write notes as the person (F6)", options: json, commands: {
67
+ note: { description: "Write curated notes through the configured policy (F6)", options: json, commands: {
68
68
  add: { description: "Add one curated note: text as arguments, `-` to type it on the terminal until end of input, or no text to open $EDITOR. A fail note takes --approach, --symptom, --cause, --evidence (repeatable) and --retry-when", mutates: true, options: {
69
69
  "--kind": { description: "Note kind (default finding)", value: KINDS }, "--tier": { description: "Memory tier (default long)", value: ["short", "long"] },
70
70
  "--scope": { description: "Scope (default project)", value: ["project", "shared"] }, "--title": text("One-line title"),
@@ -101,12 +101,13 @@ export const COMMANDS: Record<string, CommandDefinition> = {
101
101
  reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
102
102
  walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } } },
103
103
  } },
104
- decay: { description: "Preview old live long-term notes unprotected by open goals; apply only after person review", mutates: "apply", options: {
105
- ...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Show the exact revision preview and ask before applying"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
106
- migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
107
- ...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
104
+ decay: { description: "Preview old live long-term notes unprotected by open goals; apply only with --apply --confirm", mutates: "apply", options: {
105
+ ...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Preview exact revisions; combine with --confirm to apply"), "--confirm": flag("Apply the exact revision preview with --apply"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
106
+ migrate: { description: "Preview a generation migration; apply only with --apply, or deactivate it explicitly", mutates: "apply", options: {
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 },
@@ -120,7 +121,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
120
121
  ...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview"),
121
122
  "--split": flag("Write the per-shard split of the global budget to each shard's own config") } },
122
123
  } },
123
- alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
124
+ alias: { description: "Explicit project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
124
125
  list: { description: "List alias ops" },
125
126
  add: { description: "Map one key onto its canonical key (append-only op)", args: [{ name: "from-key" }, { name: "to-key" }], mutates: true },
126
127
  remove: { description: "Remove an alias so the key resolves to itself again (append-only op, the latest wins)", args: [{ name: "alias-key" }], mutates: true },
@@ -197,5 +198,5 @@ export function commandHelp(): string {
197
198
  return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
198
199
  const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
199
200
  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.", "Mutating commands require a terminal on stdin. Agents use the `amem mcp` server; the person reviews what agents propose with `amem review`."].join("\n");
201
+ }), "--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
202
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./memory/shard-migration.ts";
29
29
  export * from "./mcp.ts";
30
30
  export * from "./mcp-memory.ts";
31
31
  export * from "./memory/policy.ts";
32
+ export { configuredPolicy } from "./memory/config.ts";
32
33
  export * from "./memory/work-lists.ts";
33
34
  export * from "./memory/vault.ts";
34
35
 
package/src/ingest.ts CHANGED
@@ -20,7 +20,9 @@ export interface IngestOptions {
20
20
  }
21
21
  export interface IngestReceipt {
22
22
  files: number; read: number; imported: number; skipped: number; spooled: number; remaining: boolean; errors: number;
23
- reason?: "source_bound" | "source_unavailable" | "schema_or_time" | "cursor_busy" | "scope_changed";
23
+ reason?: "source_bound" | "source_unavailable" | "schema_or_time" | "cursor_busy" | "scope_changed" | "unsupported_version";
24
+ /** With `unsupported_version` (#136): the first native writer version seen that this decoder does not support. A validated version token, never transcript text. */
25
+ version?: string;
24
26
  }
25
27
 
26
28
  type Scope = "own" | "foreign" | "unknown";
@@ -30,6 +32,10 @@ const MAX_BYTES = 16 * 1024 * 1024, DEFAULT_BYTES = 4 * 1024 * 1024, MAX_FILES =
30
32
  const MAX_CURSOR_BYTES = 128 * 1024;
31
33
  // Bump after extending the verified Qwen decoder: replay skipped versions with existing capture receipts deduplicating accepted rows.
32
34
  const QWEN_CONTRACT = "qwen-0.26.1-v1";
35
+ /** A native writer version as reported in a receipt: a short token only, so no transcript content can pass through it (#136). */
36
+ const VERSION_TOKEN = /^[0-9]{1,6}(\.[0-9]{1,6}){1,3}([-+][0-9A-Za-z.]{1,16})?$/; // a dotted version shape: a bare dashed number (000-00-0000) never qualifies
37
+ const versionToken = (value: unknown): string | undefined => typeof value === "string" && value.length <= 32 && VERSION_TOKEN.test(value) ? value : undefined;
38
+ const KIMI_PROTOCOLS = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"];
33
39
  const utf8 = new TextDecoder("utf-8", { fatal: true });
34
40
  const MAX_RECORD = 256 * 1024, MAX_RECORDS = 4096, MAX_DISCOVERY = 4096, MAX_DEPTH = 8, ANCHOR_BYTES = 1024;
35
41
  const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
@@ -270,6 +276,12 @@ function extract(kind: IngestKind, raw: Record<string, any>, project: string): C
270
276
  export async function ingestTranscript(kind: IngestKind, options: IngestOptions): Promise<IngestReceipt> {
271
277
  const result: IngestReceipt = { files: 0, read: 0, imported: 0, skipped: 0, spooled: 0, remaining: false, errors: 0 };
272
278
  const fail = (reason: NonNullable<IngestReceipt["reason"]>, remaining = false) => { result.errors++; result.reason ??= reason; result.remaining ||= remaining; };
279
+ // An unsupported writer version explains its records better than the generic schema reason, so it replaces only that one; cursors and replay are untouched.
280
+ // The version is screened by the current policy every time it would be reported, including one restored from a cursor written under an older policy.
281
+ const unsupported = async (version: string, screen: (value: unknown) => Promise<boolean>) => {
282
+ if (!await screen({ version })) { fail("schema_or_time"); return; }
283
+ result.errors++; if (result.reason === undefined || result.reason === "schema_or_time") { result.reason = "unsupported_version"; result.version = version; }
284
+ };
273
285
  const bytes = options.maxBytes ?? DEFAULT_BYTES, count = options.maxFiles ?? DEFAULT_FILES;
274
286
  if (!["codex", "kimi", "pi", "qwen"].includes(kind) || !Number.isSafeInteger(bytes) || bytes < 1 || bytes > MAX_BYTES || !Number.isSafeInteger(count) || count < 1 || count > MAX_FILES
275
287
  || options.files !== undefined && (!Array.isArray(options.files) || options.files.length > MAX_FILES || options.files.some(file => typeof file !== "string" || !isAbsolute(file) || file.includes("\0")))
@@ -392,13 +404,18 @@ export async function ingestTranscript(kind: IngestKind, options: IngestOptions)
392
404
  else if (context !== undefined) position.scope = permitted && object(context) ? scopeOf(context.cwd) : "unknown";
393
405
  else if (raw.cwd !== undefined) position.scope = permitted ? scopeOf(raw.cwd) : "unknown";
394
406
  if (kind === "kimi" && raw.type === "metadata") {
395
- position.protocol = typeof raw.protocol_version === "string" && ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"].includes(raw.protocol_version) ? raw.protocol_version : "unknown";
407
+ // A validated unsupported version is kept (not "unknown") so later records can name it; the gate below still refuses it.
408
+ // Supported literals carry no content and are kept as before; only an unsupported token is gated on the policy, so a declined record never lends it to the cursor or receipt.
409
+ position.protocol = typeof raw.protocol_version === "string" && KIMI_PROTOCOLS.includes(raw.protocol_version) ? raw.protocol_version
410
+ : permitted ? versionToken(raw.protocol_version) ?? "unknown" : "unknown";
396
411
  }
397
- if (!known) fail("schema_or_time");
412
+ const qwenVersion = kind === "qwen" && !known && permitted ? versionToken(raw.version) : undefined;
413
+ if (!known) { if (qwenVersion !== undefined && qwenVersion !== "0.26.1") await unsupported(qwenVersion, value => policy.screen(value)); else fail("schema_or_time"); }
398
414
  const input = known ? extract(kind, raw, project) : undefined;
399
415
  if (input) {
400
416
  const at = timeOf(raw, kind);
401
- if (!at || position.scope === "unknown" || kind === "kimi" && !/^1\.[0-5]$/.test(position.protocol)) { fail("schema_or_time"); result.skipped++; }
417
+ if (kind === "kimi" && permitted && at && position.scope !== "unknown" && position.protocol !== "unknown" && versionToken(position.protocol) !== undefined && !KIMI_PROTOCOLS.includes(position.protocol)) { await unsupported(position.protocol, value => policy.screen(value)); result.skipped++; }
418
+ else if (!at || position.scope === "unknown" || kind === "kimi" && !KIMI_PROTOCOLS.includes(position.protocol)) { fail("schema_or_time"); result.skipped++; }
402
419
  else if (!permitted || position.scope !== "own" || since !== undefined && at < since) result.skipped++;
403
420
  else {
404
421
  input.at = at;
package/src/mcp.ts CHANGED
@@ -7,7 +7,7 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
7
7
  const tool = <Name extends string>(name: Name, description: string, inputSchema: ReturnType<typeof object>, readOnlyHint = true) => ({ name, description, inputSchema,
8
8
  annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } });
9
9
 
10
- /** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */
10
+ /** Storage scope comes from startup, never tool arguments. MCP proposes candidates only; the CLI accepts or rejects them under the current policy and records actor/peer. */
11
11
  export const MCP_TOOLS = [
12
12
  tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" }, explain: { type: "boolean" } }, ["query"])),
13
13
  tool("amem_evidence", "Return a bounded bundle of cited, live curated entries for a question. Untrusted memory data only: no answer or model call. Capture, repo-only notes, pending candidates and model answers are not evidence.", object({ question: string(1000), limit: { type: "integer", minimum: 1, maximum: 20 }, maxBytes: { type: "integer", minimum: 256, maximum: 8192 } }, ["question"])),
@@ -18,11 +18,11 @@ export const MCP_TOOLS = [
18
18
  tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })),
19
19
  tool("amem_work_state_write", "Append scalar project work-list fields; null clears, task selects an item, list status done closes.", object({ list: string(200), fields: { type: "object", minProperties: 1, maxProperties: 64,
20
20
  additionalProperties: { type: ["string", "number", "boolean", "null"], maxLength: 4000 } } }, ["list", "fields"]), false),
21
- tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Person acceptance only; never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] },
21
+ tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Review and accept or reject through the CLI under current policy; this tool never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] },
22
22
  approach: string(4000), symptom: string(4000), cause: string(4000), evidenceRefs: { type: "array", minItems: 1, maxItems: 16, items: string(400) }, retryWhen: string(4000) }), false),
23
- tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. A person must accept before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false),
24
- tool("amem_vault_candidates", "List screened durable curated notes eligible for a person to move to the vault. Read only.", object({ limit })),
25
- tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it is person-only.", object({})),
23
+ tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. Review and accept or reject through the CLI under current policy before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false),
24
+ tool("amem_vault_candidates", "List screened durable curated notes for review before moving them to a vault. Read only.", object({ limit })),
25
+ tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it requires the matching CLI preview and `--confirm`. This tool has no apply operation.", object({})),
26
26
  ] as const;
27
27
  export type McpToolName = typeof MCP_TOOLS[number]["name"];
28
28
  export type McpHandlers = Record<McpToolName, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
@@ -123,10 +123,10 @@ export async function serveMcp(options: McpOptions): Promise<void> {
123
123
  if (text === undefined || Buffer.byteLength(text) > RESULT_BYTES) throw new Error("result unavailable");
124
124
  result = { content: [{ type: "text", text }] }; observedValue = value; succeeded = true;
125
125
  } catch (failure) {
126
- // Every failure is opaque to the model except one named, fixed, text-free condition: the person's review queue is full. It carries no stored content, so it is safe to say, and it
127
- // tells the agent what to do (stop proposing, ask the person) instead of leaving it to retry blindly.
126
+ // Every failure is opaque to the model except one named, fixed, text-free condition: the candidate queue is full. It carries no stored content, so it is safe to say, and it
127
+ // tells the caller to stop proposing and use `amem review` instead of retrying blindly.
128
128
  const full = typeof failure === "object" && failure !== null && (failure as { code?: unknown }).code === "candidate_queue_full";
129
- result = { isError: true, content: [{ type: "text", text: full ? "The person's review queue is full: no more candidates are accepted until the person runs `amem review`. Do not retry; ask the person to review the pending candidates." : "Memory operation unavailable or refused; no partial result is shown." }] };
129
+ result = { isError: true, content: [{ type: "text", text: full ? "The review queue is full: no more candidates are accepted. Do not retry; review pending candidates through the screened CLI." : "Memory operation unavailable or refused; no partial result is shown." }] };
130
130
  }
131
131
  if (succeeded && options.onEmitted) {
132
132
  await writeConfirmed(output, JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
@@ -4,6 +4,7 @@ import { chmodSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { CANDIDATE_SHOWN_GRACE_MS, hasShownColumn } from "./candidate-pending.ts";
6
6
  import { oneLine } from "./note-add.ts";
7
+ import { cliAttribution } from "./cli-attribution.ts";
7
8
  import { memoryHome } from "./home.ts";
8
9
  import { realPath } from "./platform.ts";
9
10
  import { stripPrivateDeep } from "./private-spans.ts";
@@ -43,7 +44,7 @@ export interface CounterValues { rendered?: number | null; delivered?: number |
43
44
  export interface Counter extends CounterValues { id: string; key: CounterKey; createdAt: string; rendered: number | null; delivered: number | null; cited: number | null }
44
45
  export interface CandidateAcceptance {
45
46
  project: string;
46
- /** The host binds this to authenticated person intent; it is not an authentication token. */
47
+ /** The host binds this to CLI attribution; it is not an authentication token. */
47
48
  actor: "person" | "agent";
48
49
  verdict: CandidateVerdict;
49
50
  store: MemoryStore;
@@ -63,7 +64,8 @@ const text = (value: unknown, max = 4000): value is string => typeof value === "
63
64
  const entryId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
64
65
  const candidateId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
65
66
  const publicOnly = (verdict: CandidateVerdict) => { if (verdict !== "public") throw new Error("candidate data refused by host privacy verdict"); };
66
- const personOnly = (actor: string) => { if (actor !== "person") throw new Error("candidate acceptance and rejection require a person"); };
67
+ const validActor = (actor: string) => { if (actor !== "person" && actor !== "agent") throw new Error("candidate actor must be person or agent"); };
68
+ const attributionPeer = (actor: "person" | "agent") => cliAttribution()?.peer ?? actor;
67
69
  const validTarget = (value: CandidateTarget | undefined) => value !== undefined && entryId(value.id) && candidateId(value.revision);
68
70
  function validate(input: CandidateInput): void {
69
71
  if (!input || !text(input.project, 400)) throw new Error("invalid candidate project");
@@ -126,10 +128,10 @@ function retryLocked(work: () => void): void {
126
128
  }
127
129
  }
128
130
 
129
- /** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it; the message is worded for an agent, which cannot run the person-only commands itself. */
131
+ /** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it. */
130
132
  export class CandidateQueueFull extends Error {
131
133
  readonly code = "candidate_queue_full";
132
- constructor(readonly waiting: number) { super(`${waiting} unshown candidates await the person's review; no more are accepted until they have run \`amem review\`. Ask the person to review them`); this.name = "CandidateQueueFull"; }
134
+ constructor(readonly waiting: number) { super(`${waiting} unshown candidates await review; no more are accepted until the queue is reviewed. Run \`amem review\``); this.name = "CandidateQueueFull"; }
133
135
  }
134
136
  export class CandidateStore {
135
137
  readonly dir: string;
@@ -253,7 +255,7 @@ export class CandidateStore {
253
255
  return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload) as CounterKey, createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })).filter(row => !entry || row.key.entryId === entry);
254
256
  }
255
257
  reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
256
- personOnly(context.actor); publicOnly(context.verdict);
258
+ validActor(context.actor); publicOnly(context.verdict);
257
259
  this.db.transaction(() => {
258
260
  const candidate = this.get(id, context.project);
259
261
  if (!candidate) throw new Error("candidate unavailable");
@@ -263,7 +265,7 @@ export class CandidateStore {
263
265
  }).immediate();
264
266
  }
265
267
  accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
266
- personOnly(context.actor); publicOnly(context.verdict);
268
+ validActor(context.actor); publicOnly(context.verdict);
267
269
  if (realPath(context.store.profile.home) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
268
270
  return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
269
271
  }
@@ -292,7 +294,7 @@ export class CandidateStore {
292
294
  scope: data.kind === "correction" ? target!.scope : "project",
293
295
  // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
294
296
  tier: data.kind === "correction" && target!.local ? "short" : "long",
295
- kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
297
+ kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: attributionPeer(context.actor), source: "person", about: [...target?.about ?? []],
296
298
  text: data.kind === "correction" ? acceptedCorrectionText(data, id) : `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
297
299
  if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
298
300
  if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
@@ -303,7 +305,7 @@ export class CandidateStore {
303
305
  // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
304
306
  // automatic replay; add explicit person-audited recovery before offering background acceptance retries.
305
307
  const noteId = append();
306
- if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
308
+ if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, context.actor, context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
307
309
  this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
308
310
  return { candidateId: id, noteId, alreadyAccepted: false };
309
311
  });
@@ -316,13 +318,13 @@ export class CandidateStore {
316
318
  }
317
319
  private acceptConsolidation(candidate: Candidate, data: Extract<CandidateInput, { kind: "consolidation" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
318
320
  if (typeof context.policyVersion !== "function") throw Error("consolidation requires a policy revision guard");
319
- const boundStore = context.store, boundProject = context.project, boundPolicy = context.policy;
321
+ const boundStore = context.store, boundProject = context.project, boundActor = context.actor, boundPolicy = context.policy;
320
322
  const policyVersion = context.policyVersion(); if (typeof policyVersion !== "string") throw Error("consolidation policy revision unavailable");
321
323
  return context.store.withCurrentEntries(data.targets.map(target => target.id), context.project, () => {
322
324
  const sourcePlacements = context.store.entryPlacements(data.targets.map(target => target.id), context.project);
323
325
  if (data.targets.some(target => sourcePlacements.get(target.id) !== "synced")) throw Error("consolidation requires verified synced source placement");
324
326
  const policyCurrent = () => {
325
- if (context.actor !== "person" || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed");
327
+ if (context.actor !== boundActor || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed");
326
328
  if (context.policyVersion!() !== policyVersion) throw Error("consolidation policy changed");
327
329
  };
328
330
  // One placement proof per locked section: `sourcePlacements` (read once inside withCurrentEntries, whose fences stay held) serves every check before the append section;
@@ -341,7 +343,7 @@ export class CandidateStore {
341
343
  return current;
342
344
  };
343
345
  const sources = check(), first = sources[0]!;
344
- const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: "person", source: "person", about: [...data.about],
346
+ const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: attributionPeer(context.actor), source: "person", about: [...data.about],
345
347
  text: `${ACCEPTED_CONSOLIDATION_PREFIX}${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` };
346
348
  if (note.text.length > 4000) throw Error("accepted consolidation exceeds the v1 note limit");
347
349
  for (const source of sources) if (context.policy(structuredClone(candidate), structuredClone(source), structuredClone(note)) !== true) throw Error("consolidation refused by current host policy");
@@ -352,7 +354,7 @@ export class CandidateStore {
352
354
  const noteId = append(), applied: string[] = [];
353
355
  try {
354
356
  if (data.action !== "recurring-fix") for (const target of data.targets) {
355
- if (!context.store.supersede(target.id, noteId, "person", context.project)) throw Error("supersession refused");
357
+ if (!context.store.supersede(target.id, noteId, context.actor, context.project)) throw Error("supersession refused");
356
358
  applied.push(target.id);
357
359
  }
358
360
  } catch {
@@ -385,7 +387,7 @@ export class CandidateStore {
385
387
  resolve();
386
388
  const replay = this.beginAcceptance(candidate.id, context.project); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
387
389
  // A thrown append remains accepting: its authored-row outcome needs person-audited recovery.
388
- const noteId = context.store.promote(target.id, "person", context.project);
390
+ const noteId = context.store.promote(target.id, context.actor, context.project);
389
391
  if (noteId === undefined) {
390
392
  this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'accepting'").run(candidate.id, this.scope(context.project));
391
393
  throw Error("promotion unavailable or declined; candidate remains pending");
@@ -1,3 +1,4 @@
1
+ import { assertCliPolicy } from "./cli-attribution.ts";
1
2
  import { lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { basename, join } from "node:path";
3
4
  import { capacityReport, type ShardSplit } from "./capacity.ts";
@@ -8,7 +9,7 @@ import { machineId } from "./store.ts";
8
9
 
9
10
  /**
10
11
  * #15 T5 / D7: `amem capacity apply --split`. Writes each shard's own capture config (`machine/<id>/config.json`, which overrides the home default) with its
11
- * share of the global budget. Person-only like the other apply (the CLI refuses an agent marker and a missing terminal before this runs), and in two steps:
12
+ * share of the global budget. Like the other apply, it uses two steps:
12
13
  * a preview that records which shards, which current configs and which target values the person is shown, and a `--confirm` that writes exactly those
13
14
  * target values and nothing recomputed. It refuses unless the set of shards and every shard's current config are unchanged, and unless the preview is younger
14
15
  * than `PREVIEW_MAX_AGE_MS`: a week-old preview cannot write a cap the person never saw, and free disk drifting a little between the two steps changes nothing
@@ -46,6 +47,7 @@ export function previewSplit(base: string, opts: { diskShare?: number; now?: ()
46
47
  if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
47
48
  const found = targets(base, report.split);
48
49
  const preview: SplitPreview = { at: (opts.now ?? Date.now)(), split: found.targets, skipped: found.skipped };
50
+ assertCliPolicy(preview);
49
51
  writeFileSync(previewPath(base), JSON.stringify(preview) + "\n", { mode: 0o600 });
50
52
  return { ...preview, confirm: "re-run with --confirm to write each shard's share" };
51
53
  }
@@ -66,6 +68,7 @@ export function applySplit(base: string, opts: { diskShare?: number; now?: () =>
66
68
  // What was previewed is what is written: the same shards, each with the config the person saw replaced by the target the person saw.
67
69
  const identity = (rows: SplitTarget[]) => JSON.stringify(rows.map(({ key, dir, current }) => ({ key, dir, current })));
68
70
  if (!Array.isArray(approved.split) || identity(approved.split) !== identity(live)) throw new Error("capacity preview changed (a shard appeared or went, or a shard's config moved); re-run without --confirm");
71
+ assertCliPolicy({ approved, live });
69
72
  const written = approved.split.map(row => {
70
73
  const next = writeCaptureConfigFile(join(row.dir, "config.json"), row.target);
71
74
  return { key: row.key, cap_mb: next.capMb ?? row.target.capMb, retention_days: next.retentionDays ?? row.target.retentionDays };
@@ -8,17 +8,18 @@ export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./me
8
8
  import { walkShards } from "./catalog.ts";
9
9
  import { lifecycleResolver } from "./lifecycle.ts";
10
10
  import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
11
+ import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
11
12
 
12
13
  /**
13
14
  * #13 R7: the capacity advisor. `capacityReport()` reads this machine's load and proposes capture bounds; the
14
15
  * `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
15
- * agent may read the proposal through this library function; applying stays person-only at the CLI.
16
+ * marked CLI calls may read the proposal; the CLI applies only after a matching preview and `--confirm`.
16
17
  *
17
18
  * Proposal rule (owner decision, 2026-10-17, item 2): the proposed cap and retention are the largest values whose projection stays within the
18
19
  * disk-share bound (default 2% of free space, configurable) and whose measured search and hook readings stay within R2/R3, and they may be above
19
20
  * the current ones. The cap is the disk-share bound itself; retention is the share over the daily growth, up to a 365-day ceiling. R2 halves the cap
20
21
  * and R3 halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`,
21
- * `R3 search RSS`, `R2 hook cost`. The advisor never writes: applying stays person-only, preview first.
22
+ * `R3 search RSS`, `R2 hook cost`. The advisor never writes: the CLI applies only after a preview and matching `--confirm`.
22
23
  *
23
24
  * Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
24
25
  * `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
@@ -60,7 +61,7 @@ export interface CapacityReport {
60
61
  current: CaptureConfig;
61
62
  diskShare: number;
62
63
  proposal: { capMb: number; retentionDays: number; reason: string };
63
- /** #15 D7: the per-shard split of the global capture budget (the disk share, less what forgotten shards still hold), one row per non-forgotten shard. A proposal: applying it is person-only. */
64
+ /** #15 D7: the per-shard split of the global capture budget (the disk share, less what forgotten shards still hold), one row per non-forgotten shard. A proposal; applying it requires a matching preview and `--confirm`. */
64
65
  split: ShardSplit[];
65
66
  /** Why `split` is empty although shards exist: the budget cannot give each shard its 1 MB minimum. Absent when the split was made. */
66
67
  splitRefused?: string;
@@ -222,7 +223,7 @@ export function capacityReport(opts: {
222
223
  };
223
224
  const diskShare = opts.diskShare ?? 0.02;
224
225
  // The single proposal below is one shard's bounds (the home default). #15 T5 (D7): `split` shares the machine-wide budget across the shards, so a
225
- // home with N shards no longer holds N x cap once the person applies it (`amem capacity apply --split`).
226
+ // home with N shards no longer holds N x cap once `amem capacity apply --split --confirm` writes it.
226
227
  const budgetBytes = diskShare * disk.freeBytes;
227
228
  // Growth (owner decision 2026-10-17, item 2): propose the largest cap and retention that fit the disk share, above the current values when there is room.
228
229
  // The cap's ceiling is the disk-share bound itself. A person's own setting above the retention ceiling is never proposed down for the ceiling alone.
@@ -280,3 +281,46 @@ export function capacityText(report: CapacityReport): string {
280
281
  ...(report.split.length ? ["split of the global budget per shard (a proposal: amem capacity apply --split):", ...report.split.map(row => ` ${row.key}: cap ${row.capMb} MB, retention ${row.retentionDays} days (${row.reason})`)] : []),
281
282
  ].join("\n");
282
283
  }
284
+
285
+ /** Human-readable `capacity apply` result. JSON output remains the caller's responsibility. */
286
+ export function capacityApplyText(result: {
287
+ preview: { file: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string };
288
+ confirm?: string;
289
+ written?: { cap_mb?: number; retention_days?: number };
290
+ }, s: Style = PLAIN): string {
291
+ const applied = result.written !== undefined;
292
+ return [
293
+ `amem capacity apply: ${applied ? s.ok("complete") : s.attention("preview")}`,
294
+ `${label(s, applied ? "ok" : "attention")} target: ${singleLine(result.preview.file)}`,
295
+ `${label(s, "ok")} current: ${facts({ cap_mb: result.preview.current.capMb, retention_days: result.preview.current.retentionDays }) || "defaults"}`,
296
+ `${label(s, "attention")} proposed: ${facts({ cap_mb: result.preview.target.capMb, retention_days: result.preview.target.retentionDays })}`,
297
+ `${label(s, "ok")} reason: ${singleLine(result.preview.reason)}`,
298
+ ...(applied ? [`${label(s, "ok")} written: ${facts(result.written!) || "no settings reported"}`] : []),
299
+ `Next action: ${applied ? "none" : "re-run the same capacity apply command with --confirm, keeping all other arguments"}`,
300
+ ].join("\n");
301
+ }
302
+
303
+ /** Human-readable `capacity apply --split` result, retaining skipped shard identities. */
304
+ export function capacitySplitText(result:
305
+ | { at: number; split: { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }[]; skipped: string[]; confirm: string }
306
+ | { split: { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }[]; written: { key: string; cap_mb: number; retention_days: number }[] },
307
+ s: Style = PLAIN,
308
+ ): string {
309
+ const applied = "written" in result;
310
+ const rows = result.split.map(row => [
311
+ `${label(s, applied ? "ok" : "attention")} shard: ${singleLine(row.key)}`,
312
+ `${label(s, applied ? "ok" : "attention")} target: ${singleLine(row.dir)}`,
313
+ `${label(s, "ok")} current: ${facts({ cap_mb: row.current.capMb, retention_days: row.current.retentionDays }) || "defaults"}`,
314
+ `${label(s, applied ? "ok" : "attention")} proposed: ${facts({ cap_mb: row.target.capMb, retention_days: row.target.retentionDays })}`,
315
+ `${label(s, "ok")} reason: ${singleLine(row.reason)}`,
316
+ ].join("\n"));
317
+ const skipped = "skipped" in result ? result.skipped : [];
318
+ return [
319
+ `amem capacity apply --split: ${applied ? s.ok("complete") : s.attention("preview")}`,
320
+ ...(result.split.length ? [] : [`${label(s, "skipped")} targets: none`]),
321
+ ...rows,
322
+ ...skipped.map(key => `${label(s, "skipped")} skipped shard: ${singleLine(key)} (no local shard config to write)`),
323
+ ...(applied ? result.written.map(row => `${label(s, "ok")} written ${singleLine(row.key)}: ${facts({ cap_mb: row.cap_mb, retention_days: row.retention_days })}`) : []),
324
+ `Next action: ${applied || !result.split.length ? "none" : "re-run the same capacity apply --split command with --confirm, keeping all other arguments"}`,
325
+ ].join("\n");
326
+ }
@@ -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(); }
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /** CLI labels are attribution, never authentication or permission to bypass policy. */
4
+ export interface CliAttribution { actor: "person" | "agent"; peer: string; policy?: () => { screenStandalone(value: unknown): boolean } }
5
+ const scope = new AsyncLocalStorage<Readonly<CliAttribution>>();
6
+
7
+ /** Preserve library defaults outside an ordinary CLI invocation, including concurrent calls. */
8
+ export function cliAttribution(): Readonly<CliAttribution> | undefined { return scope.getStore(); }
9
+ export function withCliAttribution<T>(identity: CliAttribution, work: () => T): T { return scope.run(Object.freeze({ ...identity, peer: identity.actor === "agent" && identity.peer === "person" ? "agent" : identity.peer }), work); }
10
+
11
+ /** Display metadata derived from the existing peer field; never a claim of authentication. */
12
+ export const noteDisplayActor = (peer: string): "person" | "agent" => peer === "person" ? "person" : "agent";
13
+
14
+ /** Internal CLI-only guard: library callers outside a CLI scope retain their existing contract. */
15
+ export function assertCliPolicy(value: unknown): void {
16
+ const identity = scope.getStore();
17
+ if (identity?.actor !== "agent") return;
18
+ try {
19
+ const plain: unknown = JSON.parse(JSON.stringify(value));
20
+ if (identity.policy?.().screenStandalone(plain) === true) return;
21
+ } catch { /* Withhold the complete value, including configuration errors. */ }
22
+ throw Error("Memory operation unavailable or refused; no partial result is shown.");
23
+ }