@staix/agent-mem 0.18.0 → 0.20.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.
@@ -0,0 +1,205 @@
1
+ import { assertFormatFloor } from "./memory/formats.ts";
2
+ import { Database } from "bun:sqlite";
3
+ import { createHash } from "node:crypto";
4
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { canonicalPath, realPath } from "./memory/platform.ts";
7
+ import { configuredPolicyOptions } from "./memory/config.ts";
8
+ import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
9
+ import { HookSpoolFull, stageTranscriptCapture, type PendingHookCapture } from "./hook-spool.ts";
10
+ import { appendMetrics } from "./memory/metrics.ts";
11
+ import type { HookTranscriptProgress } from "./hook-contract.ts";
12
+
13
+ export const HOOK_TRANSCRIPT_BYTES = 256 * 1024;
14
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex");
15
+ type SummaryEvent = "Title" | "Recap" | "Compact";
16
+ interface Cursor { offset: number; dev: string; ino: string; size: number; title: string; at: string; readStart: number; readAt: string; skipping: number }
17
+ /** `deadline` is the hard end of the scan. With `workBudgetMs`, the line loop gets that budget measured from the end of setup
18
+ * (policy, cursor database, read), never past `deadline` (#226 task 110: on a slow runner setup ate a fixed window). */
19
+ interface Options { cwd: string; base: string; deadline: number; workBudgetMs?: number; policy?: StandalonePolicyOptions;
20
+ /** Worker-local replay opportunities; no text crosses these callbacks. */
21
+ onPublication?: (pending: PendingHookCapture) => Promise<void>;
22
+ onBackpressure?: () => Promise<boolean>;
23
+ }
24
+ const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
25
+ const timestamp = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : undefined;
26
+
27
+ /** Claude's three free texts only; ordinary transcript lines never enter JSON.parse. */
28
+ export function transcriptSummary(line: string): { event: SummaryEvent; text: string; at?: string; subagent?: true } | undefined {
29
+ if (!line.includes('"ai-title"') && !line.includes('"away_summary"') && !line.includes('"isCompactSummary"')) return undefined;
30
+ const row = object(JSON.parse(line)); if (!row) return undefined;
31
+ let event: SummaryEvent, text: unknown;
32
+ if (row.type === "ai-title") { event = "Title"; text = row.aiTitle; }
33
+ else if (row.type === "system" && row.subtype === "away_summary") { event = "Recap"; text = row.content; }
34
+ else if (row.type === "user" && row.isCompactSummary === true) {
35
+ event = "Compact"; text = object(row.message)?.content;
36
+ if (Array.isArray(text)) text = text.flatMap(part => { const value = object(part); return value?.type === "text" && typeof value.text === "string" ? [value.text] : []; }).join("\n");
37
+ } else return undefined;
38
+ if (typeof text !== "string" || !text.trim()) return undefined;
39
+ const visibleText = event === "Recap" ? text.replace(/\s*\(disable recaps in \/config\)\s*$/, "") : text;
40
+ return { event, text: visibleText, ...(timestamp(row.timestamp) ? { at: timestamp(row.timestamp)! } : {}), ...(row.isSidechain === true || typeof row.agentId === "string" && row.agentId.length > 0 ? { subagent: true as const } : {}) };
41
+ }
42
+
43
+ /** Read only a top-level timestamp token, without parsing ordinary message bodies. */
44
+ function transcriptTimestamp(line: string): string | undefined {
45
+ if (!line.includes('"timestamp"')) return;
46
+ let depth = 0, at: string | undefined;
47
+ for (let index = 0; index < line.length; index++) {
48
+ const character = line[index];
49
+ if (character === '"') {
50
+ const start = index++;
51
+ for (; index < line.length; index++) {
52
+ if (line[index] === "\\") index++;
53
+ else if (line[index] === '"') break;
54
+ }
55
+ if (depth === 1 && line.slice(start, index + 1) === '"timestamp"') {
56
+ const value = /^\s*:\s*"([^"\\]+)"/.exec(line.slice(index + 1));
57
+ if (value) at = timestamp(value[1]);
58
+ }
59
+ } else if (character === "{" || character === "[") depth++;
60
+ else if (character === "}" || character === "]") depth--;
61
+ }
62
+ return at;
63
+ }
64
+
65
+ function privateDirectory(dir: string): void {
66
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ const stat = lstatSync(dir);
68
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("transcript cursor unavailable");
69
+ chmodSync(dir, 0o700);
70
+ }
71
+ function cursorDatabase(file: string, dev: string, ino: string, size: number): Database {
72
+ const fd = openSync(file, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
73
+ try {
74
+ const stat = fstatSync(fd), named = lstatSync(file);
75
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) throw new Error("transcript cursor unavailable");
76
+ chmodSync(file, 0o600);
77
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
78
+ const sidecar = lstatSync(file + suffix, { throwIfNoEntry: false });
79
+ if (sidecar && (!sidecar.isFile() || sidecar.isSymbolicLink() || sidecar.nlink !== 1 || sidecar.size > 1024 * 1024)) throw new Error("transcript cursor unavailable");
80
+ if (sidecar) chmodSync(file + suffix, 0o600);
81
+ }
82
+ } finally { closeSync(fd); }
83
+ const db = new Database(file);
84
+ try {
85
+ db.run("PRAGMA busy_timeout = 0");
86
+ db.transaction(() => {
87
+ db.run("CREATE TABLE IF NOT EXISTS fallback (digest TEXT PRIMARY KEY, at TEXT NOT NULL)");
88
+ db.run("CREATE TABLE IF NOT EXISTS cursor (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL, dev TEXT NOT NULL, ino TEXT NOT NULL, size INTEGER NOT NULL, title TEXT NOT NULL, at TEXT NOT NULL, readStart INTEGER NOT NULL, readAt TEXT NOT NULL, skipping INTEGER NOT NULL CHECK(skipping IN (0,1)))");
89
+ // One durable initialization commit; persist fallback time and known identity before publication.
90
+ db.query("INSERT OR IGNORE INTO cursor VALUES (1,0,?,?,?,'','',0,?,0)").run(dev, ino, size, new Date().toISOString());
91
+ }).immediate();
92
+ return db;
93
+ } catch (error) { db.close(); throw error; }
94
+ }
95
+
96
+ /** Private per-session transaction serializes screens and cursor advancement. No path or raw id is persisted. */
97
+ export async function scanHookTranscriptTail(input: unknown, options: Options): Promise<HookTranscriptProgress | undefined> {
98
+ const value = object(input);
99
+ if (!value || options.policy?.host || typeof value.transcript_path !== "string" || typeof value.session_id !== "string" || !value.session_id || value.session_id.length > 256) return;
100
+ if (performance.now() >= options.deadline) return { stopReason: "deadline" };
101
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base);
102
+ assertFormatFloor(base, "hook-spool");
103
+ if (value.cwd !== undefined && (typeof value.cwd !== "string" || realPath(value.cwd) !== cwd)) return;
104
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
105
+ if (!await policy.screen(value)) return;
106
+ const session = hash(`amem-session\0${value.session_id}`).slice(0, 16);
107
+ if (lstatSync(value.transcript_path).isSymbolicLink()) throw new Error("transcript unavailable");
108
+ const transcript = realPath(value.transcript_path);
109
+ // Parent aliases must not hide a configured deny path behind an allowed spelling.
110
+ if (!await policy.screen({ transcript_path: transcript })) return;
111
+ const fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
112
+ let db: Database | undefined;
113
+ try {
114
+ const stat = fstatSync(fd), named = lstatSync(value.transcript_path);
115
+ if (!stat.isFile() || stat.nlink !== 1 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) return;
116
+ const dir = join(base, "hook-transcripts"); privateDirectory(dir);
117
+ db = cursorDatabase(join(dir, `${session}.db`), String(stat.dev), String(stat.ino), stat.size); db.run("BEGIN IMMEDIATE");
118
+ let cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
119
+ if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || !Number.isSafeInteger(cursor.size) || cursor.size < 0 || cursor.at !== "" && !timestamp(cursor.at)
120
+ || !Number.isSafeInteger(cursor.readStart) || cursor.readStart < 0 || !timestamp(cursor.readAt) || ![0, 1].includes(cursor.skipping) || !/^(?:[a-f0-9]{64})?$/.test(cursor.title)) throw new Error("transcript cursor unavailable");
121
+ const reset = cursor.dev !== String(stat.dev) || cursor.ino !== String(stat.ino) || stat.size < cursor.size || stat.size < cursor.offset;
122
+ if (reset || cursor.offset !== cursor.readStart || cursor.size === 0 && stat.size > 0) {
123
+ // Commit reset metadata before staging too: titles without a native timestamp
124
+ // retain their fallback time if the process dies between publication and cursor advance.
125
+ if (reset) { cursor.offset = 0; cursor.at = ""; cursor.skipping = 0; }
126
+ db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,at=?,readStart=?,readAt=?,skipping=? WHERE id=1")
127
+ .run(cursor.offset, String(stat.dev), String(stat.ino), stat.size, cursor.at, cursor.offset, new Date().toISOString(), cursor.skipping);
128
+ db.run("COMMIT"); db.run("BEGIN IMMEDIATE");
129
+ cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
130
+ }
131
+ const bytes = Buffer.alloc(Math.min(HOOK_TRANSCRIPT_BYTES, Math.max(0, stat.size - cursor.offset)));
132
+ const length = readSync(fd, bytes, 0, bytes.length, cursor.offset);
133
+ const after = fstatSync(fd), live = lstatSync(value.transcript_path);
134
+ if (length !== bytes.length || after.size < stat.size || live.dev !== stat.dev || live.ino !== stat.ino || realPath(value.transcript_path) !== transcript) return;
135
+ let start = 0, stopReason: HookTranscriptProgress["stopReason"];
136
+ const workStart = performance.now();
137
+ // The caller's deadline is read live (a caller may move it); the work budget only adds an earlier end after setup.
138
+ const due = () => performance.now() >= options.deadline || options.workBudgetMs !== undefined && performance.now() - workStart >= options.workBudgetMs;
139
+ while (start < length && !due()) {
140
+ const newline = bytes.indexOf(10, start);
141
+ if (cursor.skipping) {
142
+ start = newline < 0 ? length : newline + 1;
143
+ if (newline >= 0) cursor.skipping = 0;
144
+ continue;
145
+ }
146
+ if (newline < 0) {
147
+ // ponytail: a JSONL line exceeding the invocation's 256 KiB ceiling is
148
+ // lost, not buffered in memory/home. Upgrade with a bounded streaming extractor.
149
+ if (start === 0 && length === HOOK_TRANSCRIPT_BYTES && stat.size - cursor.offset > length) {
150
+ cursor.skipping = 1; start = length;
151
+ appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "transcript_line_bound" }]);
152
+ }
153
+ break;
154
+ }
155
+ let line: string;
156
+ try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, newline)); }
157
+ catch { start = newline + 1; continue; }
158
+ let summary: ReturnType<typeof transcriptSummary>;
159
+ try { summary = transcriptSummary(line); } catch { /* Malformed native JSON is not capture. */ }
160
+ if (summary && !await policy.screen(JSON.parse(line))) summary = undefined;
161
+ if (summary) {
162
+ const title = summary.event === "Title" ? hash(summary.text) : "";
163
+ if (!title || title !== cursor.title) {
164
+ try {
165
+ let at = summary.at ?? cursor.at;
166
+ if (!at) {
167
+ // Native timestamps normally identify summaries. Untimed content retains its
168
+ // first fallback across rotation, so an unchanged row cannot get a new receipt.
169
+ const digest = hash(JSON.stringify([summary.event, summary.text, summary.subagent ?? false]));
170
+ db.query("INSERT OR IGNORE INTO fallback VALUES (?,?)").run(digest, cursor.readAt);
171
+ at = (db.query("SELECT at FROM fallback WHERE digest=?").get(digest) as { at: string }).at;
172
+ if (!timestamp(at)) throw new Error("transcript cursor unavailable");
173
+ }
174
+ const publish = () => stageTranscriptCapture({ agent: "claude", event: summary!.event, excerpt: summary!.text, at, session,
175
+ ...(summary!.subagent || typeof value.agent_id === "string" && value.agent_id.length > 0 ? { subagent: true } : {}) }, { cwd, base, policy: options.policy, deferAtCapacity: true }, summary!.event === "Title" ? `title:${cursor.offset + start}` : undefined);
176
+ let staged: PendingHookCapture | undefined;
177
+ try { staged = await publish(); }
178
+ catch (error) {
179
+ if (!(error instanceof HookSpoolFull) || due() || !options.onBackpressure || !await options.onBackpressure() || due()) throw error;
180
+ staged = await publish(); // Same line/receipt, no second read or cursor advance on backpressure.
181
+ }
182
+ if (staged && title) cursor.title = title;
183
+ if (staged) await options.onPublication?.(staged);
184
+ } catch (error) {
185
+ // Advance only through durable/refused rows; a full spool retries this line
186
+ // after the ordinary hook worker drains earlier publications.
187
+ if (error instanceof HookSpoolFull) { stopReason = "spool_full"; break; }
188
+ throw error;
189
+ }
190
+ }
191
+ }
192
+ // Carry timestamps without parsing ordinary messages (including across invocation boundaries).
193
+ const at = transcriptTimestamp(line); if (at) cursor.at = at;
194
+ start = newline + 1;
195
+ }
196
+ if (!stopReason && start < length && due()) stopReason = "deadline";
197
+ if (!stopReason && cursor.offset + start < stat.size && length === HOOK_TRANSCRIPT_BYTES) stopReason = "cap";
198
+ assertFormatFloor(base, "hook-spool");
199
+ db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,title=?,at=?,skipping=? WHERE id=1").run(cursor.offset + start, String(stat.dev), String(stat.ino), stat.size, cursor.title, cursor.at, cursor.skipping);
200
+ db.run("COMMIT");
201
+ return { offset: cursor.offset + start, ...(stopReason ? { stopReason } : {}) };
202
+ } finally {
203
+ try { if (db) { try { db.run("ROLLBACK"); } catch { /* Already committed. */ } db.close(); } } finally { closeSync(fd); }
204
+ }
205
+ }
package/src/hooks.ts CHANGED
@@ -1,19 +1,21 @@
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
- const HANDLER_MS = 140;
18
+ export const HANDLER_MS = 140;
17
19
  // A timed-out child must be reaped and metrics flushed inside the unchanged handler/R2 budgets.
18
20
  // ponytail: reserve measured native teardown variance; an asynchronous hard-deadline transport
19
21
  // through storage/cleanup is the upgrade, not a longer external deadline.
@@ -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,161 @@ 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, register?: (cancel: () => void) => void, onDrain?: (value: unknown) => void): Promise<string | undefined> {
72
+ const reader = stream.getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
73
+ // #226: after a bounded reap the parent abandons this pipe; cancelling it leaves no pending read holding the event loop.
74
+ register?.(() => { reader.cancel().catch(() => {}); });
75
+ let length = 0, pending = "", body = "";
76
+ const line = (text: string) => {
77
+ if (!text) return;
78
+ const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown; drain?: unknown };
79
+ if (onRss && Object.hasOwn(value, "rss")) onRss(value.rss);
80
+ else if (onTranscript && Object.hasOwn(value, "transcript")) onTranscript(value.transcript);
81
+ else if (onDrain && Object.hasOwn(value, "drain")) onDrain(value.drain);
82
+ else { if (body) throw new Error("worker output bound"); body = text; }
83
+ };
71
84
  try {
72
85
  for (;;) {
73
86
  const next = await reader.read(); if (next.done) break;
74
87
  if (length + next.value.byteLength > HOOK_OUTPUT_BYTES) return undefined;
75
- chunks.push(next.value); length += next.value.byteLength;
88
+ length += next.value.byteLength; pending += decoder.decode(next.value, { stream: true });
89
+ for (;;) { const at = pending.indexOf("\n"); if (at < 0) break; line(pending.slice(0, at)); pending = pending.slice(at + 1); }
76
90
  }
77
- return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
78
- } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
91
+ pending += decoder.decode(); if (pending) line(pending);
92
+ return body;
93
+ } catch { return undefined; }
94
+ finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
95
+ }
96
+
97
+ /** Native handles always belong to the CLI that writes the metric, never a killable worker. */
98
+ async function parentProject(options: HookOptions, base: string, deadline: number) {
99
+ const cwd = realPath(options.cwd);
100
+ const scope = hookCaptureScope(cwd, base);
101
+ const usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
102
+ let project: string | undefined, receipt: HookProjectReceipt | undefined;
103
+ try {
104
+ project = await resolveHookProjectAsync({ cwd, base, scope, timeRemaining: () => deadline - performance.now(), maintainCache: true, prefetch: true,
105
+ onResolved: value => { receipt = value; } }, usage);
106
+ } catch { /* All native finally/reap barriers have completed before this returns. */ }
107
+ if (project === undefined && scope !== undefined) try { quarantineHookScope({ cwd, base, scope }); }
108
+ catch { /* Failed maintenance cannot change native fail-open behavior or its accounting. */ }
109
+ return { cwd, scope, project, receipt, usage };
110
+ }
111
+
112
+ /** #226: after SIGKILL the parent waits at most this long for the owned worker's exit and output. A worker stuck in
113
+ * uninterruptible I/O (fsync) can outlive the hook; the kill is already sent, its pipe is cancelled and its handle unref'd,
114
+ * so nothing keeps this process alive and nothing stays resident. R1 judges it after a fixed 2 s post-exit window. */
115
+ const REAP_BOUND_MS = 10;
116
+ async function boundedWait<T>(work: Promise<T>): Promise<{ value?: T; late: boolean }> {
117
+ let timer: ReturnType<typeof setTimeout> | undefined, late = false;
118
+ const value = await Promise.race([work, new Promise<undefined>(resolve => { timer = setTimeout(() => { late = true; resolve(undefined); }, REAP_BOUND_MS); })]);
119
+ if (timer) clearTimeout(timer);
120
+ return late ? { late } : { value: value as T, late };
79
121
  }
122
+ /** Owned children killed but still alive when the hook returned; recorded in the hook metric. */
123
+ let abandonedChildren = 0;
124
+ /** Transcript hooks whose worker was killed before its drain finished; their staged records stay durable and pending. */
125
+ let drainSkipped = 0;
126
+ /** #226 instrumentation: the last worker drain's start (ms after hook start) and committed record count. */
127
+ let lastDrain: { startMs: number; drained: number } | undefined;
128
+ /** Hook start on the performance clock, set by the entry points, so a worker can report times relative to it. */
129
+ let hookStartedAt = performance.now();
80
130
 
81
- /** Native metadata and cold indexes are bounded in a short-lived owned child, never a resident worker.
131
+ /** Only capture work uses a short-lived owned child; native handles remain in this CLI.
132
+ * #226 (ruling b): a transcript hook boots that one worker at hook start, in parallel with native metadata. The worker
133
+ * scans as soon as its first stdin line (scan options) arrives and drains after the second (the native receipt). At the
134
+ * deadline the parent kills it and waits at most REAP_BOUND_MS; a kill skips that call's drain (counted, never a drop).
82
135
  * 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 }> {
136
+ 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
137
  const workDeadline = deadline - WORKER_CLEANUP_MS;
85
138
  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; });
139
+ const early = transcript !== undefined;
140
+ const policy = { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns };
141
+ let owned: Bun.Subprocess<"pipe", "pipe", "ignore"> | undefined, exited = false, killed = false, cancelOutput: (() => void) | undefined;
142
+ let tracker: HookWorkerRss | undefined, output: Promise<string | undefined> | undefined, exit: Promise<number> | undefined;
143
+ const result = { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, rssSources: emptyRssSources(), transcript: undefined as HookTranscriptProgress | undefined };
144
+ const drainProgress: { startMs?: number; drained?: number } = {};
145
+ const spawn = () => {
146
+ const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
147
+ { cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
148
+ owned = child;
149
+ exit = child.exited.then(code => { exited = true; return code; });
150
+ tracker = new HookWorkerRss(child.pid);
151
+ output = boundedOutput(child.stdout, value => tracker!.frame(value), value => {
152
+ if (!isHookTranscriptProgress(value)) throw new Error("worker transcript progress bound");
153
+ if (value.offset !== undefined && result.transcript?.offset !== undefined && value.offset < result.transcript.offset) throw new Error("worker transcript progress regressed");
154
+ // A reported scan fault stays reported: a later worker deadline frame cannot turn it into accepted progress (#221).
155
+ result.transcript = { ...result.transcript, ...value, ...(result.transcript?.stopReason === "unavailable" ? { stopReason: "unavailable" as const } : {}) };
156
+ }, cancel => { cancelOutput = cancel; }, value => {
157
+ // #226 instrumentation: bounded numeric drain progress; anything else is ignored, never trusted.
158
+ const row = value as { startMs?: unknown; drained?: unknown } | null;
159
+ if (row && typeof row.startMs === "number" && Number.isFinite(row.startMs) && row.startMs >= 0 && row.startMs < 10_000) drainProgress.startMs = row.startMs;
160
+ if (row && Number.isSafeInteger(row.drained) && (row.drained as number) >= 0 && (row.drained as number) <= 1024) drainProgress.drained = row.drained as number;
161
+ });
162
+ return child;
163
+ };
164
+ const send = (line: object) => { try { owned!.stdin.write(JSON.stringify(line) + "\n"); owned!.stdin.flush(); } catch { /* A dead worker reports nothing. */ } };
165
+ const close = () => { try { owned?.stdin.end(); } catch { /* Already closed. */ } };
96
166
  const stop = () => {
97
- if (exited) return;
167
+ if (!owned || exited) return;
168
+ tracker!.captureLive();
169
+ killed = true;
98
170
  // 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. */ } }
171
+ // The capture-only worker owns no native helper; those handles were reaped in this parent.
172
+ try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already exited. */ } }
101
173
  };
102
- let timer: ReturnType<typeof setTimeout> | undefined;
174
+ let timer: ReturnType<typeof setTimeout> | undefined, metadata: Awaited<ReturnType<typeof parentProject>> | undefined;
103
175
  try {
176
+ if (early) {
177
+ spawn();
178
+ send({ ownedGroup: true, scan: { cwd: options.cwd, base, transcript, policy, deadlineAt: performance.timeOrigin + workDeadline } });
179
+ }
180
+ const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
181
+ const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
182
+ metadata = await parentProject(options, base, workDeadline);
183
+ result.rssKB = metadata.usage.rssKB; result.rawRssKB = metadata.usage.rawRssKB; result.rssSources = combineRssSources(metadata.usage.rssSources);
184
+ if (!early && (!metadata.receipt || workDeadline - performance.now() < 5)) return result;
185
+ if (!owned) spawn();
186
+ result.rssSources.unavailable++;
187
+ // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
188
+ // Without a receipt the worker still finishes the scan and reports it, but drains nothing.
189
+ send({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline, hookStartAt: performance.timeOrigin + hookStartedAt, policy });
190
+ close();
104
191
  const answer = await Promise.race([
105
- Promise.all([exit, boundedOutput(child.stdout)]),
192
+ Promise.all([exit!, output!]),
106
193
  new Promise<undefined>(resolve => { timer = setTimeout(() => { stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
107
194
  ]);
108
195
  if (!answer || !answer[1]) return result;
109
- const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown };
196
+ const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
110
197
  if (parsed.complete !== true) return result;
111
- if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
198
+ if (parsed.rssTelemetryValid !== true) tracker!.invalidate();
112
199
  if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
113
200
  result.captured = parsed.captured === true; result.valid = parsed.valid === true;
114
201
  return result;
115
- } catch { return result; }
202
+ } catch { result.valid = false; return result; }
116
203
  finally {
117
204
  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;
205
+ close();
206
+ if (owned) {
207
+ stop();
208
+ // #226: bounded reap; a worker stuck in uninterruptible I/O is abandoned after REAP_BOUND_MS, never awaited.
209
+ const reaped = exited || !(await boundedWait(exit!)).late;
210
+ const body = reaped ? await boundedWait(output!) : { late: true };
211
+ if (!reaped || body.late) { cancelOutput?.(); owned.unref(); }
212
+ if (!reaped) abandonedChildren++;
213
+ // Bun subprocess usage is bytes; process.resourceUsage is KiB. Not reaped: the last live sample is the RSS (ruling Q2).
214
+ const workerRawKB = reaped ? (owned.resourceUsage()?.maxRSS ?? 0) / 1024 : 0;
215
+ if (reaped ? body.late || body.value === undefined : !tracker!.hasLiveSample()) tracker!.invalidate();
216
+ const accounting = tracker!.finish(workerRawKB);
217
+ const usage = metadata?.usage ?? { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
218
+ result.rssKB = usage.rssKB + accounting.rssKB; result.rawRssKB = usage.rawRssKB + workerRawKB; result.rssSources = combineRssSources(usage.rssSources, accounting.rssSources);
219
+ if (drainProgress.startMs !== undefined) lastDrain = { startMs: drainProgress.startMs, drained: drainProgress.drained ?? 0 };
220
+ if (early && killed) {
221
+ // #221: a kill without accepted progress is a deadline stop; a kill also skips this call's drain (ruling b).
222
+ if (result.transcript?.offset === undefined && result.transcript?.stopReason !== "unavailable") result.transcript = { stopReason: "deadline" };
223
+ if (!result.valid) drainSkipped++;
224
+ }
225
+ } else if (early) result.transcript ??= { stopReason: "deadline" };
125
226
  }
126
227
  }
127
228
 
@@ -143,7 +244,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
143
244
  catch (error) { if (!(error instanceof HookSpoolFull)) throw error; full = true; }
144
245
  if (!pending && !full) return undefined;
145
246
  const cwd = realPath(options.cwd), scope = hookCaptureScope(cwd, base);
146
- const remaining = () => deadline - performance.now(), usage = { rssKB: 0, rawRssKB: 0 };
247
+ const remaining = () => deadline - performance.now(), usage: HookNativeUsage = { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
147
248
  const result = { context: unavailable, captured: false, valid: false, pending: !!pending, dropped: false, ...usage };
148
249
  if (remaining() <= 0) { result.dropped = !pending; return result; }
149
250
  if (sessionSource(input) === "resume") {
@@ -158,7 +259,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
158
259
  try {
159
260
  // Independent native reads and module loading share the same deadline, not three serial cold starts.
160
261
  // Separate usage ports conservatively sum concurrent helper peaks instead of hiding overlap.
161
- const projectUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage = { rssKB: 0, rawRssKB: 0 };
262
+ const projectUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 }, signatureUsage: HookNativeUsage = { rssKB: 0, rawRssKB: 0 };
162
263
  // Reaping native groups and serializing the fail-open result stay inside the handler deadline.
163
264
  const nativeRemaining = () => remaining() - 15;
164
265
  const metadata = await Promise.allSettled([
@@ -168,6 +269,7 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
168
269
  ] as const);
169
270
  usage.rssKB = projectUsage.rssKB + signatureUsage.rssKB;
170
271
  usage.rawRssKB = projectUsage.rawRssKB + signatureUsage.rawRssKB;
272
+ usage.rssSources = combineRssSources(projectUsage.rssSources, signatureUsage.rssSources);
171
273
  const [identity, owner, briefModule] = metadata;
172
274
  if (identity.status !== "fulfilled" || owner.status !== "fulfilled" || briefModule.status !== "fulfilled") throw new Error("hook metadata unavailable");
173
275
  const project = resolveAlias(base, identity.value);
@@ -185,20 +287,27 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
185
287
  result.valid = result.context.length > 0;
186
288
  return result;
187
289
  } catch { result.dropped = !pending; return result; }
188
- finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; }
290
+ finally { result.rssKB = usage.rssKB; result.rawRssKB = usage.rawRssKB; result.rssSources = usage.rssSources; }
189
291
  }
190
292
 
191
293
  /** A full pending tier must still be drainable. Recover bounded old records before retrying
192
294
  * publication; this path uses one helper too, and the current normalized event stays pending. */
193
295
  async function captureAndWork(event: string, input: unknown, options: HookOptions, base: string, deadline: number) {
194
296
  if (event === "SessionStart") return sessionInParent(input, options, base, deadline);
195
- let result: Awaited<ReturnType<typeof hookWorker>> | undefined;
297
+ const transcript = (event === "Stop" || event === "SessionEnd") && input !== null && typeof input === "object" && "transcript_path" in input && typeof input.transcript_path === "string" ? input : undefined;
298
+ // #221: a transcript-bearing hook that ends without worker progress reports `deadline` only on positive evidence:
299
+ // the remaining time, measured at that exit, is below the worker's own start threshold. Policy, floor or invalid-input
300
+ // exits keep reporting nothing, so they cannot pass for bounded progress.
301
+ const deadlineEvidence = () => transcript !== undefined && deadline - WORKER_CLEANUP_MS - performance.now() < 5;
302
+ const withDeadline = <T extends { transcript?: HookTranscriptProgress }>(value: T): T => value.transcript === undefined && deadlineEvidence() ? { ...value, transcript: { stopReason: "deadline" } } : value;
303
+ const work = async (pending: PendingHookCapture) => withDeadline(await hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript));
304
+ let result: Awaited<ReturnType<typeof work>> | undefined;
196
305
  let pending: PendingHookCapture | undefined;
197
306
  try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
198
307
  catch (error) {
199
308
  if (!(error instanceof HookSpoolFull)) throw error;
200
309
  const cwd = realPath(options.cwd);
201
- result = await hookWorker({ ...options, cwd }, base, deadline, { name: "", receipt: "", cwd }, event);
310
+ result = await work({ name: "", receipt: "", cwd });
202
311
  try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
203
312
  catch (retryError) {
204
313
  // A concurrent publisher can refill the soft tier. Capture admission cannot discard
@@ -207,8 +316,12 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
207
316
  return { ...result, pending: false, dropped: true };
208
317
  }
209
318
  }
210
- if (!pending) return result ? { ...result, pending: false } : undefined;
211
- result ??= await hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event);
319
+ if (!pending) {
320
+ if (result) return { ...result, pending: false };
321
+ // Staging declined (policy, floor, invalid input) or ran out of time: only measured time makes it a deadline stop.
322
+ return deadlineEvidence() ? { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, pending: false, transcript: { stopReason: "deadline" as const } } : undefined;
323
+ }
324
+ result ??= await work(pending);
212
325
  return { ...result, pending: !result.captured };
213
326
  }
214
327
 
@@ -216,7 +329,10 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
216
329
  export async function runHook(event: string, input: unknown, options: HookOptions, host?: HookHostPorts): Promise<HookReceipt> {
217
330
  const now = performance.now(), started = options.startedAt !== undefined && Number.isFinite(options.startedAt) && options.startedAt <= now ? options.startedAt : now;
218
331
  const deadline = started + handlerMs(host);
332
+ hookStartedAt = started;
219
333
  let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
334
+ let childSources = emptyRssSources();
335
+ let transcriptProgress: HookTranscriptProgress | undefined;
220
336
  try {
221
337
  base = canonicalPath(options.base ?? memoryHome());
222
338
  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,25 +341,38 @@ export async function runHook(event: string, input: unknown, options: HookOption
225
341
  const result = await captureAndWork(event, input, options, base, deadline - 3);
226
342
  if (!result) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
227
343
  captured = result.captured; pending = result.pending; dropped = "dropped" in result && result.dropped === true;
228
- childRssKB = result.rssKB; childRawRssKB = result.rawRssKB;
344
+ childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources();
345
+ transcriptProgress = "transcript" in result ? result.transcript : undefined;
229
346
  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
347
  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
348
  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
349
  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
350
  } catch { return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true }; }
234
351
  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. */ }
352
+ if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
353
+ rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
354
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
237
355
  }
238
356
  }
239
357
 
240
358
  /** Bounded stdin and a fixed overall handler budget; failures produce no native decision and exit successfully. */
241
359
  export async function hookMain(args: readonly string[], startedAt = performance.now(), host?: HookHostPorts): Promise<string> {
242
360
  const deadlineMs = handlerMs(host);
361
+ hookStartedAt = startedAt;
243
362
  let cleanup: (() => Promise<void>) | undefined;
244
363
  let childRssKB = 0, childRawRssKB = 0, dropped = false;
364
+ let childSources = emptyRssSources();
365
+ let transcriptProgress: HookTranscriptProgress | undefined;
245
366
  try {
246
367
  if (args.length !== 1) return "";
368
+ if (args[0] === "jcode") {
369
+ // #203: jcode observers have no stdin and discard stdout; the event and its cwd arrive in the environment.
370
+ const jcode = jcodeHookCapture(process.env);
371
+ if (!jcode) return "";
372
+ const result = await captureAndWork(jcode.event, jcode.input, { cwd: jcode.cwd, agent: "jcode" }, canonicalPath(memoryHome()), startedAt + deadlineMs - 3);
373
+ if (result) { childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true; }
374
+ return "";
375
+ }
247
376
  const reader = Bun.stdin.stream().getReader();
248
377
  cleanup = async () => { await reader.cancel().catch(() => {}); reader.releaseLock(); };
249
378
  const chunks: Uint8Array[] = []; let length = 0;
@@ -260,7 +389,8 @@ export async function hookMain(args: readonly string[], startedAt = performance.
260
389
  const base = canonicalPath(memoryHome());
261
390
  const result = await captureAndWork(args[0]!, input, { cwd: process.cwd() }, base, startedAt + deadlineMs - 3);
262
391
  if (!result) return "";
263
- childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; dropped = "dropped" in result && result.dropped === true;
392
+ childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true;
393
+ transcriptProgress = "transcript" in result ? result.transcript : undefined;
264
394
  if (!result.valid || args[0] !== "SessionStart") return "";
265
395
  return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } });
266
396
  }
@@ -269,8 +399,10 @@ export async function hookMain(args: readonly string[], startedAt = performance.
269
399
  finally {
270
400
  await cleanup?.();
271
401
  try {
402
+ const self = hookRssReading();
272
403
  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 } : {}) }]);
404
+ rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
405
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
274
406
  } catch { /* Invalid input and unavailable homes still fail open. */ }
275
407
  }
276
408
  }
@@ -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");