@staix/agent-mem 0.17.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,31 +1,45 @@
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 { normalizeCaptureFields, 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";
14
16
  import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
15
17
 
16
- const SCHEMA = "agent-mem.hook-capture/v1";
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,
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
20
+ // (they still count it toward their file and byte caps). A later format that adds fields takes a new prefix again.
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-";
17
24
  const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
18
25
  const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
19
26
  // ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
20
27
  // Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
21
28
  const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
22
- const namePattern = /^(hook-([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
29
+ const namePattern = /^(hook-(?:v[23]-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
23
30
  const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
24
- interface Record { schema: typeof SCHEMA; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
31
+ interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
25
32
  export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
26
33
  export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
27
- 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 }
28
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
+ }
29
43
  function physicalRoot(cwd: string): string {
30
44
  const stat = lstatSync(cwd);
31
45
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook root unavailable");
@@ -264,21 +278,26 @@ function claimOwned(base: string, name: string, file: OwnedFile): OwnedFile | un
264
278
  const owned = { ...file, file: claim };
265
279
  return owns(owned) ? owned : undefined;
266
280
  }
267
- function decoded(file: OwnedFile): { value: Record; bytes: Buffer } | undefined {
281
+ /** `unknown`: a valid envelope (cwd, nonce, scope) under an unknown or newer schema. It stays pending until the TTL, never read as corrupt. */
282
+ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: true } | undefined {
268
283
  if (file.initial.size > RECORD_BYTES) return undefined; // Positively oversized: do not materialize its body.
269
284
  const bytes = Buffer.alloc(file.initial.size);
270
285
  if (readSync(file.fd, bytes, 0, bytes.length, 0) !== bytes.length || !owns(file)) throw new Error("hook record changed");
271
286
  try {
272
287
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
273
- if (!value || typeof value !== "object" || Array.isArray(value) || ![5, 6].includes(Object.keys(value).length)
274
- || Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture"].includes(key))
275
- || value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))
276
- || value.schema !== SCHEMA || typeof value.cwd !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
288
+ if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.cwd !== "string" || typeof value.nonce !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
277
289
  || value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
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;
292
+ if (!v1 && !current) return typeof value.schema === "string" ? { value, bytes, unknown: true } : undefined;
293
+ const keys = current ? 1 : 0;
294
+ if (![5 + keys, 6 + keys].includes(Object.keys(value).length)
295
+ || Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture", ...current ? ["format"] : []].includes(key))
296
+ || value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))) return undefined;
278
297
  const c = value.capture;
279
298
  if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
280
299
  || Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
281
- || 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)
282
301
  || typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
283
302
  || !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
284
303
  const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
@@ -394,7 +413,11 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
394
413
  const record = decoded(file);
395
414
  const valid = record && hash(record.value.cwd) === match[2] && record.value.nonce === match[4] && (record.value.scope ?? "0".repeat(64)) === match[3];
396
415
  let reason: CaptureDropReason | undefined;
397
- if (valid) {
416
+ if (valid && record.unknown) {
417
+ // #199: a newer writer's record waits for a reader that knows it, then leaves with a counted reason, never at once.
418
+ if (Date.now() - row.modified < RETIRE_AFTER_MS) continue;
419
+ reason = "unknown_schema_expired";
420
+ } else if (valid) {
398
421
  if (Date.now() - Date.parse(record.value.capture.at) < RETIRE_AFTER_MS) continue;
399
422
  try { const cwd = lstatSync(record.value.cwd); if (!cwd.isDirectory() || cwd.isSymbolicLink()) reason = "cwd_removed"; }
400
423
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
@@ -425,7 +448,7 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
425
448
  * discard a valid pending record; only positively invalid publications leave admission. */
426
449
  export function quarantineHookScope(options: { cwd: string; base: string; scope: string }): { retired: number } {
427
450
  let retired = 0;
428
- if (!/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
451
+ if (belowFloor(options.base) || !/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
429
452
  const cwd = realPath(options.cwd), base = canonicalPath(options.base), cwdHash = hash(cwd);
430
453
  trimQuarantine(base);
431
454
  const rows = entries(base).filter(row => {
@@ -451,7 +474,7 @@ export function quarantineHookScope(options: { cwd: string; base: string; scope:
451
474
 
452
475
  /** Only screened normalized capture fields are retained, never the native input envelope. */
453
476
  export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
454
- 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;
455
478
  const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
456
479
  if (!await policy.screen(input)) return undefined;
457
480
  const value = structuredClone(input) as {[key: string]: unknown};
@@ -465,19 +488,47 @@ export async function stageHookCapture(event: string, input: unknown, options: O
465
488
  // F4: the person's configured caps apply at staging. Replay (`decoded`) re-normalizes with the built-in caps, which are the ceiling, so a record
466
489
  // staged under a tighter cap still verifies after the cap is raised or removed.
467
490
  const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config; // the repository's .amem/config narrows here too
491
+ const toolEvent = event === "PreToolUse" || event === "PostToolUse" || event === "PostToolUseFailure";
492
+ const contentText = typeof content === "string" ? content : JSON.stringify(content) ?? "";
493
+ const excerpt = toolEvent
494
+ ? clipCaptureUtf8(toolCaptureExcerpt(toolInputLine(tool), contentText), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT))
495
+ : contentText;
468
496
  // #173: attribution. Only a fixed-width hash of the native session id is kept, never the id or transcript path;
469
497
  // a non-empty agent_id marks a subagent (or in-process teammate) event. Both are read from the already-screened input.
470
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;
471
499
  const subagent = typeof value.agent_id === "string" && value.agent_id.length > 0;
472
- 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",
473
501
  ...(session ? { session } : {}), ...(subagent ? { subagent: true } : {}),
474
- ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt: typeof content === "string" ? content : JSON.stringify(content) },
502
+ ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt },
475
503
  undefined,
476
- (text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // the configured patterns redact the excerpt, as the store does
504
+ (text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // keep the normalizer's screen as well as the full envelope and bounded tool text screens
477
505
  { excerptBytes: configured.excerptBytes, paths: configured.capturePaths, ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) });
506
+ // The unchanged normalizer screen and final whole-capture policy also cover the joined input/content.
507
+ // Enriched tool rows are refused rather than persisting the normalizer's redaction marker.
508
+ if (toolEvent && capture.excerpt === "[secret-screened]") return undefined;
478
509
  if (!await policy.screen({ cwd, capture })) return undefined;
479
- const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
480
- const record: Record = { schema: SCHEMA, 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);
481
532
  if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
482
533
  const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
483
534
  let destination = dir, deferred = false, backlog = false;
@@ -506,7 +557,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
506
557
  deferred = true;
507
558
  }
508
559
  }
509
- const name = `hook-${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);
510
561
  try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
511
562
  finally { rmSync(temporary, { force: true }); }
512
563
  return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
@@ -524,6 +575,7 @@ export function hookCapturePending(base: string, name: string): boolean {
524
575
  export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number },
525
576
  append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
526
577
  const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
578
+ if (belowFloor(base)) return { captured, blocked: 1 };
527
579
  if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
528
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.
529
581
  options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
@@ -548,6 +600,14 @@ export async function drainHookCaptures(options: Options & { project: string; ex
548
600
  if (!record || record.value.cwd !== cwd || record.value.scope !== null && record.value.scope !== stamp || record.value.nonce !== match[4]) {
549
601
  retireClaim(base, claim, false); blocked++; continue;
550
602
  }
603
+ // #199: an unknown or newer schema stays claimed and pending (later passes reclaim it) until the TTL, then retires with a counted reason.
604
+ // ponytail: pending unknown records still take drain slots oldest first; a writer that adds fields uses a new prefix, so this is the fallback only.
605
+ if (record.unknown) {
606
+ if (Date.now() - row.modified < RETIRE_AFTER_MS) { blocked++; continue; }
607
+ appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "unknown_schema_expired" }]);
608
+ if (!retireClaim(base, claim, false)) blocked++;
609
+ continue;
610
+ }
551
611
  const value = record.value, c = value.capture;
552
612
  if (value.scope === null) { blocked++; continue; }
553
613
  // #15 T5: a project the person archived or forgot takes no new capture. Retire the record here with a counted reason instead of leaving it pending:
@@ -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
+ }