@staix/agent-mem 0.6.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -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 +144 -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/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -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
+
@@ -6,6 +6,7 @@ import { memoryBase } from "./home.ts";
6
6
  import { readMetrics, type MetricSample } from "./metrics.ts";
7
7
  export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./metrics.ts";
8
8
  import { walkShards } from "./catalog.ts";
9
+ import { lifecycleResolver } from "./lifecycle.ts";
9
10
  import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
10
11
 
11
12
  /**
@@ -59,6 +60,116 @@ export interface CapacityReport {
59
60
  current: CaptureConfig;
60
61
  diskShare: number;
61
62
  proposal: { capMb: number; retentionDays: number; reason: string };
63
+ /** #15 D7: the per-shard split of the global capture budget (the disk share, less what forgotten shards still hold), one row per non-forgotten shard. A proposal: applying it is person-only. */
64
+ split: ShardSplit[];
65
+ /** Why `split` is empty although shards exist: the budget cannot give each shard its 1 MB minimum. Absent when the split was made. */
66
+ splitRefused?: string;
67
+ }
68
+
69
+ export interface ShardSplit { key: string; capMb: number; retentionDays: number; reason: string }
70
+ export interface SplitInput {
71
+ /** The global capture budget in bytes: the disk-share bound the single-shard advisor already uses. */
72
+ budgetBytes: number;
73
+ shards: { key: string; useBytes: number; growthBytesPerDay: number; lastAt: string | null }[];
74
+ now: number;
75
+ }
76
+
77
+ const ACTIVITY_HALF_LIFE_DAYS = 14, ACTIVITY_FLOOR = 0.02, SPLIT_HORIZON_DAYS = 30;
78
+ // ponytail: a quiet shard keeps its last month of rows, counted from its last capture, so a proposal never empties a shard that merely went quiet (gc evicts by
79
+ // age from now). That floor is the one case allowed above the year ceiling: a shard idle for more than 335 days keeps idle days plus the floor. The upgrade path is a configured floor.
80
+ const SPLIT_RETENTION_FLOOR_DAYS = 30;
81
+
82
+ /** Share `amount` whole MB over `items` in proportion to weight x want, no item above its want (a bounded water-fill); the shares sum to at most `amount`. */
83
+ function waterFill(items: { key: string; weight: number; want: number }[], amount: number): Map<string, number> {
84
+ const out = new Map<string, number>(items.map(item => [item.key, 0]));
85
+ let free = items.filter(item => item.want > 0), remaining = amount;
86
+ for (let guard = 0; guard <= items.length && free.length; guard++) {
87
+ const total = free.reduce((sum, item) => sum + item.weight * item.want, 0);
88
+ const share = (item: (typeof items)[number]) => remaining * item.weight * item.want / total;
89
+ const high = free.filter(item => share(item) > item.want);
90
+ if (high.length) { for (const item of high) { out.set(item.key, item.want); remaining -= item.want; } free = free.filter(item => !high.includes(item)); continue; }
91
+ for (const item of free) out.set(item.key, Math.floor(share(item)));
92
+ break;
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * #15 D7: split one global capture budget across shards. Only capture is in it: the inputs are capture bytes, capture growth and the last capture time,
99
+ * so curated notes, goals and work lists have no quantity here to evict.
100
+ *
101
+ * Each shard has an activity weight, 0.5 per 14 days since its last capture (a shard with none counts as a year cold), plus a small floor. What a shard needs
102
+ * is what it holds now plus 30 days of its growth scaled by that weight. Three cases, by what fits:
103
+ * 1. every need fits: each shard gets its need and the surplus goes by weight.
104
+ * 2. every shard's use fits but not every need: each shard gets its use first (rounded up to whole MB, at least 1), and only the headroom is shared, by weight times
105
+ * the growth it asked for, so a cold shard keeps what it holds and a hot one gets the room.
106
+ * 3. the use itself does not fit: the budget is shared by weight times need, between 1 MB and the need, so cold shards shrink first, and a shard pushed
107
+ * below what it holds says so.
108
+ * The caps are whole MB, each at least 1 (a cap of 0 is not a bound), and their sum never exceeds the budget. When the budget holds fewer whole MB than there are shards, no split can
109
+ * satisfy both, so the function refuses with `SplitBudgetError` instead of returning an over-budget proposal.
110
+ * A shard's retention is the activity window, but never shorter than its idle time plus a month: gc counts age from now, so a shorter window would delete rows the shard still holds.
111
+ */
112
+ /** The budget cannot give every shard its 1 MB minimum: there is no valid split to propose. */
113
+ export class SplitBudgetError extends Error {}
114
+
115
+ export function capacitySplit(input: SplitInput): ShardSplit[] {
116
+ if (!Number.isFinite(input.budgetBytes) || input.budgetBytes < 0) throw new Error("the capture budget must be a finite number of bytes, zero or more");
117
+ if (!Number.isFinite(input.now)) throw new Error("the split needs a clock");
118
+ for (const shard of input.shards) {
119
+ if (!Number.isFinite(shard.useBytes) || shard.useBytes < 0) throw new Error(`shard use must be a finite number of bytes, zero or more (${shard.key})`);
120
+ if (!Number.isFinite(shard.growthBytesPerDay) || shard.growthBytesPerDay < 0) throw new Error(`shard growth must be a finite number of bytes per day, zero or more (${shard.key})`);
121
+ }
122
+ const shards = [...input.shards].sort((a, b) => a.key.localeCompare(b.key));
123
+ if (!shards.length) return [];
124
+ const budgetMb = Math.floor(input.budgetBytes / MB);
125
+ if (budgetMb < shards.length) throw new SplitBudgetError(`the capture budget holds ${budgetMb} MB, fewer than the ${shards.length} shards that each need a 1 MB minimum; free disk or raise --disk-share before splitting`);
126
+ const rows = shards.map((shard) => {
127
+ const lastMs = shard.lastAt === null ? Number.NaN : Date.parse(shard.lastAt);
128
+ const known = Number.isFinite(lastMs), days = known ? Math.max(0, (input.now - lastMs) / (24 * 3600_000)) : 365;
129
+ const activity = Math.pow(0.5, days / ACTIVITY_HALF_LIFE_DAYS);
130
+ const weight = activity + ACTIVITY_FLOOR;
131
+ const useMb = shard.useBytes / MB, growthMb = shard.growthBytesPerDay / MB;
132
+ const base = Math.max(1, Math.ceil(useMb)), need = Math.max(base, Math.ceil(useMb + growthMb * SPLIT_HORIZON_DAYS * activity));
133
+ const windowDays = Math.round(SPLIT_RETENTION_FLOOR_DAYS + (RETENTION_CEILING_DAYS - SPLIT_RETENTION_FLOOR_DAYS) * activity);
134
+ // A shard with no capture time holds no row to protect; the idle floor applies to a known last capture only.
135
+ const retentionDays = Math.max(windowDays, known ? Math.ceil(days) + SPLIT_RETENTION_FLOOR_DAYS : 0);
136
+ return { key: shard.key, useMb, activity, weight, base, need, retentionDays, cap: 1 };
137
+ });
138
+ const totalNeed = rows.reduce((sum, row) => sum + row.need, 0), totalBase = rows.reduce((sum, row) => sum + row.base, 0);
139
+ const reasons = new Map<string, string>();
140
+ if (totalNeed <= budgetMb) {
141
+ const surplus = budgetMb - totalNeed, totalWeight = rows.reduce((sum, row) => sum + row.weight, 0);
142
+ for (const row of rows) {
143
+ row.cap = row.need + Math.floor(surplus * row.weight / totalWeight);
144
+ reasons.set(row.key, `fits the budget: keeps its ${row.useMb.toFixed(1)} MB in use plus growth headroom for ${SPLIT_HORIZON_DAYS} days at its recent activity (weight ${row.activity.toFixed(2)})`);
145
+ }
146
+ } else if (totalBase <= budgetMb) {
147
+ const extra = waterFill(rows.map(row => ({ key: row.key, weight: row.weight, want: row.need - row.base })), budgetMb - totalBase);
148
+ for (const row of rows) {
149
+ row.cap = row.base + (extra.get(row.key) ?? 0);
150
+ reasons.set(row.key, `budget is tight: keeps the ${row.useMb.toFixed(1)} MB it holds, and its growth headroom is shared by recent activity (weight ${row.activity.toFixed(2)})`);
151
+ }
152
+ } else {
153
+ // The use itself does not fit: shares by weight x need, between 1 MB and the need; fix the violators of one bound at a time and share what is left.
154
+ let free = [...rows], remaining = budgetMb;
155
+ const fixed = new Map<string, number>();
156
+ for (let guard = 0; guard <= rows.length && free.length; guard++) {
157
+ const total = free.reduce((sum, row) => sum + row.weight * row.need, 0);
158
+ const share = (row: (typeof rows)[number]) => remaining * row.weight * row.need / total;
159
+ const low = free.filter(row => share(row) < 1), high = free.filter(row => share(row) > row.need);
160
+ if (low.length) { for (const row of low) { fixed.set(row.key, 1); remaining -= 1; } free = free.filter(row => !low.includes(row)); continue; }
161
+ if (high.length) { for (const row of high) { fixed.set(row.key, row.need); remaining -= row.need; } free = free.filter(row => !high.includes(row)); continue; }
162
+ for (const row of free) fixed.set(row.key, Math.max(1, Math.floor(share(row))));
163
+ free = [];
164
+ }
165
+ for (const row of rows) {
166
+ row.cap = fixed.get(row.key) ?? 1;
167
+ reasons.set(row.key, row.cap < row.useMb
168
+ ? `budget forces shrink: the global budget is smaller than what the shards hold, and this shard's recent activity (weight ${row.activity.toFixed(2)}) ranks it ${row.cap <= 1 ? "last: the 1 MB minimum" : "below the others"}`
169
+ : `budget is tight: capped below its growth headroom by recent activity (weight ${row.activity.toFixed(2)}), still above what it holds`);
170
+ }
171
+ }
172
+ return rows.map(row => ({ key: row.key, capMb: row.cap, retentionDays: row.retentionDays, reason: reasons.get(row.key)! }));
62
173
  }
63
174
 
64
175
  const MB = 1048576, GROWTH_DAYS = 7, PROJECT_DAYS = 30;
@@ -75,15 +186,24 @@ export function capacityReport(opts: {
75
186
  mkdirSync(base, { recursive: true, mode: 0o700 }); // a report on a fresh home still needs a path df can read
76
187
  const now = opts.now ?? Date.now;
77
188
  const since = new Date(now() - GROWTH_DAYS * 24 * 3600_000).toISOString();
78
- const shards = walkShards(base);
79
- let bytes = 0, rows = 0, growthRows = 0, growthBytes = 0;
80
- for (const shard of shards) for (const dir of shard.machineDirs) {
81
- const total = captureShardStats(dir);
82
- rows += total.rows;
83
- bytes += total.bytes;
84
- const growth = captureShardGrowth(dir, since);
85
- growthRows += growth.rows;
86
- growthBytes += growth.bytes;
189
+ const shards = walkShards(base), stateOf = lifecycleResolver(base);
190
+ let bytes = 0, rows = 0, growthRows = 0, growthBytes = 0, forgottenBytes = 0;
191
+ const perShard: SplitInput["shards"] = [];
192
+ for (const shard of shards) {
193
+ let useBytes = 0, growthShard = 0, lastAt: string | null = null;
194
+ for (const dir of shard.machineDirs) {
195
+ const total = captureShardStats(dir);
196
+ rows += total.rows;
197
+ bytes += total.bytes;
198
+ useBytes += total.bytes;
199
+ if (total.lastAt && (lastAt === null || total.lastAt > lastAt)) lastAt = total.lastAt;
200
+ const growth = captureShardGrowth(dir, since);
201
+ growthRows += growth.rows;
202
+ growthBytes += growth.bytes;
203
+ growthShard += growth.bytes;
204
+ }
205
+ if (stateOf(shard.key) === "forgotten") forgottenBytes += useBytes;
206
+ else perShard.push({ key: shard.key, useBytes, growthBytesPerDay: growthShard / GROWTH_DAYS, lastAt });
87
207
  }
88
208
  const rowsPerDay = growthRows / GROWTH_DAYS, bytesPerDay = growthBytes / GROWTH_DAYS;
89
209
  const disk = opts.inject?.disk ?? diskReadings(base);
@@ -101,8 +221,8 @@ export function capacityReport(opts: {
101
221
  retentionDays: homeConfig.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
102
222
  };
103
223
  const diskShare = opts.diskShare ?? 0.02;
104
- // ponytail: the 2026-10-17 owner decision keeps R6 caps per shard in P1, while the disk-share budget is
105
- // machine-wide; #15 T5 (D7) owns the machine-total split. Until then a home with N shards can hold N x cap.
224
+ // The single proposal below is one shard's bounds (the home default). #15 T5 (D7): `split` shares the machine-wide budget across the shards, so a
225
+ // home with N shards no longer holds N x cap once the person applies it (`amem capacity apply --split`).
106
226
  const budgetBytes = diskShare * disk.freeBytes;
107
227
  // Growth (owner decision 2026-10-17, item 2): propose the largest cap and retention that fit the disk share, above the current values when there is room.
108
228
  // The cap's ceiling is the disk-share bound itself. A person's own setting above the retention ceiling is never proposed down for the ceiling alone.
@@ -127,6 +247,10 @@ export function capacityReport(opts: {
127
247
  binding.push("R2 hook cost: measured hook latency or RSS exceeds the budget");
128
248
  }
129
249
  const reason = binding.join("; ");
250
+ // The split shares the same disk-share budget; what a forgotten shard still holds on disk is not available to the others.
251
+ let split: ShardSplit[] = [], splitRefused: string | undefined;
252
+ try { split = capacitySplit({ budgetBytes: Math.max(0, budgetBytes - forgottenBytes), shards: perShard, now: now() }); }
253
+ catch (error) { if (!(error instanceof SplitBudgetError)) throw error; splitRefused = error.message; }
130
254
  return {
131
255
  at: new Date(now()).toISOString(),
132
256
  capture: { shards: shards.length, bytes, rows, rowsPerDay, bytesPerDay },
@@ -137,6 +261,8 @@ export function capacityReport(opts: {
137
261
  current,
138
262
  diskShare,
139
263
  proposal: { capMb, retentionDays, reason },
264
+ split,
265
+ ...(splitRefused ? { splitRefused } : {}),
140
266
  };
141
267
  }
142
268
 
@@ -150,5 +276,7 @@ export function capacityText(report: CapacityReport): string {
150
276
  `projected capture in ${report.projected.days} days: ${mb(report.projected.bytes)}`,
151
277
  `current: cap ${report.current.capMb} MB, retention ${report.current.retentionDays} days`,
152
278
  `proposal: cap ${report.proposal.capMb} MB, retention ${report.proposal.retentionDays} days (${report.proposal.reason})`,
279
+ ...(report.splitRefused ? [`split: none proposed (${report.splitRefused})`] : []),
280
+ ...(report.split.length ? ["split of the global budget per shard (a proposal: amem capacity apply --split):", ...report.split.map(row => ` ${row.key}: cap ${row.capMb} MB, retention ${row.retentionDays} days (${row.reason})`)] : []),
153
281
  ].join("\n");
154
282
  }
@@ -1,5 +1,6 @@
1
1
  import { looksSecret } from "./screen.ts";
2
2
  import { isDenied } from "./platform.ts";
3
+ import { stripPrivateSpans } from "./private-spans.ts";
3
4
 
4
5
  export const CAPTURE_MAX_EXCERPT = 2000;
5
6
  export const CAPTURE_MAX_PATHS = 32;
@@ -17,14 +18,18 @@ export interface NormalizedCaptureFields {
17
18
  }
18
19
 
19
20
  /** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
20
- export function normalizeCaptureFields(input: CaptureFields, now: () => number = Date.now, screen: (text: string) => boolean = looksSecret): NormalizedCaptureFields {
21
+ /** The caps a person's configuration may lower (F4); the built-in caps are the ceiling, so a larger value is ignored. */
22
+ export interface CaptureCaps { excerptBytes?: number; paths?: number; denyPaths?: readonly string[] }
23
+ export function normalizeCaptureFields(input: CaptureFields, now: () => number = Date.now, screen: (text: string) => boolean = looksSecret, caps: CaptureCaps = {}): NormalizedCaptureFields {
24
+ const maxExcerpt = Math.min(CAPTURE_MAX_EXCERPT, caps.excerptBytes ?? CAPTURE_MAX_EXCERPT), maxPaths = Math.min(CAPTURE_MAX_PATHS, caps.paths ?? CAPTURE_MAX_PATHS);
21
25
  const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
22
26
  // Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
23
27
  const at = new Date(input.at === undefined ? now() : Date.parse(input.at)).toISOString();
24
28
  const tool = str(input.tool, 64) ? input.tool! : null;
25
29
  const status = str(input.status, 64) ? input.status! : null;
26
- const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
27
- let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
30
+ const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p, caps.denyPaths ? [...caps.denyPaths] : [])).slice(0, maxPaths).map((p) => p.slice(0, 400));
31
+ // `<private>` spans go first: they are never screened, counted or stored. The cap applies to what is left.
32
+ let excerpt = stripPrivateSpans(input.excerpt ?? "").slice(0, maxExcerpt);
28
33
  if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
29
34
  return { at, agent: input.agent, event: input.event, tool, paths, status, excerpt,
30
35
  bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };