@staix/agent-mem 0.6.0 → 0.8.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 (71) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +143 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +537 -39
  6. package/src/commands-table.ts +60 -16
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +20 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +168 -33
  12. package/src/mcp.ts +43 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +228 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/consolidate.ts +78 -0
  25. package/src/memory/consolidation-clusters.ts +56 -0
  26. package/src/memory/consolidation-model.ts +97 -0
  27. package/src/memory/cross-project.ts +75 -0
  28. package/src/memory/curated-read.ts +29 -0
  29. package/src/memory/decay-review.ts +136 -0
  30. package/src/memory/doctor.ts +415 -0
  31. package/src/memory/editor.ts +58 -0
  32. package/src/memory/eval-fixture.ts +145 -0
  33. package/src/memory/eval-set-ko.json +90 -0
  34. package/src/memory/eval-set.json +443 -0
  35. package/src/memory/eval.ts +152 -0
  36. package/src/memory/evidence.ts +76 -0
  37. package/src/memory/external-import.ts +267 -0
  38. package/src/memory/gc.ts +97 -0
  39. package/src/memory/generation-index.ts +22 -17
  40. package/src/memory/generation-sync.ts +3 -0
  41. package/src/memory/goal-promotion.ts +71 -0
  42. package/src/memory/home.ts +15 -4
  43. package/src/memory/intent-brief.ts +89 -0
  44. package/src/memory/lifecycle-log.ts +54 -0
  45. package/src/memory/lifecycle.ts +61 -0
  46. package/src/memory/metrics.ts +2 -2
  47. package/src/memory/note-add.ts +100 -0
  48. package/src/memory/path-glob.ts +94 -0
  49. package/src/memory/plan-guard.ts +47 -0
  50. package/src/memory/platform.ts +12 -1
  51. package/src/memory/policy.ts +44 -13
  52. package/src/memory/private-spans.ts +32 -0
  53. package/src/memory/project-export.ts +157 -0
  54. package/src/memory/project-import.ts +147 -0
  55. package/src/memory/recall-staleness.ts +56 -0
  56. package/src/memory/recall.ts +74 -0
  57. package/src/memory/repo-scope.ts +404 -0
  58. package/src/memory/shard-sync-format.ts +116 -0
  59. package/src/memory/shard-sync.ts +421 -0
  60. package/src/memory/store.ts +344 -35
  61. package/src/memory/sync-cadence.ts +149 -0
  62. package/src/memory/sync-screen.ts +86 -0
  63. package/src/memory/sync.ts +91 -11
  64. package/src/memory/usage.ts +35 -0
  65. package/src/memory/use-telemetry.ts +51 -0
  66. package/src/memory/work-lists.ts +10 -4
  67. package/src/session-brief-worker.ts +3 -2
  68. package/src/session-brief.ts +74 -15
  69. package/src/setup-owned.ts +120 -0
  70. package/src/setup-toml.ts +296 -0
  71. package/src/setup.ts +327 -0
@@ -2,32 +2,56 @@ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type Go
2
2
  import { syncMemory, type SyncOptions } from "./sync.ts";
3
3
  import { importClaudeMem } from "./import.ts";
4
4
  import { memoryProjectKey, projectChain } from "./project.ts";
5
+ import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
6
+ import type { GoalPromotionReport } from "./goal-promotion.ts";
7
+
8
+ /**
9
+ * What the commands call themselves in their own messages. The default is amem's own surface; a host that exposes the same commands under its own
10
+ * name (agent-hub's `ahub memory` and `ahub goal`) passes it, so no host name is baked into the library.
11
+ */
12
+ export interface CommandNames { memory: string; goal: string }
13
+ export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
5
14
 
6
15
  export interface MemoryCommandOptions {
16
+ /** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
17
+ person?: boolean;
18
+ /** Defaults to amem's own names. */
19
+ names?: CommandNames;
7
20
  /** Startup-bound canonical project supplied by a host; no model-facing selector. */
8
21
  project?: string;
9
22
  store?: MemoryStore;
10
23
  sync?: Omit<SyncOptions, "remote">;
11
24
  open: () => MemoryStore;
12
25
  readable: (notes: View[]) => View[];
26
+ stale?: (view: View) => boolean | undefined;
27
+ /** Prepared row ids only: the host must wait for successful output before recording rendered. */
28
+ outputReady?: (notes: readonly View[]) => void;
13
29
  importPolicy: () => (text: string) => boolean;
14
30
  /** Explicit person-status receipt; model-facing hosts omit this callback. */
15
31
  onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
16
32
  }
17
33
  export interface GoalCommandOptions {
34
+ /** Defaults to amem's own names. */
35
+ names?: CommandNames;
18
36
  project?: string;
19
37
  store?: MemoryStore;
20
38
  link?: (goal: string, task: string) => Promise<string>;
39
+ /** Optional host proposal hook; the goal state is saved before it runs. It never grants mutation authority. */
40
+ onDone?: (goal: GoalView, store: MemoryStore) => Promise<GoalPromotionReport>;
21
41
  open: () => MemoryStore;
22
42
  clean: (title: string, milestones: string[], project: string) => boolean;
23
43
  }
24
44
 
25
45
  /**
26
- * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
46
+ * #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,
27
47
  * supersede and rebuild are a person's (the identity gate refuses agent shells).
28
48
  */
29
- export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]";
30
- export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
49
+ /** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
50
+ 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] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
51
+ export const MEMORY_USAGE = memoryUsage();
52
+ /** The usage line of the goal commands under `name` (`amem goal` by default; agent-hub passes `ahub goal`). */
53
+ export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${name} [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]`;
54
+ export const GOAL_USAGE = goalUsage();
31
55
 
32
56
  const fail = (message: string): never => { throw new Error(message); };
33
57
  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)})` : ""}`;
@@ -41,64 +65,86 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
41
65
  const rest = args.filter((a) => a !== "--json");
42
66
  const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
43
67
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
68
+ const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
69
+ if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
44
70
  const store = opts.store ?? opts.open();
45
71
  const actor = "user";
46
72
  const project = opts.project ?? memoryProjectKey(cwd);
47
- const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
73
+ const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
74
+ // #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.
75
+ for (const crossing of ["--all-projects", "--include-archived"]) if (rest.includes(crossing)) fail(`${crossing} crosses projects: run amem search --all-projects; ${names.memory} reads this project only`);
76
+ // #15 T4: repo scope is the person's `amem promote --to repo`, which screens, previews the committed line and checks the repository's visibility. Here the flag would otherwise be ignored and the note promoted privately.
77
+ for (const repoOnly of ["--to", "--confirm", "--confirm-public"]) if (rest.includes(repoOnly)) fail(`${repoOnly} belongs to the person's amem promote <id> --to repo; ${names.memory} never writes a repository file`);
78
+ const stale = (view: View) => { try { return opts.stale?.(view) === true; } catch { return false; } };
79
+ const ready = (rows: readonly View[]) => { try { opts.outputReady?.(rows); } catch { /* optional output metadata cannot fail recall */ } };
80
+ const decorated = (view: View) => ({ ...view, ...(stale(view) ? { stale: true } : {}) });
81
+ if (!["list", "search", "show", "graph"].includes(sub) && (rest.includes("--history") || rest.includes("--as-of"))) fail("history/as-of apply only to memory reads");
48
82
  try {
49
83
  switch (sub) {
50
84
  case "list": case "search": {
51
- const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
85
+ const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit"), asOf = flag("--as-of");
86
+ const history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
52
87
  if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
53
88
  if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
54
89
  if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
55
90
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
56
- const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
57
- // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
58
- // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
59
- const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
60
- // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
91
+ const q = sub === "search" ? rest.join(" ") : undefined;
92
+ const state = recallRows(store, project, { person: opts.person, history, asOf, query: q, limit: Number.MAX_SAFE_INTEGER });
93
+ const all = state.views.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
61
94
  const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
95
+ ready(rows);
96
+ if (history || asOf !== undefined) {
97
+ const emitted = recallForViews(state, rows), publicIds = new Set(rows.map(view => view.id)), allForgotten = q?.trim() ? [] : state.forgotten;
98
+ const forgotten = allForgotten.slice(0, Math.max(0, (limit ? Math.floor(Number(limit)) : 50) - rows.length));
99
+ const result = { at: state.at, entries: rows.map(decorated), forgotten, relations: state.relations.filter(edge => publicIds.has(edge.from) && publicIds.has(edge.to)), incomplete: emitted.incomplete || readable.length > rows.length || forgotten.length < allForgotten.length, notices: emitted.notices };
100
+ return json ? JSON.stringify(result, null, 2) : [`retained memory as of ${state.at}`, ...result.notices, ...rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}${view.expiresAt && Date.parse(view.expiresAt) <= Date.parse(state.at) ? ` (expired at ${view.expiresAt})` : ""}`), ...forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(result.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
101
+ }
62
102
  if (json) return JSON.stringify(rows, null, 2);
63
- // Preserve project-wide header semantics, but count only rows the host policy lets this reader see.
64
- const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
65
- const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
103
+ const counted = opts.readable(store.withDeferredPromotions(() => store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })));
66
104
  const withheld = all.length - readable.length;
67
- return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
105
+ return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ? rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}`) : ["(nothing)"]),
68
106
  ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
69
107
  }
70
108
  case "show": {
71
- const id = rest[0] ?? fail("usage: ahub memory show <id>");
72
- const v = need(store.get(id, project), id);
73
- if (!opts.readable([v]).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
74
- return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
109
+ const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
110
+ const id = rest[0] ?? fail(`usage: ${names.memory} show <id>`), shown = recallShow(store, project, id, { person: opts.person, history, asOf });
111
+ if (!shown.ids.length) return fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
112
+ if (shown.state) {
113
+ const rows = opts.readable(shown.state.views), ids = new Set(rows.map(view => view.id)); ready(rows);
114
+ const state = { ...recallForViews(shown.state, rows), views: rows.map(decorated), relations: shown.state.relations.filter(edge => ids.has(edge.from) && ids.has(edge.to)) };
115
+ return json ? JSON.stringify(state, null, 2) : [`retained memory as of ${state.at}`, ...state.notices, ...rows.flatMap(view => [...(stale(view) ? ["(stale?)"] : []), ...detail(view)]), ...state.forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(state.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
116
+ }
117
+ const view = need(shown.entry, id); if (!opts.readable([view]).length) fail(`${view.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
118
+ ready([view]); return json ? JSON.stringify(view, null, 2) : [...(stale(view) ? ["(stale?)"] : []), ...detail(view)].join("\n");
75
119
  }
76
120
  case "graph": {
77
121
  const hops = Number(flag("--hops") ?? 1);
78
122
  if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
79
- const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
80
- const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
81
- const g = store.graph(project, node, hops);
123
+ const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
124
+ const start = rest[0] ?? fail(`usage: ${names.memory} graph <id|node>`);
125
+ const g = recallGraph(store, project, start, hops, { person: opts.person, history, asOf });
126
+ if ((!start.includes(":") || start.startsWith("mem:")) && !g.nodes.length) fail(`no memory entry ${start} (${names.memory} list shows the ids)`);
127
+ const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
82
128
  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");
83
129
  }
84
- case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_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)}`; }
85
- case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_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"}`; }
86
- case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_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)"}`; }
130
+ 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)}`; }
131
+ 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"}`; }
132
+ 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)"}`; }
87
133
  case "supersede": {
88
- if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
89
- 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 (ahub memory promote it first)");
134
+ if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
135
+ 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)`);
90
136
  }
91
137
  case "sync": {
92
138
  const remote = flag("--remote");
93
- if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
139
+ if (rest.length) fail(`usage: ${names.memory} sync [--remote <private git url>]`);
94
140
  const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
95
141
  if (json) return JSON.stringify(r, null, 2);
96
- if (!r.ok) fail(`hub memory sync: ${r.error}`);
142
+ if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
97
143
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
98
144
  }
99
145
  case "dedupe": {
100
146
  const apply = rest.includes("--apply");
101
- if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
147
+ if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
102
148
  const result = store.dedupe(project, apply);
103
149
  if (json) return JSON.stringify(result, null, 2);
104
150
  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",
@@ -108,7 +154,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
108
154
  const db = flag("--db"), limit = flag("--limit");
109
155
  const restart = rest.includes("--restart");
110
156
  if (restart) rest.splice(rest.indexOf("--restart"), 1);
111
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
157
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart]`);
112
158
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
113
159
  const isPii = opts.importPolicy();
114
160
  const key = opts.project ?? memoryProjectKey(cwd);
@@ -120,10 +166,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
120
166
  case "suggestions": {
121
167
  const pending = store.suggestions(project).filter((x) => x.status === "suggested");
122
168
  if (json) return JSON.stringify(pending, null, 2);
123
- 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 the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
169
+ 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";
124
170
  }
125
171
  case "confirm": case "reject": {
126
- const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
172
+ 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)`);
127
173
  return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
128
174
  }
129
175
  case "machine": {
@@ -132,7 +178,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
132
178
  return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
133
179
  }
134
180
  case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
135
- default: return fail(MEMORY_USAGE);
181
+ default: return fail(usage);
136
182
  }
137
183
  } finally {
138
184
  try { opts.onBridgeStatus?.(store.bridgeStatus()); }
@@ -140,15 +186,15 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
140
186
  }
141
187
  }
142
188
 
143
- const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
144
- function leaseText(g: GoalView, me: string): string {
189
+ const goalOne = (g: GoalView, me: string, goal: string = AMEM_NAMES.goal) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me, goal)}`;
190
+ function leaseText(g: GoalView, me: string, goal: string): string {
145
191
  const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
146
- if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
192
+ if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ${goal} take --force)` : ""}`;
147
193
  return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
148
194
  }
149
195
 
150
196
  /**
151
- * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
197
+ * #258: the goal commands (agent-hub exposes the same as `ahub goal`), long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
152
198
  * person's. A peer links its own board task with hub_task_propose's `goal`.
153
199
  */
154
200
  export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
@@ -156,11 +202,12 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
156
202
  const rest = args.filter((a) => a !== "--json");
157
203
  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; };
158
204
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
205
+ const names = opts.names ?? AMEM_NAMES, usage = goalUsage(names.goal);
159
206
  const store = opts.store ?? opts.open();
160
207
  const project = opts.project ?? memoryProjectKey(cwd);
161
208
  const me = store.machine.id;
162
209
  // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
163
- const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
210
+ const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(usage), project) ?? fail(`no long-term task ${id} for this project (${names.goal} list shows them)`);
164
211
  const clean = (title: string, milestones: string[], of = project) => opts.clean(title, milestones, of);
165
212
  try {
166
213
  switch (sub) {
@@ -169,36 +216,43 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
169
216
  const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
170
217
  const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
171
218
  if (json) return JSON.stringify(goals, null, 2);
172
- return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
219
+ return [...(goals.length ? goals.map((g) => goalOne(g, me, names.goal)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (${names.goal} new <title>)`]),
173
220
  ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
174
221
  }
175
222
  case "show": {
176
223
  const g = need(rest[0]);
177
224
  if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
178
225
  if (json) return JSON.stringify(g, null, 2);
179
- return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
226
+ return [goalOne(g, me, names.goal), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
180
227
  ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
181
228
  ...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
182
229
  }
183
230
  case "new": {
184
231
  const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
185
232
  const shared = rest.includes("--shared");
186
- const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
233
+ const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
187
234
  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");
188
235
  const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
189
236
  return `long-term task ${id.slice(0, 8)} created: ${title}`;
190
237
  }
191
238
  case "link": {
192
239
  const g = need(rest[0]);
193
- const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
240
+ const task = rest[1] ?? fail(`usage: ${names.goal} link <id> <task id>`);
194
241
  if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
195
- return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
242
+ return await (opts.link ?? fail("linking a goal to a task needs a host that has a task board; amem alone has none"))(g.id, task);
196
243
  }
197
244
  case "state": case "done": {
198
245
  const g = need(rest[0]);
199
246
  const state = (sub === "done" ? "done" : rest[1]) as GoalState;
200
247
  if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
201
248
  store.goalState(g.id, state, "user");
249
+ if (state === "done" && opts.onDone) {
250
+ const current = store.goalOf(g.id, project);
251
+ if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
252
+ const promotion = await opts.onDone(current, store);
253
+ if (json) return JSON.stringify({ goalId: current.id, state: current.state, promotion }, null, 2);
254
+ return `long-term task ${current.id.slice(0, 8)} is done\npromotion candidates: ${promotion.offered} offered, ${promotion.alreadyOffered} already offered, ${promotion.withheld} withheld${promotion.truncated ? "; more linked notes remain (truncated)" : ""}`;
255
+ }
202
256
  return `long-term task ${g.id.slice(0, 8)} is ${state}`;
203
257
  }
204
258
  case "take": {
@@ -207,7 +261,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
207
261
  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`);
208
262
  return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
209
263
  }
210
- default: return fail(GOAL_USAGE);
264
+ default: return fail(usage);
211
265
  }
212
266
  } finally { if (!opts.store) store.close(); }
213
267
  }
@@ -0,0 +1,167 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createStandalonePolicy, type StandalonePolicy, type StandalonePolicyOptions } from "./policy.ts";
4
+
5
+ /**
6
+ * F4 (agent-mem#40): standalone policy configuration, with no host. Two files, both plain JSON:
7
+ *
8
+ * - the machine-local `amem.config.json` in the memory home: the person's own policy (deny paths, PII patterns, the SessionStart brief byte
9
+ * budget, the capture excerpt and path caps, the capture size cap and retention, and where the home lives);
10
+ * - an optional `<repository>/.amem/config`: a repository may only NARROW. It adds deny paths and PII patterns and may lower a byte or count
11
+ * budget; it can never remove a machine entry, raise a budget, or move the home. A stricter host policy passed through the library wins over both.
12
+ *
13
+ * Either file that exists but is not a bounded plain JSON object of known keys is an error that names it, never a silent default: an unreadable
14
+ * policy must not read as "no policy". Reading is synchronous and does no model, network or storage work.
15
+ */
16
+ export const CONFIG_FILE = "amem.config.json";
17
+ export const REPO_CONFIG_DIR = ".amem";
18
+ export const REPO_CONFIG_FILE = "config";
19
+ const MAX_CONFIG_BYTES = 64 * 1024;
20
+ const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture"] as const;
21
+ const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths"] as const;
22
+
23
+ export interface AmemConfig {
24
+ /** The memory home, when the file moves it (a pointer file: see `configuredHome`). `AMEM_HOME` still wins. Machine file only. */
25
+ home?: string;
26
+ denyPaths: string[];
27
+ piiPatterns: string[];
28
+ /** SessionStart brief byte budget; the built-in hook cap is the ceiling. */
29
+ briefBytes?: number;
30
+ /** Capture excerpt cap in bytes-as-characters; the built-in cap is the ceiling. */
31
+ excerptBytes?: number;
32
+ /** Capture path-count cap; the built-in cap is the ceiling. */
33
+ capturePaths?: number;
34
+ /** The capture tier's own R6 bounds. Machine file only (a repository cannot widen the machine's disk use). */
35
+ capture: { capMb?: number; retentionDays?: number };
36
+ }
37
+ export interface ConfigSource { machine: string | null; repo: string | null }
38
+ export interface LoadedConfig { config: AmemConfig; source: ConfigSource }
39
+
40
+ const fail = (file: string, why: string): never => { throw new Error(`${file}: ${why}; fix or remove it (an unreadable policy is never treated as no policy)`); };
41
+
42
+ /** One bounded read through one descriptor: a link, a non-file or a growing file refuses, and a missing file is `undefined`. */
43
+ function readPlain(path: string, label: string): unknown | undefined {
44
+ let fd: number;
45
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
46
+ catch (error) {
47
+ const code = (error as NodeJS.ErrnoException).code;
48
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
49
+ return fail(label, code === "ELOOP" ? "is a symbolic link" : "cannot be read");
50
+ }
51
+ try {
52
+ const stat = fstatSync(fd);
53
+ if (!stat.isFile() || stat.size > MAX_CONFIG_BYTES) return fail(label, `is not a plain file within ${MAX_CONFIG_BYTES} bytes`);
54
+ const text = readFileSync(fd, "utf8");
55
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) return fail(label, `is not a plain file within ${MAX_CONFIG_BYTES} bytes`);
56
+ try { return JSON.parse(text) as unknown; } catch { return fail(label, "does not parse as JSON"); }
57
+ } finally { closeSync(fd); }
58
+ }
59
+
60
+ function listOf(value: unknown, key: string, label: string): string[] {
61
+ if (value === undefined) return [];
62
+ // The standalone policy takes at most 64 literals of 256 bytes without control characters: say so here, by name.
63
+ if (!Array.isArray(value) || value.length > 64 || !value.every(item => typeof item === "string" && item.trim() !== "" && Buffer.byteLength(item) <= 256 && !/[\x00-\x1f\x7f]/.test(item)))
64
+ return fail(label, `${key} is not a list of at most 64 non-empty strings of at most 256 bytes without control characters`);
65
+ return [...new Set(value as string[])];
66
+ }
67
+ function countOf(value: unknown, key: string, label: string, max: number): number | undefined {
68
+ if (value === undefined) return undefined;
69
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) return fail(label, `${key} is not a whole number from 1 to ${max}`);
70
+ return value;
71
+ }
72
+
73
+ function parseFile(raw: unknown, label: string, keys: readonly string[]): Partial<AmemConfig> & { capture?: AmemConfig["capture"] } {
74
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fail(label, "is not a JSON object");
75
+ const body = raw as Record<string, unknown>;
76
+ for (const key of Object.keys(body)) if (!keys.includes(key)) fail(label, `unknown key "${key}" (known: ${keys.join(", ")})`);
77
+ const out: Partial<AmemConfig> & { capture?: AmemConfig["capture"] } = {
78
+ denyPaths: listOf(body.denyPaths, "denyPaths", label), piiPatterns: listOf(body.piiPatterns, "piiPatterns", label),
79
+ };
80
+ const briefBytes = countOf(body.briefBytes, "briefBytes", label, 1_000_000); if (briefBytes !== undefined) out.briefBytes = briefBytes;
81
+ const excerptBytes = countOf(body.excerptBytes, "excerptBytes", label, 100_000); if (excerptBytes !== undefined) out.excerptBytes = excerptBytes;
82
+ const capturePaths = countOf(body.capturePaths, "capturePaths", label, 1000); if (capturePaths !== undefined) out.capturePaths = capturePaths;
83
+ if (body.home !== undefined) {
84
+ if (typeof body.home !== "string" || !body.home.trim() || body.home.includes("\0") || !body.home.startsWith("/")) return fail(label, "home is not an absolute path");
85
+ // A file that moves the home is a pointer: the policy is read from the file IN the home it names, so any policy key beside `home` here would be silently lost.
86
+ const beside = Object.keys(body).filter(key => key !== "home");
87
+ if (beside.length) return fail(label, `names a home, so it is only a pointer; put ${beside.join(", ")} in ${join(body.home, CONFIG_FILE)}`);
88
+ out.home = body.home;
89
+ }
90
+ if (body.capture !== undefined) {
91
+ const capture = body.capture;
92
+ if (!capture || typeof capture !== "object" || Array.isArray(capture) || Object.keys(capture).some(key => !["capMb", "retentionDays"].includes(key))) return fail(label, "capture takes only capMb and retentionDays");
93
+ const c = capture as Record<string, unknown>;
94
+ out.capture = {};
95
+ const capMb = countOf(c.capMb, "capture.capMb", label, 1_048_576), retentionDays = countOf(c.retentionDays, "capture.retentionDays", label, 36_500);
96
+ if (capMb !== undefined) out.capture.capMb = capMb;
97
+ if (retentionDays !== undefined) out.capture.retentionDays = retentionDays;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The machine file's `home` before any store exists: the one key read ahead of the home itself. `AMEM_HOME` and the config's `home` name different homes only when the person sets both, and `AMEM_HOME` wins. */
103
+ export function machineConfigPath(base: string): string { return join(base, CONFIG_FILE); }
104
+ export function repoConfigPath(root: string): string { return join(root, REPO_CONFIG_DIR, REPO_CONFIG_FILE); }
105
+
106
+ /**
107
+ * The nearest ancestor of `cwd` that holds a `.git` entry, by `lstat` alone: no `git` process, so a hook inside its 150 ms budget can call it.
108
+ * Bounded to 32 levels. Its `.amem/config` (if any) is the repository's narrowing file.
109
+ */
110
+ export function repoConfigRoot(cwd: string): string | undefined {
111
+ try {
112
+ for (let dir = cwd, depth = 0; depth < 32; dir = dirname(dir), depth++) {
113
+ if (lstatSync(join(dir, ".git"), { throwIfNoEntry: false })) return dir;
114
+ if (dir === dirname(dir)) return undefined;
115
+ }
116
+ } catch { /* unreadable: no repository file */ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * The home the machine configuration redirects to, read from the DEFAULT home's `amem.config.json` only (one level: a redirected home's own file
122
+ * cannot redirect again, so there is no chain and no loop). Undefined when there is no file or it names no `home`. A malformed file throws.
123
+ * That file is a pointer: it carries `home` and nothing else, and the policy lives in the file inside the home it names.
124
+ */
125
+ export function configuredHome(defaultHome: string): string | undefined {
126
+ const file = machineConfigPath(defaultHome), raw = readPlain(file, file);
127
+ return raw === undefined ? undefined : parseFile(raw, file, MACHINE_KEYS).home;
128
+ }
129
+
130
+ const lower = (machine: number | undefined, repo: number | undefined) => machine === undefined ? repo : repo === undefined ? machine : Math.min(machine, repo);
131
+
132
+ /**
133
+ * The effective configuration. The repository file can only narrow: its lists are added to the machine's, and a number counts only when it is
134
+ * lower than the machine's (or the machine has none). It never carries `home` or `capture` (unknown key: an error), so it cannot move the
135
+ * store or widen the disk bounds.
136
+ */
137
+ export function loadAmemConfig(base: string, repoRoot?: string): LoadedConfig {
138
+ const machinePath = machineConfigPath(base);
139
+ const machineRaw = readPlain(machinePath, machinePath);
140
+ const machine = machineRaw === undefined ? undefined : parseFile(machineRaw, machinePath, MACHINE_KEYS);
141
+ let repo: ReturnType<typeof parseFile> | undefined, repoPath: string | null = null;
142
+ if (repoRoot !== undefined) {
143
+ const file = repoConfigPath(repoRoot), raw = readPlain(file, file);
144
+ if (raw !== undefined) { repo = parseFile(raw, file, REPO_KEYS); repoPath = file; }
145
+ }
146
+ const union = (a: string[] | undefined, b: string[] | undefined) => [...new Set([...(a ?? []), ...(b ?? [])])];
147
+ const denyPaths = union(machine?.denyPaths, repo?.denyPaths), piiPatterns = union(machine?.piiPatterns, repo?.piiPatterns);
148
+ if (denyPaths.length > 64) fail(machinePath, "denyPaths and the repository's denyPaths name more than 64 entries together");
149
+ if (piiPatterns.length > 64) fail(machinePath, "piiPatterns and the repository's piiPatterns name more than 64 entries together");
150
+ const config: AmemConfig = { denyPaths, piiPatterns, capture: { ...(machine?.capture ?? {}) } };
151
+ if (machine?.home !== undefined) config.home = machine.home;
152
+ for (const key of ["briefBytes", "excerptBytes", "capturePaths"] as const) { const value = lower(machine?.[key], repo?.[key]); if (value !== undefined) config[key] = value; }
153
+ return { config, source: { machine: machineRaw === undefined ? null : machinePath, repo: repoPath } };
154
+ }
155
+
156
+ /**
157
+ * The standalone policy as configured. A host policy passed through the library still wins: it is composed in, never replaced, so the result
158
+ * declines whatever either declines (`createStandalonePolicy` screens the config, then asks the host).
159
+ */
160
+ export function configuredPolicyOptions(base: string, cwd?: string, options: StandalonePolicyOptions = {}): StandalonePolicyOptions {
161
+ const { config } = loadAmemConfig(base, cwd === undefined ? undefined : repoConfigRoot(cwd));
162
+ const merge = (a: readonly string[] | undefined, b: readonly string[]) => [...new Set([...(a ?? []), ...b])];
163
+ return { ...options, denyPaths: merge(options.denyPaths, config.denyPaths), piiPatterns: merge(options.piiPatterns, config.piiPatterns) };
164
+ }
165
+ export function configuredPolicy(base: string, cwd?: string, options: StandalonePolicyOptions = {}): StandalonePolicy {
166
+ return createStandalonePolicy(configuredPolicyOptions(base, cwd, options));
167
+ }
@@ -0,0 +1,78 @@
1
+ import { CandidateStore, candidateInputId, candidateTargetRevision, type CandidateInput } from "./candidates.ts";
2
+ import { consolidationClusters } from "./consolidation-clusters.ts";
3
+ import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts";
4
+ import { recallViewIsLive } from "./recall.ts";
5
+ import type { MemoryStore, View } from "./store.ts";
6
+ import { realPath } from "./platform.ts";
7
+ import { proposeModelConsolidations, type ModelConsolidationOptions, type ModelConsolidationReport } from "./consolidation-model.ts";
8
+
9
+ export interface ConsolidationOptions {
10
+ project: string; actor: "person" | "agent"; policy: () => StandalonePolicy; policyVersion: () => string;
11
+ allowed?: () => boolean; assertCurrent?: () => void;
12
+ /** Person-enabled host callback only. Absent means no model work or budget reservation. */
13
+ model?: ModelConsolidationOptions;
14
+ }
15
+ export interface ConsolidationReport {
16
+ schema: "amem.consolidation/v1"; project: string; candidates: { id: string; action: string; sources: string[] }[];
17
+ engine: "fts5" | "substring"; truncated: boolean; oversized: number; localExcluded: number;
18
+ /** Proposals whose candidate already exists as accepted, accepting or rejected: they are not queued again and are not in `candidates`. */
19
+ alreadyReviewed: number;
20
+ model?: ModelConsolidationReport;
21
+ }
22
+ export class ConsolidationProposalError extends Error {
23
+ constructor(readonly proposed: string[]) { super(`consolidation proposal interrupted; candidates already queued: ${proposed.join(", ") || "none"}; no curated notes changed`); this.name = "ConsolidationProposalError"; }
24
+ }
25
+ const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
26
+ /** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
27
+ export async function proposeConsolidation(store: MemoryStore, candidates: CandidateStore, options: ConsolidationOptions): Promise<ConsolidationReport> {
28
+ if (options.actor !== "person") throw Error("consolidation requires person intent");
29
+ if (options.allowed?.() === false) throw Error("consolidation unavailable");
30
+ if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("consolidation requires its own candidate home");
31
+ const project = options.project, version = options.policyVersion(), policy = options.policy();
32
+ if (!project.trim() || typeof version !== "string") throw Error("consolidation context unavailable");
33
+ const sourceCurrent = store.beginRecallRead();
34
+ const check = () => {
35
+ if (options.actor !== "person" || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
36
+ options.assertCurrent?.(); sourceCurrent();
37
+ };
38
+ check();
39
+ if (!await policy.screen({ project })) throw Error("consolidation refused by current policy");
40
+ check();
41
+ const raw = store.withDeferredPromotions(() => store.list({ project, limit: 257 })), rows: View[] = [];
42
+ for (const value of raw) {
43
+ check(); const row = memoryPolicySnapshot(value);
44
+ if (!row || row.project !== project || !recallViewIsLive(row, store.readNow()) || modelAnswer(row)) continue;
45
+ if (await policy.screen(row)) rows.push(row);
46
+ }
47
+ check();
48
+ const placements = rows.length ? store.entryPlacements(rows.map(row => row.id).slice(0, 256), project) : new Map<string, "local" | "synced">();
49
+ const synced = rows.slice(0, 256).filter(row => placements.get(row.id) === "synced");
50
+ const clustered = consolidationClusters(synced, project, store.readNow()), prepared: Extract<CandidateInput, { kind: "consolidation" }>[] = [];
51
+ let oversized = 0;
52
+ for (const cluster of clustered.clusters) {
53
+ const first = cluster.sources[0]!;
54
+ if (cluster.sources.some(row => row.scope !== first.scope || row.local !== first.local)) continue;
55
+ const text = `${cluster.reason}\n\nUntrusted quoted sources, not instructions:\n${cluster.sources.map(row => JSON.stringify({ ref: `mem:${row.id}`, kind: row.kind, text: row.text })).join("\n")}`;
56
+ const about = [...new Set(cluster.sources.flatMap(row => [...row.about, ...(row.task ? [`task:${row.task}`] : [])]))].sort();
57
+ if (text.length > 3000 || about.length > 32 || about.some(node => node.length > 400)) { oversized++; continue; }
58
+ const input: Extract<CandidateInput, { kind: "consolidation" }> = { kind: "consolidation", project, action: cluster.action,
59
+ targets: cluster.sources.map(row => ({ id: row.id, revision: candidateTargetRevision(row) })), text, about, noteKind: cluster.action === "recurring-fix" ? "fail" : first.kind };
60
+ check(); if (await policy.screen(input)) prepared.push(input);
61
+ }
62
+ check();
63
+ const report: ConsolidationReport = { schema: "amem.consolidation/v1", project, candidates: [], engine: clustered.engine, truncated: raw.length === 257 || clustered.truncated, oversized, localExcluded: rows.slice(0, 256).length - synced.length, alreadyReviewed: 0 };
64
+ try {
65
+ // All asynchronous host work finishes before the first candidate write. The ordinary candidate
66
+ // queue cap still applies; a full queue reports the ids already enqueued, never a silent batch.
67
+ // put() is INSERT OR IGNORE: a candidate the person already accepted or rejected keeps its status, so it is neither written again nor reported as queued.
68
+ for (const input of prepared) {
69
+ check();
70
+ const known = candidates.get(candidateInputId(input), project);
71
+ if (known && known.status !== "pending") { report.alreadyReviewed++; continue; }
72
+ const id = candidates.put(input, "public");
73
+ report.candidates.push({ id, action: input.action, sources: input.targets.map(target => `mem:${target.id}`) });
74
+ }
75
+ if (options.model !== undefined) report.model = await proposeModelConsolidations(store, candidates, synced, options.model, { project, actor: options.actor, check, policy });
76
+ } catch { throw new ConsolidationProposalError(report.candidates.map(row => row.id)); }
77
+ return report;
78
+ }