@staix/agent-mem 0.18.0 → 0.19.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.
package/src/hooks.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { resolveHookProjectAsync } from "./hook-project.ts";
3
- import { hookProcessSignature } from "./hook-native.ts";
2
+ import { resolveHookProjectAsync, type HookProjectReceipt } from "./hook-project.ts";
3
+ import { hookProcessSignature, type HookNativeUsage } from "./hook-native.ts";
4
4
  import { resolveAlias } from "./memory/aliases.ts";
5
- import { hookPeakRssKB } from "./hook-resources.ts";
5
+ import { combineRssSources, countedRssSources, emptyRssSources, hookRssReading, rssSource, type HookRssSources } from "./hook-resources.ts";
6
+ import { HookWorkerRss } from "./hook-rss-wire.ts";
6
7
  import { appendMetrics, captureDropSummary } from "./memory/metrics.ts";
7
- import { stageHookCapture, hookCaptureScope, HookSpoolFull, type PendingHookCapture } from "./hook-spool.ts";
8
+ import { stageHookCapture, drainHookCaptures, hookCaptureScope, quarantineHookScope, HookSpoolFull, type HookAgent, type PendingHookCapture } from "./hook-spool.ts";
9
+ import { jcodeHookCapture } from "./hook-jcode.ts";
8
10
  import { memoryHome } from "./memory/home.ts";
9
11
  import { canonicalPath, realPath } from "./memory/platform.ts";
10
12
  import { createStandalonePolicy, type MemoryPolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
11
13
  import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
12
14
 
13
- import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES } from "./hook-contract.ts";
15
+ import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, isHookTranscriptProgress, type HookTranscriptProgress } from "./hook-contract.ts";
14
16
  export { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, type HookEvent } from "./hook-contract.ts";
15
17
  // Leave 10ms of the external 150ms R2 budget for teardown and the metrics write.
16
18
  const HANDLER_MS = 140;
@@ -25,7 +27,7 @@ function handlerMs(host?: HookHostPorts): number {
25
27
  if (!Number.isSafeInteger(value) || value < 1 || value > 10_000) throw new TypeError("invalid hook host deadline");
26
28
  return value;
27
29
  }
28
- export interface HookOptions { cwd: string; base?: string; policy?: StandalonePolicyOptions; maxTokens?: number; startedAt?: number }
30
+ export interface HookOptions { cwd: string; base?: string; policy?: StandalonePolicyOptions; maxTokens?: number; startedAt?: number; agent?: HookAgent }
29
31
  export interface HookReceipt {
30
32
  captured: boolean; skipped: boolean;
31
33
  /** Screened capture is durable but still awaits project-bound replay. */
@@ -66,62 +68,111 @@ async function before<T>(work: Promise<T>, deadline: number): Promise<T | undefi
66
68
  }
67
69
  const publicBefore = async (policy: MemoryPolicy, value: unknown, deadline: number) => await before(policy.screen(value), deadline) === true;
68
70
 
69
- async function boundedOutput(stream: ReadableStream<Uint8Array>): Promise<string | undefined> {
70
- const reader = stream.getReader(), chunks: Uint8Array[] = []; let length = 0;
71
+ async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void): Promise<string | undefined> {
72
+ const reader = stream.getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
73
+ let length = 0, pending = "", body = "";
74
+ const line = (text: string) => {
75
+ if (!text) return;
76
+ const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown };
77
+ if (onRss && Object.hasOwn(value, "rss")) onRss(value.rss);
78
+ else if (onTranscript && Object.hasOwn(value, "transcript")) onTranscript(value.transcript);
79
+ else { if (body) throw new Error("worker output bound"); body = text; }
80
+ };
71
81
  try {
72
82
  for (;;) {
73
83
  const next = await reader.read(); if (next.done) break;
74
84
  if (length + next.value.byteLength > HOOK_OUTPUT_BYTES) return undefined;
75
- chunks.push(next.value); length += next.value.byteLength;
85
+ length += next.value.byteLength; pending += decoder.decode(next.value, { stream: true });
86
+ for (;;) { const at = pending.indexOf("\n"); if (at < 0) break; line(pending.slice(0, at)); pending = pending.slice(at + 1); }
76
87
  }
77
- return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
78
- } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
88
+ pending += decoder.decode(); if (pending) line(pending);
89
+ return body;
90
+ } catch { return undefined; }
91
+ finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
92
+ }
93
+
94
+ /** Native handles always belong to the CLI that writes the metric, never a killable worker. */
95
+ async function parentProject(options: HookOptions, base: string, deadline: number) {
96
+ const cwd = realPath(options.cwd);
97
+ const scope = hookCaptureScope(cwd, base);
98
+ const usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
99
+ let project: string | undefined, receipt: HookProjectReceipt | undefined;
100
+ try {
101
+ project = await resolveHookProjectAsync({ cwd, base, scope, timeRemaining: () => deadline - performance.now(), maintainCache: true, prefetch: true,
102
+ onResolved: value => { receipt = value; } }, usage);
103
+ } catch { /* All native finally/reap barriers have completed before this returns. */ }
104
+ if (project === undefined && scope !== undefined) try { quarantineHookScope({ cwd, base, scope }); }
105
+ catch { /* Failed maintenance cannot change native fail-open behavior or its accounting. */ }
106
+ return { cwd, scope, project, receipt, usage };
79
107
  }
80
108
 
81
- /** Native metadata and cold indexes are bounded in a short-lived owned child, never a resident worker.
109
+ /** Only capture work uses a short-lived owned child; native handles remain in this CLI.
82
110
  * Host callbacks cannot cross this boundary; a host needing them calls sessionStartBrief in-process instead. */
83
- async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean }> {
111
+ async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string, transcript?: unknown): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean; rssSources?: HookRssSources; transcript?: HookTranscriptProgress }> {
84
112
  const workDeadline = deadline - WORKER_CLEANUP_MS;
85
113
  if (options.policy?.host || workDeadline - performance.now() < 5) return { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
86
- const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
87
- const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
88
- // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
89
- const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
90
- policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
91
- const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
92
- { cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: Buffer.from(payload), stdout: "pipe", stderr: "ignore" });
93
- let exited = false;
94
- const result = { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
95
- const exit = child.exited.then(code => { exited = true; return code; });
96
- const stop = () => {
97
- if (exited) return;
98
- // The still-live leader is the detached process we just spawned, so its group is ours.
99
- // Kill its native metadata children too; never signal a group after observing leader exit.
100
- try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }
101
- };
102
- let timer: ReturnType<typeof setTimeout> | undefined;
114
+ const metadata = await parentProject(options, base, workDeadline);
115
+ if (!metadata.receipt && transcript === undefined || workDeadline - performance.now() < 5) return { context: unavailable, ...metadata.usage, captured: false, valid: false, ...(transcript !== undefined ? { transcript: { stopReason: "deadline" as const } } : {}) };
116
+ const result = { context: unavailable, ...metadata.usage, captured: false, valid: false,
117
+ rssSources: combineRssSources(metadata.usage.rssSources), transcript: undefined as HookTranscriptProgress | undefined };
118
+ let owned: ReturnType<typeof Bun.spawn> | undefined, exited = false;
103
119
  try {
104
- const answer = await Promise.race([
105
- Promise.all([exit, boundedOutput(child.stdout)]),
106
- new Promise<undefined>(resolve => { timer = setTimeout(() => { stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
107
- ]);
108
- if (!answer || !answer[1]) return result;
109
- const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown };
110
- if (parsed.complete !== true) return result;
111
- if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
112
- if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
113
- result.captured = parsed.captured === true; result.valid = parsed.valid === true;
114
- return result;
115
- } catch { return result; }
120
+ const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
121
+ const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
122
+ // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
123
+ const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, transcript, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
124
+ policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
125
+ const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
126
+ { cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: Buffer.from(payload), stdout: "pipe", stderr: "ignore" });
127
+ owned = child;
128
+ result.rssSources.unavailable++;
129
+ const exit = child.exited.then(code => { exited = true; return code; });
130
+ const tracker = new HookWorkerRss(child.pid);
131
+ const output = boundedOutput(child.stdout, value => tracker.frame(value), value => {
132
+ if (!isHookTranscriptProgress(value)) throw new Error("worker transcript progress bound");
133
+ if (value.offset !== undefined && result.transcript?.offset !== undefined && value.offset < result.transcript.offset) throw new Error("worker transcript progress regressed");
134
+ result.transcript = { ...result.transcript, ...value };
135
+ });
136
+ const stop = () => {
137
+ if (exited) return;
138
+ tracker.captureLive();
139
+ // The still-live leader is the detached process we just spawned, so its group is ours.
140
+ // The capture-only worker owns no native helper; those handles were reaped in this parent.
141
+ try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }
142
+ };
143
+ let timer: ReturnType<typeof setTimeout> | undefined;
144
+ try {
145
+ const answer = await Promise.race([
146
+ Promise.all([exit, output]),
147
+ new Promise<undefined>(resolve => { timer = setTimeout(() => { if (transcript !== undefined) result.transcript ??= { stopReason: "deadline" }; stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
148
+ ]);
149
+ if (!answer || !answer[1]) return result;
150
+ const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
151
+ if (parsed.complete !== true) return result;
152
+ if (parsed.rssTelemetryValid !== true) tracker.invalidate();
153
+ if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
154
+ if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
155
+ result.captured = parsed.captured === true; result.valid = parsed.valid === true;
156
+ return result;
157
+ } catch { return result; }
158
+ finally {
159
+ if (timer) clearTimeout(timer);
160
+ stop();
161
+ await exit;
162
+ // Bun subprocess usage is bytes; process.resourceUsage below is KiB.
163
+ const workerRawKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
164
+ result.rawRssKB = metadata.usage.rawRssKB + workerRawKB;
165
+ if (await output === undefined) tracker.invalidate();
166
+ const accounting = tracker.finish(workerRawKB);
167
+ result.rssKB = metadata.usage.rssKB + accounting.rssKB; result.rssSources = combineRssSources(metadata.usage.rssSources, accounting.rssSources);
168
+ }
169
+ } catch { result.valid = false; return result; }
116
170
  finally {
117
- if (timer) clearTimeout(timer);
118
- stop();
119
- await exit;
120
- // Bun subprocess usage is bytes; process.resourceUsage below is KiB.
121
- result.rawRssKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
122
- // Darwin self samples can precede final residency; use the reaped peak as a floor.
123
- // Linux wait4 inherits launcher high-water, so retain the proven per-image sampler there.
124
- result.rssKB = process.platform === "darwin" ? Math.max(result.rssKB, result.rawRssKB) : result.rssKB || result.rawRssKB;
171
+ // Even setup or accounting failure after spawn cannot leave an owned image live.
172
+ if (owned) {
173
+ if (!exited) try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already gone. */ } }
174
+ await owned.exited;
175
+ }
125
176
  }
126
177
  }
127
178
 
@@ -143,7 +194,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
143
194
  catch (error) { if (!(error instanceof HookSpoolFull)) throw error; full = true; }
144
195
  if (!pending && !full) return undefined;
145
196
  const cwd = realPath(options.cwd), scope = hookCaptureScope(cwd, base);
146
- const remaining = () => deadline - performance.now(), usage = { rssKB: 0, rawRssKB: 0 };
197
+ const remaining = () => deadline - performance.now(), usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
147
198
  const result = { context: unavailable, captured: false, valid: false, pending: !!pending, dropped: false, ...usage };
148
199
  if (remaining() <= 0) { result.dropped = !pending; return result; }
149
200
  if (sessionSource(input) === "resume") {
@@ -158,7 +209,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
158
209
  try {
159
210
  // Independent native reads and module loading share the same deadline, not three serial cold starts.
160
211
  // Separate usage ports conservatively sum concurrent helper peaks instead of hiding overlap.
161
- const projectUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage = { rssKB: 0, rawRssKB: 0 };
212
+ const projectUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 };
162
213
  // Reaping native groups and serializing the fail-open result stay inside the handler deadline.
163
214
  const nativeRemaining = () => remaining() - 15;
164
215
  const metadata = await Promise.allSettled([
@@ -168,6 +219,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
168
219
  ] as const);
169
220
  usage.rssKB = projectUsage.rssKB + signatureUsage.rssKB;
170
221
  usage.rawRssKB = projectUsage.rawRssKB + signatureUsage.rawRssKB;
222
+ usage.rssSources = combineRssSources(projectUsage.rssSources, signatureUsage.rssSources);
171
223
  const [identity, owner, briefModule] = metadata;
172
224
  if (identity.status !== "fulfilled" || owner.status !== "fulfilled" || briefModule.status !== "fulfilled") throw new Error("hook metadata unavailable");
173
225
  const project = resolveAlias(base, identity.value);
@@ -185,20 +237,22 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
185
237
  result.valid = result.context.length > 0;
186
238
  return result;
187
239
  } catch { result.dropped = !pending; return result; }
188
- finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; }
240
+ finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; result.rssSources = usage.rssSources; }
189
241
  }
190
242
 
191
243
  /** A full pending tier must still be drainable. Recover bounded old records before retrying
192
244
  * publication; this path uses one helper too, and the current normalized event stays pending. */
193
245
  async function captureAndWork(event: string, input: unknown, options: HookOptions, base: string, deadline: number) {
194
246
  if (event === "SessionStart") return sessionInParent(input, options, base, deadline);
195
- let result: Awaited<ReturnType<typeof hookWorker>> | undefined;
247
+ const transcript = (event === "Stop" || event === "SessionEnd") && input !== null && typeof input === "object" && "transcript_path" in input && typeof input.transcript_path === "string" ? input : undefined;
248
+ const work = (pending: PendingHookCapture) => hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript);
249
+ let result: Awaited<ReturnType<typeof work>> | undefined;
196
250
  let pending: PendingHookCapture | undefined;
197
251
  try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
198
252
  catch (error) {
199
253
  if (!(error instanceof HookSpoolFull)) throw error;
200
254
  const cwd = realPath(options.cwd);
201
- result = await hookWorker({ ...options, cwd }, base, deadline, { name: "", receipt: "", cwd }, event);
255
+ result = await work({ name: "", receipt: "", cwd });
202
256
  try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
203
257
  catch (retryError) {
204
258
  // A concurrent publisher can refill the soft tier. Capture admission cannot discard
@@ -208,7 +262,7 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
208
262
  }
209
263
  }
210
264
  if (!pending) return result ? { ...result, pending: false } : undefined;
211
- result ??= await hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event);
265
+ result ??= await work(pending);
212
266
  return { ...result, pending: !result.captured };
213
267
  }
214
268
 
@@ -217,6 +271,8 @@ export async function runHook(event: string, input: unknown, options: HookOption
217
271
  const now = performance.now(), started = options.startedAt !== undefined && Number.isFinite(options.startedAt) && options.startedAt <= now ? options.startedAt : now;
218
272
  const deadline = started + handlerMs(host);
219
273
  let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
274
+ let childSources = emptyRssSources();
275
+ let transcriptProgress: HookTranscriptProgress | undefined;
220
276
  try {
221
277
  base = canonicalPath(options.base ?? memoryHome());
222
278
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
@@ -225,15 +281,17 @@ export async function runHook(event: string, input: unknown, options: HookOption
225
281
  const result = await captureAndWork(event, input, options, base, deadline - 3);
226
282
  if (!result) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
227
283
  captured = result.captured; pending = result.pending; dropped = "dropped" in result && result.dropped === true;
228
- childRssKB = result.rssKB; childRawRssKB = result.rawRssKB;
284
+ childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources();
285
+ transcriptProgress = "transcript" in result ? result.transcript : undefined;
229
286
  if (!result.valid) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
230
287
  if (event !== "SessionStart") return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
231
288
  if (!await publicBefore(policy, { context: result.context }, deadline)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
232
289
  return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false, output: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } } };
233
290
  } catch { return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true }; }
234
291
  finally {
235
- if (base) try { appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
236
- rssMB: (hookPeakRssKB() + childRssKB) / 1024, rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024, ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
292
+ if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
293
+ rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
294
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
237
295
  }
238
296
  }
239
297
 
@@ -242,8 +300,18 @@ export async function hookMain(args: readonly string[], startedAt = performance.
242
300
  const deadlineMs = handlerMs(host);
243
301
  let cleanup: (() => Promise<void>) | undefined;
244
302
  let childRssKB = 0, childRawRssKB = 0, dropped = false;
303
+ let childSources = emptyRssSources();
304
+ let transcriptProgress: HookTranscriptProgress | undefined;
245
305
  try {
246
306
  if (args.length !== 1) return "";
307
+ if (args[0] === "jcode") {
308
+ // #203: jcode observers have no stdin and discard stdout; the event and its cwd arrive in the environment.
309
+ const jcode = jcodeHookCapture(process.env);
310
+ if (!jcode) return "";
311
+ const result = await captureAndWork(jcode.event, jcode.input, { cwd: jcode.cwd, agent: "jcode" }, canonicalPath(memoryHome()), startedAt + deadlineMs - 3);
312
+ if (result) { childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true; }
313
+ return "";
314
+ }
247
315
  const reader = Bun.stdin.stream().getReader();
248
316
  cleanup = async () => { await reader.cancel().catch(() => {}); reader.releaseLock(); };
249
317
  const chunks: Uint8Array[] = []; let length = 0;
@@ -260,7 +328,8 @@ export async function hookMain(args: readonly string[], startedAt = performance.
260
328
  const base = canonicalPath(memoryHome());
261
329
  const result = await captureAndWork(args[0]!, input, { cwd: process.cwd() }, base, startedAt + deadlineMs - 3);
262
330
  if (!result) return "";
263
- childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; dropped = "dropped" in result && result.dropped === true;
331
+ childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true;
332
+ transcriptProgress = "transcript" in result ? result.transcript : undefined;
264
333
  if (!result.valid || args[0] !== "SessionStart") return "";
265
334
  return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } });
266
335
  }
@@ -269,8 +338,10 @@ export async function hookMain(args: readonly string[], startedAt = performance.
269
338
  finally {
270
339
  await cleanup?.();
271
340
  try {
341
+ const self = hookRssReading();
272
342
  appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt,
273
- rssMB: (hookPeakRssKB() + childRssKB) / 1024, rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024, ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
343
+ rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
344
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
274
345
  } catch { /* Invalid input and unavailable homes still fail open. */ }
275
346
  }
276
347
  }
@@ -1,3 +1,4 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash } from "node:crypto";
3
4
  import { chmodSync, mkdirSync } from "node:fs";
@@ -146,6 +147,7 @@ export class CandidateStore {
146
147
  if (options.reconcile?.aliases.length) this.reconcile = { canonical: options.reconcile.canonical, aliases: [...options.reconcile.aliases] };
147
148
  if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
148
149
  this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
150
+ assertFormatFloor(this.dir, "candidates");
149
151
  this.now = options.now ?? Date.now;
150
152
  mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
151
153
  this.path = join(this.dir, "candidates.db");
@@ -1,3 +1,4 @@
1
+ import { assertFormatFloor } from "./formats.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -49,6 +50,7 @@ export const CAPTURE_EVICT_MAX_BATCHES = 4;
49
50
  /** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
50
51
  export const CAPTURE_EVICT_MAX_MS = 25;
51
52
  export const CAPTURE_DRAIN_MAX_LINES = 1000;
53
+ const BRIEF_SUMMARY_CANDIDATES = 32;
52
54
  const SPOOL = "spool.jsonl";
53
55
  const DRAINING = "spool.jsonl.draining";
54
56
  const SPOOL_DIR = "spool";
@@ -210,6 +212,7 @@ export class CaptureStore {
210
212
  const maxReadRows = opts.maxReadRows ?? 128;
211
213
  if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
212
214
  this.base = opts.memoryDir ?? memoryBase(opts.home);
215
+ assertFormatFloor(this.base, "capture");
213
216
  const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
214
217
  if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
215
218
  this.requestedKey = opts.project;
@@ -288,6 +291,7 @@ export class CaptureStore {
288
291
  db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
289
292
  // #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
290
293
  addAttributionColumns(db);
294
+ this.ensureBriefSummaryIndex(true);
291
295
  this.ensureFts();
292
296
  } catch (error) {
293
297
  if (!lockError(error)) { db.close(); throw error; }
@@ -383,6 +387,12 @@ export class CaptureStore {
383
387
  }
384
388
 
385
389
  private mcpReady = false;
390
+ private briefReady = false;
391
+ private prepareBriefSummaryIndex(): void {
392
+ if (this.readOnly || this.briefReady) return;
393
+ try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
394
+ catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
395
+ }
386
396
  /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
387
397
  private ensureMcpFts(): boolean {
388
398
  if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
@@ -637,6 +647,41 @@ export class CaptureStore {
637
647
  return rows.map(toRow);
638
648
  }
639
649
 
650
+ /** Generic SQLite index maintenance also covers older writers sharing this file. */
651
+ private ensureBriefSummaryIndex(bounded: boolean): void {
652
+ if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get()) return;
653
+ const build = () => {
654
+ if (bounded) {
655
+ const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
656
+ if (count > 128) return;
657
+ }
658
+ this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_summaries_v1 ON rows(project,at,id) WHERE event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1)");
659
+ };
660
+ // Keep the small-store decision and build atomic against a concurrent older writer.
661
+ if (bounded) this.db.transaction(build).immediate(); else build();
662
+ }
663
+
664
+ /** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
665
+ readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
666
+ this.checkReadBinding();
667
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief summary limit");
668
+ const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_summaries_v1' AND type='index'").get();
669
+ let rows: RawRow[], partial = false;
670
+ if (indexed) {
671
+ rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_summaries_v1 WHERE project=? AND event IN ('Recap','Compact') AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
672
+ .all(this.key, limit + 1) as RawRow[];
673
+ } else {
674
+ // ponytail: a large legacy store stays partial within128 rows until an off-hook
675
+ // query builds the partial index; never scan or build the history on a hook.
676
+ const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
677
+ const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
678
+ rows = this.db.query(`SELECT * FROM (SELECT * FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 128) WHERE project=? AND event IN ('Recap','Compact')${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
679
+ .all(this.key, limit + 1) as RawRow[];
680
+ partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
681
+ }
682
+ return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
683
+ }
684
+
640
685
  /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
641
686
  readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
642
687
  this.checkReadBinding();
@@ -645,6 +690,7 @@ export class CaptureStore {
645
690
  if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
646
691
  where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
647
692
  }
693
+ if (window.query !== undefined) this.prepareBriefSummaryIndex();
648
694
  const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
649
695
  const plan = captureReadPlan(window, engine, where, bind);
650
696
  if (!plan) return { rows: [], engine };
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { indexFileName } from "./index-file.ts";
2
3
  import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { memoryBase } from "./home.ts";
@@ -48,7 +49,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
48
49
  if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
49
50
  const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
50
51
  try {
51
- const db = new Database(join(dir, store.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), { readonly: true });
52
+ const db = new Database(join(dir, indexFileName(!!store.profile.foreignPruneFormat)), { readonly: true });
52
53
  try {
53
54
  db.run("PRAGMA busy_timeout = 0");
54
55
  const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
@@ -1,4 +1,6 @@
1
1
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { indexFileName } from "./index-file.ts";
3
+ import { assertFormatFloor } from "./formats.ts";
2
4
  import { tmpdir } from "node:os";
3
5
  import { basename, join } from "node:path";
4
6
  import { Database } from "bun:sqlite";
@@ -12,10 +14,11 @@ import { readSelector } from "./generation.ts";
12
14
  import { processLiveness, withGitTimeout } from "./platform.ts";
13
15
  import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
14
16
  import { boundRepoRoot } from "./repo-scope.ts";
15
- import { facts, label, PLAIN, type Style } from "./style.ts";
17
+ import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
16
18
  import { aliasedKeys, shardDir } from "./shard.ts";
17
19
  import type { MemoryStore, View } from "./store.ts";
18
20
  import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
21
+ import { readSetupDrift } from "../setup-drift.ts";
19
22
 
20
23
  /**
21
24
  * F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
@@ -42,6 +45,8 @@ const MAX_IDS = 10;
42
45
 
43
46
  export interface DoctorOptions {
44
47
  home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
48
+ /** Test seam for setup path resolution; the CLI uses the process environment. */
49
+ env?: Readonly<Record<string, string | undefined>>;
45
50
  /** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
46
51
  openCurated: (base: string) => MemoryStore;
47
52
  /** The cadence command the person runs from their own shell; shown in the report, never installed. */
@@ -60,6 +65,8 @@ export interface DoctorReport {
60
65
  drops: Section;
61
66
  /** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
62
67
  masked?: Section;
68
+ /** #202, additive: applied native setup registrations checked against the shared path resolver. */
69
+ setup?: Section;
63
70
  };
64
71
  }
65
72
 
@@ -202,7 +209,7 @@ export async function withBriefSnapshot<T>(home: string, project: string, read:
202
209
 
203
210
  /** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
204
211
  function snapshotIndex(live: string, to: string): boolean {
205
- const file = join(live, "index.db");
212
+ const file = join(live, indexFileName(false));
206
213
  if (!regularFile(file)) return false;
207
214
  const forms: (() => Database)[] = [
208
215
  () => new Database(file, { readonly: true }),
@@ -435,7 +442,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
435
442
  * a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
436
443
  */
437
444
  export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
438
- export function doctorReport(options: DoctorOptions): Promise<DoctorReport> { return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options)); }
445
+ export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
446
+ // Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
447
+ for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
448
+ return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
449
+ }
439
450
  async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
440
451
  let scratch: string | undefined, unguard: (() => void) | undefined;
441
452
  let duplicates: Section, stale: Section, index: Section;
@@ -459,7 +470,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
459
470
  } else {
460
471
  const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
461
472
  copyCurated(options, layout, home, budget);
462
- const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, "index.db"));
473
+ const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
463
474
  curated = options.openCurated(home);
464
475
  const rows = curated.list({ project: options.project, limit: LIST_ALL });
465
476
  await tick();
@@ -480,10 +491,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
480
491
  candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
481
492
  : { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
482
493
  } catch (error) { candidates = unavailable(message(error)); }
494
+ let setup: Section;
495
+ try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
496
+ catch { setup = unavailable("setup registrations could not be checked"); }
483
497
  let sections: DoctorReport["sections"];
484
498
  try {
485
499
  await tick();
486
- sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), ...maskedSection(curated) };
500
+ sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), setup, ...maskedSection(curated) };
487
501
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
488
502
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
489
503
  return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
@@ -493,6 +507,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
493
507
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
494
508
  duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
495
509
  index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
510
+ setup: "native setup registrations",
496
511
  masked: "forgotten or pruned entries (informational)",
497
512
  };
498
513
 
@@ -513,6 +528,10 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
513
528
  const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
514
529
  return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
515
530
  }
531
+ if (name === "setup" && Array.isArray(detail.entries)) {
532
+ return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
533
+ singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
534
+ }
516
535
  if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
517
536
  const { examples, ...rest } = detail;
518
537
  const line = facts(rest);
@@ -0,0 +1,65 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Format floors (#199 AC5). `formats.json` in the memory home is an append-only JSON array of `{ surface, minReader, since }` records: a
6
+ * person appends one to stop releases older than `minReader` from reading that surface (`home` covers every surface). This package never
7
+ * writes the file, so no writer raises a floor implicitly. Each surface checks its floor before it creates, opens or writes anything, and a
8
+ * reader below it refuses with `code: "format_floor"`; a file this version cannot read refuses with `code: "format_floor_unreadable"`.
9
+ * A record naming a surface this version does not know is not its concern. Releases before this one do not read the file.
10
+ */
11
+ export const FORMATS_FILE = "formats.json";
12
+ export const FORMAT_SURFACES = ["curated", "capture", "hook-spool", "candidates", "work-lists", "sync"] as const;
13
+ export type FormatSurface = typeof FORMAT_SURFACES[number];
14
+ export interface FormatFloor { surface: string; minReader: string; since: string }
15
+ const MAX_BYTES = 64 * 1024, SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
16
+
17
+ export class FormatFloorError extends Error {
18
+ constructor(readonly code: "format_floor" | "format_floor_unreadable", message: string, readonly floor?: FormatFloor & { reader: string }) { super(message); this.name = "FormatFloorError"; }
19
+ }
20
+ /**
21
+ * This release's version, read once when the module loads, so a long-running process whose install is replaced in place keeps comparing its own
22
+ * version. Undefined when it cannot be read: then every applicable floor refuses.
23
+ */
24
+ const READER_VERSION: string | undefined = (() => {
25
+ try { const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown }; return typeof version === "string" && SEMVER.test(version) ? version : undefined; }
26
+ catch { return undefined; }
27
+ })();
28
+ const parts = (version: string) => SEMVER.exec(version)!.slice(1, 4).map(Number);
29
+ function below(reader: string, floor: string): boolean {
30
+ const a = parts(reader), b = parts(floor);
31
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!;
32
+ return false;
33
+ }
34
+
35
+ /** Bounded read of the floor records: undefined when there is no file; anything that is not a regular file of valid records refuses. */
36
+ export function readFormatFloors(home: string): FormatFloor[] | undefined {
37
+ const file = join(home, FORMATS_FILE), unreadable = (why: string) => new FormatFloorError("format_floor_unreadable", `${file}: ${why}; nothing was read or written. Fix or restore the file (it is append-only and written by a person).`);
38
+ let fd: number;
39
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
40
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw unreadable("not a readable regular file"); }
41
+ let text: string;
42
+ try {
43
+ const stat = fstatSync(fd);
44
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
45
+ const data = Buffer.alloc(MAX_BYTES + 1); let size = 0, n: number;
46
+ while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
47
+ if (size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
48
+ text = data.subarray(0, size).toString("utf8");
49
+ } finally { closeSync(fd); }
50
+ let value: unknown;
51
+ try { value = JSON.parse(text); } catch { throw unreadable("not JSON"); }
52
+ if (!Array.isArray(value) || !value.every(record => record && typeof record === "object" && !Array.isArray(record)
53
+ && typeof record.surface === "string" && typeof record.minReader === "string" && SEMVER.test(record.minReader) && typeof record.since === "string")) throw unreadable("not an array of { surface, minReader, since } records");
54
+ return value.map(({ surface, minReader, since }) => ({ surface, minReader, since }));
55
+ }
56
+
57
+ /** Refuse before touching `surface` when the home records a floor above this release. */
58
+ export function assertFormatFloor(home: string, surface: FormatSurface): void {
59
+ const floors = readFormatFloors(home);
60
+ if (!floors) return;
61
+ const reader = READER_VERSION ?? "unknown";
62
+ for (const floor of floors) if ((floor.surface === surface || floor.surface === "home") && (READER_VERSION === undefined || below(READER_VERSION, floor.minReader))) {
63
+ throw new FormatFloorError("format_floor", `amem ${reader} is below the ${surface} format floor ${floor.minReader} (since ${floor.since}) recorded in ${join(home, FORMATS_FILE)}; nothing was read or written. Upgrade amem to ${floor.minReader} or later.`, { ...floor, reader });
64
+ }
65
+ }