@staix/agent-mem 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +143 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +537 -39
  6. package/src/commands-table.ts +60 -16
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +20 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +168 -33
  12. package/src/mcp.ts +43 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +228 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/consolidate.ts +78 -0
  25. package/src/memory/consolidation-clusters.ts +56 -0
  26. package/src/memory/consolidation-model.ts +97 -0
  27. package/src/memory/cross-project.ts +75 -0
  28. package/src/memory/curated-read.ts +29 -0
  29. package/src/memory/decay-review.ts +136 -0
  30. package/src/memory/doctor.ts +415 -0
  31. package/src/memory/editor.ts +58 -0
  32. package/src/memory/eval-fixture.ts +145 -0
  33. package/src/memory/eval-set-ko.json +90 -0
  34. package/src/memory/eval-set.json +443 -0
  35. package/src/memory/eval.ts +152 -0
  36. package/src/memory/evidence.ts +76 -0
  37. package/src/memory/external-import.ts +267 -0
  38. package/src/memory/gc.ts +97 -0
  39. package/src/memory/generation-index.ts +22 -17
  40. package/src/memory/generation-sync.ts +3 -0
  41. package/src/memory/goal-promotion.ts +71 -0
  42. package/src/memory/home.ts +15 -4
  43. package/src/memory/intent-brief.ts +89 -0
  44. package/src/memory/lifecycle-log.ts +54 -0
  45. package/src/memory/lifecycle.ts +61 -0
  46. package/src/memory/metrics.ts +2 -2
  47. package/src/memory/note-add.ts +100 -0
  48. package/src/memory/path-glob.ts +94 -0
  49. package/src/memory/plan-guard.ts +47 -0
  50. package/src/memory/platform.ts +12 -1
  51. package/src/memory/policy.ts +44 -13
  52. package/src/memory/private-spans.ts +32 -0
  53. package/src/memory/project-export.ts +157 -0
  54. package/src/memory/project-import.ts +147 -0
  55. package/src/memory/recall-staleness.ts +56 -0
  56. package/src/memory/recall.ts +74 -0
  57. package/src/memory/repo-scope.ts +404 -0
  58. package/src/memory/shard-sync-format.ts +116 -0
  59. package/src/memory/shard-sync.ts +421 -0
  60. package/src/memory/store.ts +344 -35
  61. package/src/memory/sync-cadence.ts +149 -0
  62. package/src/memory/sync-screen.ts +86 -0
  63. package/src/memory/sync.ts +91 -11
  64. package/src/memory/usage.ts +35 -0
  65. package/src/memory/use-telemetry.ts +51 -0
  66. package/src/memory/work-lists.ts +10 -4
  67. package/src/session-brief-worker.ts +3 -2
  68. package/src/session-brief.ts +74 -15
  69. package/src/setup-owned.ts +120 -0
  70. package/src/setup-toml.ts +296 -0
  71. package/src/setup.ts +327 -0
@@ -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 };
@@ -7,7 +7,10 @@ import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
7
7
  export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
8
8
  import { memoryBase } from "./home.ts";
9
9
  import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
10
+ import { assertProjectActive } from "./lifecycle.ts";
10
11
  import { machineId } from "./machine.ts";
12
+ import { loadAmemConfig, repoConfigRoot } from "./config.ts";
13
+ import { fold } from "./policy.ts";
11
14
  import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
12
15
 
13
16
  /**
@@ -81,6 +84,15 @@ export interface CaptureOptions {
81
84
  recentReadRows?: boolean;
82
85
  /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
83
86
  screen?: (text: string) => boolean;
87
+ /** Caps a caller may lower (F4); the built-in caps and the person's configured caps are the ceiling, so a larger value is ignored (a host can tighten, never loosen). */
88
+ excerptBytes?: number; capturePaths?: number;
89
+ /** The working directory the capture belongs to: its repository's `.amem/config` narrows the policy (deny paths, caps) for this store. Absent: the machine configuration only. */
90
+ cwd?: string;
91
+ /**
92
+ * #15 T5: a writable open of an archived or forgotten project is refused (the person took it out of recall; `amem project unarchive` brings an archived one back).
93
+ * `ignore` is for the maintenance that must still reach the shard (gc, export): it never appends.
94
+ */
95
+ lifecycle?: "enforce" | "ignore";
84
96
  }
85
97
  export interface CaptureAppendOptions {
86
98
  /** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
@@ -125,13 +137,17 @@ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConf
125
137
  return next;
126
138
  }
127
139
 
128
- /** The effective R6 settings for a shard directory: shard config over the home default over the built-in defaults. */
140
+ /**
141
+ * The effective R6 settings for a shard directory: the shard's config over the home default (`capture-config.json`, which the capacity advisor writes)
142
+ * over the person's machine configuration (`amem.config.json` `capture`, F4) over the built-in defaults. A malformed machine configuration throws.
143
+ */
129
144
  export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
130
145
  const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
131
146
  const shard = readCaptureConfigFile(join(dir, "config.json"));
147
+ const machine = base === undefined ? {} : loadAmemConfig(base).config.capture;
132
148
  return {
133
- capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
134
- retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
149
+ capMb: shard.capMb ?? homeDefault.capMb ?? machine.capMb ?? CAPTURE_DEFAULT_CAP_MB,
150
+ retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? machine.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
135
151
  };
136
152
  }
137
153
 
@@ -157,6 +173,7 @@ export class CaptureStore {
157
173
  private readonly db: Database;
158
174
  private readonly now: () => number;
159
175
  private readonly screen: (text: string) => boolean;
176
+ private readonly caps: import("./capture-input.ts").CaptureCaps;
160
177
  private readonly capMb: number;
161
178
  private readonly retentionDays: number;
162
179
  /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
@@ -179,6 +196,7 @@ export class CaptureStore {
179
196
  if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
180
197
  this.requestedKey = opts.project;
181
198
  this.key = resolveAlias(this.base, opts.project);
199
+ if (!this.readOnly && opts.lifecycle !== "ignore") assertProjectActive(this.base, this.key);
182
200
  if (this.readOnly) {
183
201
  const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
184
202
  if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
@@ -197,7 +215,14 @@ export class CaptureStore {
197
215
  this.capMb = opts.capMb ?? config.capMb;
198
216
  this.retentionDays = opts.retentionDays ?? config.retentionDays;
199
217
  this.now = opts.now ?? Date.now;
200
- this.screen = opts.screen ?? looksSecret;
218
+ const configured = loadAmemConfig(this.base, opts.cwd === undefined ? undefined : repoConfigRoot(opts.cwd)).config;
219
+ // F4: the person's PII patterns screen every excerpt and their deny paths filter every path, whoever appends (hook, ingest or library). A host's own `screen` replaces only the secret part.
220
+ const patterns = configured.piiPatterns.map(fold).filter(Boolean);
221
+ const base = opts.screen ?? looksSecret;
222
+ this.screen = patterns.length ? (text: string) => base(text) || patterns.some(pattern => fold(text).includes(pattern)) : base;
223
+ const lowest = (a: number | undefined, b: number | undefined) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
224
+ this.caps = { excerptBytes: lowest(opts.excerptBytes, configured.excerptBytes), paths: lowest(opts.capturePaths, configured.capturePaths),
225
+ ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) };
201
226
  const file = join(this.dir, "capture.db");
202
227
  if (this.readOnly) {
203
228
  // Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
@@ -412,7 +437,7 @@ export class CaptureStore {
412
437
 
413
438
  /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
414
439
  private normalize(input: CaptureInput): NormalizedCapture {
415
- const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text));
440
+ const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
416
441
  return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
417
442
  }
418
443
 
@@ -1,11 +1,12 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { chmodSync, existsSync, readdirSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
5
  import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
6
  import { readProfileJSON, validGeneration } from "./generation.ts";
7
7
  import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
8
  import { openShardStore } from "./store.ts";
9
+ import { lifecycleResolver, type LifecycleState } from "./lifecycle.ts";
9
10
 
10
11
  /**
11
12
  * #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
@@ -20,12 +21,16 @@ export interface CatalogProject {
20
21
  captureRows: number; captureBytes: number; captureLastAt: string | null;
21
22
  curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
22
23
  capMb: number; retentionDays: number;
24
+ /** #15 T5: the person's lifecycle decision (`lifecycle.jsonl`); forgotten projects stay in the scan so the state is visible, and listings leave them out. */
25
+ state: LifecycleState;
26
+ /** Bytes the shard directory holds on disk (its logs, indexes, capture and work lists), counting files and never following a link. */
27
+ diskBytes: number;
23
28
  }
24
29
 
25
- interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
30
+ interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number; state: string; disk_bytes: number }
26
31
 
27
32
  const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
28
- captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
33
+ captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days, state: r.state === "archived" || r.state === "forgotten" ? r.state : "active", diskBytes: r.disk_bytes });
29
34
 
30
35
  /** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
31
36
  function capturedSelection(base: string): boolean {
@@ -41,7 +46,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
41
46
  const dir = join(base, "projects", hash);
42
47
  // A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
43
48
  if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
44
- const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
49
+ const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
45
50
  try {
46
51
  const db = new Database(join(dir, "index.db"), { readonly: true });
47
52
  try {
@@ -52,6 +57,23 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
52
57
  } finally { store.close(); }
53
58
  }
54
59
 
60
+ /** Bytes of regular files under a shard directory. A link is not followed (counted as nothing), an unreadable entry as nothing: the figure is a lower bound, never a guess. */
61
+ export function directoryBytes(dir: string, depth = 0): number {
62
+ if (depth > 12) return 0;
63
+ let total = 0;
64
+ let entries: import("node:fs").Dirent[] = [];
65
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
66
+ for (const entry of entries) {
67
+ const path = join(dir, entry.name);
68
+ try {
69
+ if (entry.isSymbolicLink()) continue;
70
+ if (entry.isDirectory()) total += directoryBytes(path, depth + 1);
71
+ else if (entry.isFile()) total += lstatSync(path).size;
72
+ } catch { /* vanished or unreadable: not counted */ }
73
+ }
74
+ return total;
75
+ }
76
+
55
77
  /** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
56
78
  export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
57
79
  const projectsDir = join(base, "projects");
@@ -75,6 +97,7 @@ export function scanCatalog(base: string): CatalogProject[] {
75
97
  const captured = capturedSelection(base);
76
98
  // The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
77
99
  const latest = effectiveAliases(base);
100
+ const stateOf = lifecycleResolver(base);
78
101
  const out: CatalogProject[] = [];
79
102
  for (const shard of walkShards(base)) {
80
103
  let rows = 0, bytes = 0, lastAt: string | null = null;
@@ -88,11 +111,17 @@ export function scanCatalog(base: string): CatalogProject[] {
88
111
  const curated = curatedStats(base, shard.hash, shard.key, captured);
89
112
  out.push({ hash: shard.hash, key: shard.key, local: shard.local,
90
113
  aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
91
- captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays });
114
+ captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays,
115
+ state: stateOf(shard.key), diskBytes: directoryBytes(join(base, "projects", shard.hash)) });
92
116
  }
93
117
  return out.sort((a, b) => a.key.localeCompare(b.key));
94
118
  }
95
119
 
120
+ /** A fresh scan of the shards for a read-only command (`amem usage`, `amem projects`): it writes nothing, `catalog.db` included. */
121
+ export function readCatalogFresh(base: string): CatalogProject[] {
122
+ return scanCatalog(base);
123
+ }
124
+
96
125
  /** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
97
126
  export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
98
127
  const base = memoryBase(opts.home);
@@ -105,12 +134,13 @@ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
105
134
  db.transaction(() => {
106
135
  const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
107
136
  // This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
108
- if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at"))) db.run("DROP TABLE projects");
137
+ if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
138
+ || !columns.some(column => column.name === "state") || !columns.some(column => column.name === "disk_bytes"))) db.run("DROP TABLE projects");
109
139
  db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
110
- capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
140
+ capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL, state TEXT NOT NULL DEFAULT 'active', disk_bytes INTEGER NOT NULL DEFAULT 0)`);
111
141
  db.run("DELETE FROM projects");
112
- for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
113
- .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
142
+ for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days, state, disk_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
143
+ .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays, p.state, p.diskBytes);
114
144
  }).immediate();
115
145
  } finally { db.close(); }
116
146
  return projects;
@@ -127,8 +157,11 @@ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
127
157
  db = new Database(file, { readonly: true });
128
158
  db.run("PRAGMA busy_timeout = 3000");
129
159
  const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
130
- if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
160
+ if (!columns.has("curated_entries") || !columns.has("curated_last_at") || !columns.has("state") || !columns.has("disk_bytes")) throw new Error("catalog schema needs rebuild");
161
+ // The state is the person's decision in lifecycle.jsonl, which changes without touching a shard: read it live, never from the cached row.
162
+ const stateOf = lifecycleResolver(base);
131
163
  return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
164
+ .map(project => ({ ...project, state: stateOf(project.key) }))
132
165
  .map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
133
166
  } catch { return rebuildCatalog(opts); }
134
167
  finally { db?.close(); }