@staix/agent-mem 0.12.0 → 0.13.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.
@@ -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,10 +101,10 @@ 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
110
  } },
@@ -120,7 +120,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
120
120
  ...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview"),
121
121
  "--split": flag("Write the per-shard split of the global budget to each shard's own config") } },
122
122
  } },
123
- alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
123
+ alias: { description: "Explicit project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
124
124
  list: { description: "List alias ops" },
125
125
  add: { description: "Map one key onto its canonical key (append-only op)", args: [{ name: "from-key" }, { name: "to-key" }], mutates: true },
126
126
  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 +197,5 @@ export function commandHelp(): string {
197
197
  return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
198
198
  const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
199
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.", "Mutating commands require a terminal on stdin. Agents use the `amem mcp` server; the person reviews what agents propose with `amem review`."].join("\n");
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");
201
201
  }
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
+ }
@@ -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
+ }
@@ -1,6 +1,8 @@
1
+ import { cliAttribution, noteDisplayActor } from "./cli-attribution.ts";
1
2
  import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "./store.ts";
2
3
  import { syncMemory, type SyncOptions } from "./sync.ts";
3
- import { importClaudeMem } from "./import.ts";
4
+ import { importClaudeMem, type ImportResult, type WorkStateImportResult, type CaptureImportResult } from "./import.ts";
5
+ import { facts, label, PLAIN, type Style } from "./style.ts";
4
6
  import { memoryProjectKey, projectChain } from "./project.ts";
5
7
  import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
6
8
  import type { GoalPromotionReport } from "./goal-promotion.ts";
@@ -47,8 +49,8 @@ export interface GoalCommandOptions {
47
49
  }
48
50
 
49
51
  /**
50
- * #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Reading is anyone's; promote, demote, forget,
51
- * supersede and rebuild are a person's (the identity gate refuses agent shells).
52
+ * #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Callers provide read visibility, retained-history intent and write policy;
53
+ * ordinary CLI calls carry scoped attribution while library calls keep their existing defaults.
52
54
  */
53
55
  /** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
54
56
  export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] [--capture-days N] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
@@ -58,10 +60,39 @@ export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${n
58
60
  export const GOAL_USAGE = goalUsage();
59
61
 
60
62
  const fail = (message: string): never => { throw new Error(message); };
63
+
64
+ /** A person-facing aggregate receipt; import results and their JSON representation stay untouched. */
65
+ export function claudeMemImportText(notes: ImportResult, work: WorkStateImportResult, capture?: CaptureImportResult,
66
+ options: { captureDays?: number; restart?: boolean } = {}, s: Style = PLAIN): string {
67
+ const more = notes.more || work.more || capture?.more === true;
68
+ const row = (name: string, result: { read: number; added: number; more: boolean }, skipped: number, spooled?: number) =>
69
+ `${label(s, result.more || (spooled ?? 0) > 0 ? "attention" : "ok")} ${name}: ${facts({ read: String(result.read), added: String(result.added), skipped: String(skipped), spooled: spooled === undefined ? "n/a" : String(spooled) })}${result.more ? "; more remain" : ""}`;
70
+ const noteSkipped = notes.read - notes.added, workSkipped = work.read - work.added;
71
+ const otherNotes = Math.max(0, noteSkipped - notes.skipped.model - notes.skipped.pii - notes.skipped.secret);
72
+ const duplicateWork = Math.max(0, workSkipped - work.skipped.invalid - work.skipped.policy);
73
+ const lines = [`amem import claude-mem: ${more ? s.attention("run again") : s.ok("complete")}`,
74
+ row("notes", notes, noteSkipped),
75
+ ` skipped reasons: ${facts({ model: notes.skipped.model, PII: notes.skipped.pii, secret: notes.skipped.secret, "empty/malformed/already imported": otherNotes }) || "none"}`,
76
+ row("work lists", work, workSkipped),
77
+ ` skipped reasons: ${facts({ invalid: work.skipped.invalid, policy: work.skipped.policy, "already imported": duplicateWork }) || "none"}`];
78
+ if (capture) {
79
+ lines.push(row("capture", capture, capture.skipped, capture.spooled));
80
+ if (capture.skipped) lines.push(` skipped reasons: combined ${capture.skipped} (empty/invalid, policy or already imported; no breakdown reported)`);
81
+ } else lines.push(`${label(s, "skipped")} capture: not requested (use --capture-days N for recent capture)`);
82
+ lines.push(more ? options.restart
83
+ ? "Next action: run the same import command again without --restart, keeping all other arguments, until all receipts are complete."
84
+ : "Next action: run the same import command again until all receipts are complete."
85
+ : capture?.spooled ? `Next action: ${capture.spooled} capture ${capture.spooled === 1 ? "row was" : "rows were"} spooled while busy; the next capture write (hook event or import adding capture rows) replays them.`
86
+ : "Next action: none; all requested import receipts are complete.");
87
+ if (capture && options.captureDays !== undefined) lines.push(`Capture window: last ${options.captureDays} days. Widening --capture-days later requires --restart.`);
88
+ if (options.restart) lines.push("Restart: selected cursors replayed from the start; existing import receipts prevent duplicates.");
89
+ return lines.join("\n");
90
+ }
91
+
61
92
  const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
62
93
  function detail(v: View): string[] {
63
94
  return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
64
- ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
95
+ ...(cliAttribution() ? [`actor: ${noteDisplayActor(v.peer)}, peer: ${v.peer}`] : []), ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
65
96
  }
66
97
 
67
98
  export async function rawMemoryCommand(args: string[], cwd: string, opts: MemoryCommandOptions): Promise<string> {
@@ -77,7 +108,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
77
108
  if (args.includes("--explain") && (opts.person !== true || opts.rankedSearch !== true || opts.explainSearch !== true)) fail("score explanation requires ranked search and person intent");
78
109
  for (let index = rest.indexOf("--explain"); index >= 0; index = rest.indexOf("--explain")) rest.splice(index, 1);
79
110
  const store = opts.store ?? opts.open();
80
- const actor = "user";
111
+ const actor = cliAttribution()?.actor ?? "user";
112
+ const visibleTarget = (view: View | undefined, id: string): View => {
113
+ if (cliAttribution()?.actor === "agent" && (!view || !opts.readable([view]).some(row => row.id === view.id))) fail("Memory operation unavailable or refused; no partial result is shown.");
114
+ return need(view, id);
115
+ };
81
116
  const project = opts.project ?? memoryProjectKey(cwd);
82
117
  const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
83
118
  // #15 D4: this command is bound to one project. Crossing projects is the person's `amem search --all-projects`; here the flags would otherwise read as query words.
@@ -146,11 +181,14 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
146
181
  const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
147
182
  return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
148
183
  }
149
- case "promote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
150
- case "demote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
151
- case "forget": { const v = need(store.forget(rest[0] ?? fail(usage), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
184
+ case "promote": { const v = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
185
+ case "demote": { const v = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
186
+ case "forget": { const target = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!);
187
+ if (cliAttribution()?.actor === "agent") for (const member of store.forgetTargets(target.id, project)) visibleTarget(member, member.id);
188
+ const v = need(store.forget(target.id, actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
152
189
  case "supersede": {
153
190
  if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
191
+ if (cliAttribution()?.actor === "agent") for (const id of rest) visibleTarget(store.get(id, project), id);
154
192
  return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(`both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (${names.memory} promote it first)`);
155
193
  }
156
194
  case "sync": {
@@ -164,6 +202,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
164
202
  case "dedupe": {
165
203
  const apply = rest.includes("--apply");
166
204
  if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
205
+ if (cliAttribution()?.actor === "agent") {
206
+ const preview = store.dedupe(project, false);
207
+ for (const group of preview.groups) { for (const view of group.members) visibleTarget(view, view.id); visibleTarget(store.get(group.target, project), group.target); }
208
+ }
167
209
  const result = store.dedupe(project, apply);
168
210
  if (json) return JSON.stringify(result, null, 2);
169
211
  return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
@@ -188,6 +230,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
188
230
  return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by a model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
189
231
  }
190
232
  case "confirm": case "reject": {
233
+ if (cliAttribution()?.actor === "agent") {
234
+ const matches = store.suggestions(project).filter(row => row.id === rest[0] || row.id.startsWith(rest[0] ?? ""));
235
+ if (matches.length !== 1) fail("Memory operation unavailable or refused; no partial result is shown.");
236
+ for (const id of [matches[0]!.from, matches[0]!.to]) visibleTarget(store.get(id, project), id);
237
+ }
191
238
  const x = store.judge(rest[0] ?? fail(`usage: ${names.memory} ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (${names.memory} suggestions lists them)`);
192
239
  return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
193
240
  }
@@ -217,6 +264,7 @@ function leaseText(g: GoalView, me: string, goal: string): string {
217
264
  * person's. A peer links its own board task with hub_task_propose's `goal`.
218
265
  */
219
266
  export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
267
+ const actor = cliAttribution()?.actor ?? "user";
220
268
  const json = args.includes("--json");
221
269
  const rest = args.filter((a) => a !== "--json");
222
270
  const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
@@ -251,7 +299,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
251
299
  const shared = rest.includes("--shared");
252
300
  const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
253
301
  if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
254
- const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
302
+ const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor });
255
303
  return `long-term task ${id.slice(0, 8)} created: ${title}`;
256
304
  }
257
305
  case "link": {
@@ -264,7 +312,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
264
312
  const g = need(rest[0]);
265
313
  const state = (sub === "done" ? "done" : rest[1]) as GoalState;
266
314
  if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
267
- store.goalState(g.id, state, "user");
315
+ store.goalState(g.id, state, actor);
268
316
  if (state === "done" && opts.onDone) {
269
317
  const current = store.goalOf(g.id, project);
270
318
  if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
@@ -276,7 +324,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
276
324
  }
277
325
  case "take": {
278
326
  const g = need(rest[0]);
279
- const r = store.take(g.id, "user", { force: rest.includes("--force") });
327
+ const r = store.take(g.id, actor, { force: rest.includes("--force") });
280
328
  if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
281
329
  return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
282
330
  }