@staix/agent-mem 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +70 -44
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +105 -0
  5. package/src/cli.ts +219 -56
  6. package/src/commands-table.ts +10 -9
  7. package/src/index.ts +1 -0
  8. package/src/ingest.ts +21 -4
  9. package/src/mcp.ts +8 -8
  10. package/src/memory/candidates.ts +15 -13
  11. package/src/memory/capacity-split.ts +4 -1
  12. package/src/memory/capacity.ts +48 -4
  13. package/src/memory/catalog.ts +4 -4
  14. package/src/memory/cli-attribution.ts +23 -0
  15. package/src/memory/commands.ts +63 -11
  16. package/src/memory/consolidate.ts +3 -2
  17. package/src/memory/decay-review.ts +4 -4
  18. package/src/memory/doctor.ts +36 -7
  19. package/src/memory/generation-index.ts +56 -11
  20. package/src/memory/generation.ts +73 -7
  21. package/src/memory/metrics.ts +20 -8
  22. package/src/memory/migrate.ts +31 -0
  23. package/src/memory/mutation.ts +24 -4
  24. package/src/memory/note-add.ts +4 -4
  25. package/src/memory/prune-publisher-lineage.ts +139 -0
  26. package/src/memory/prune.ts +28 -1
  27. package/src/memory/public-prune-activation.ts +260 -0
  28. package/src/memory/public-prune-advance.ts +149 -0
  29. package/src/memory/public-prune-input-chain.ts +73 -0
  30. package/src/memory/public-prune-inputs.ts +455 -0
  31. package/src/memory/public-prune-view.ts +139 -0
  32. package/src/memory/public-prune-witness.ts +168 -0
  33. package/src/memory/reader-lease.ts +46 -34
  34. package/src/memory/shard-migration.ts +42 -3
  35. package/src/memory/shard-sync-format.ts +40 -2
  36. package/src/memory/shard-sync-graph.ts +87 -0
  37. package/src/memory/shard-sync.ts +341 -53
  38. package/src/memory/shard.ts +50 -9
  39. package/src/memory/store.ts +51 -12
  40. package/src/memory/style.ts +3 -0
  41. package/src/memory/sync.ts +141 -5
  42. package/src/memory/vault.ts +31 -0
  43. package/src/setup.ts +59 -31
@@ -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": {
@@ -159,11 +197,19 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
159
197
  const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
160
198
  if (json) return JSON.stringify(r, null, 2);
161
199
  if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
200
+ if (r.pruneApplication) {
201
+ const a = r.pruneApplication;
202
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
203
+ }
162
204
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
163
205
  }
164
206
  case "dedupe": {
165
207
  const apply = rest.includes("--apply");
166
208
  if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
209
+ if (cliAttribution()?.actor === "agent") {
210
+ const preview = store.dedupe(project, false);
211
+ for (const group of preview.groups) { for (const view of group.members) visibleTarget(view, view.id); visibleTarget(store.get(group.target, project), group.target); }
212
+ }
167
213
  const result = store.dedupe(project, apply);
168
214
  if (json) return JSON.stringify(result, null, 2);
169
215
  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 +234,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
188
234
  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
235
  }
190
236
  case "confirm": case "reject": {
237
+ if (cliAttribution()?.actor === "agent") {
238
+ const matches = store.suggestions(project).filter(row => row.id === rest[0] || row.id.startsWith(rest[0] ?? ""));
239
+ if (matches.length !== 1) fail("Memory operation unavailable or refused; no partial result is shown.");
240
+ for (const id of [matches[0]!.from, matches[0]!.to]) visibleTarget(store.get(id, project), id);
241
+ }
191
242
  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
243
  return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
193
244
  }
@@ -217,6 +268,7 @@ function leaseText(g: GoalView, me: string, goal: string): string {
217
268
  * person's. A peer links its own board task with hub_task_propose's `goal`.
218
269
  */
219
270
  export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
271
+ const actor = cliAttribution()?.actor ?? "user";
220
272
  const json = args.includes("--json");
221
273
  const rest = args.filter((a) => a !== "--json");
222
274
  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 +303,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
251
303
  const shared = rest.includes("--shared");
252
304
  const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
253
305
  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" });
306
+ const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor });
255
307
  return `long-term task ${id.slice(0, 8)} created: ${title}`;
256
308
  }
257
309
  case "link": {
@@ -264,7 +316,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
264
316
  const g = need(rest[0]);
265
317
  const state = (sub === "done" ? "done" : rest[1]) as GoalState;
266
318
  if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
267
- store.goalState(g.id, state, "user");
319
+ store.goalState(g.id, state, actor);
268
320
  if (state === "done" && opts.onDone) {
269
321
  const current = store.goalOf(g.id, project);
270
322
  if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
@@ -276,7 +328,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
276
328
  }
277
329
  case "take": {
278
330
  const g = need(rest[0]);
279
- const r = store.take(g.id, "user", { force: rest.includes("--force") });
331
+ const r = store.take(g.id, actor, { force: rest.includes("--force") });
280
332
  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
333
  return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
282
334
  }
@@ -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); }
@@ -1,9 +1,10 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { sessionStartBrief } from "../session-brief.ts";
6
6
  import { pendingSummary } from "./candidate-pending.ts";
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
 
@@ -84,8 +87,10 @@ class TooBig extends Error {}
84
87
  function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
85
88
  mkdirSync(to, { recursive: true, mode: 0o700 });
86
89
  for (const name of readdirSync(from)) {
87
- if (SKIP.test(name) || skipNames.has(name)) continue;
90
+ const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
91
+ if (SKIP.test(name) && !proofObject || skipNames.has(name)) continue;
88
92
  const source = join(from, name), stat = lstatSync(source);
93
+ if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
89
94
  if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
90
95
  if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
91
96
  else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
@@ -100,6 +105,14 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
100
105
  const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
101
106
  copyFileSync(join(home, name), join(scratch, name));
102
107
  }
108
+ const selected = readSelector(home).selector;
109
+ if (selected?.schema === "agent-mem.profile/v2") {
110
+ const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
111
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
112
+ budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
113
+ const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
114
+ copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
115
+ }
103
116
  if (layout === "split") {
104
117
  for (const key of [options.project, ...options.aliases]) {
105
118
  const dir = shardDir(home, key);
@@ -219,7 +232,7 @@ function captureSection(options: DoctorOptions): Section {
219
232
  const needs = registered ? state !== "fresh" : state === "stale";
220
233
  if (needs) attention.push(cli);
221
234
  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` } : {}) };
235
+ ...(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
236
  }
224
237
  return { status: !readable ? "unavailable" : attention.length ? "attention" : "ok", ...(readable ? {} : { reason: "a capture database could not be read" }), clis };
225
238
  }
@@ -271,6 +284,17 @@ function policySection(options: DoctorOptions): Section {
271
284
  } catch (error) { return unavailable(message(error)); }
272
285
  }
273
286
 
287
+ /** #132: capture rows hooks dropped, by reason, from the bounded rolling metrics tail (counts only; no row text is ever recorded there). */
288
+ function dropsSection(options: DoctorOptions): Section {
289
+ const read = readCaptureDrops(options.home);
290
+ if (!read.readable) return unavailable(read.reason); // an existing file doctor cannot read is never a healthy zero
291
+ // One torn line (an interrupted append) is reported, not fatal; a tail with lines of which none parses is a file doctor cannot decode.
292
+ if (read.lines > 0 && read.malformedLines === read.lines) return unavailable("metrics.jsonl has no decodable record in its recent tail");
293
+ const counts = read.counts, total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
294
+ return { status: total ? "attention" : "ok", window: "recent metrics tail", total, counts, ...(read.malformedLines ? { malformedLines: read.malformedLines } : {}),
295
+ ...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
296
+ }
297
+
274
298
  /** 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
299
  function cadenceSection(options: DoctorOptions): Section {
276
300
  const ingest: Record<string, string> = {};
@@ -398,7 +422,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
398
422
  let sections: DoctorReport["sections"];
399
423
  try {
400
424
  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) };
425
+ 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
426
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
403
427
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
404
428
  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 +431,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
407
431
  /** Section order and one-line descriptions for the person-facing report (#125). */
408
432
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
409
433
  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",
434
+ index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
411
435
  };
412
436
 
413
437
  function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
@@ -422,6 +446,11 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
422
446
  return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
423
447
  `sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
424
448
  }
449
+ if (name === "drops") {
450
+ if (detail.counts === undefined) return []; // unavailable: only its reason is shown
451
+ const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
452
+ 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);
453
+ }
425
454
  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
455
  const { examples, ...rest } = detail;
427
456
  const line = facts(rest);
@@ -1,16 +1,20 @@
1
- import { SHARDED_SCHEMA } from "./shard.ts";
1
+ import { createPublicPruneReplayCache, type PublicPruneProjection } from "./public-prune-inputs.ts";
2
+ import { readPublicPruneView, readPublicPruneViewForActivation } from "./public-prune-view.ts";
3
+ import { SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
2
4
  import { readShardLegacySnapshot } from "./shard-bridge.ts";
3
5
  import type { Database } from "bun:sqlite";
4
6
  import { createHash } from "node:crypto";
5
7
  import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
6
8
  import { join } from "node:path";
7
- import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
9
+ import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink, rebindPublicPruneProfile } from "./generation.ts";
8
10
  import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
9
11
  import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
10
12
  import { PROMOTED_KINDS, type Entry } from "./store.ts";
11
13
 
14
+ const FOREIGN_TABLES = ["generation_foreign_inputs", "generation_foreign_rows", "generation_foreign_targets"] as const;
12
15
  const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
13
16
  const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
17
+ class PublicPruneViewChanged extends Error { constructor() { super("public prune view changed repeatedly; retry the read"); } }
14
18
  export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
15
19
  export interface GenerationIndexOptions {
16
20
  profile: BoundProfile; db: Database; now: () => number;
@@ -65,9 +69,17 @@ export class GenerationIndex {
65
69
  private fullCapture = false;
66
70
  private retainedTail = false;
67
71
  private replayProvenance = false;
72
+ private foreignStamp?: string;
73
+ private foreignCache?: { set: string; load: () => PublicPruneProjection };
68
74
  constructor(private readonly options: GenerationIndexOptions) {
69
75
  this.profile = options.profile;
70
76
  const db = options.db;
77
+ if (this.profile.foreignPruneFormat) {
78
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_inputs (id TEXT PRIMARY KEY)");
79
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_rows (id TEXT PRIMARY KEY, hash TEXT NOT NULL)");
80
+ db.run("CREATE TABLE IF NOT EXISTS generation_foreign_targets (id TEXT PRIMARY KEY)");
81
+ this.replayProvenance = true; // persistent SQLite never supplies foreign membership
82
+ }
71
83
  db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
72
84
  if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
73
85
  db.run("CREATE TABLE IF NOT EXISTS generation_vault_links (id TEXT PRIMARY KEY, target TEXT NOT NULL, row_hash TEXT NOT NULL, project TEXT NOT NULL, path TEXT NOT NULL, at TEXT NOT NULL)");
@@ -93,9 +105,14 @@ export class GenerationIndex {
93
105
  /** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
94
106
  private shardFrontier(): string | undefined {
95
107
  if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
96
- const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
97
- if (!value || value.schema !== SHARDED_SCHEMA || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
98
- return hashJSON(value);
108
+ for (let attempt = 0; attempt < 6; attempt++) {
109
+ rebindPublicPruneProfile(this.profile);
110
+ const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
111
+ if (selectorRevision(this.profile.home) !== this.profile.selectorRevision) { if (this.profile.foreignPruneFormat) continue; return fail(); }
112
+ if (!value || value.schema !== (this.profile.foreignPruneFormat ? SHARDED_V2_SCHEMA : SHARDED_SCHEMA) || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
113
+ return hashJSON(value);
114
+ }
115
+ return fail();
99
116
  }
100
117
  private capture(reader: SegmentReader = this.reader): { segments: LegacySegment[]; commit?: string } {
101
118
  const shardBefore = this.shardFrontier();
@@ -119,7 +136,7 @@ export class GenerationIndex {
119
136
  const segment = own.find(s => s.path === old.path);
120
137
  if (!segment || segment.offset < old.offset) fail();
121
138
  }
122
- if (this.shardFrontier() !== shardBefore) return fail();
139
+ if (this.shardFrontier() !== shardBefore) { if (this.profile.foreignPruneFormat) throw new PublicPruneViewChanged(); return fail(); }
123
140
  return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
124
141
  }
125
142
  private index(captured: { segments: LegacySegment[]; commit?: string }): void {
@@ -161,7 +178,35 @@ export class GenerationIndex {
161
178
  const reduced = reducePruneRows(prunes, snapshot, this.profile.generation!);
162
179
  db.run("DELETE FROM generation_targets");
163
180
  for (const id of [...reduced.targets].sort()) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
164
- this.pruned = reduced.targets;
181
+ this.applyForeign(true);
182
+ this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
183
+ }
184
+ private applyForeign(validateRows = false): void {
185
+ if (!this.profile.foreignPruneFormat) return;
186
+ const profile = this.profile;
187
+ if (!profile.pruneInputSetHash || !profile.generation || !profile.sharded) return fail();
188
+ const view = (this.options.limits !== undefined ? readPublicPruneViewForActivation : readPublicPruneView)(profile.home, profile.pruneInputSetHash, profile.generation);
189
+ const key = profile.shardUser ? "user" : `project:${profile.shardKey}`, set = view.realms[key];
190
+ const db = this.options.db;
191
+ if (!set) {
192
+ if ((db.query("SELECT COUNT(*) AS n FROM generation_foreign_inputs").get() as {n:number}).n) return fail();
193
+ return;
194
+ }
195
+ if (!this.foreignCache || this.foreignCache.set !== set) this.foreignCache = { set, load: createPublicPruneReplayCache({ dir: profile.dir,
196
+ realm: profile.shardUser ? { scope: "user" } : { scope: "project", key: profile.shardKey! }, receivingGeneration: profile.generation, inputSetHash: set }) };
197
+ const proof = this.foreignCache.load();
198
+ if (validateRows || this.foreignStamp !== set) for (const item of db.query("SELECT id,line FROM generation_rows").all() as {id:string;line:string}[]) {
199
+ const expected = proof.rowHashes.get(item.id); if (expected && hashJSON(JSON.parse(item.line)) !== expected) return fail();
200
+ }
201
+ if (validateRows || this.foreignStamp !== set) {
202
+ for (const table of FOREIGN_TABLES) db.run(`DELETE FROM ${table}`);
203
+ for (const id of proof.inputs) db.query("INSERT INTO generation_foreign_inputs VALUES (?)").run(id);
204
+ for (const [id, hash] of proof.rowHashes) db.query("INSERT INTO generation_foreign_rows VALUES (?,?)").run(id, hash);
205
+ for (const id of proof.targets) db.query("INSERT INTO generation_foreign_targets VALUES (?)").run(id);
206
+ this.foreignStamp = set;
207
+ }
208
+ const current = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
209
+ for (const id of proof.targets) if (!current.has(id)) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
165
210
  }
166
211
  private recordSkipped(segment: LegacySegment): void {
167
212
  this.options.db.query("INSERT INTO generation_state VALUES (?,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT)").run(`skipped:${segment.source}:${segment.path}`);
@@ -183,12 +228,12 @@ export class GenerationIndex {
183
228
  const before = this.frontier(), db = this.options.db;
184
229
  let input: ReturnType<GenerationIndex["capture"]>;
185
230
  try { input = this.capture(reader); }
186
- catch (error) { if (attempt < 5 && this.frontier() !== before) return this.refresh(attempt + 1, reader); throw error; }
231
+ catch (error) { if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.refresh(attempt + 1, reader); throw error; }
187
232
  if (this.frontier() !== before) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
188
233
  const known = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments").all() as { source: string; path: string; offset: number; hash: string; commit_id: string | null; stamp: string | null }[];
189
234
  const currentCommit = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value;
190
235
  const unchanged = known.length === input.segments.length && currentCommit === input.commit && input.segments.every(segment => known.some(row => row.source === segment.source && row.path === segment.path && row.offset === segment.offset && row.hash === segment.hash && row.commit_id === (segment.commit ?? null) && row.stamp === (segment.stamp ?? null)));
191
- if (unchanged) { this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
236
+ if (unchanged) { if (this.profile.foreignPruneFormat) db.transaction(() => this.applyForeign()).deferred(); this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
192
237
  const committed = db.transaction(() => { if (this.frontier() !== before) return false; this.index(input); return true; }).immediate();
193
238
  if (!committed) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
194
239
  this.retainedTail = input.segments.some(segment => segment.tailBytes > 0);
@@ -197,11 +242,11 @@ export class GenerationIndex {
197
242
  const before = this.frontier();
198
243
  let input: ReturnType<GenerationIndex["capture"]>;
199
244
  try { this.fullCapture = true; input = this.capture(reader); }
200
- catch (error) { this.fullCapture = false; if (attempt < 5 && this.frontier() !== before) return this.rebuild(attempt + 1, reader); throw error; }
245
+ catch (error) { this.fullCapture = false; if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.rebuild(attempt + 1, reader); throw error; }
201
246
  finally { this.fullCapture = false; }
202
247
  const committed = this.options.db.transaction(() => {
203
248
  if (this.frontier() !== before) return false;
204
- for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES]) this.options.db.run(`DELETE FROM ${table}`);
249
+ for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES, ...(this.profile.foreignPruneFormat ? FOREIGN_TABLES : [])]) this.options.db.run(`DELETE FROM ${table}`);
205
250
  this.index(input); return true;
206
251
  }).immediate();
207
252
  if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1, reader); return fail(); }
@@ -1,9 +1,12 @@
1
+ import { assertNoPublicPruneIntent, readPublicPruneView, waitForPublicPruneWriter, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { types } from "node:util";
3
4
  import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
5
  import { readerAdmissionDatabase } from "./mutation.ts";
5
6
  import { dirname, join } from "node:path";
6
7
  import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
8
+ import { shardDir, userShardDir } from "./shard.ts";
9
+ import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
7
10
 
8
11
  export interface BoundProfile {
9
12
  kind: "shared-v1" | "generation";
@@ -12,6 +15,9 @@ export interface BoundProfile {
12
15
  dir: string;
13
16
  generation?: string;
14
17
  selectorRevision: string;
18
+ /** Explicit v2 projection only, never inferred from an archive or caller flag. */
19
+ foreignPruneFormat?: "agent-mem.public-prune-witness/v1";
20
+ pruneInputSetHash?: string;
15
21
  /** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
16
22
  legacyHome?: string;
17
23
  /** Public legacy witnesses stay distinct from same-machine private bridge input. */
@@ -21,7 +27,8 @@ export interface BoundProfile {
21
27
  shardUser?: boolean;
22
28
  }
23
29
  export type ProfileInput = "shared-v1" | { generation: string };
24
- export interface ProfileSelector { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
30
+ export type ProfileSelector = { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
31
+ | { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: "agent-mem.public-prune-witness/v1"; pruneInputSetHash: string };
25
32
  export interface GenerationDescriptor { schema: "agent-mem.generation/v1"; generation: string; legacySchema: "agent-hub.memory/v1" }
26
33
  export interface GenerationSeal { schema: "agent-mem.generation-seal/v1"; generation: string; descriptorHash: string; sourceDigest: string }
27
34
  export const PROFILE_LOCK_TIMEOUT_MS = 3000;
@@ -44,16 +51,69 @@ export function readProfileJSON(path: string, limit = LOCAL_METADATA_MAX_BYTES):
44
51
  } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; return unavailable(); }
45
52
  }
46
53
  function keys(value: object, allowed: string[]): boolean { return Object.keys(value).every(key => allowed.includes(key)); }
47
- export function readSelector(memoryDir: string): { selector?: ProfileSelector; revision: string } {
54
+ function selectorSnapshot(memoryDir: string, deadline: number): { selector?: ProfileSelector; revision: string } {
55
+ assertNoPublicPruneIntent(memoryDir, deadline);
48
56
  const value = readProfileJSON(join(memoryDir, "profile.json"));
49
57
  if (value === undefined) return { revision: "absent" };
50
- if (!value || typeof value !== "object" || Array.isArray(value) || !keys(value, ["schema", "kind", "generation"])) return unavailable();
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return unavailable();
51
59
  const selector = value as ProfileSelector;
52
- if (selector.schema !== "agent-mem.profile/v1" || !["shared-v1", "generation"].includes(selector.kind)
53
- || (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
60
+ if (selector.schema === "agent-mem.profile/v2") {
61
+ if (Object.keys(selector).length !== 5 || !keys(selector, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"])
62
+ || selector.kind !== "generation" || !validGeneration(selector.generation) || selector.generation.length !== 32
63
+ || selector.foreignPruneFormat !== "agent-mem.public-prune-witness/v1" || typeof selector.pruneInputSetHash !== "string"
64
+ || selector.pruneInputSetHash.length !== 64 || !/^[a-f0-9]+$/.test(selector.pruneInputSetHash)) return unavailable();
65
+ const shard = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
66
+ if (!shard || shard.schema !== "agent-mem.shards/v2" || shard.phase !== "active" || shard.generation !== selector.generation || shard.selectorRevision !== hashJSON(selector)) return unavailable();
67
+ readPublicPruneView(memoryDir, selector.pruneInputSetHash, selector.generation, deadline);
68
+ } else if (selector.schema !== "agent-mem.profile/v1" || !keys(selector, ["schema", "kind", "generation"])
69
+ || !["shared-v1", "generation"].includes(selector.kind) || (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
70
+ else if ((readProfileJSON(join(memoryDir, "shards.json")) as {schema?:unknown}|undefined)?.schema === "agent-mem.shards/v2") return unavailable();
54
71
  return { selector, revision: hashJSON(selector) };
55
72
  }
73
+
74
+ /** A v2 writer can replace its two markers between individual reads. Retry its
75
+ * bounded completed publication rather than exposing a transient mixed pair. */
76
+ export function readSelector(memoryDir: string, absoluteDeadline?: number): { selector?: ProfileSelector; revision: string } {
77
+ const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
78
+ for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
79
+ try {
80
+ const result = selectorSnapshot(memoryDir, deadline); assertNoPublicPruneIntent(memoryDir, deadline);
81
+ const current = readProfileJSON(join(memoryDir, "profile.json"));
82
+ if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
83
+ } catch (error) {
84
+ if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
85
+ if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
86
+ waitForPublicPruneWriter(memoryDir, deadline);
87
+ }
88
+ }
89
+ return unavailable();
90
+ }
91
+
56
92
  export const selectorRevision = (memoryDir: string): string => readSelector(memoryDir).revision;
93
+ /** Refresh the same shared profile object only across proved monotone same-generation v2 views.
94
+ * Unopened realms contribute set metadata only; their descriptors and bodies stay unopened. */
95
+ export function rebindPublicPruneProfile(profile: BoundProfile): void {
96
+ if (!profile.foreignPruneFormat) return;
97
+ for (let attempt = 0; attempt < 6; attempt++) {
98
+ const current = readSelector(profile.home), selector = current.selector;
99
+ if (current.revision === profile.selectorRevision) return;
100
+ if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.pruneInputSetHash
101
+ || selector?.schema !== "agent-mem.profile/v2" || selector.generation !== profile.generation || selector.foreignPruneFormat !== profile.foreignPruneFormat) return unavailable();
102
+ const previous = readPublicPruneView(profile.home, profile.pruneInputSetHash, profile.generation);
103
+ const next = readPublicPruneView(profile.home, selector.pruneInputSetHash, profile.generation);
104
+ if (!Object.keys(previous.realms).every(key => Object.hasOwn(next.realms, key))) return unavailable();
105
+ for (const [key, hash] of Object.entries(next.realms)) {
106
+ const old = previous.realms[key]; if (old === hash) continue;
107
+ const dir = key === "user" ? userShardDir(profile.home) : shardDir(profile.home, key.slice(8));
108
+ const chain = readPublicPruneInputSetChain({ dir, receivingGeneration: profile.generation, inputSetHash: hash });
109
+ if (old !== undefined && !chain.commitments.includes(old)) return unavailable();
110
+ }
111
+ if (readSelector(profile.home).revision !== current.revision) continue;
112
+ profile.selectorRevision = current.revision; profile.pruneInputSetHash = selector.pruneInputSetHash;
113
+ return;
114
+ }
115
+ return unavailable();
116
+ }
57
117
  export function generationDescriptor(generation: string): GenerationDescriptor {
58
118
  if (!validGeneration(generation)) return unavailable();
59
119
  return { schema: "agent-mem.generation/v1", generation, legacySchema: "agent-hub.memory/v1" };
@@ -83,8 +143,9 @@ export function validateGeneration(memoryDir: string, generation: string): strin
83
143
  export function resolveProfile(memoryDir: string, explicit?: ProfileInput): BoundProfile {
84
144
  const home = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), selected = readSelector(home);
85
145
  if (explicit !== undefined && explicit !== "shared-v1" && (!explicit || typeof explicit !== "object" || !keys(explicit, ["generation"]) || !validGeneration(explicit.generation))) return unavailable();
146
+ if (selected.selector?.schema === "agent-mem.profile/v2" && explicit !== undefined) return unavailable();
86
147
  const generation = explicit === "shared-v1" ? undefined : explicit?.generation ?? (selected.selector?.kind === "generation" ? selected.selector.generation : undefined);
87
- return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision }
148
+ return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision, ...(selected.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selected.selector.foreignPruneFormat, pruneInputSetHash: selected.selector.pruneInputSetHash } : {}) }
88
149
  : { kind: "shared-v1", home, dir: home, selectorRevision: selected.revision };
89
150
  }
90
151
 
@@ -142,7 +203,12 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
142
203
  await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
143
204
  continue;
144
205
  }
145
- try { return await work(); }
206
+ try {
207
+ const result = work();
208
+ // A synchronous publisher must release its lock before a queued sync reader can block this event loop.
209
+ if (result !== null && (typeof result === "object" || typeof result === "function") && typeof (result as PromiseLike<T>).then === "function") return await result;
210
+ return result;
211
+ }
146
212
  finally {
147
213
  // A live owner's token cannot be reclaimed; do not remove a file another actor replaced.
148
214
  if (existsSync(file) && !lstatSync(file).isSymbolicLink() && readFileSync(file, "utf8") === owner) { unlinkSync(file); fsyncDirectory(memoryDir); }