@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/package.json +3 -2
- package/src/cli.ts +82 -6
- package/src/commands-table.ts +8 -1
- package/src/hook-contract.ts +12 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +87 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +315 -66
- package/src/hook-transcript.ts +205 -0
- package/src/hooks.ts +183 -51
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +54 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +5 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/public-prune-view.ts +6 -2
- package/src/memory/search-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +117 -65
- package/src/session-brief.ts +19 -7
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
|
@@ -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 {
|
|
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
|
|
70
|
-
const reader = stream.getReader(),
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
try { process.kill(-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
211
|
-
|
|
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: (
|
|
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: (
|
|
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
|
}
|
package/src/memory/candidates.ts
CHANGED
|
@@ -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");
|