@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.
@@ -28,14 +28,15 @@ export class ConsolidationProposalError extends Error {
28
28
  const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
29
29
  /** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
30
30
  export async function proposeConsolidation(store: MemoryStore, candidates: CandidateStore, options: ConsolidationOptions): Promise<ConsolidationReport> {
31
- if (options.actor !== "person") throw Error("consolidation requires person intent");
31
+ const actor = options.actor;
32
+ if (actor !== "person" && actor !== "agent") throw Error("invalid consolidation actor");
32
33
  if (options.allowed?.() === false) throw Error("consolidation unavailable");
33
34
  if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("consolidation requires its own candidate home");
34
35
  const project = options.project, version = options.policyVersion(), policy = options.policy();
35
36
  if (!project.trim() || typeof version !== "string") throw Error("consolidation context unavailable");
36
37
  const sourceCurrent = store.beginRecallRead();
37
38
  const check = () => {
38
- if (options.actor !== "person" || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
39
+ if (options.actor !== actor || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
39
40
  options.assertCurrent?.(); sourceCurrent();
40
41
  };
41
42
  check();
@@ -74,7 +74,7 @@ export function decayPreview(store: MemoryStore, project: string, options: Decay
74
74
  return { project, profile: hashJSON(store.profile), days, at: new Date(now).toISOString(), cutoff: new Date(cutoff).toISOString(), demoteExpiresAt: store.shortExpiry(), entries: entries.slice(0, DECAY_PREVIEW_LIMIT), withheld, citedExemptions: counters === undefined ? null : citedExemptions, truncated: entries.length > DECAY_PREVIEW_LIMIT, citationHistory: "unknown" };
75
75
  });
76
76
  }
77
- /** Duplicate-group forgetting requires a separate person group review, never an implicit decay expansion. */
77
+ /** Duplicate-group forgetting requires a separate group review, never an implicit decay expansion. */
78
78
  export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void {
79
79
  store.withDeferredPromotions(() => {
80
80
  for (const id of ids) {
@@ -104,10 +104,10 @@ export class DecayApplyError extends Error {
104
104
  }
105
105
  /** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */
106
106
  export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } {
107
- if (options.actor !== "person") throw Error("decay apply requires person authority");
107
+ if (options.actor !== "person" && options.actor !== "agent") throw Error("decay apply requires a valid actor");
108
108
  options.assertCurrent?.();
109
109
  const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids);
110
- if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit person confirmation");
110
+ if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit confirmation");
111
111
  if (hashJSON(store.profile) !== plan.profile || ids.some(id => !plan.entries.some(entry => entry.target.id === id))) throw Error("decay preview binding changed or target was not previewed");
112
112
  const action = options.forget ? "forget" : "demote";
113
113
  if (!ids.length) return { action, ids };
@@ -126,7 +126,7 @@ export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayAp
126
126
  for (let index = 0; index < ids.length; index++) {
127
127
  const id = ids[index]!;
128
128
  try {
129
- const result = options.forget ? store.forget(id, "person", plan.project, true) : store.demote(id, "person", plan.project, plan.demoteExpiresAt);
129
+ const result = options.forget ? store.forget(id, options.actor, plan.project, true) : store.demote(id, options.actor, plan.project, plan.demoteExpiresAt);
130
130
  if (!result) throw Error("decay target outcome unavailable");
131
131
  applied.push(id);
132
132
  } catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { sessionStartBrief } from "../session-brief.ts";
6
6
  import { pendingSummary } from "./candidate-pending.ts";
7
+ import { readCaptureDrops } from "./metrics.ts";
7
8
  import { walkShards } from "./catalog.ts";
8
9
  import { loadAmemConfig, repoConfigRoot } from "./config.ts";
9
10
  import { readSelector } from "./generation.ts";
@@ -26,7 +27,7 @@ import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } fro
26
27
  export type SectionStatus = "ok" | "attention" | "skipped" | "unavailable";
27
28
  export type Section<T extends object = Record<string, unknown>> = { status: SectionStatus } & T;
28
29
  export const DOCTOR_SCHEMA = "amem.doctor/v1";
29
- export const CLIS = ["claude", "codex", "kimi", "pi"] as const;
30
+ export const CLIS = ["claude", "codex", "kimi", "pi", "qwen"] as const;
30
31
  export type Cli = typeof CLIS[number];
31
32
  /** A CLI whose last capture is older than this is called stale. */
32
33
  export const CAPTURE_STALE_MS = 7 * 86400_000;
@@ -54,6 +55,8 @@ export interface DoctorReport {
54
55
  project: string;
55
56
  sections: {
56
57
  duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
58
+ /** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
59
+ drops: Section;
57
60
  };
58
61
  }
59
62
 
@@ -219,7 +222,7 @@ function captureSection(options: DoctorOptions): Section {
219
222
  const needs = registered ? state !== "fresh" : state === "stale";
220
223
  if (needs) attention.push(cli);
221
224
  clis[cli] = { hook: registered ? "registered" : "manual", ...(registered ? { hookEvidence: "setup receipt" } : {}), ...(phase === "prepared" ? { setup: "prepared, not applied" } : {}), capture: state, lastCaptureAt: last, rows: entry?.rows ?? 0,
222
- ...(needs || (!registered && state === "never") ? { hint: registered ? `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
225
+ ...(needs || (!registered && state === "never") ? { hint: registered ? cli === "codex" && state === "never" ? "the codex hook is registered but has never captured: approve it in Codex's /hooks trust review (setup never grants hook trust), then open a session" : `the ${cli} hook is registered but its capture is ${state}: open a session${cli === "claude" ? "" : `, or run amem ingest ${cli}`}` : cli === "claude" ? "run amem setup claude --apply to register the capture hooks" : `run amem setup ${cli} --apply, or amem ingest ${cli} by hand` } : {}) };
223
226
  }
224
227
  return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis };
225
228
  }
@@ -271,6 +274,17 @@ function policySection(options: DoctorOptions): Section {
271
274
  } catch (error) { return unavailable(message(error)); }
272
275
  }
273
276
 
277
+ /** #132: capture rows hooks dropped, by reason, from the bounded rolling metrics tail (counts only; no row text is ever recorded there). */
278
+ function dropsSection(options: DoctorOptions): Section {
279
+ const read = readCaptureDrops(options.home);
280
+ if (!read.readable) return unavailable(read.reason); // an existing file doctor cannot read is never a healthy zero
281
+ // One torn line (an interrupted append) is reported, not fatal; a tail with lines of which none parses is a file doctor cannot decode.
282
+ if (read.lines > 0 && read.malformedLines === read.lines) return unavailable("metrics.jsonl has no decodable record in its recent tail");
283
+ const counts = read.counts, total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
284
+ return { status: total ? "attention" : "ok", window: "recent metrics tail", total, counts, ...(read.malformedLines ? { malformedLines: read.malformedLines } : {}),
285
+ ...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
286
+ }
287
+
274
288
  /** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
275
289
  function cadenceSection(options: DoctorOptions): Section {
276
290
  const ingest: Record<string, string> = {};
@@ -398,7 +412,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
398
412
  let sections: DoctorReport["sections"];
399
413
  try {
400
414
  await tick();
401
- sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options) };
415
+ sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options) };
402
416
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
403
417
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
404
418
  return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
@@ -407,7 +421,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
407
421
  /** Section order and one-line descriptions for the person-facing report (#125). */
408
422
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
409
423
  duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
410
- index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence",
424
+ index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
411
425
  };
412
426
 
413
427
  function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
@@ -422,6 +436,11 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
422
436
  return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
423
437
  `sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
424
438
  }
439
+ if (name === "drops") {
440
+ if (detail.counts === undefined) return []; // unavailable: only its reason is shown
441
+ const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
442
+ return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
443
+ }
425
444
  if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
426
445
  const { examples, ...rest } = detail;
427
446
  const line = facts(rest);
@@ -38,26 +38,38 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
38
38
 
39
39
  export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
40
40
  const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
41
- /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
41
+ /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
42
42
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
+ const read = readCaptureDrops(base);
44
+ return read.readable ? read.counts : {};
45
+ }
46
+
47
+ /**
48
+ * The same bounded read, but it says when an existing metrics file could not be read (a person-facing report must not show that as zero, #132).
49
+ * An absent file is readable with no counts. `reason` is a fixed phrase, never file content.
50
+ */
51
+ export function readCaptureDrops(base: string): { readable: true; counts: Partial<Record<CaptureDropReason, number>>; lines: number; malformedLines: number } | { readable: false; reason: string } {
43
52
  const counts: Partial<Record<CaptureDropReason, number>> = {};
44
- let fd: number | undefined;
53
+ let fd: number | undefined, lines = 0, malformedLines = 0;
45
54
  try {
46
- fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
47
- const stat = fstatSync(fd); if (!stat.isFile()) return counts;
55
+ try { fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
56
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { readable: true, counts, lines: 0, malformedLines: 0 }; return { readable: false, reason: "metrics.jsonl exists but cannot be opened" }; }
57
+ const stat = fstatSync(fd); if (!stat.isFile()) return { readable: false, reason: "metrics.jsonl is not a plain file" };
48
58
  const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
49
59
  const length = readSync(fd, bytes, 0, bytes.length, start);
50
60
  let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
51
61
  if (start) text = text.slice(text.indexOf("\n") + 1);
52
- for (const line of text.split("\n").filter(Boolean).slice(-METRICS_KEEP)) {
62
+ const tail = text.split("\n").filter(line => line.trim() !== "").slice(-METRICS_KEEP);
63
+ lines = tail.length;
64
+ for (const line of tail) {
53
65
  try {
54
66
  const sample = JSON.parse(line) as MetricSample;
55
67
  if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
56
68
  const reason = sample.captureDropReason!;
57
69
  counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
58
- } catch { /* A malformed metric is never a notice. */ }
70
+ } catch { malformedLines++; /* A malformed metric is never a notice; doctor reports how many were skipped. */ }
59
71
  }
60
- } catch { /* Metric availability never denies the native call. */ }
72
+ } catch { return { readable: false, reason: "metrics.jsonl cannot be read or is not UTF-8" }; }
61
73
  finally { if (fd !== undefined) closeSync(fd); }
62
- return counts;
74
+ return { readable: true, counts, lines, malformedLines };
63
75
  }
@@ -5,6 +5,7 @@ import { legacyEntries, legacyHead, readLegacySnapshot, type LegacyCheckpoint }
5
5
  import { acquireMutation } from "./mutation.ts";
6
6
  import { canonicalPath, realPath } from "./platform.ts";
7
7
  import { canonicalJSON, fsyncDirectory, generationDescriptor, generationDir, hashJSON, readProfileJSON, readSelector, resolveProfile, validGeneration, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
8
+ import { label, PLAIN, singleLine, type Style } from "./style.ts";
8
9
 
9
10
  export interface SourcePrefix extends LegacyCheckpoint { tailBytes: number }
10
11
  interface MigrationJournal {
@@ -222,3 +223,32 @@ export async function migrate(options: MigrateOptions): Promise<MigrationResult>
222
223
  } finally { releaseGeneration(); }
223
224
  });
224
225
  }
226
+
227
+ /** Render a migration result for a person; JSON output remains owned by the CLI. */
228
+ export function migrationText(result: MigrationResult, s: Style = PLAIN): string {
229
+ const active = result.profile.kind === "generation";
230
+ const needsResume = result.mode === "resume" && !active;
231
+ const state = needsResume ? "attention" : result.mode === "dry-run" ? "preview" : "complete";
232
+ const groups = (value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${singleLine(key)}=${count}`).join(", ");
233
+ const countRows = result.counts ? [
234
+ ["valid", result.counts.validRows], ["skipped", result.counts.skippedRows], ["tailBytes", result.counts.tailBytes],
235
+ ["expired", result.counts.expiredRows], ["eligible", result.counts.eligibleRows],
236
+ ].map(([key, value]) => `${label(s, "ok")} ${key} ${value}`) : [`${label(s, "skipped")} counts not returned for this mode`];
237
+ const operationCounts = result.counts ? groups(result.counts.byOp) : "", tierCounts = result.counts ? groups(result.counts.byTier) : "";
238
+ if (operationCounts) countRows.push(`${label(s, "ok")} operations ${operationCounts}`);
239
+ if (tierCounts) countRows.push(`${label(s, "ok")} tiers ${tierCounts}`);
240
+ let action = "none";
241
+ if (result.mode === "dry-run" && !active) action = "apply migration: amem migrate --apply";
242
+ else if (needsResume) action = "resume migration: amem migrate --resume";
243
+ else if (result.mode === "deactivate" && active) action = "deactivate generation: amem migrate --deactivate";
244
+ const lines = [
245
+ `${s.bold("amem migrate:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
246
+ `${label(s, "ok")} profile ${result.profile.kind}${result.profile.generation ? ` (generation ${result.profile.generation.slice(0, 8)})` : ""}`,
247
+ `${label(s, "ok")} home ${singleLine(result.profile.home)}`,
248
+ `${label(s, "ok")} current directory ${singleLine(result.profile.dir)}`,
249
+ ...countRows,
250
+ `${label(s, "ok")} effect ${singleLine(result.consequence)}`,
251
+ `Next action: ${action}`,
252
+ ];
253
+ return lines.join("\n");
254
+ }
@@ -1,9 +1,9 @@
1
1
  import { KINDS, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "./store.ts";
2
+ import { cliAttribution } from "./cli-attribution.ts";
2
3
  import type { StandalonePolicy } from "./policy.ts";
3
4
 
4
5
  /**
5
- * F6 (agent-mem#40): person note authoring. `amem note add` writes one curated note the person typed, through the configured policy. The note is authored by the
6
- * person (`peer: "person"`, `source: "person"`), which is why the command is person-only like every ordinary command (an agent marker is refused before it runs).
6
+ * F6 (agent-mem#40): note authoring writes one curated note through the configured policy. The invocation peer is recorded for attribution; source remains `person`.
7
7
  *
8
8
  * A `fail` note carries structured fields. Its body uses the same labelled lines an accepted `fail` candidate produces, so a note written here and one accepted
9
9
  * from `amem_remember` read alike and `fail` search and the plan guard see one shape.
@@ -59,7 +59,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
59
59
  if (text.includes("\0")) refuse("the note text holds a NUL byte");
60
60
  }
61
61
  if (text.length > NOTE_MAX_CHARS) refuse(`the note is ${text.length} characters; the limit is ${NOTE_MAX_CHARS}`);
62
- return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: "person", source: "person", ...(title ? { title } : {}), text, about: [] };
62
+ return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: cliAttribution()?.peer ?? "person", source: "person", ...(title ? { title } : {}), text, about: [] };
63
63
  }
64
64
 
65
65
  /**
@@ -68,7 +68,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
68
68
  */
69
69
  export function noteAdd(store: MemoryStore, project: string, input: NoteAddInput, policy: StandalonePolicy): NoteAddResult {
70
70
  const note = buildNote(input, project);
71
- if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about }) /* no undefined field: the screen refuses one */) refuse("the note is refused by the configured privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json); edit it or the policy and try again");
71
+ if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about, peer: note.peer })) refuse("the note is refused by the configured privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json); edit it or the policy and try again");
72
72
  return { id: store.note(note, input.tier === "short") };
73
73
  }
74
74
 
@@ -1,5 +1,6 @@
1
1
  import { canonicalJSON, hashJSON, validGeneration } from "./generation.ts";
2
2
  import { entryId, type Entry, type NoteEntry } from "./store.ts";
3
+ import { label, PLAIN, singleLine, type Style } from "./style.ts";
3
4
 
4
5
  export const PRUNE_ROW_MAX_BYTES = 16_384, PRUNE_PLAN_MAX_BYTES = 262_144;
5
6
  export const PRUNE_ROW_MAX_TARGETS = 32, PRUNE_PLAN_MAX_ROWS = 16, PRUNE_PLAN_MAX_TARGETS = 256;
@@ -128,7 +129,7 @@ export function reducePruneRows(rows: readonly { row: unknown; placement: PruneP
128
129
  }
129
130
  function authenticate(port: PruneStorePort, auth: PruneAuthorization): void {
130
131
  guard(validGeneration(port.generation) && /^[a-f0-9]{16}$/.test(port.machine) && revision(port.selectorRevision()));
131
- guard(auth.actor === "person" && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
132
+ guard((auth.actor === "person" || auth.actor === "agent") && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
132
133
  }
133
134
  function policyRevision(auth: PruneAuthorization): string {
134
135
  if (!auth.policy) return "local-only";
@@ -257,3 +258,29 @@ export function applyPrune(port: PruneStorePort, input: unknown, auth: PruneAuth
257
258
  });
258
259
  });
259
260
  }
261
+
262
+ /** Human-readable preview or apply result. A preview is descriptive output, not a serialized sealed plan. */
263
+ export function pruneText(result: PrunePlan | { completed: string[] }, s: Style = PLAIN): string {
264
+ if ("completed" in result) {
265
+ return [
266
+ `amem prune: ${s.ok("complete")}`,
267
+ `${label(s, result.completed.length ? "ok" : "skipped")} completed prune rows: ${result.completed.length}`,
268
+ ...result.completed.map(id => ` ${label(s, "ok")} ${id}`),
269
+ "Next action: none",
270
+ ].join("\n");
271
+ }
272
+ const targetIds = result.rows.flatMap(row => row.payload.targets.map(target => target.id));
273
+ const next = targetIds.length
274
+ ? "save this preview with --json, then amem prune --apply --plan <file>; keep --user if used in the preview"
275
+ : "none; the sealed plan contains no prune rows";
276
+ return [
277
+ `amem prune: ${s.attention("preview")}`,
278
+ `${label(s, "ok")} project: ${singleLine(result.project)}`,
279
+ ...(result.rows.length ? [`${label(s, "ok")} cutoff: ${result.rows[0]!.payload.cutoff}`] : []),
280
+ `${label(s, targetIds.length ? "attention" : "skipped")} selection: eligible ${result.counts.eligible}, selected ${result.counts.selected}, local ${result.counts.local}, synced ${result.counts.synced}`,
281
+ ...(result.hasMore ? ["more eligible entries remain beyond this plan"] : []),
282
+ ...result.rows.flatMap(row => row.payload.targets.map(target => ` ${label(s, "attention")} ${row.placement}: ${target.id} (expired ${target.expiredAt})`)),
283
+ `Next action: ${next}`,
284
+ ...(targetIds.length ? ["The readable preview is not a sealed JSON plan."] : []),
285
+ ].join("\n");
286
+ }
@@ -11,6 +11,7 @@ import { assertNoLiveReaders } from "./reader-lease.ts";
11
11
  import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
12
12
  import { buildGenerationIndex, type Entry } from "./store.ts";
13
13
  import { readMigrationSources, type SourcePrefix } from "./migrate.ts";
14
+ import { label, PLAIN, singleLine, type Style } from "./style.ts";
14
15
 
15
16
  const USER = Symbol("shared-user-shard");
16
17
  type Destination = string | typeof USER;
@@ -560,3 +561,40 @@ export async function migrateShards(options: ShardMigrationOptions): Promise<Sha
560
561
  return { mode: options.mode, generation, active: true, ...(alreadyActive ? { alreadyActive: true } : {}), counts: counts(plan), ...refusalSummary(plan), consequence: CONSEQUENCE, ...removalEvidence(journal) };
561
562
  });
562
563
  }
564
+
565
+ /** Render a shard migration result for a person; JSON output remains owned by the CLI. */
566
+ export function shardMigrationText(result: ShardMigrationResult, s: Style = PLAIN): string {
567
+ const counts = result.counts;
568
+ const groupRows = (name: string, value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
569
+ .map(([key, count]) => `${label(s, "ok")} ${name} ${singleLine(key)} ${count}`);
570
+ const countRows = [
571
+ ["rows", counts.rows], ["skipped", counts.skippedRows], ["userRows", counts.userRows], ["tailBytes", counts.tailBytes],
572
+ ["legacyRows", counts.legacyRows], ["generationRows", counts.generationRows], ["generationLocalRows", counts.generationLocalRows],
573
+ ["legacyLocalRows", counts.legacyLocalRows], ["legacyMainRows", counts.legacyMainRows], ["pruneRows", counts.pruneRows],
574
+ ["translatedPruneRows", counts.translatedPruneRows], ["refusedRows", counts.refusedRows],
575
+ ["sharedSupersededByPrivate", counts.sharedSupersededByPrivate], ["crossPrivateReplacement", counts.crossPrivateReplacement],
576
+ ["danglingRows", counts.danglingRows],
577
+ ].map(([key, value]) => {
578
+ const flagged = key === "refusedRows" && counts.refusedRows > 0 || key === "sharedSupersededByPrivate" && counts.sharedSupersededByPrivate > 0
579
+ || key === "crossPrivateReplacement" && counts.crossPrivateReplacement > 0 || key === "danglingRows" && counts.danglingRows > 0;
580
+ return `${label(s, flagged ? "attention" : "ok")} ${key} ${value}`;
581
+ });
582
+ const warnings = result.refusals ?? [];
583
+ const attention = warnings.length > 0 || counts.refusedRows > 0 || counts.danglingRows > 0 || (!result.active && result.mode === "resume");
584
+ const state = attention ? "attention" : result.mode === "dry-run" ? "preview" : result.active ? "complete" : "attention";
585
+ const action = warnings.length || counts.refusedRows > 0 || counts.danglingRows > 0 ? "resolve the listed refusals or flagged rows before retrying"
586
+ : result.mode === "dry-run" && (!result.active || counts.rows > 0) ? "apply shard migration: amem migrate --shards --apply"
587
+ : result.mode === "resume" && !result.active ? "resume shard migration: amem migrate --shards --resume"
588
+ : "none";
589
+ return [
590
+ `${s.bold("amem migrate --shards:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
591
+ `${label(s, "ok")} generation ${result.generation.slice(0, 8)}`,
592
+ ...countRows,
593
+ ...groupRows("shard rows", counts.byShard),
594
+ ...groupRows("project rows", counts.byProject),
595
+ ...(result.deadLeasesRemoved?.length ? [`${label(s, "ok")} dead reader leases removed ${result.deadLeasesRemoved.length}`] : []),
596
+ ...(warnings.length ? ["Refusals:", ...warnings.map(reason => ` ${label(s, "attention")} ${singleLine(reason)}`)] : []),
597
+ `${label(s, "ok")} effect ${singleLine(result.consequence)}`,
598
+ `Next action: ${action}`,
599
+ ].join("\n");
600
+ }
@@ -1,3 +1,4 @@
1
+ import { cliAttribution } from "./cli-attribution.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash } from "node:crypto";
3
4
  import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -193,7 +194,7 @@ export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: numbe
193
194
  lifecycle?: "enforce" | "ignore";
194
195
  /** #27: read the `aliases` keys' curated rows as part of the `canonical` project (CLI and MCP only); the library default reads the raw key. Rows are presented under `canonical`. */
195
196
  reconcile?: { canonical: string; aliases: readonly string[] };
196
- /** Person-only project import may initialize configured alias shards; read-only reconciliation still never creates them. */
197
+ /** Explicit project import may initialize configured alias shards; read-only reconciliation still never creates them. */
197
198
  createAliases?: boolean;
198
199
  }
199
200
 
@@ -589,7 +590,7 @@ export class MemoryStore {
589
590
  let applied = 0;
590
591
  if (apply) for (const group of groups) for (const head of group.heads) {
591
592
  if (head === group.target) continue;
592
- if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
593
+ if (!this.supersede(head, group.target, cliAttribution()?.actor ?? "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
593
594
  applied++;
594
595
  }
595
596
  return { groups, applied };
@@ -1587,7 +1588,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1587
1588
  const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
1588
1589
  const aliasStores: MemoryStore[] = [];
1589
1590
  try {
1590
- // #27: read-only reconciliation never creates an alias shard. A person-only project import explicitly can.
1591
+ // #27: read-only reconciliation never creates an alias shard. An explicit project import can.
1591
1592
  if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
1592
1593
  for (const alias of reconcile.aliases) {
1593
1594
  if (alias === opts.project || !opts.createAliases && !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
@@ -1600,7 +1601,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1600
1601
  } catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
1601
1602
  }
1602
1603
 
1603
- /** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */
1604
+ /** Administrative migration receipt; this inspector reads raw rows without opening a store or writing any index. */
1604
1605
  export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
1605
1606
  const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
1606
1607
  let malformed = 0;
@@ -23,6 +23,9 @@ export function style(color: boolean): Style {
23
23
  }
24
24
  export const PLAIN: Style = style(false);
25
25
 
26
+ /** Data stays on one human-output row and cannot inject terminal or bidirectional controls. */
27
+ export const singleLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ");
28
+
26
29
  /** Status label, padded so the columns line up; the word is always present. */
27
30
  export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
28
31
  const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
@@ -1,6 +1,7 @@
1
1
  import { validVaultPath } from "./generation.ts";
2
2
  import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
3
3
  import type { MemoryStore, View, VaultEntry } from "./store.ts";
4
+ import { label, PLAIN, singleLine, type Style } from "./style.ts";
4
5
 
5
6
  export interface VaultSelection { project: string; policy: MemoryPolicy; limit?: number }
6
7
  export type { VaultEntry } from "./store.ts";
@@ -29,6 +30,36 @@ export async function vaultCandidates(store: MemoryStore, options: VaultSelectio
29
30
  .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, options.limit ?? 50);
30
31
  }
31
32
 
33
+ /** Human-readable list of screened vault candidates; raw note text is intentionally omitted. */
34
+ export function vaultCandidatesText(rows: readonly View[], s: Style = PLAIN): string {
35
+ return [
36
+ `amem vault-candidates: ${rows.length ? s.attention("attention") : s.ok("complete")}`,
37
+ `${label(s, rows.length ? "attention" : "skipped")} candidates: ${rows.length}`,
38
+ ...rows.map(row => {
39
+ const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
40
+ return ` ${label(s, "attention")} ${singleLine(row.project)}: ${row.id} ${row.kind} ${title}`;
41
+ }),
42
+ `Next action: ${rows.length ? "open the source repository, then review a candidate with amem show <id> before recording it in your vault" : "none; no screened durable candidates are available"}`,
43
+ ].join("\n");
44
+ }
45
+
46
+ /** Human-readable cross-project list, preserving project grouping and omitting note bodies. */
47
+ export function vaultCandidatesAllText(result: {
48
+ crossedProjects: boolean;
49
+ projects: readonly { project: string; candidates: readonly View[] }[];
50
+ }, s: Style = PLAIN): string {
51
+ const count = result.projects.reduce((sum, group) => sum + group.candidates.length, 0);
52
+ return [
53
+ `amem vault-candidates: ${count ? s.attention("attention") : s.ok("complete")}${result.crossedProjects ? " (across projects)" : ""}`,
54
+ `${label(s, count ? "attention" : "skipped")} candidates: ${count}`,
55
+ ...result.projects.flatMap(group => group.candidates.map(row => {
56
+ const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
57
+ return ` ${label(s, "attention")} ${singleLine(group.project)}: ${row.id} ${row.kind} ${title}`;
58
+ })),
59
+ `Next action: ${count ? (result.crossedProjects ? "open the candidate's source repository, then review it with amem show <id> before recording it in your vault" : "open the source repository, then review a candidate with amem show <id> before recording it in your vault") : "none; no screened durable candidates are available"}`,
60
+ ].join("\n");
61
+ }
62
+
32
63
  /** Person-facing hosts authenticate intent before calling; no MCP vault-link tool is provided. */
33
64
  export async function recordVaultLink(store: MemoryStore, id: string, path: string, options: VaultSelection): Promise<string> {
34
65
  const policy = selection(options);