@staix/agent-mem 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -0,0 +1,566 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
6
+ import { normalizeCaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
7
+ import { canonicalPath, realPath } from "./memory/platform.ts";
8
+ import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
9
+ import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
10
+ import { fold } from "./memory/policy.ts";
11
+ import { looksSecret } from "./memory/screen.ts";
12
+ import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
13
+ import { projectState } from "./memory/lifecycle.ts";
14
+ import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
15
+
16
+ const SCHEMA = "agent-mem.hook-capture/v1";
17
+ const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
18
+ const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
19
+ // ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
20
+ // Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
21
+ const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
22
+ const namePattern = /^(hook-([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
23
+ const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
24
+ interface Record { schema: typeof SCHEMA; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
25
+ export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
26
+ export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
27
+ interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true }
28
+ const directory = (base: string) => join(base, "hook-spool");
29
+ function physicalRoot(cwd: string): string {
30
+ const stat = lstatSync(cwd);
31
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook root unavailable");
32
+ return hash(`${stat.dev}:${stat.ino}:${stat.birthtimeMs}`);
33
+ }
34
+
35
+ function privateDirectory(dir: string): void {
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const stat = lstatSync(dir);
38
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook storage unavailable");
39
+ chmodSync(dir, 0o700);
40
+ }
41
+ function entries(base: string, capacity = false): { name: string; size: number; modified: number }[] {
42
+ const dir = capacity ? join(directory(base), ".capacity") : directory(base), maximum = capacity ? CAPACITY_FILES : MAX_FILES, stat = lstatSync(dir, { throwIfNoEntry: false });
43
+ if (!stat) return [];
44
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook storage unavailable");
45
+ const reader = opendirSync(dir), rows: { name: string; size: number; modified: number }[] = [];
46
+ try {
47
+ for (let scanned = 0; scanned <= maximum + 4; scanned++) {
48
+ const entry = reader.readSync(); if (!entry) return rows;
49
+ if ([".writing", ".quarantine", ".capacity", ".maintenance"].includes(entry.name) && entry.isDirectory()) continue;
50
+ if (rows.length === maximum) return rows;
51
+ const item = lstatSync(join(dir, entry.name), { throwIfNoEntry: false });
52
+ if (item) rows.push({ name: entry.name, size: item.size, modified: item.mtimeMs });
53
+ }
54
+ return rows;
55
+ } finally { reader.closeSync(); }
56
+ }
57
+
58
+ /** Validate ordinary Git value syntax even for ignored keys: invalid unused configuration can make
59
+ * native Git refuse the whole file, so ignoring its syntax could falsely certify project resolution. */
60
+ function validateConfigValue(value: string): void {
61
+ let quoted = false;
62
+ for (let index = 0; index < value.length; index++) {
63
+ const character = value[index]!;
64
+ if (character === "\\") {
65
+ const escaped = value[++index];
66
+ if (escaped === undefined || !["n", "t", "b", "\\", '"'].includes(escaped)) throw new Error("unsupported Git config escape");
67
+ } else if (character === '"') quoted = !quoted;
68
+ else if (!quoted && (character === "#" || character === ";")) break;
69
+ }
70
+ if (quoted) throw new Error("unclosed Git config quote");
71
+ }
72
+
73
+ /** Strict subset, not a substitute Git parser. Native Git decides include conditions and values;
74
+ * this parser discovers bounded candidate dependencies without applying them as active config. */
75
+ function identityConfig(text: string, include?: (path: string, condition?: string) => void): [string, string][] {
76
+ const relevant: [string, string][] = [];
77
+ let section = "", subsection = "";
78
+ for (const raw of text.split(/\r?\n/)) {
79
+ const line = raw.trim();
80
+ if (!line || /^[#;]/.test(line)) continue;
81
+ if (line.startsWith("[")) {
82
+ const match = /^\[([a-zA-Z0-9-]+)(?:\s+"([^"\\]*)")?\]\s*(?:[#;].*)?$/.exec(line);
83
+ if (!match) throw new Error("unsupported Git config section");
84
+ section = match[1]!.toLowerCase(); subsection = match[2] ?? "";
85
+ continue;
86
+ }
87
+ const match = /^([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(.*))?$/.exec(line);
88
+ if (!section || !match) throw new Error("unsupported Git config entry");
89
+ const key = match[1]!.toLowerCase();
90
+ let value = match[2] ?? "true";
91
+ validateConfigValue(value);
92
+ const includePath = (section === "include" || section === "includeif") && key === "path";
93
+ const deciding = includePath || section === "remote" && subsection === "origin" && key === "url"
94
+ || section === "url" && key === "insteadof"
95
+ || section === "core" && ["bare", "worktree"].includes(key)
96
+ || section === "extensions" && key === "worktreeconfig";
97
+ if (!deciding) continue; // Valid alias/user/etc. values never affect project identity.
98
+ if (value.includes("\\")) throw new Error("unsupported identity config escape");
99
+ if (value.startsWith('"')) {
100
+ const quoted = /^"([^"\\]*)"\s*(?:[#;].*)?$/.exec(value);
101
+ if (!quoted) throw new Error("unsupported Git config value");
102
+ value = quoted[1]!;
103
+ } else {
104
+ if (value.includes('"')) throw new Error("unsupported Git config value");
105
+ value = value.split(/[#;]/, 1)[0]!.trim();
106
+ }
107
+ if (includePath) {
108
+ if (!value || section === "includeif" && !subsection) throw new Error("unsupported Git include path");
109
+ include?.(value, section === "includeif" ? subsection : undefined);
110
+ } else if (section === "remote" && subsection === "origin" && key === "url") relevant.push(["origin", value]);
111
+ else if (section === "url" && key === "insteadof") relevant.push([`rewrite:${subsection}`, value]);
112
+ else if (section === "core" && ["bare", "worktree"].includes(key) || section === "extensions" && key === "worktreeconfig") {
113
+ if (key !== "worktree") {
114
+ const folded = value.toLowerCase();
115
+ if (!["true", "false", "yes", "no", "on", "off", "1", "0", ""].includes(folded)) throw new Error("invalid Git boolean");
116
+ value = ["true", "yes", "on", "1"].includes(folded) ? "true" : "false";
117
+ }
118
+ relevant.push([`${section}.${key}`, value]);
119
+ }
120
+ }
121
+ return relevant;
122
+ }
123
+
124
+ /** Physical cwd/common-dir identity plus project-deciding configuration, never branch HEAD or config inode.
125
+ * ponytail: strict unsupported Git configuration remains unknown, not guessed. A native historical-origin
126
+ * receipt would broaden that supported subset without relaxing the physical replacement boundary. */
127
+ export function hookCaptureScope(cwd: string, base: string, cacheValidity = false, extraGitConfig?: string): string | undefined {
128
+ try {
129
+ const parts: string[] = [], identity = (file: string) => {
130
+ const stat = lstatSync(file);
131
+ if (!stat.isDirectory()) throw new Error("physical scope unavailable");
132
+ return `${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;
133
+ };
134
+ const content = (file: string, maximum = 64 * 1024): string => {
135
+ const named = lstatSync(file, { throwIfNoEntry: false });
136
+ if (!named) return "";
137
+ const actual = realPath(file), fd = openSync(actual, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
138
+ try {
139
+ const stat = fstatSync(fd);
140
+ if (!stat.isFile() || stat.size > maximum) throw new Error("control bound");
141
+ const bytes = Buffer.alloc(stat.size), length = readSync(fd, bytes, 0, bytes.length, 0), after = lstatSync(actual);
142
+ if (length !== stat.size || stat.dev !== after.dev || stat.ino !== after.ino || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs) throw new Error("control changed");
143
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
144
+ // Pending capture survives harmless Git rewrites. A derived native-identity cache
145
+ // has a stricter validity binding: even an ignored/unsupported config change must miss.
146
+ if (cacheValidity) parts.push(`${file}:${hash(text)}`);
147
+ return text;
148
+ } finally { closeSync(fd); }
149
+ };
150
+ cwd = realPath(cwd);
151
+ const home = canonicalPath(process.env.HOME?.trim() || homedir());
152
+ parts.push(`${cwd}:${identity(cwd)}`, `home:${home}`);
153
+ // The fixed worker keeps HOME but strips inherited Git routing/override environment. Check the ordinary
154
+ // system locations conservatively too; unsupported identity-affecting configuration refuses resolution.
155
+ const configuration: [string, string][] = [];
156
+ let hasIncludes = false;
157
+ const includedFiles = new Map<string, { digest: string; depth: number; deciding: boolean }>();
158
+ // ponytail: at most four nested include levels and sixteen candidate files (64 KiB each).
159
+ // Conditions are not interpreted here; identity-changing dynamic conditions stay unknown
160
+ // until a bounded native activation receipt can bind their effect without hashing branch state.
161
+ const configurationFile = (file: string, depth = 0, ancestors = new Set<string>()): { relevant: [string, string][]; digest: string; depth: number; deciding: boolean } => {
162
+ const text = content(file);
163
+ if (ancestors.has(file)) throw new Error("cyclic Git config include");
164
+ const next = new Set(ancestors).add(file), dependencies: [string, string][] = [];
165
+ let nesting = 0, nestedDeciding = false;
166
+ const relevant = identityConfig(text, (candidate, condition) => {
167
+ hasIncludes = true;
168
+ if (depth >= 4 || candidate.startsWith("~") && !candidate.startsWith("~/") || candidate.includes("%(")) throw new Error("unsupported Git config include");
169
+ const target = candidate.startsWith("~/") ? resolve(home, candidate.slice(2)) : resolve(dirname(file), candidate);
170
+ if (next.has(target)) throw new Error("cyclic Git config include");
171
+ let dependency = includedFiles.get(target);
172
+ if (!dependency) {
173
+ if (includedFiles.size >= 16) throw new Error("Git config include file bound");
174
+ // Reserve before recursion: the bound covers all candidates, not just completed subtrees.
175
+ includedFiles.set(target, { digest: "", depth: 0, deciding: false });
176
+ dependency = configurationFile(target, depth + 1, next);
177
+ includedFiles.set(target, dependency);
178
+ }
179
+ nesting = Math.max(nesting, 1 + dependency.depth);
180
+ if (depth + nesting > 4) throw new Error("Git config include depth bound");
181
+ if (!cacheValidity && dependency.deciding && condition && !/^gitdir(?:\/i)?:/i.test(condition)) throw new Error("dynamic Git identity condition unavailable");
182
+ nestedDeciding ||= dependency.deciding;
183
+ // Bind only the same deciding subset as ordinary config. Included origins are
184
+ // dependencies, never active values used by the direct-origin ambiguity check.
185
+ if (dependency.deciding) dependencies.push([condition ?? "", dependency.digest]);
186
+ });
187
+ return { relevant, digest: hash(JSON.stringify([relevant, dependencies])), depth: nesting, deciding: relevant.length > 0 || nestedDeciding };
188
+ };
189
+ const addConfiguration = (file: string) => { const parsed = configurationFile(file); configuration.push(...parsed.relevant); parts.push(`configuration:${file}:${parsed.digest}`); };
190
+ for (const file of new Set(["/etc/gitconfig", "/usr/local/etc/gitconfig", "/opt/homebrew/etc/gitconfig", ...(cacheValidity && extraGitConfig ? [extraGitConfig] : [])])) addConfiguration(file);
191
+ for (const file of [join(home, ".config", "git", "config"), join(home, ".gitconfig")]) addConfiguration(file);
192
+ let found = false, reachedRoot = false;
193
+ for (let folder = cwd, depth = 0; depth < 32; depth++) {
194
+ const git = join(folder, ".git"), stat = lstatSync(git, { throwIfNoEntry: false });
195
+ if (stat) {
196
+ found = true;
197
+ const actual = realPath(git), actualStat = lstatSync(actual);
198
+ let gitDir = actual;
199
+ if (actualStat.isFile()) {
200
+ const pointer = /^gitdir:\s*(.+)\s*$/i.exec(content(actual, 4096).trim());
201
+ if (!pointer) throw new Error("git pointer unavailable");
202
+ gitDir = realPath(resolve(folder, pointer[1]!));
203
+ } else if (!actualStat.isDirectory()) throw new Error("git directory unavailable");
204
+ const common = content(join(gitDir, "commondir"), 4096).trim();
205
+ const commonDir = common ? realPath(resolve(gitDir, common)) : gitDir;
206
+ parts.push(`${gitDir}:${identity(gitDir)}`, `${commonDir}:${identity(commonDir)}`);
207
+ if (cacheValidity) {
208
+ const head = lstatSync(join(gitDir, "HEAD"));
209
+ if (!head.isFile() || head.isSymbolicLink()) return undefined;
210
+ parts.push(`HEAD:${hash(content(join(gitDir, "HEAD"), 4096))}`);
211
+ for (const name of ["objects", "refs"]) parts.push(`${name}:${identity(join(commonDir, name))}`);
212
+ }
213
+ // Git still consults legacy remote definitions when config has no origin.
214
+ // Bind bytes for both pending capture and the stricter identity cache.
215
+ for (const dir of new Set([commonDir, gitDir])) for (const legacy of ["remotes", "branches"]) {
216
+ const file = join(dir, legacy, "origin");
217
+ parts.push(`${file}:${hash(content(file))}`);
218
+ }
219
+ addConfiguration(join(commonDir, "config"));
220
+ const worktreeConfig = configuration.filter(([key]) => key === "extensions.worktreeconfig").at(-1)?.[1];
221
+ if (worktreeConfig === "true") addConfiguration(join(gitDir, "config.worktree"));
222
+ else if (hasIncludes) {
223
+ // An included candidate may enable this source; bind it without guessing whether it does.
224
+ const file = join(gitDir, "config.worktree"), candidate = configurationFile(file); if (candidate.deciding) parts.push(`worktree-candidate:${candidate.digest}`);
225
+ }
226
+ break;
227
+ }
228
+ const parent = dirname(folder); if (parent === folder) { reachedRoot = true; break; } folder = parent;
229
+ }
230
+ if (!found && (!reachedRoot || cacheValidity)) return undefined;
231
+ if (configuration.filter(([key]) => key === "origin").length > 1) return undefined;
232
+ const scalars = new Map(configuration.filter(([key]) => !key.startsWith("rewrite:")));
233
+ const rewrites = configuration.filter(([key]) => key.startsWith("rewrite:"));
234
+ parts.push(found ? "git" : "no-git", JSON.stringify([...scalars].sort()), JSON.stringify(rewrites));
235
+ // Opaque bounded binding only. Actual schema/project interpretation stays with the worker's core readers;
236
+ // importing those here would load SQLite in the low-RSS parent. File replacement alone is irrelevant.
237
+ if (cacheValidity) for (const file of ["aliases.jsonl", "profile.json", "shards.json"]) parts.push(`${file}:${hash(content(join(base, file)))}`);
238
+ return hash(parts.join("\n"));
239
+ } catch { return undefined; }
240
+ }
241
+
242
+ interface OwnedFile { file: string; fd: number; initial: Stats }
243
+ interface Quarantined { name: string; size: number; modified: number; dev: number; ino: number }
244
+ function owns(file: OwnedFile): boolean {
245
+ const named = lstatSync(file.file, { throwIfNoEntry: false }), now = fstatSync(file.fd), initial = file.initial;
246
+ return !!named && named.isFile() && !named.isSymbolicLink() && named.nlink === 1
247
+ && named.dev === initial.dev && named.ino === initial.ino && named.size === initial.size && named.mtimeMs === initial.mtimeMs
248
+ && now.dev === initial.dev && now.ino === initial.ino && now.size === initial.size && now.mtimeMs === initial.mtimeMs;
249
+ }
250
+ function openOwned(file: string): OwnedFile {
251
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
252
+ try {
253
+ const initial = fstatSync(fd), owned = { file, fd, initial };
254
+ if (!initial.isFile() || initial.nlink !== 1 || !owns(owned)) throw new Error("hook record unavailable");
255
+ return owned;
256
+ } catch (error) { closeSync(fd); throw error; }
257
+ }
258
+ /** Claim only the inode just inspected; subsequent cleanup addresses this unique name, never the old one. */
259
+ function claimOwned(base: string, name: string, file: OwnedFile): OwnedFile | undefined {
260
+ const match = namePattern.exec(name);
261
+ if (!match || !owns(file)) return undefined;
262
+ const claim = join(dirname(file.file), `${match[1]}.draining-${randomBytes(12).toString("hex")}`);
263
+ renameSync(file.file, claim);
264
+ const owned = { ...file, file: claim };
265
+ return owns(owned) ? owned : undefined;
266
+ }
267
+ function decoded(file: OwnedFile): { value: Record; bytes: Buffer } | undefined {
268
+ if (file.initial.size > RECORD_BYTES) return undefined; // Positively oversized: do not materialize its body.
269
+ const bytes = Buffer.alloc(file.initial.size);
270
+ if (readSync(file.fd, bytes, 0, bytes.length, 0) !== bytes.length || !owns(file)) throw new Error("hook record changed");
271
+ try {
272
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
273
+ if (!value || typeof value !== "object" || Array.isArray(value) || ![5, 6].includes(Object.keys(value).length)
274
+ || Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture"].includes(key))
275
+ || value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))
276
+ || value.schema !== SCHEMA || typeof value.cwd !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
277
+ || value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
278
+ const c = value.capture;
279
+ if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length !== 8
280
+ || Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes"].includes(key))
281
+ || c.agent !== "claude" || !(HOOK_EVENTS as readonly string[]).includes(c.event)
282
+ || typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
283
+ || !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
284
+ const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
285
+ ...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }) });
286
+ const actual = c as unknown as {[key: string]: unknown};
287
+ // A record staged under a configured cap is already inside the built-in one, so normalizing it again must leave every field unchanged.
288
+ if (Object.entries(normalized).some(([key, item]) => JSON.stringify(actual[key]) !== JSON.stringify(item))) return undefined;
289
+ return { value, bytes };
290
+ } catch { return undefined; }
291
+ }
292
+ function quarantineEntries(dir: string): Quarantined[] {
293
+ const reader = opendirSync(dir), rows: Quarantined[] = [];
294
+ try {
295
+ for (let scanned = 0; scanned <= MAX_FILES; scanned++) {
296
+ const entry = reader.readSync(); if (!entry) break;
297
+ const item = lstatSync(join(dir, entry.name), { throwIfNoEntry: false });
298
+ if (!item || !item.isFile() || item.isSymbolicLink() || item.nlink !== 1) continue;
299
+ rows.push({ name: entry.name, size: item.size, modified: item.mtimeMs, dev: item.dev, ino: item.ino });
300
+ }
301
+ } finally { reader.closeSync(); }
302
+ return rows.sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
303
+ }
304
+ interface QuarantineBatch { held?: Quarantined[] }
305
+ function trimQuarantine(base: string, files = 0, bytes = 0, batch?: QuarantineBatch): boolean {
306
+ const dir = join(directory(base), ".quarantine");
307
+ if (!lstatSync(dir, { throwIfNoEntry: false })) { if (batch) batch.held = []; return true; }
308
+ privateDirectory(dir);
309
+ const held = batch?.held ?? quarantineEntries(dir);
310
+ if (batch) batch.held = held;
311
+ let size = held.reduce((sum, row) => sum + row.size, 0), count = held.length;
312
+ for (const row of held) {
313
+ if (count + files <= MAX_FILES && size + bytes <= MAX_BYTES) return true;
314
+ let file: OwnedFile | undefined;
315
+ try {
316
+ file = openOwned(join(dir, row.name));
317
+ if (file.initial.dev !== row.dev || file.initial.ino !== row.ino || file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) continue;
318
+ if (owns(file)) {
319
+ rmSync(file.file); size -= row.size; count--;
320
+ if (batch) batch.held = batch.held!.filter(kept => kept !== row);
321
+ }
322
+ } catch { /* A replacement is never removed using an older inventory receipt. */ }
323
+ finally { if (file) closeSync(file.fd); }
324
+ }
325
+ return count + files <= MAX_FILES && size + bytes <= MAX_BYTES;
326
+ }
327
+ /** Policy refusals retain only a bounded numeric receipt, never the rejected capture body. */
328
+ function retireClaim(base: string, file: OwnedFile, preserve: boolean, batch?: QuarantineBatch): boolean {
329
+ if (!owns(file)) return false;
330
+ if (!preserve) { rmSync(file.file); return true; }
331
+ const quarantine = join(directory(base), ".quarantine"); privateDirectory(quarantine);
332
+ const body = JSON.stringify({ schema: "agent-mem.hook-quarantine/v1", reason: "policy", bytes: file.initial.size, at: Date.now() });
333
+ if (!trimQuarantine(base, 1, Buffer.byteLength(body), batch) || !owns(file)) return false;
334
+ const name = `${file.file.slice(file.file.lastIndexOf("/") + 1)}.quarantined-${randomBytes(12).toString("hex")}`;
335
+ const destination = join(quarantine, name);
336
+ writeFileSync(destination, body, { mode: 0o600, flag: "wx" });
337
+ if (!owns(file)) { rmSync(destination); return false; }
338
+ rmSync(file.file);
339
+ if (batch) {
340
+ const stat = lstatSync(destination);
341
+ batch.held!.push({ name, size: stat.size, modified: stat.mtimeMs, dev: stat.dev, ino: stat.ino });
342
+ batch.held!.sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
343
+ }
344
+ return true;
345
+ }
346
+
347
+ const RETIRE_AFTER_MS = 7 * 24 * 3600_000;
348
+ /** Private numeric maintenance cursor: each invocation inspects a different bounded slice. */
349
+ function retirementCursor(base: string, capacity: boolean, length: number): { start: number; save(n: number): void } {
350
+ const dir = join(directory(base), ".maintenance"); privateDirectory(dir);
351
+ const name = join(dir, capacity ? "reserve" : "active");
352
+ let start = 0, file: OwnedFile | undefined;
353
+ try {
354
+ file = openOwned(name);
355
+ if (file.initial.size <= 16) {
356
+ const bytes = Buffer.alloc(file.initial.size);
357
+ if (readSync(file.fd, bytes, 0, bytes.length, 0) === bytes.length && owns(file) && /^\d+$/.test(bytes.toString())) start = Number(bytes.toString()) % Math.max(1, length);
358
+ }
359
+ } catch { /* A missing or invalid derived cursor starts at the first slice. */ }
360
+ finally { if (file) closeSync(file.fd); }
361
+ return { start, save(n) {
362
+ const temporary = join(dir, `.cursor-${randomBytes(12).toString("hex")}`);
363
+ try { writeFileSync(temporary, String(n % Math.max(1, length)), { mode: 0o600, flag: "wx" }); renameSync(temporary, name); }
364
+ finally { rmSync(temporary, { force: true }); }
365
+ } };
366
+ }
367
+ /** ponytail: remove undrainable data only after seven days, at
368
+ * most32 files/256KiB per admission. Count loss before deleting bodies. Upgrade:
369
+ * a person-visible durable dead-letter export and coordinated maintenance accounting. */
370
+ function retireUnresolved(base: string, _admittingCwd: string | undefined, inventory: ReturnType<typeof entries>, incomingBytes: number, minimal: boolean, capacity = false, _sharedBatch?: QuarantineBatch): Set<string> {
371
+ const retired = new Set<string>();
372
+ let count = inventory.length, bytes = inventory.reduce((sum, row) => sum + row.size, 0);
373
+ const fits = () => count < (capacity ? CAPACITY_FILES : MAX_FILES) && bytes + incomingBytes <= (capacity ? CAPACITY_BYTES : MAX_BYTES);
374
+ const due = inventory.some(row => Date.now() - row.modified >= RETIRE_AFTER_MS);
375
+ if (!inventory.length || minimal && fits() && !due) return retired;
376
+ const active = [...inventory].sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
377
+ let cursor: ReturnType<typeof retirementCursor>;
378
+ try { cursor = retirementCursor(base, capacity, active.length); } catch { return retired; }
379
+ let remaining = DRAIN_BYTES, scanned = 0;
380
+ const scopes = new Map<string, string | undefined>();
381
+ try {
382
+ for (; scanned < Math.min(DRAIN_FILES, active.length); scanned++) {
383
+ if (minimal && fits() && !due) break;
384
+ const row = active[(cursor.start + scanned) % active.length]!;
385
+ const match = namePattern.exec(row.name); if (!match) continue;
386
+ if (row.size <= RECORD_BYTES && row.size > remaining) break;
387
+ let file: OwnedFile | undefined;
388
+ try {
389
+ file = openOwned(join(capacity ? join(directory(base), ".capacity") : directory(base), row.name));
390
+ if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) continue;
391
+ if (row.size <= RECORD_BYTES) remaining -= row.size;
392
+ const record = decoded(file);
393
+ const valid = record && hash(record.value.cwd) === match[2] && record.value.nonce === match[4] && (record.value.scope ?? "0".repeat(64)) === match[3];
394
+ let reason: CaptureDropReason | undefined;
395
+ if (valid) {
396
+ if (Date.now() - Date.parse(record.value.capture.at) < RETIRE_AFTER_MS) continue;
397
+ try { const cwd = lstatSync(record.value.cwd); if (!cwd.isDirectory() || cwd.isSymbolicLink()) reason = "cwd_removed"; }
398
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
399
+ if (!reason) {
400
+ if (record.value.scope === null) reason = "scope_unknown";
401
+ else {
402
+ if (!scopes.has(record.value.cwd)) scopes.set(record.value.cwd, hookCaptureScope(record.value.cwd, base));
403
+ const currentScope = scopes.get(record.value.cwd);
404
+ if (currentScope === undefined) reason = "scope_unknown";
405
+ else if (currentScope !== record.value.scope) reason = "undrainable_expired";
406
+ }
407
+ }
408
+ if (!reason) continue;
409
+ }
410
+ const claim = claimOwned(base, row.name, file);
411
+ if (!claim || !owns(claim)) continue;
412
+ // Failure to persist this numeric receipt keeps the body pending, never silently lost.
413
+ if (reason) appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: reason }]);
414
+ if (retireClaim(base, claim, false)) { retired.add(row.name); count--; bytes -= row.size; }
415
+ } catch { /* Transient I/O and changed evidence stay pending. */ }
416
+ finally { if (file) closeSync(file.fd); }
417
+ }
418
+ } finally { try { cursor.save(cursor.start + Math.max(1, scanned)); } catch { /* Derived cursor failure never deletes data. */ } }
419
+ return retired;
420
+ }
421
+
422
+ /** INTERNAL malformed-publication maintenance. A native Git failure is not permission to
423
+ * discard a valid pending record; only positively invalid publications leave admission. */
424
+ export function quarantineHookScope(options: { cwd: string; base: string; scope: string }): { retired: number } {
425
+ let retired = 0;
426
+ if (!/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
427
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base), cwdHash = hash(cwd);
428
+ trimQuarantine(base);
429
+ const rows = entries(base).filter(row => {
430
+ const match = namePattern.exec(row.name); return match && match[2] === cwdHash && match[3] === options.scope;
431
+ }).sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
432
+ let remaining = DRAIN_BYTES;
433
+ for (const row of rows.slice(0, DRAIN_FILES)) {
434
+ if (row.size <= RECORD_BYTES && row.size > remaining) break;
435
+ let file: OwnedFile | undefined;
436
+ try {
437
+ file = openOwned(join(directory(base), row.name));
438
+ if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) continue;
439
+ const claim = claimOwned(base, row.name, file); if (!claim) continue;
440
+ if (claim.initial.size <= RECORD_BYTES) remaining -= claim.initial.size;
441
+ const record = decoded(claim), match = namePattern.exec(row.name)!;
442
+ const valid = !!record && record.value.cwd === cwd && record.value.scope === options.scope && record.value.nonce === match[4];
443
+ if (!valid && retireClaim(base, claim, false)) retired++;
444
+ } catch { /* A transient failure or foreign replacement is never treated as acknowledgement. */ }
445
+ finally { if (file) closeSync(file.fd); }
446
+ }
447
+ return { retired };
448
+ }
449
+
450
+ /** Only screened normalized capture fields are retained, never the native input envelope. */
451
+ export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
452
+ if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host) return undefined;
453
+ const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
454
+ if (!await policy.screen(input)) return undefined;
455
+ const value = structuredClone(input) as {[key: string]: unknown};
456
+ if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.byteLength(JSON.stringify(value)) > HOOK_INPUT_BYTES || !await policy.screen(value)) return undefined;
457
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base);
458
+ if (value.hook_event_name !== undefined && value.hook_event_name !== event) return undefined;
459
+ if (value.cwd !== undefined && (typeof value.cwd !== "string" || realPath(value.cwd) !== cwd)) return undefined;
460
+ const tool = value.tool_input && typeof value.tool_input === "object" && !Array.isArray(value.tool_input) ? value.tool_input as {[key: string]: unknown} : {};
461
+ const paths = [tool.file_path, tool.path, ...(Array.isArray(tool.paths) ? tool.paths : [])].filter((p): p is string => typeof p === "string");
462
+ const content = event === "PostToolUseFailure" ? value.error ?? value.tool_response ?? "" : value.prompt ?? value.last_assistant_message ?? value.tool_response ?? "";
463
+ // F4: the person's configured caps apply at staging. Replay (`decoded`) re-normalizes with the built-in caps, which are the ceiling, so a record
464
+ // staged under a tighter cap still verifies after the cap is raised or removed.
465
+ const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config; // the repository's .amem/config narrows here too
466
+ const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
467
+ ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt: typeof content === "string" ? content : JSON.stringify(content) },
468
+ undefined,
469
+ (text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // the configured patterns redact the excerpt, as the store does
470
+ { excerptBytes: configured.excerptBytes, paths: configured.capturePaths, ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) });
471
+ if (!await policy.screen({ cwd, capture })) return undefined;
472
+ const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
473
+ const record: Record = { schema: SCHEMA, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
474
+ if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
475
+ const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
476
+ let destination = dir, deferred = false, backlog = false;
477
+ if (event === "SessionStart") {
478
+ // ponytail: reserve-only admission scans at most 32 metadata entries, not O(1).
479
+ // Never inspect/retire the active tier on the brief deadline. Fixed-slot byte
480
+ // reservations with crash recovery are the upgrade if this bound is too costly.
481
+ destination = join(dir, ".capacity"); privateDirectory(destination);
482
+ const reserved = entries(base, true);
483
+ if (reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
484
+ deferred = true; backlog = reserved.length > 0;
485
+ } else {
486
+ const inventory = entries(base), quarantineBatch: QuarantineBatch = {};
487
+ const retired = retireUnresolved(base, cwd, inventory, Buffer.byteLength(bytes), false, false, quarantineBatch);
488
+ const queued = inventory.filter(row => !retired.has(row.name));
489
+ let reserved = entries(base, true);
490
+ // Reserve maintenance must progress even while ordinary admission has space:
491
+ // SessionStart never retires, so otherwise expired undrainable reserve data pins it.
492
+ const retiredCapacity = retireUnresolved(base, cwd, reserved, Buffer.byteLength(bytes), true, true, quarantineBatch);
493
+ reserved = reserved.filter(row => !retiredCapacity.has(row.name));
494
+ backlog = queued.length > 0 || reserved.length > 0;
495
+ if (queued.length >= MAX_FILES || queued.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > MAX_BYTES) {
496
+ if (options.deferAtCapacity !== true) throw new HookSpoolFull();
497
+ destination = join(dir, ".capacity"); privateDirectory(destination);
498
+ if (reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
499
+ deferred = true;
500
+ }
501
+ }
502
+ const name = `hook-${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
503
+ try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
504
+ finally { rmSync(temporary, { force: true }); }
505
+ return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
506
+ }
507
+
508
+ export function hookCapturePending(base: string, name: string): boolean {
509
+ try {
510
+ if ([...entries(base), ...entries(base, true)].some(row => row.name === name || row.name.startsWith(name + "."))) return true;
511
+ const quarantine = join(directory(base), ".quarantine");
512
+ if (!lstatSync(quarantine, { throwIfNoEntry: false })) return false;
513
+ return quarantineEntries(quarantine).some(row => row.name === name || row.name.startsWith(name + "."));
514
+ } catch { return true; } // Unreadable evidence is pending, never an acknowledgement.
515
+ }
516
+
517
+ export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number },
518
+ append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
519
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
520
+ if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
521
+ // F4: the configured policy applies at drain too. `options.policy` carries it, so the anchored line below (a test diagnostic matches its exact text) stays as it was.
522
+ options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
523
+ const policy = createStandalonePolicy(options.policy), dir = directory(base);
524
+ const matching = (rows: ReturnType<typeof entries>, capacity: boolean) => rows.filter(row => {
525
+ const m = namePattern.exec(row.name); return m && m[2] === hash(cwd) && m[3] === stamp;
526
+ }).sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name)).map(row => ({ ...row, capacity }));
527
+ const rows = [...matching(entries(base, true), true), ...matching(entries(base), false)]
528
+ .sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
529
+ let remaining = DRAIN_BYTES, blocked = 0;
530
+ for (const row of rows.slice(0, DRAIN_FILES)) {
531
+ if (options.timeRemaining && options.timeRemaining() < 8 || row.size <= RECORD_BYTES && row.size > remaining) break;
532
+ const match = namePattern.exec(row.name)!, name = match[1]!;
533
+ let file: OwnedFile | undefined;
534
+ try {
535
+ file = openOwned(join(row.capacity ? join(dir, ".capacity") : dir, row.name));
536
+ if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) { blocked++; continue; }
537
+ const claim = claimOwned(base, row.name, file);
538
+ if (!claim) { blocked++; continue; }
539
+ if (claim.initial.size <= RECORD_BYTES) remaining -= claim.initial.size;
540
+ const record = decoded(claim);
541
+ if (!record || record.value.cwd !== cwd || record.value.scope !== null && record.value.scope !== stamp || record.value.nonce !== match[4]) {
542
+ retireClaim(base, claim, false); blocked++; continue;
543
+ }
544
+ const value = record.value, c = value.capture;
545
+ if (value.scope === null) { blocked++; continue; }
546
+ // #15 T5: a project the person archived or forgot takes no new capture. Retire the record here with a counted reason instead of leaving it pending:
547
+ // the home-wide spool is bounded, so a kept record would crowd out every active project. Read per record: an op can land mid-drain. A receipt that
548
+ // cannot be written keeps the body pending, as for every other retirement.
549
+ const state = projectState(base, options.project);
550
+ if (state !== "active") {
551
+ appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: state === "archived" ? "project_archived" : "project_forgotten" }]);
552
+ if (!retireClaim(base, claim, false)) blocked++;
553
+ continue;
554
+ }
555
+ const input: CaptureInput = { project: options.project, agent: c.agent, event: c.event, at: c.at, paths: c.paths,
556
+ ...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }), excerpt: c.excerpt };
557
+ if (!await policy.screen(value) || !await policy.screen(input)) { retireClaim(base, claim, true); blocked++; continue; }
558
+ if (!owns(claim) || hookCaptureScope(cwd, base) !== stamp) { blocked++; continue; }
559
+ append(input, hash(record.bytes));
560
+ if (owns(claim)) { rmSync(claim.file); captured.push(name); }
561
+ else blocked++;
562
+ } catch { blocked++; } // Transient failure keeps the unique claim for a later idempotent retry.
563
+ finally { if (file) closeSync(file.fd); }
564
+ }
565
+ return { captured, blocked };
566
+ }