@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
@@ -1,9 +1,12 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
3
  import { totalmem } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { memoryBase } from "./home.ts";
6
+ import { readMetrics, type MetricSample } from "./metrics.ts";
7
+ export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./metrics.ts";
6
8
  import { walkShards } from "./catalog.ts";
9
+ import { lifecycleResolver } from "./lifecycle.ts";
7
10
  import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
8
11
 
9
12
  /**
@@ -11,49 +14,16 @@ import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_D
11
14
  * `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
12
15
  * agent may read the proposal through this library function; applying stays person-only at the CLI.
13
16
  *
14
- * Proposal rule (owner decision, 2026-10-17): P1 is shrink-only — the proposed cap and retention are the largest
15
- * values not exceeding the current ones whose projection stays within the disk-share bound (default 2% of free
16
- * space, configurable) and whose measured search and hook readings stay within R2/R3. Growth proposals are a
17
- * follow-up after the agent-hub release; growing in P1 is the person's explicit `amem capacity apply --cap-mb N`.
18
- * The reason names the binding constraint: `disk-share retention`, `disk-share cap`, `R3 search RSS`, `R2 hook cost`, or `none`.
17
+ * Proposal rule (owner decision, 2026-10-17, item 2): the proposed cap and retention are the largest values whose projection stays within the
18
+ * disk-share bound (default 2% of free space, configurable) and whose measured search and hook readings stay within R2/R3, and they may be above
19
+ * the current ones. The cap is the disk-share bound itself; retention is the share over the daily growth, up to a 365-day ceiling. R2 halves the cap
20
+ * and R3 halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`,
21
+ * `R3 search RSS`, `R2 hook cost`. The advisor never writes: applying stays person-only, preview first.
19
22
  *
20
23
  * Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
21
24
  * `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
22
25
  */
23
26
 
24
- export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; idleMB?: number }
25
- const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
26
-
27
- export function metricsPath(base: string): string {
28
- return join(base, "metrics.jsonl");
29
- }
30
-
31
- /** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
32
- export function appendMetrics(base: string, samples: MetricSample[]): void {
33
- mkdirSync(base, { recursive: true, mode: 0o700 });
34
- const file = metricsPath(base);
35
- appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
36
- try {
37
- if (statSync(file).size <= METRICS_MAX_BYTES) return;
38
- const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
39
- writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
40
- } catch { /* a trim failure loses nothing: the file simply stays larger */ }
41
- }
42
-
43
- /** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
44
- export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
45
- let lines: string[] = [];
46
- try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
47
- const out: MetricSample[] = [];
48
- for (const line of lines.slice(-limit)) {
49
- try {
50
- const s = JSON.parse(line) as MetricSample;
51
- if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
52
- } catch { /* a malformed line is skipped */ }
53
- }
54
- return out;
55
- }
56
-
57
27
  const p95 = (samples: number[]): number | null => samples.length ? [...samples].sort((a, b) => a - b)[Math.max(0, Math.ceil(samples.length * 0.95) - 1)]! : null;
58
28
 
59
29
  function diskReadings(path: string): { freeBytes: number; totalBytes: number } {
@@ -90,9 +60,121 @@ export interface CapacityReport {
90
60
  current: CaptureConfig;
91
61
  diskShare: number;
92
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)! }));
93
173
  }
94
174
 
95
175
  const MB = 1048576, GROWTH_DAYS = 7, PROJECT_DAYS = 30;
176
+ // ponytail: the advisor never proposes more than a year of retention. The ceiling keeps a quiet home's proposal finite; the upgrade path is a person setting (a configured ceiling) once someone wants longer.
177
+ const RETENTION_CEILING_DAYS = 365;
96
178
 
97
179
  export function capacityReport(opts: {
98
180
  home?: string; memoryDir?: string; diskShare?: number; now?: () => number;
@@ -104,15 +186,24 @@ export function capacityReport(opts: {
104
186
  mkdirSync(base, { recursive: true, mode: 0o700 }); // a report on a fresh home still needs a path df can read
105
187
  const now = opts.now ?? Date.now;
106
188
  const since = new Date(now() - GROWTH_DAYS * 24 * 3600_000).toISOString();
107
- const shards = walkShards(base);
108
- let bytes = 0, rows = 0, growthRows = 0, growthBytes = 0;
109
- for (const shard of shards) for (const dir of shard.machineDirs) {
110
- const total = captureShardStats(dir);
111
- rows += total.rows;
112
- bytes += total.bytes;
113
- const growth = captureShardGrowth(dir, since);
114
- growthRows += growth.rows;
115
- 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 });
116
207
  }
117
208
  const rowsPerDay = growthRows / GROWTH_DAYS, bytesPerDay = growthBytes / GROWTH_DAYS;
118
209
  const disk = opts.inject?.disk ?? diskReadings(base);
@@ -130,19 +221,23 @@ export function capacityReport(opts: {
130
221
  retentionDays: homeConfig.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
131
222
  };
132
223
  const diskShare = opts.diskShare ?? 0.02;
133
- // ponytail: the 2026-10-17 owner decision keeps R6 caps per shard in P1, while the disk-share budget is
134
- // 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`).
135
226
  const budgetBytes = diskShare * disk.freeBytes;
136
- let { capMb, retentionDays } = current;
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.
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.
229
+ const retentionCeiling = Math.max(RETENTION_CEILING_DAYS, current.retentionDays);
230
+ let capMb = Math.max(1, Math.floor(budgetBytes / MB));
231
+ let retentionDays = retentionCeiling;
137
232
  const binding: string[] = [];
138
- if (bytesPerDay > 0 && bytesPerDay * retentionDays > budgetBytes) {
139
- retentionDays = Math.max(1, Math.floor(budgetBytes / bytesPerDay));
140
- binding.push("disk-share retention: steady-state capture at the current growth exceeds the disk-share bound");
141
- }
142
- if (capMb * MB > budgetBytes) {
143
- capMb = Math.max(1, Math.floor(budgetBytes / MB));
144
- binding.push("disk-share cap: the cap exceeds the disk-share bound");
233
+ const fitDays = bytesPerDay > 0 ? Math.floor(budgetBytes / bytesPerDay) : Infinity;
234
+ if (fitDays < retentionCeiling) {
235
+ retentionDays = Math.max(1, fitDays);
236
+ binding.push("disk-share retention: steady-state capture at the current growth fills the disk-share bound");
237
+ } else {
238
+ binding.push(`retention ceiling: ${retentionCeiling} days fit the disk share, so the ceiling stops growth`);
145
239
  }
240
+ binding.push("disk-share cap: the cap is the largest size the disk-share bound allows");
146
241
  if (metrics.searchRssMB !== null && metrics.searchRssMB > 200) {
147
242
  retentionDays = Math.max(1, Math.floor(retentionDays / 2));
148
243
  binding.push("R3 search RSS: measured peak search memory exceeds 200 MB");
@@ -151,7 +246,11 @@ export function capacityReport(opts: {
151
246
  capMb = Math.max(1, Math.floor(capMb / 2));
152
247
  binding.push("R2 hook cost: measured hook latency or RSS exceeds the budget");
153
248
  }
154
- const reason = binding.length ? binding.join("; ") : "none: current bounds fit the disk share and the measured R2/R3 readings";
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; }
155
254
  return {
156
255
  at: new Date(now()).toISOString(),
157
256
  capture: { shards: shards.length, bytes, rows, rowsPerDay, bytesPerDay },
@@ -162,6 +261,8 @@ export function capacityReport(opts: {
162
261
  current,
163
262
  diskShare,
164
263
  proposal: { capMb, retentionDays, reason },
264
+ split,
265
+ ...(splitRefused ? { splitRefused } : {}),
165
266
  };
166
267
  }
167
268
 
@@ -175,5 +276,7 @@ export function capacityText(report: CapacityReport): string {
175
276
  `projected capture in ${report.projected.days} days: ${mb(report.projected.bytes)}`,
176
277
  `current: cap ${report.current.capMb} MB, retention ${report.current.retentionDays} days`,
177
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})`)] : []),
178
281
  ].join("\n");
179
282
  }
@@ -0,0 +1,36 @@
1
+ import { looksSecret } from "./screen.ts";
2
+ import { isDenied } from "./platform.ts";
3
+ import { stripPrivateSpans } from "./private-spans.ts";
4
+
5
+ export const CAPTURE_MAX_EXCERPT = 2000;
6
+ export const CAPTURE_MAX_PATHS = 32;
7
+ const ROW_OVERHEAD_BYTES = 96;
8
+
9
+ export interface CaptureFields {
10
+ agent: string; event: string;
11
+ tool?: string; paths?: string[]; status?: string; excerpt?: string;
12
+ /** Tests and the spool replay pin the time; hooks leave it to the store clock. */
13
+ at?: string;
14
+ }
15
+ export interface NormalizedCaptureFields {
16
+ at: string; agent: string; event: string;
17
+ tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
18
+ }
19
+
20
+ /** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
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);
25
+ const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
26
+ // Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
27
+ const at = new Date(input.at === undefined ? now() : Date.parse(input.at)).toISOString();
28
+ const tool = str(input.tool, 64) ? input.tool! : null;
29
+ const status = str(input.status, 64) ? input.status! : null;
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);
33
+ if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
34
+ return { at, agent: input.agent, event: input.event, tool, paths, status, excerpt,
35
+ bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
36
+ }