@staix/agent-mem 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -4
- package/package.json +3 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -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 +85 -0
- package/src/hook-spool.ts +45 -12
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- 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 +24 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- 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 +38 -37
- 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,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
|
-
|
|
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-(?:
|
|
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
|
-
|
|
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),
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
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
|
+
}
|