@staix/agent-mem 0.5.0 → 0.7.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -0,0 +1,119 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * F7 (agent-mem#40): pending-review visibility. This leaf imports only `bun:sqlite` and node built-ins, so the SessionStart hook can read it without loading the
7
+ * candidate store (no create, no prune, no schema change on that path).
8
+ *
9
+ * AC4, "no candidate expires without having been shown": a candidate's age expiry (30 days from creation) only deletes it once it was SHOWN, and not before a grace period after
10
+ * it was first shown, or once the person decided it (accepted or rejected). Only `amem review walk` stamps `shown_at`, after it has written the candidate to the terminal. The
11
+ * brief line and `amem review list` print no candidate content, and the SessionStart hook cannot know the context was read, so none of them stamps: the brief line informs, it does
12
+ * not count as showing. An unshown pending candidate stays however old it is. The 500-per-project count cap keeps unshown pending rows first, then shown pending rows (owed their
13
+ * grace), then decided ones, so it only reaches an unshown candidate when 500 of them wait.
14
+ */
15
+ export const CANDIDATE_AGE_MS = 30 * 86400_000;
16
+ /** After a candidate is first shown it stays at least this long, so the person has a real chance to act on what the brief said. */
17
+ export const CANDIDATE_SHOWN_GRACE_MS = 7 * 86400_000;
18
+ /** A candidate this close to expiry is flagged in `amem status`. */
19
+ export const CANDIDATE_DUE_MS = 7 * 86400_000;
20
+ const DAY_MS = 86400_000;
21
+
22
+ export interface PendingSummary {
23
+ /** Candidates waiting for the person (status pending or an interrupted accepting). */
24
+ count: number;
25
+ /** Whole days until the soonest of them can expire, counting an unshown one as shown now (so at least the grace period). 0 when it may go today. Null with nothing pending. */
26
+ oldestExpiresInDays: number | null;
27
+ /** How many are within CANDIDATE_DUE_MS of their 30-day age expiry (an unshown one is protected beyond it, shown or not it is flagged so the person acts). */
28
+ dueWithin7Days: number;
29
+ /** How many were never shown. */
30
+ unshown: number;
31
+ }
32
+ export const EMPTY_PENDING: PendingSummary = { count: 0, oldestExpiresInDays: null, dueWithin7Days: 0, unshown: 0 };
33
+
34
+ /**
35
+ * Open the sidecar read-only. A WAL database whose `-wal` and `-shm` files are gone (a truncating checkpoint, a copy) cannot be opened read-only, so the fallback is an immutable
36
+ * open: it writes nothing and creates nothing, at the price of not seeing a write another process has not checkpointed yet, which a count line can live with. Undefined when
37
+ * neither opens (a missing or unreadable file).
38
+ */
39
+ function openReadOnly(file: string): Database | undefined {
40
+ // A construction that succeeds can still fail at its first read (no -wal or -shm to attach to), so each form is proven with a real query before it is returned.
41
+ const forms: (() => Database)[] = [
42
+ () => new Database(file, { readonly: true }),
43
+ () => new Database(`file:${encodeURI(file).replace(/\?/g, "%3F").replace(/#/g, "%23")}?immutable=1`, { readonly: true } as ConstructorParameters<typeof Database>[1]),
44
+ ];
45
+ for (const open of forms) {
46
+ let db: Database | undefined;
47
+ try { db = open(); db.query("SELECT 1 FROM sqlite_master LIMIT 1").get(); return db; }
48
+ catch { try { db?.close(); } catch { /* nothing to close */ } }
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** The first-shown column, added in place: an old database gets it when a `CandidateStore` opens, a reader treats its absence as "never shown". */
54
+ export function hasShownColumn(db: Database): boolean {
55
+ return (db.query("PRAGMA table_info(candidates)").all() as { name: string }[]).some(column => column.name === "shown_at");
56
+ }
57
+
58
+ /** The instant a pending candidate becomes deletable: the later of its age expiry and the end of its grace period (counted from `now` while it is unshown). */
59
+ export function expiresAt(createdAt: number, shownAt: number | null, now: number): number {
60
+ return Math.max(createdAt + CANDIDATE_AGE_MS, (shownAt ?? now) + CANDIDATE_SHOWN_GRACE_MS);
61
+ }
62
+
63
+ /**
64
+ * Read-only summary of the pending candidates of one project (with its aliases), or of every project when `projects` is undefined. Never throws. No file means nothing is
65
+ * pending (`EMPTY_PENDING`); a file that exists but cannot be read gives `null`, so `amem status` can say "unavailable" instead of a false zero.
66
+ */
67
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number): PendingSummary;
68
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict: true): PendingSummary | null;
69
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict?: true): PendingSummary | null {
70
+ const file = join(base, "candidates.db");
71
+ if (!existsSync(file)) return EMPTY_PENDING;
72
+ const unreadable = strict ? null : EMPTY_PENDING;
73
+ let db: Database | undefined;
74
+ try {
75
+ db = openReadOnly(file);
76
+ if (!db) return unreadable;
77
+ db.run("PRAGMA busy_timeout = 0");
78
+ const shown = hasShownColumn(db);
79
+ const where = projects === undefined ? "" : " AND project IN (SELECT value FROM json_each(?))";
80
+ const rows = db.query(`SELECT at, ${shown ? "shown_at" : "NULL AS shown_at"} FROM candidates WHERE status IN ('pending', 'accepting')${where}`)
81
+ .all(...(projects === undefined ? [] : [JSON.stringify(projects)])) as { at: number; shown_at: number | null }[];
82
+ if (!rows.length) return EMPTY_PENDING;
83
+ let soonest = Infinity, due = 0, unshown = 0;
84
+ for (const row of rows) {
85
+ const at = expiresAt(row.at, row.shown_at, now);
86
+ soonest = Math.min(soonest, at);
87
+ if (row.at + CANDIDATE_AGE_MS - now <= CANDIDATE_DUE_MS) due++; // by age: an unshown row is protected past it, but the person should hear it is old
88
+ if (row.shown_at === null) unshown++;
89
+ }
90
+ return { count: rows.length, oldestExpiresInDays: Math.max(0, Math.ceil((soonest - now) / DAY_MS)), dueWithin7Days: due, unshown };
91
+ } catch { return unreadable; } // fail open: a locked or unreadable sidecar costs the brief this line, never the brief
92
+ finally { try { db?.close(); } catch { /* nothing to close */ } }
93
+ }
94
+
95
+ /** The one brief line. Numbers only, so it can carry no private text. */
96
+ export function pendingLine(summary: PendingSummary): string | undefined {
97
+ if (!summary.count || summary.oldestExpiresInDays === null) return undefined;
98
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
99
+ return `${plural(summary.count, "candidate")} pending, oldest expires in ${plural(summary.oldestExpiresInDays, "day")}`;
100
+ }
101
+
102
+ /**
103
+ * Stamp the first time candidates were shown (`shown_at` stays at its first value). Best effort and zero-wait: on a locked, absent or pre-column database it stamps
104
+ * nothing, which only keeps candidates longer. Returns how many rows it stamped.
105
+ */
106
+ export function markShown(base: string, projects: readonly string[], now: number, ids?: readonly string[]): number {
107
+ const file = join(base, "candidates.db");
108
+ if (!existsSync(file) || !projects.length) return 0;
109
+ let db: Database | undefined;
110
+ try {
111
+ db = new Database(file);
112
+ db.run("PRAGMA busy_timeout = 0");
113
+ if (!hasShownColumn(db)) return 0;
114
+ const filter = ids === undefined ? "" : " AND id IN (SELECT value FROM json_each(?))";
115
+ return db.query(`UPDATE candidates SET shown_at = ? WHERE shown_at IS NULL AND status IN ('pending', 'accepting') AND project IN (SELECT value FROM json_each(?))${filter}`)
116
+ .run(now, JSON.stringify(projects), ...(ids === undefined ? [] : [JSON.stringify(ids)])).changes;
117
+ } catch { return 0; }
118
+ finally { try { db?.close(); } catch { /* nothing to close */ } }
119
+ }
@@ -2,9 +2,12 @@ import { Database } from "bun:sqlite";
2
2
  import { createHash } from "node:crypto";
3
3
  import { chmodSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { CANDIDATE_SHOWN_GRACE_MS, hasShownColumn } from "./candidate-pending.ts";
6
+ import { oneLine } from "./note-add.ts";
5
7
  import { memoryHome } from "./home.ts";
6
8
  import { realPath } from "./platform.ts";
7
- import { MemoryStore, type NoteInput, type View } from "./store.ts";
9
+ import { stripPrivateDeep } from "./private-spans.ts";
10
+ import { MemoryStore, type GoalView, type NoteInput, type View } from "./store.ts";
8
11
 
9
12
  export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
10
13
  export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
@@ -16,6 +19,7 @@ export type CandidateInput = { project: string } & (
16
19
  | { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string }
17
20
  | { kind: "memory"; text: string; title?: string; noteKind?: "decision" | "finding" | "contract" | "fail" }
18
21
  | { kind: "decay"; target: CandidateTarget; reason: string }
22
+ | { kind: "promotion"; target: CandidateTarget; goalId: string }
19
23
  );
20
24
  export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
21
25
  export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
@@ -29,7 +33,7 @@ export interface CandidateAcceptance {
29
33
  actor: "person" | "agent";
30
34
  verdict: CandidateVerdict;
31
35
  store: MemoryStore;
32
- policy: (candidate: Candidate, target: View | undefined, note: NoteInput) => boolean;
36
+ policy: (candidate: Candidate, target: View | undefined, note: NoteInput, goal?: GoalView) => boolean;
33
37
  }
34
38
  export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean }
35
39
 
@@ -53,12 +57,28 @@ function validate(input: CandidateInput): void {
53
57
  if (input.target !== undefined && !validTarget(input.target) || ![input.approach, input.symptom, input.cause, input.retryWhen].every(x => text(x)) || !Array.isArray(input.evidenceRefs) || input.evidenceRefs.length < 1 || input.evidenceRefs.length > 16 || !input.evidenceRefs.every(x => text(x, 400))) throw new Error("invalid fail candidate");
54
58
  } else if (input.kind === "memory") {
55
59
  if (!text(input.text, 3000) || input.title !== undefined && !text(input.title, 200) || input.noteKind !== undefined && !["decision", "finding", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid memory candidate");
60
+ } else if (input.kind === "promotion") {
61
+ if (!validTarget(input.target) || !entryId(input.goalId)) throw new Error("invalid promotion candidate");
56
62
  } else if (input.kind === "decay") {
57
63
  if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
58
64
  } else throw new Error("invalid candidate kind");
59
65
  if (Buffer.byteLength(canonical(input), "utf8") > 16_384) throw new Error("candidate payload exceeds 16 KiB");
60
66
  }
61
67
 
68
+ /** The same normalized identity used by put(), without a sidecar write or a capped listing. */
69
+ export function candidateInputId(rawInput: CandidateInput): string {
70
+ const input = stripPrivateDeep(rawInput); validate(input);
71
+ return hash({ schema: "agent-mem.candidate/v1", input });
72
+ }
73
+ /** Exact correction body shared by proposal validation and person acceptance. */
74
+ export function acceptedCorrectionText(input: Extract<CandidateInput, { kind: "correction" }>, id = candidateInputId(input)): string {
75
+ const data = stripPrivateDeep(input);
76
+ return `Accepted candidate: ${id}\nTarget revision: ${data.target.revision}\nTarget entry: ${data.target.id}\n\n${data.proposedText}\n\nReason: ${data.reason}`;
77
+ }
78
+ export function goalPromotionLinked(view: Pick<View, "task" | "about">, goal: Pick<GoalView, "id" | "links">): boolean {
79
+ const tasks = new Set(goal.links.map(link => link.task));
80
+ return !!view.task && tasks.has(view.task) || view.about.some(node => node === `goal:${goal.id}` || node.startsWith("task:") && tasks.has(node.slice(5)));
81
+ }
62
82
  /** Includes current lifecycle state, so a changed promotion, supersession or demotion makes a proposal stale. */
63
83
  export function candidateTargetRevision(target: View): string {
64
84
  return hash({ ...target, about: [...target.about].sort(), supersedes: [...target.supersedes].sort() });
@@ -71,6 +91,23 @@ function counterKey(key: CounterKey): CounterKey {
71
91
  }
72
92
 
73
93
  /** Explicitly opened machine-local data. Importing this module never opens a store or registers a consumer. */
94
+ /** Run `work`, retrying only SQLite's "database is locked" for at most about two seconds; any other error, or the last lock error, is thrown. */
95
+ function retryLocked(work: () => void): void {
96
+ const until = Date.now() + 2000;
97
+ for (let delay = 5;; delay = Math.min(delay * 2, 100)) {
98
+ try { work(); return; }
99
+ catch (error) {
100
+ if (!/database is locked|SQLITE_BUSY/i.test((error as Error).message) || Date.now() >= until) throw error;
101
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
102
+ }
103
+ }
104
+ }
105
+
106
+ /** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it; the message is worded for an agent, which cannot run the person-only commands itself. */
107
+ export class CandidateQueueFull extends Error {
108
+ readonly code = "candidate_queue_full";
109
+ constructor(readonly waiting: number) { super(`${waiting} unshown candidates await the person's review; no more are accepted until they have run \`amem review\`. Ask the person to review them`); this.name = "CandidateQueueFull"; }
110
+ }
74
111
  export class CandidateStore {
75
112
  readonly dir: string;
76
113
  readonly path: string;
@@ -88,18 +125,27 @@ export class CandidateStore {
88
125
  this.path = join(this.dir, "candidates.db");
89
126
  this.db = new Database(this.path, { create: true });
90
127
  this.db.run(`PRAGMA busy_timeout = ${CANDIDATE_DB_BUSY_TIMEOUT_MS}`);
91
- chmodSync(this.path, 0o600); this.db.run("PRAGMA journal_mode = WAL");
128
+ chmodSync(this.path, 0o600);
129
+ // `journal_mode = WAL` takes a lock that busy_timeout does not always wait for, so several processes opening a brand-new home at once (every `amem mcp` builds this store at
130
+ // startup) can see "database is locked". It is retried for a bounded time: the first caller switches the mode, the others then find it already WAL.
131
+ retryLocked(() => this.db.run("PRAGMA journal_mode = WAL"));
92
132
  this.db.run("CREATE TABLE IF NOT EXISTS candidates (id TEXT PRIMARY KEY, project TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, status TEXT NOT NULL, note_id TEXT)");
133
+ // F7 / AC4: when a candidate was first shown to the person. In place and nullable: an older database gains it here, and a reader without it treats every row as unshown.
134
+ // Several processes open this store at once (every `amem mcp` builds it at startup), so the check and the ALTER are one immediate transaction: the loser waits, then sees the column.
135
+ if (!hasShownColumn(this.db)) this.db.transaction(() => { if (!hasShownColumn(this.db)) this.db.run("ALTER TABLE candidates ADD COLUMN shown_at INTEGER"); }).immediate();
93
136
  this.db.run("CREATE TABLE IF NOT EXISTS counters (id TEXT PRIMARY KEY, project TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, rendered INTEGER, delivered INTEGER, cited INTEGER)");
94
137
  this.prune();
95
138
  }
96
139
  close(): void { this.db.close(); }
97
140
  private prune(): void {
98
141
  this.db.transaction(() => {
99
- for (const [table, retention] of [["candidates", CANDIDATE_RETENTION], ["counters", COUNTER_RETENTION]] as const) {
100
- this.db.query(`DELETE FROM ${table} WHERE at <= ?`).run(this.now() - retention.ageMs);
101
- this.db.query(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM ${table}) WHERE rank > ?)`).run(retention.count);
102
- }
142
+ const now = this.now();
143
+ // Candidates (AC4, no candidate expires unshown): by age only a decided one, or a pending one that was shown and has had its grace period, goes. By count three tiers decide who is
144
+ // kept: unshown pending first, then shown pending, then decided, so a cap eviction takes decided rows before a shown one (which is owed its grace) and shown ones before an unseen one.
145
+ this.db.query("DELETE FROM candidates WHERE at <= ? AND (status IN ('accepted', 'rejected') OR (shown_at IS NOT NULL AND shown_at <= ?))").run(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS);
146
+ this.db.query(`DELETE FROM candidates WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY CASE WHEN status IN ('pending', 'accepting') AND shown_at IS NULL THEN 2 WHEN status IN ('pending', 'accepting') THEN 1 ELSE 0 END DESC, at DESC, rowid DESC) AS rank FROM candidates) WHERE rank > ?)`).run(CANDIDATE_RETENTION.count);
147
+ this.db.query("DELETE FROM counters WHERE at <= ?").run(now - COUNTER_RETENTION.ageMs);
148
+ this.db.query("DELETE FROM counters WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM counters) WHERE rank > ?)").run(COUNTER_RETENTION.count);
103
149
  }).immediate();
104
150
  }
105
151
  private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
@@ -107,11 +153,22 @@ export class CandidateStore {
107
153
  if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
108
154
  return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
109
155
  }
110
- put(input: CandidateInput, verdict: CandidateVerdict): string {
111
- publicOnly(verdict); validate(input); this.prune();
112
- const payload = canonical(input), id = hash({ schema: "agent-mem.candidate/v1", input });
113
- this.db.query("INSERT OR IGNORE INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, input.project, input.kind, payload, this.now());
114
- this.prune(); return id;
156
+ put(rawInput: CandidateInput, verdict: CandidateVerdict): string {
157
+ // `<private>` spans are never stored: dropped before the input is validated, hashed (so the id) or written.
158
+ const input = stripPrivateDeep(rawInput);
159
+ publicOnly(verdict); validate(input);
160
+ const payload = canonical(input), id = candidateInputId(input);
161
+ // Prune, count, insert and prune again are ONE immediate transaction: concurrent proposers (several agents) cannot all pass the count and then let the cap evict an unseen candidate.
162
+ return this.db.transaction((): string => {
163
+ this.prune();
164
+ // AC4: a new candidate never pushes an unshown one out. With the cap's worth waiting the person has to review first, and the refusal says so (a named error an MCP tool can map).
165
+ const waiting = (this.db.query("SELECT COUNT(*) AS n FROM candidates WHERE project = ? AND status IN ('pending', 'accepting') AND shown_at IS NULL").get(input.project) as { n: number }).n;
166
+ const known = this.db.query("SELECT 1 FROM candidates WHERE id = ?").get(id);
167
+ if (!known && waiting >= CANDIDATE_RETENTION.count) throw new CandidateQueueFull(waiting);
168
+ this.db.query("INSERT OR IGNORE INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, input.project, input.kind, payload, this.now());
169
+ this.prune();
170
+ return id;
171
+ }).immediate();
115
172
  }
116
173
  get(id: string, project: string): Candidate | undefined {
117
174
  if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
@@ -120,7 +177,7 @@ export class CandidateStore {
120
177
  return row ? this.decode(row) : undefined;
121
178
  }
122
179
  list(filter: CandidateFilter): Candidate[] {
123
- if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory"].includes(filter.kind)) throw new Error("invalid candidate filter");
180
+ if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory", "promotion"].includes(filter.kind)) throw new Error("invalid candidate filter");
124
181
  const limit = filter.limit ?? CANDIDATE_RETENTION.count;
125
182
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
126
183
  this.prune();
@@ -163,42 +220,82 @@ export class CandidateStore {
163
220
  if (!candidate) throw new Error("candidate unavailable");
164
221
  if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true };
165
222
  if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
166
- if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
223
+ const data = candidate.input;
224
+ if (data.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
225
+ if (data.kind === "promotion") return this.acceptPromotion(candidate, data, context);
226
+ const execute = () => {
227
+ const resolve = () => {
228
+ const target = data.kind === "memory" ? undefined : data.target;
229
+ if (!target) return undefined;
230
+ const current = context.store.get(target.id, context.project);
231
+ if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
232
+ return current;
233
+ };
234
+ const target = resolve();
235
+ const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
236
+ : `Approach: ${oneLine(data.approach)}\nSymptom: ${oneLine(data.symptom)}\nCause: ${oneLine(data.cause)}\nEvidence: ${data.evidenceRefs.map(oneLine).join(", ")}\nRetry when: ${oneLine(data.retryWhen)}`;
237
+ const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project,
238
+ scope: data.kind === "correction" ? target!.scope : "project",
239
+ // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
240
+ tier: data.kind === "correction" && target!.local ? "short" : "long",
241
+ kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
242
+ text: data.kind === "correction" ? acceptedCorrectionText(data, id) : `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
243
+ if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
244
+ if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
245
+ return context.store.withAcceptedNote(note, append => {
246
+ resolve(); // Recheck under the same mutation lock as the note and supersede appends.
247
+ const started = this.beginAcceptance(id, context.project);
248
+ if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
249
+ // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
250
+ // automatic replay; add explicit person-audited recovery before offering background acceptance retries.
251
+ const noteId = append();
252
+ if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
253
+ this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
254
+ return { candidateId: id, noteId, alreadyAccepted: false };
255
+ });
256
+ };
257
+ const target = data.kind === "memory" ? undefined : data.target;
258
+ if (!target) return execute();
259
+ const current = context.store.get(target.id, context.project);
260
+ if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
261
+ return context.store.withCurrentEntries([target.id], context.project, execute);
262
+ }
263
+ private acceptPromotion(candidate: Candidate, data: Extract<CandidateInput, { kind: "promotion" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
167
264
  const resolve = () => {
168
- const target = candidate.input.kind === "memory" ? undefined : candidate.input.target;
169
- if (!target) return undefined;
170
- const current = context.store.get(target.id, context.project);
171
- if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
172
- return current;
265
+ const target = context.store.get(data.target.id, context.project);
266
+ if (!target || target.id !== data.target.id || candidateTargetRevision(target) !== data.target.revision || target.tier !== "short" || target.supersededBy) throw Error("candidate target revision is stale");
267
+ const goal = context.store.goalOf(data.goalId, context.project);
268
+ if (!goal || goal.id !== data.goalId || goal.state !== "done" || !goalPromotionLinked(target, goal)) throw Error("promotion requires a current done goal and a linked short note");
269
+ return { target, goal };
173
270
  };
174
- const target = resolve(), data = candidate.input;
175
- const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
176
- : `Approach: ${data.approach}\nSymptom: ${data.symptom}\nCause: ${data.cause}\nEvidence: ${data.evidenceRefs.join(", ")}\nRetry when: ${data.retryWhen}`;
177
- const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project,
178
- scope: data.kind === "correction" ? target!.scope : "project",
179
- // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
180
- tier: data.kind === "correction" && target!.local ? "short" : "long",
181
- kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
182
- text: `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
183
- if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
184
- if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
185
- return context.store.withAcceptedNote(note, append => {
186
- resolve(); // Recheck under the same mutation lock as the note and supersede appends.
187
- const started = this.db.transaction(() => {
188
- const current = this.get(id, context.project);
189
- if (!current) throw new Error("candidate unavailable");
190
- if (current.status === "accepted") return current.acceptedNoteId!;
191
- if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
192
- this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(context.project));
193
- return undefined;
194
- }).immediate();
195
- if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
196
- // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
197
- // automatic replay; add explicit person-audited recovery before offering background acceptance retries.
198
- const noteId = append();
199
- if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
200
- this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
201
- return { candidateId: id, noteId, alreadyAccepted: false };
271
+ resolve();
272
+ return context.store.withCurrentEntries([data.target.id], context.project, () => {
273
+ const { target, goal } = resolve();
274
+ const note: NoteInput = { op: "note", project: target.project, scope: target.scope, tier: "long", kind: target.kind, peer: target.peer, source: target.source as NoteInput["source"], text: target.text, about: [...target.about],
275
+ ...(target.title ? { title: target.title } : {}), ...(target.task ? { task: target.task } : {}), promotedFrom: target.id };
276
+ if (context.policy(structuredClone(candidate), structuredClone(target), structuredClone(note), structuredClone(goal)) !== true) throw Error("candidate refused by current host policy");
277
+ resolve();
278
+ const replay = this.beginAcceptance(candidate.id, context.project); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
279
+ // A thrown append remains accepting: its authored-row outcome needs person-audited recovery.
280
+ const noteId = context.store.promote(target.id, "person", context.project);
281
+ if (noteId === undefined) {
282
+ this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'accepting'").run(candidate.id, this.scope(context.project));
283
+ throw Error("promotion unavailable or declined; candidate remains pending");
284
+ }
285
+ const promoted = context.store.get(noteId, context.project);
286
+ if (noteId === target.id || !promoted || promoted.id !== noteId || promoted.tier !== "long" || promoted.promotedFrom !== target.id) throw Error("promotion outcome unconfirmed; inspect the accepting candidate");
287
+ this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project));
288
+ return { candidateId: candidate.id, noteId, alreadyAccepted: false };
202
289
  });
203
290
  }
291
+ private beginAcceptance(id: string, project: string): string | undefined {
292
+ return this.db.transaction(() => {
293
+ const current = this.get(id, project);
294
+ if (!current) throw Error("candidate unavailable");
295
+ if (current.status === "accepted") return current.acceptedNoteId!;
296
+ if (current.status !== "pending") throw Error("candidate is rejected or acceptance was interrupted; no automatic replay");
297
+ this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(project));
298
+ return undefined;
299
+ }).immediate();
300
+ }
204
301
  }
@@ -0,0 +1,76 @@
1
+ import { lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { capacityReport, type ShardSplit } from "./capacity.ts";
4
+ import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./capture.ts";
5
+ import { walkShards } from "./catalog.ts";
6
+ import { lifecycleResolver } from "./lifecycle.ts";
7
+ import { machineId } from "./store.ts";
8
+
9
+ /**
10
+ * #15 T5 / D7: `amem capacity apply --split`. Writes each shard's own capture config (`machine/<id>/config.json`, which overrides the home default) with its
11
+ * share of the global budget. Person-only like the other apply (the CLI refuses an agent marker and a missing terminal before this runs), and in two steps:
12
+ * a preview that records which shards, which current configs and which target values the person is shown, and a `--confirm` that writes exactly those
13
+ * target values and nothing recomputed. It refuses unless the set of shards and every shard's current config are unchanged, and unless the preview is younger
14
+ * than `PREVIEW_MAX_AGE_MS`: a week-old preview cannot write a cap the person never saw, and free disk drifting a little between the two steps changes nothing
15
+ * that is written. A forgotten shard is never written. This changes capture bounds only: gc and the capture path apply them later, in bounded
16
+ * batches (R2); nothing is evicted here, and no curated note, goal or work list is read or written.
17
+ */
18
+
19
+ export interface SplitTarget { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }
20
+ export interface SplitPreview { at: number; split: SplitTarget[]; skipped: string[] }
21
+ /** A preview older than this is refused: the person saw those values, but the readings behind them are stale. */
22
+ export const PREVIEW_MAX_AGE_MS = 3600_000;
23
+
24
+ const previewPath = (base: string): string => join(base, "capacity-split.preview.json");
25
+
26
+ /**
27
+ * One target per non-forgotten shard that has a directory of this machine. A shard that only another machine has (a synced home) is never written here: that
28
+ * machine's config is its own, so the shard is left out and named in `skipped`. The directory is matched by its name, which is portable, not by a path suffix.
29
+ */
30
+ function targets(base: string, split: ShardSplit[]): { targets: SplitTarget[]; skipped: string[] } {
31
+ const dirs = new Map(walkShards(base).map(shard => [shard.key, shard.machineDirs]));
32
+ const stateOf = lifecycleResolver(base);
33
+ const machine = machineId(base).id;
34
+ const out: SplitTarget[] = [], skipped: string[] = [];
35
+ for (const row of split) {
36
+ if (stateOf(row.key) === "forgotten") continue;
37
+ const dir = (dirs.get(row.key) ?? []).find(candidate => basename(candidate) === machine);
38
+ if (!dir) { skipped.push(row.key); continue; }
39
+ out.push({ key: row.key, dir, current: readCaptureConfigFile(join(dir, "config.json")), target: { capMb: row.capMb, retentionDays: row.retentionDays }, reason: row.reason });
40
+ }
41
+ return { targets: out, skipped };
42
+ }
43
+
44
+ export function previewSplit(base: string, opts: { diskShare?: number; now?: () => number } = {}): SplitPreview & { confirm: string } {
45
+ const report = capacityReport({ memoryDir: base, ...(opts.diskShare !== undefined ? { diskShare: opts.diskShare } : {}) });
46
+ if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
47
+ const found = targets(base, report.split);
48
+ const preview: SplitPreview = { at: (opts.now ?? Date.now)(), split: found.targets, skipped: found.skipped };
49
+ writeFileSync(previewPath(base), JSON.stringify(preview) + "\n", { mode: 0o600 });
50
+ return { ...preview, confirm: "re-run with --confirm to write each shard's share" };
51
+ }
52
+
53
+ export function applySplit(base: string, opts: { diskShare?: number; now?: () => number } = {}): { split: SplitTarget[]; written: { key: string; cap_mb: number; retention_days: number }[] } {
54
+ const file = previewPath(base);
55
+ let approved: SplitPreview;
56
+ try {
57
+ const stat = lstatSync(file);
58
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error();
59
+ approved = JSON.parse(readFileSync(file, "utf8")) as SplitPreview;
60
+ } catch { throw new Error("capacity apply --split needs a preview first; re-run without --confirm"); }
61
+ const report = capacityReport({ memoryDir: base, ...(opts.diskShare !== undefined ? { diskShare: opts.diskShare } : {}) });
62
+ if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
63
+ const live = targets(base, report.split).targets;
64
+ const age = (opts.now ?? Date.now)() - approved.at;
65
+ if (!Number.isFinite(age) || age < 0 || age > PREVIEW_MAX_AGE_MS) throw new Error("capacity preview is out of date (older than an hour); re-run without --confirm");
66
+ // What was previewed is what is written: the same shards, each with the config the person saw replaced by the target the person saw.
67
+ const identity = (rows: SplitTarget[]) => JSON.stringify(rows.map(({ key, dir, current }) => ({ key, dir, current })));
68
+ if (!Array.isArray(approved.split) || identity(approved.split) !== identity(live)) throw new Error("capacity preview changed (a shard appeared or went, or a shard's config moved); re-run without --confirm");
69
+ const written = approved.split.map(row => {
70
+ const next = writeCaptureConfigFile(join(row.dir, "config.json"), row.target);
71
+ return { key: row.key, cap_mb: next.capMb ?? row.target.capMb, retention_days: next.retentionDays ?? row.target.retentionDays };
72
+ });
73
+ rmSync(file, { force: true });
74
+ return { split: approved.split, written };
75
+ }
76
+