@staix/agent-mem 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ import { combineRssSources, processImageMemory, rssSource, selectHookRss, type HookRssReading, type HookRssSources } from "./hook-resources.ts";
2
+
3
+ const SOURCES = ["VmHWM", "vmpeak-bound", "platform-rusage", "raw-fallback", "unavailable"] as const;
4
+ export const rssSourceCode = (value: HookRssReading): number => SOURCES.indexOf(value.source);
5
+ function reading(kb: unknown, raw: unknown, code: unknown): HookRssReading | undefined {
6
+ if (!Number.isSafeInteger(kb) || (kb as number) < 0 || !Number.isSafeInteger(raw) || (raw as number) < 0 || !Number.isSafeInteger(code) || (code as number) < 0 || (code as number) >= SOURCES.length) return;
7
+ const source = SOURCES[code as number]!;
8
+ if ((code as number) < 3 && (kb as number) <= 0) return;
9
+ return { kb: kb as number, rawKB: raw as number, source, known: (code as number) < 3 };
10
+ }
11
+ type Image = ReturnType<typeof processImageMemory>;
12
+ interface Native { pid: number; image?: Image; result?: HookRssReading }
13
+
14
+ /** Bounded numeric progress survives worker timeout; no native arguments or project data enter it. */
15
+ export class HookWorkerRss {
16
+ private entered = false;
17
+ private invalid = false;
18
+ private readonly native = new Map<number, Native>();
19
+ private readonly active = new Set<number>();
20
+ private readonly overlaps: number[][] = [];
21
+ private image: Image = {};
22
+ private self?: HookRssReading;
23
+ constructor(private readonly pid: number) {}
24
+ invalidate(): void { this.invalid = true; }
25
+ frame(value: unknown): void {
26
+ if (!Array.isArray(value)) { this.invalid = true; return; }
27
+ const [kind, id, pid] = value;
28
+ if (kind === 0 && value.length === 1 && !this.entered) { this.entered = true; return; }
29
+ if (!this.entered) { this.invalid = true; return; }
30
+ if (kind === 3 && value.length === 4) {
31
+ const selected = reading(value[1], value[2], value[3]);
32
+ if (!selected) this.invalid = true; else this.self = selected;
33
+ return;
34
+ }
35
+ if (!Number.isSafeInteger(id) || id < 1 || id > 64) { this.invalid = true; return; }
36
+ if (kind === 1 && value.length === 3 && Number.isSafeInteger(pid) && pid >= 0) {
37
+ const prior = this.native.get(id);
38
+ if (!prior && this.native.size < 64 && pid === 0) {
39
+ this.native.set(id, { pid }); this.active.add(id); this.overlaps.push([...this.active]); return;
40
+ }
41
+ if (prior && prior.pid === 0 && !prior.result && pid > 0) { prior.pid = pid; return; }
42
+ }
43
+ if (kind === 2 && value.length === 6) {
44
+ const prior = this.native.get(id), selected = reading(value[2], value[3], value[4]);
45
+ // Reserved final slot must remain zero, so protocol extensions cannot be misread.
46
+ if (prior && !prior.result && selected && value[5] === 0) { prior.result = selected; this.active.delete(id); return; }
47
+ }
48
+ this.invalid = true;
49
+ }
50
+ captureLive(read: typeof processImageMemory = processImageMemory): void {
51
+ const retain = (before: Image | undefined, after: Image): Image => ({
52
+ hwmKB: Math.max(before?.hwmKB ?? 0, after.hwmKB ?? 0) || undefined,
53
+ virtualKB: Math.max(before?.virtualKB ?? 0, after.virtualKB ?? 0) || undefined,
54
+ });
55
+ this.image = retain(this.image, read(this.pid));
56
+ for (const id of this.active) {
57
+ const value = this.native.get(id)!;
58
+ if (value.pid > 0) value.image = retain(value.image, read(value.pid));
59
+ }
60
+ }
61
+ finish(rawWorkerKB: number, platform = process.platform): { rssKB: number; rawRssKB: number; rssSources: HookRssSources } {
62
+ const self = this.self;
63
+ const hwm = Math.max(this.image.hwmKB ?? 0, self?.source === "VmHWM" ? self.kb : 0);
64
+ const virtual = Math.max(this.image.virtualKB ?? 0, self?.source === "vmpeak-bound" ? self.kb : 0);
65
+ const ownRaw = platform !== "linux" && self?.source === "platform-rusage" ? self.kb : rawWorkerKB;
66
+ const own = selectHookRss(platform, hwm, ownRaw, virtual);
67
+ const sources = [rssSource(own)];
68
+ const native = new Map<number, HookRssReading>();
69
+ for (const [id, value] of this.native) {
70
+ const given = value.result;
71
+ const selected = given && !given.known && !value.image?.hwmKB && !value.image?.virtualKB ? given : selectHookRss(platform,
72
+ Math.max(value.image?.hwmKB ?? 0, given?.source === "VmHWM" ? given.kb : 0), given?.rawKB ?? 0,
73
+ Math.max(value.image?.virtualKB ?? 0, given?.source === "vmpeak-bound" ? given.kb : 0));
74
+ native.set(id, selected); sources.push(rssSource(selected));
75
+ }
76
+ let nativeKB = 0;
77
+ for (const group of this.overlaps) {
78
+ nativeKB = Math.max(nativeKB, group.reduce((sum, id) => sum + native.get(id)!.kb, 0));
79
+ }
80
+ const rssSources = combineRssSources(...sources);
81
+ if (this.invalid) rssSources.unavailable++;
82
+ const combined = own.kb + nativeKB;
83
+ return { rssKB: platform === "linux" ? combined : Math.max(combined, rawWorkerKB), rawRssKB: rawWorkerKB, rssSources };
84
+ }
85
+ }
package/src/hook-spool.ts CHANGED
@@ -1,13 +1,15 @@
1
+ import { assertFormatFloor, FormatFloorError } from "./memory/formats.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { homedir } from "node:os";
5
6
  import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
6
- import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type NormalizedCaptureFields } from "./memory/capture-input.ts";
7
+ import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type CaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
7
8
  import { canonicalPath, realPath } from "./memory/platform.ts";
8
9
  import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
9
10
  import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
10
11
  import { fold } from "./memory/policy.ts";
12
+ import { stripPrivateSpans } from "./memory/private-spans.ts";
11
13
  import { looksSecret } from "./memory/screen.ts";
12
14
  import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
13
15
  import { projectState } from "./memory/lifecycle.ts";
@@ -16,19 +18,28 @@ import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
16
18
  // #199: v1 records (closed key set, no format field) are read, never written. Every 0.14-0.17 decoder deletes a record it cannot verify,
17
19
  // so a record that adds a field publishes under a name the v1 pattern does not match (`hook-v2-`); old versions then ignore it
18
20
  // (they still count it toward their file and byte caps). A later format that adds fields takes a new prefix again.
19
- const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-";
21
+ // #203: format 3 (`hook-v3-`) carries every record 0.18.0 cannot decode, today jcode-agent rows; 0.14-0.18 match neither
22
+ // `hook-` nor `hook-v2-` for it, so they leave it alone. Claude native rows stay format 2, byte for byte, so 0.18.0 keeps draining them.
23
+ const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-", FORMAT_V3 = 3, PREFIX_V3 = "hook-v3-";
20
24
  const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
21
25
  const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
22
26
  // ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
23
27
  // Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
24
28
  const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
25
- const namePattern = /^(hook-(?:v2-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
29
+ const namePattern = /^(hook-(?:v[23]-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
26
30
  const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
27
31
  interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
28
32
  export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
29
33
  export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
30
- interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true }
34
+ /** #203: the agent a staged record belongs to. Claude hooks and jcode lifecycle observers share this spool, budget and replay. */
35
+ export const HOOK_AGENTS = ["claude", "jcode"] as const;
36
+ export type HookAgent = typeof HOOK_AGENTS[number];
37
+ interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true; agent?: HookAgent }
31
38
  const directory = (base: string) => join(base, "hook-spool");
39
+ /** A hook below the home's hook-spool floor (#199 AC5) stages, drains and maintains nothing; it still never denies a native action. */
40
+ function belowFloor(base: string): boolean {
41
+ try { assertFormatFloor(base, "hook-spool"); return false; } catch (error) { if (error instanceof FormatFloorError) return true; throw error; }
42
+ }
32
43
  function physicalRoot(cwd: string): string {
33
44
  const stat = lstatSync(cwd);
34
45
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook root unavailable");
@@ -276,7 +287,8 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: tru
276
287
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
277
288
  if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.cwd !== "string" || typeof value.nonce !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
278
289
  || value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
279
- const v1 = value.schema === SCHEMA && !("format" in value), current = value.schema === FAMILY && value.format === FORMAT;
290
+ const v1 = value.schema === SCHEMA && !("format" in value), v3 = value.schema === FAMILY && value.format === FORMAT_V3;
291
+ const current = value.schema === FAMILY && value.format === FORMAT || v3;
280
292
  if (!v1 && !current) return typeof value.schema === "string" ? { value, bytes, unknown: true } : undefined;
281
293
  const keys = current ? 1 : 0;
282
294
  if (![5 + keys, 6 + keys].includes(Object.keys(value).length)
@@ -285,7 +297,7 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: tru
285
297
  const c = value.capture;
286
298
  if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
287
299
  || Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
288
- || c.agent !== "claude" || !(HOOK_EVENTS as readonly string[]).includes(c.event)
300
+ || !(HOOK_AGENTS as readonly string[]).includes(c.agent) || (c.agent === "claude" && !["Title", "Recap", "Compact"].includes(c.event)) === v3 || ![...HOOK_EVENTS, "Title", "Recap", "Compact"].includes(c.event)
289
301
  || typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
290
302
  || !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
291
303
  const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
@@ -436,7 +448,7 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
436
448
  * discard a valid pending record; only positively invalid publications leave admission. */
437
449
  export function quarantineHookScope(options: { cwd: string; base: string; scope: string }): { retired: number } {
438
450
  let retired = 0;
439
- if (!/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
451
+ if (belowFloor(options.base) || !/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
440
452
  const cwd = realPath(options.cwd), base = canonicalPath(options.base), cwdHash = hash(cwd);
441
453
  trimQuarantine(base);
442
454
  const rows = entries(base).filter(row => {
@@ -462,7 +474,7 @@ export function quarantineHookScope(options: { cwd: string; base: string; scope:
462
474
 
463
475
  /** Only screened normalized capture fields are retained, never the native input envelope. */
464
476
  export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
465
- if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host) return undefined;
477
+ if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host || belowFloor(options.base)) return undefined;
466
478
  const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
467
479
  if (!await policy.screen(input)) return undefined;
468
480
  const value = structuredClone(input) as {[key: string]: unknown};
@@ -485,7 +497,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
485
497
  // a non-empty agent_id marks a subagent (or in-process teammate) event. Both are read from the already-screened input.
486
498
  const session = typeof value.session_id === "string" && value.session_id.length > 0 && value.session_id.length <= 256 ? hash(`amem-session\0${value.session_id}`).slice(0, 16) : undefined;
487
499
  const subagent = typeof value.agent_id === "string" && value.agent_id.length > 0;
488
- const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
500
+ const capture = normalizeCaptureFields({ agent: options.agent ?? "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
489
501
  ...(session ? { session } : {}), ...(subagent ? { subagent: true } : {}),
490
502
  ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt },
491
503
  undefined,
@@ -495,8 +507,28 @@ export async function stageHookCapture(event: string, input: unknown, options: O
495
507
  // Enriched tool rows are refused rather than persisting the normalizer's redaction marker.
496
508
  if (toolEvent && capture.excerpt === "[secret-screened]") return undefined;
497
509
  if (!await policy.screen({ cwd, capture })) return undefined;
498
- const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
499
- const record: Record = { schema: FAMILY, format: FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
510
+ return publishCapture(event, capture, { ...options, cwd, base });
511
+ }
512
+
513
+ /** Transcript text is screened in full before caps, then shares the ordinary durable spool. */
514
+ export async function stageTranscriptCapture(input: CaptureFields, options: Options, receiptIdentity?: string): Promise<PendingHookCapture | undefined> {
515
+ if (options.policy?.host || belowFloor(options.base) || input.agent !== "claude" || !["Title", "Recap", "Compact"].includes(input.event)) return;
516
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base);
517
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
518
+ if (!await policy.screen({ cwd, input })) return;
519
+ const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config;
520
+ const capture = normalizeCaptureFields({ ...input, excerpt: clipCaptureUtf8(stripPrivateSpans(input.excerpt ?? ""), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT)) }, undefined,
521
+ text => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))),
522
+ { excerptBytes: configured.excerptBytes, paths: configured.capturePaths, denyPaths: configured.denyPaths });
523
+ if (capture.excerpt === "[secret-screened]" || !await policy.screen({ cwd, capture })) return;
524
+ return publishCapture(input.event, capture, { ...options, cwd, base }, hash(receiptIdentity === undefined ? JSON.stringify(capture) : JSON.stringify([capture, receiptIdentity])).slice(0, 32));
525
+ }
526
+
527
+ function publishCapture(event: string, capture: NormalizedCaptureFields, options: Options, nonce = randomBytes(16).toString("hex")): PendingHookCapture {
528
+ const { cwd, base } = options;
529
+ const scope = hookCaptureScope(cwd, base) ?? null;
530
+ const v3 = capture.agent !== "claude" || ["Title", "Recap", "Compact"].includes(event);
531
+ const record: Record = { schema: FAMILY, format: v3 ? FORMAT_V3 : FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
500
532
  if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
501
533
  const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
502
534
  let destination = dir, deferred = false, backlog = false;
@@ -525,7 +557,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
525
557
  deferred = true;
526
558
  }
527
559
  }
528
- const name = `${PREFIX}${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
560
+ const name = `${v3 ? PREFIX_V3 : PREFIX}${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
529
561
  try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
530
562
  finally { rmSync(temporary, { force: true }); }
531
563
  return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
@@ -543,6 +575,7 @@ export function hookCapturePending(base: string, name: string): boolean {
543
575
  export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number },
544
576
  append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
545
577
  const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
578
+ if (belowFloor(base)) return { captured, blocked: 1 };
546
579
  if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
547
580
  // F4: the configured policy applies at drain too. `options.policy` carries it, so the anchored line below (a test diagnostic matches its exact text) stays as it was.
548
581
  options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
@@ -0,0 +1,189 @@
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 } 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
+ interface Options { cwd: string; base: string; deadline: number; policy?: StandalonePolicyOptions }
18
+ const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
19
+ const timestamp = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : undefined;
20
+
21
+ /** Claude's three free texts only; ordinary transcript lines never enter JSON.parse. */
22
+ export function transcriptSummary(line: string): { event: SummaryEvent; text: string; at?: string; subagent?: true } | undefined {
23
+ if (!line.includes('"ai-title"') && !line.includes('"away_summary"') && !line.includes('"isCompactSummary"')) return undefined;
24
+ const row = object(JSON.parse(line)); if (!row) return undefined;
25
+ let event: SummaryEvent, text: unknown;
26
+ if (row.type === "ai-title") { event = "Title"; text = row.aiTitle; }
27
+ else if (row.type === "system" && row.subtype === "away_summary") { event = "Recap"; text = row.content; }
28
+ else if (row.type === "user" && row.isCompactSummary === true) {
29
+ event = "Compact"; text = object(row.message)?.content;
30
+ if (Array.isArray(text)) text = text.flatMap(part => { const value = object(part); return value?.type === "text" && typeof value.text === "string" ? [value.text] : []; }).join("\n");
31
+ } else return undefined;
32
+ if (typeof text !== "string" || !text.trim()) return undefined;
33
+ const visibleText = event === "Recap" ? text.replace(/\s*\(disable recaps in \/config\)\s*$/, "") : text;
34
+ 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 } : {}) };
35
+ }
36
+
37
+ /** Read only a top-level timestamp token, without parsing ordinary message bodies. */
38
+ function transcriptTimestamp(line: string): string | undefined {
39
+ if (!line.includes('"timestamp"')) return;
40
+ let depth = 0, at: string | undefined;
41
+ for (let index = 0; index < line.length; index++) {
42
+ const character = line[index];
43
+ if (character === '"') {
44
+ const start = index++;
45
+ for (; index < line.length; index++) {
46
+ if (line[index] === "\\") index++;
47
+ else if (line[index] === '"') break;
48
+ }
49
+ if (depth === 1 && line.slice(start, index + 1) === '"timestamp"') {
50
+ const value = /^\s*:\s*"([^"\\]+)"/.exec(line.slice(index + 1));
51
+ if (value) at = timestamp(value[1]);
52
+ }
53
+ } else if (character === "{" || character === "[") depth++;
54
+ else if (character === "}" || character === "]") depth--;
55
+ }
56
+ return at;
57
+ }
58
+
59
+ function privateDirectory(dir: string): void {
60
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
61
+ const stat = lstatSync(dir);
62
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("transcript cursor unavailable");
63
+ chmodSync(dir, 0o700);
64
+ }
65
+ function cursorDatabase(file: string, dev: string, ino: string, size: number): Database {
66
+ const fd = openSync(file, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
67
+ try {
68
+ const stat = fstatSync(fd), named = lstatSync(file);
69
+ 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");
70
+ chmodSync(file, 0o600);
71
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
72
+ const sidecar = lstatSync(file + suffix, { throwIfNoEntry: false });
73
+ if (sidecar && (!sidecar.isFile() || sidecar.isSymbolicLink() || sidecar.nlink !== 1 || sidecar.size > 1024 * 1024)) throw new Error("transcript cursor unavailable");
74
+ if (sidecar) chmodSync(file + suffix, 0o600);
75
+ }
76
+ } finally { closeSync(fd); }
77
+ const db = new Database(file);
78
+ try {
79
+ db.run("PRAGMA busy_timeout = 0");
80
+ db.transaction(() => {
81
+ db.run("CREATE TABLE IF NOT EXISTS fallback (digest TEXT PRIMARY KEY, at TEXT NOT NULL)");
82
+ 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)))");
83
+ // One durable initialization commit; persist fallback time and known identity before publication.
84
+ db.query("INSERT OR IGNORE INTO cursor VALUES (1,0,?,?,?,'','',0,?,0)").run(dev, ino, size, new Date().toISOString());
85
+ }).immediate();
86
+ return db;
87
+ } catch (error) { db.close(); throw error; }
88
+ }
89
+
90
+ /** Private per-session transaction serializes screens and cursor advancement. No path or raw id is persisted. */
91
+ export async function scanHookTranscriptTail(input: unknown, options: Options): Promise<HookTranscriptProgress | undefined> {
92
+ const value = object(input);
93
+ if (!value || options.policy?.host || typeof value.transcript_path !== "string" || typeof value.session_id !== "string" || !value.session_id || value.session_id.length > 256) return;
94
+ if (performance.now() >= options.deadline) return { stopReason: "deadline" };
95
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base);
96
+ assertFormatFloor(base, "hook-spool");
97
+ if (value.cwd !== undefined && (typeof value.cwd !== "string" || realPath(value.cwd) !== cwd)) return;
98
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
99
+ if (!await policy.screen(value)) return;
100
+ const session = hash(`amem-session\0${value.session_id}`).slice(0, 16);
101
+ if (lstatSync(value.transcript_path).isSymbolicLink()) throw new Error("transcript unavailable");
102
+ const transcript = realPath(value.transcript_path);
103
+ // Parent aliases must not hide a configured deny path behind an allowed spelling.
104
+ if (!await policy.screen({ transcript_path: transcript })) return;
105
+ const fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
106
+ let db: Database | undefined;
107
+ try {
108
+ const stat = fstatSync(fd), named = lstatSync(value.transcript_path);
109
+ if (!stat.isFile() || stat.nlink !== 1 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) return;
110
+ const dir = join(base, "hook-transcripts"); privateDirectory(dir);
111
+ db = cursorDatabase(join(dir, `${session}.db`), String(stat.dev), String(stat.ino), stat.size); db.run("BEGIN IMMEDIATE");
112
+ let cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
113
+ if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || !Number.isSafeInteger(cursor.size) || cursor.size < 0 || cursor.at !== "" && !timestamp(cursor.at)
114
+ || !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");
115
+ const reset = cursor.dev !== String(stat.dev) || cursor.ino !== String(stat.ino) || stat.size < cursor.size || stat.size < cursor.offset;
116
+ if (reset || cursor.offset !== cursor.readStart || cursor.size === 0 && stat.size > 0) {
117
+ // Commit reset metadata before staging too: titles without a native timestamp
118
+ // retain their fallback time if the process dies between publication and cursor advance.
119
+ if (reset) { cursor.offset = 0; cursor.at = ""; cursor.skipping = 0; }
120
+ db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,at=?,readStart=?,readAt=?,skipping=? WHERE id=1")
121
+ .run(cursor.offset, String(stat.dev), String(stat.ino), stat.size, cursor.at, cursor.offset, new Date().toISOString(), cursor.skipping);
122
+ db.run("COMMIT"); db.run("BEGIN IMMEDIATE");
123
+ cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
124
+ }
125
+ const bytes = Buffer.alloc(Math.min(HOOK_TRANSCRIPT_BYTES, Math.max(0, stat.size - cursor.offset)));
126
+ const length = readSync(fd, bytes, 0, bytes.length, cursor.offset);
127
+ const after = fstatSync(fd), live = lstatSync(value.transcript_path);
128
+ if (length !== bytes.length || after.size < stat.size || live.dev !== stat.dev || live.ino !== stat.ino || realPath(value.transcript_path) !== transcript) return;
129
+ let start = 0, stopReason: HookTranscriptProgress["stopReason"];
130
+ while (start < length && performance.now() < options.deadline) {
131
+ const newline = bytes.indexOf(10, start);
132
+ if (cursor.skipping) {
133
+ start = newline < 0 ? length : newline + 1;
134
+ if (newline >= 0) cursor.skipping = 0;
135
+ continue;
136
+ }
137
+ if (newline < 0) {
138
+ // ponytail: a JSONL line exceeding the invocation's 256 KiB ceiling is
139
+ // lost, not buffered in memory/home. Upgrade with a bounded streaming extractor.
140
+ if (start === 0 && length === HOOK_TRANSCRIPT_BYTES && stat.size - cursor.offset > length) {
141
+ cursor.skipping = 1; start = length;
142
+ appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "transcript_line_bound" }]);
143
+ }
144
+ break;
145
+ }
146
+ let line: string;
147
+ try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, newline)); }
148
+ catch { start = newline + 1; continue; }
149
+ let summary: ReturnType<typeof transcriptSummary>;
150
+ try { summary = transcriptSummary(line); } catch { /* Malformed native JSON is not capture. */ }
151
+ if (summary && !await policy.screen(JSON.parse(line))) summary = undefined;
152
+ if (summary) {
153
+ const title = summary.event === "Title" ? hash(summary.text) : "";
154
+ if (!title || title !== cursor.title) {
155
+ try {
156
+ let at = summary.at ?? cursor.at;
157
+ if (!at) {
158
+ // Native timestamps normally identify summaries. Untimed content retains its
159
+ // first fallback across rotation, so an unchanged row cannot get a new receipt.
160
+ const digest = hash(JSON.stringify([summary.event, summary.text, summary.subagent ?? false]));
161
+ db.query("INSERT OR IGNORE INTO fallback VALUES (?,?)").run(digest, cursor.readAt);
162
+ at = (db.query("SELECT at FROM fallback WHERE digest=?").get(digest) as { at: string }).at;
163
+ if (!timestamp(at)) throw new Error("transcript cursor unavailable");
164
+ }
165
+ const staged = await stageTranscriptCapture({ agent: "claude", event: summary.event, excerpt: summary.text, at, session,
166
+ ...(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);
167
+ if (staged && title) cursor.title = title;
168
+ } catch (error) {
169
+ // Advance only through durable/refused rows; a full spool retries this line
170
+ // after the ordinary hook worker drains earlier publications.
171
+ if (error instanceof HookSpoolFull) { stopReason = "spool_full"; break; }
172
+ throw error;
173
+ }
174
+ }
175
+ }
176
+ // Carry timestamps without parsing ordinary messages (including across invocation boundaries).
177
+ const at = transcriptTimestamp(line); if (at) cursor.at = at;
178
+ start = newline + 1;
179
+ }
180
+ if (!stopReason && start < length && performance.now() >= options.deadline) stopReason = "deadline";
181
+ if (!stopReason && cursor.offset + start < stat.size && length === HOOK_TRANSCRIPT_BYTES) stopReason = "cap";
182
+ assertFormatFloor(base, "hook-spool");
183
+ 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);
184
+ db.run("COMMIT");
185
+ return { offset: cursor.offset + start, ...(stopReason ? { stopReason } : {}) };
186
+ } finally {
187
+ try { if (db) { try { db.run("ROLLBACK"); } catch { /* Already committed. */ } db.close(); } } finally { closeSync(fd); }
188
+ }
189
+ }