@staix/agent-mem 0.22.1 → 0.23.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 +59 -35
- package/package.json +4 -3
- package/src/cli.ts +10 -1
- package/src/hook-jcode-journal.ts +153 -0
- package/src/hook-jcode.ts +13 -0
- package/src/hook-record.ts +91 -0
- package/src/hook-spool-replay.ts +10 -1
- package/src/hook-spool.ts +43 -10
- package/src/hook-transcript.ts +68 -4
- package/src/hooks.ts +36 -11
- package/src/index.ts +4 -0
- package/src/memory/capture-input.ts +10 -1
- package/src/memory/capture.ts +42 -2
- package/src/memory/config.ts +9 -2
- package/src/memory/metrics.ts +4 -3
- package/src/memory/private-spans.ts +6 -3
- package/src/memory/repo-scope.ts +7 -1
- package/src/memory/search-score.ts +4 -2
- package/src/memory/sync-placement-prerequisites.ts +8 -1
- package/src/session-brief-worker.ts +1 -1
- package/src/session-brief.ts +21 -0
- package/src/sync-placement-writer-context.ts +31 -7
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { stripPrivateSpans } from "./memory/private-spans.ts";
|
|
2
|
+
|
|
3
|
+
/** Package-owned instruction, never assembled from captured or configured text. */
|
|
4
|
+
export const RECORD_INSTRUCTION = [
|
|
5
|
+
"Only when a turn settles a decision, fix, fail or contract, end the final answer with at most 2 records; otherwise none:",
|
|
6
|
+
"amem: <decision|fix|fail|contract|finding> | <repo/path[#symbol],... or - for decision/contract> | <text, max 300 characters>",
|
|
7
|
+
"For fix/fail use symptom/cause -> fix/retry-when. No fences, quotes, secrets or PII.",
|
|
8
|
+
].join("\n");
|
|
9
|
+
|
|
10
|
+
/** Data remains untrusted after the one exact package-owned instruction prefix. */
|
|
11
|
+
export function recordContextData(context: string): string {
|
|
12
|
+
const prefix = `${RECORD_INSTRUCTION}\n`;
|
|
13
|
+
return context.startsWith(prefix) ? context.slice(prefix.length) : context;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const RECORD_INPUT_BYTES = 256 * 1024;
|
|
17
|
+
|
|
18
|
+
const KINDS = ["decision", "fix", "fail", "contract", "finding"] as const;
|
|
19
|
+
export type RecordKind = typeof KINDS[number];
|
|
20
|
+
export type RecordDropReason = "record_invalid" | "record_count" | "record_length" | "record_policy" | "record_identity" | "record_timestamp";
|
|
21
|
+
export interface ParsedRecord { kind: RecordKind; anchors: string[]; text: string; line: string }
|
|
22
|
+
export interface ParsedRecordTurn { records: ParsedRecord[]; drops: Partial<Record<RecordDropReason, number>>; incomplete?: true }
|
|
23
|
+
const controls = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
24
|
+
|
|
25
|
+
/** Lexical repository-relative anchors; deleted paths need no filesystem lookup. */
|
|
26
|
+
function validAnchor(anchor: string): boolean {
|
|
27
|
+
const parts = anchor.split("#"), path = parts[0]!;
|
|
28
|
+
if (!path || path.startsWith("/") || path.startsWith("~") || /[\\:|<>*?\[\]{}]/.test(path) || controls.test(anchor)
|
|
29
|
+
|| parts.length > 2 || parts.length === 2 && (!parts[1] || /\s/.test(parts[1]))) return false;
|
|
30
|
+
return !path.split("/").some(part => !part || part === "." || part === "..");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function recordLine(line: string): ParsedRecord | RecordDropReason {
|
|
34
|
+
const fields = /^amem: ([^|\s]+) \| ([^|]+) \| (.*)$/.exec(line);
|
|
35
|
+
if (!fields || controls.test(line)) return "record_invalid";
|
|
36
|
+
const [, kind, rawAnchors, text] = fields;
|
|
37
|
+
if (!(KINDS as readonly string[]).includes(kind!) || !text || text !== text.trim()) return "record_invalid";
|
|
38
|
+
let characters = 0;
|
|
39
|
+
for (const _character of text) if (++characters > 300) return "record_length";
|
|
40
|
+
if ((kind === "fix" || kind === "fail") && !/^.+ -> .+$/u.test(text)) return "record_invalid";
|
|
41
|
+
const anchors = rawAnchors!.split(",").map(anchor => anchor.trim());
|
|
42
|
+
if (rawAnchors === "-") {
|
|
43
|
+
if (kind !== "decision" && kind !== "contract") return "record_invalid";
|
|
44
|
+
anchors.length = 0;
|
|
45
|
+
} else if (!anchors.every(validAnchor)) return "record_invalid";
|
|
46
|
+
return { kind: kind as RecordKind, anchors, text, line: `amem: ${kind} | ${anchors.length ? anchors.join(", ") : "-"} | ${text}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One complete assistant text, including its text blocks; no JSON/model/network/store work. */
|
|
50
|
+
export function parseRecordLines(text: string, shouldStop?: () => boolean): ParsedRecordTurn {
|
|
51
|
+
const result: ParsedRecordTurn = { records: [], drops: {} };
|
|
52
|
+
// ponytail: share the native 256 KiB ceiling; larger answers need a bounded streaming parser.
|
|
53
|
+
if (text.length > RECORD_INPUT_BYTES || Buffer.byteLength(text) > RECORD_INPUT_BYTES) return { records: [], drops: { record_length: 1 } };
|
|
54
|
+
const drop = (reason: RecordDropReason) => { result.drops[reason] = (result.drops[reason] ?? 0) + 1; };
|
|
55
|
+
const privateRanges: { start: number; end: number }[] = [];
|
|
56
|
+
stripPrivateSpans(text, (start, end) => privateRanges.push({ start, end }));
|
|
57
|
+
let fence: { character: string; length: number } | undefined, quote = false, offset = 0, rangeIndex = 0;
|
|
58
|
+
for (const chunk of text.split("\n")) {
|
|
59
|
+
if (shouldStop?.()) { result.incomplete = true; break; }
|
|
60
|
+
const newline = offset + chunk.length < text.length, line = newline && chunk.endsWith("\r") ? chunk.slice(0, -1) : chunk;
|
|
61
|
+
const start = offset, end = start + line.length; offset += chunk.length + 1;
|
|
62
|
+
while (privateRanges[rangeIndex] && privateRanges[rangeIndex]!.end <= start) rangeIndex++;
|
|
63
|
+
let safeLine = "", cursor = start, hasPrivate = false, privatePrefix = false, endsPrivate = false;
|
|
64
|
+
for (let index = rangeIndex; privateRanges[index] && privateRanges[index]!.start < end; index++) {
|
|
65
|
+
const range = privateRanges[index]!;
|
|
66
|
+
hasPrivate = true; privatePrefix ||= range.start <= start && range.end > start;
|
|
67
|
+
safeLine += text.slice(cursor, Math.max(cursor, Math.min(range.start, end)));
|
|
68
|
+
cursor = Math.min(end, Math.max(cursor, range.end));
|
|
69
|
+
endsPrivate = range.end <= end;
|
|
70
|
+
}
|
|
71
|
+
safeLine += text.slice(cursor, end);
|
|
72
|
+
if (hasPrivate && !safeLine.trim() && !endsPrivate) continue;
|
|
73
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(safeLine);
|
|
74
|
+
if (fence) {
|
|
75
|
+
if (marker && marker[1]![0] === fence.character && marker[1]!.length >= fence.length && !marker[2]!.trim()) fence = undefined;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (marker && (marker[1]![0] !== "`" || !marker[2]!.includes("`"))) {
|
|
79
|
+
fence = { character: marker[1]![0]!, length: marker[1]!.length }; quote = false; continue;
|
|
80
|
+
}
|
|
81
|
+
if (/^\s*>/.test(safeLine)) { quote = true; continue; }
|
|
82
|
+
if (quote) { if (!safeLine.trim()) quote = false; continue; }
|
|
83
|
+
if (hasPrivate) { if (!privatePrefix && line.startsWith("amem:")) drop("record_policy"); continue; }
|
|
84
|
+
if (!line.startsWith("amem:")) continue;
|
|
85
|
+
const record = recordLine(line);
|
|
86
|
+
if (typeof record === "string") drop(record);
|
|
87
|
+
else if (result.records.length >= 2) drop("record_count");
|
|
88
|
+
else result.records.push(record);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
package/src/hook-spool-replay.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { isAbsolute, join } from "node:path";
|
|
|
20
20
|
import { hookCaptureScope, hookLegacyCaptureScope, hookSpoolReplayInternals as spool, type HookSpoolOwnedFile } from "./hook-spool.ts";
|
|
21
21
|
import { aliasedKeys, resolveAlias } from "./memory/aliases.ts";
|
|
22
22
|
import { CaptureStore, type CaptureInput } from "./memory/capture.ts";
|
|
23
|
+
import { RecordNormalizationRefusal } from "./memory/capture-input.ts";
|
|
23
24
|
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
24
25
|
import { projectState } from "./memory/lifecycle.ts";
|
|
25
26
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
@@ -316,7 +317,15 @@ export async function applyHookSpoolReplay(options: { base: string; plan: HookSp
|
|
|
316
317
|
if (!decoded || decoded.unknown || hash(decoded.bytes) !== record.digest) { retain("decode_changed"); closeSync(claim.fd); held = undefined; continue; }
|
|
317
318
|
// One CaptureStore at a time: close the previous project's store before opening the next one.
|
|
318
319
|
if (storeProject !== record.project) { store?.close(); store = new CaptureStore({ memoryDir: home, project: record.project!, cwd: realPath(record.cwd) }); storeProject = record.project!; }
|
|
319
|
-
|
|
320
|
+
let result: ReturnType<CaptureStore["append"]>;
|
|
321
|
+
try { result = store!.append(captureInput(decoded.value.capture as never, record.project!), { receipt: hash(decoded.bytes) }); }
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (!(error instanceof RecordNormalizationRefusal) || decoded.value.capture.event !== "Record") throw error;
|
|
324
|
+
if (spool.retireRecordPolicy(home, claim)) {
|
|
325
|
+
receipt.retired++; receipt.outcomes.push({ name: record.name, outcome: "skipped", reason: "Record declined by current normalization policy; stage a complete compatible Record before replaying again" });
|
|
326
|
+
} else retain("claim changed before policy retirement; preview again before applying");
|
|
327
|
+
closeSync(claim.fd); held = undefined; continue;
|
|
328
|
+
}
|
|
320
329
|
const spooled = "spooled" in result;
|
|
321
330
|
if (spooled) receipt.spooled++; else receipt.applied++;
|
|
322
331
|
const retired = spool.retireOwned(claim);
|
package/src/hook-spool.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openS
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { parseRecordLines, type ParsedRecord, type RecordDropReason } from "./hook-record.ts";
|
|
8
|
+
import { CAPTURE_MAX_EXCERPT, TRANSCRIPT_CAPTURE_EVENTS, RecordNormalizationRefusal, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type CaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
8
9
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
9
10
|
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
10
11
|
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
@@ -33,7 +34,12 @@ export const hookReplayBudget = (): HookReplayBudget => ({ files: HOOK_REPLAY_MA
|
|
|
33
34
|
const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
|
|
34
35
|
const namePattern = /^(hook-(?:v[23]-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
35
36
|
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
36
|
-
const summaryEvent = (event: string) =>
|
|
37
|
+
const summaryEvent = (event: string) => TRANSCRIPT_CAPTURE_EVENTS.includes(event);
|
|
38
|
+
function recordCapture(input: Pick<CaptureFields, "excerpt" | "paths">): ParsedRecord | undefined {
|
|
39
|
+
const parsed = parseRecordLines(input.excerpt ?? ""), record = parsed.records[0];
|
|
40
|
+
return record !== undefined && parsed.records.length === 1 && !Object.keys(parsed.drops).length && record.line === input.excerpt
|
|
41
|
+
&& JSON.stringify(record.anchors) === JSON.stringify(input.paths ?? []) ? record : undefined;
|
|
42
|
+
}
|
|
37
43
|
interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
|
|
38
44
|
export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
|
|
39
45
|
export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
|
|
@@ -379,9 +385,10 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: tru
|
|
|
379
385
|
const c = value.capture;
|
|
380
386
|
if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
|
|
381
387
|
|| Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
|
|
382
|
-
|| !(HOOK_AGENTS as readonly string[]).includes(c.agent) || (c.agent === "claude" && !
|
|
388
|
+
|| !(HOOK_AGENTS as readonly string[]).includes(c.agent) || (c.agent === "claude" && !TRANSCRIPT_CAPTURE_EVENTS.includes(c.event)) === v3 || ![...HOOK_EVENTS, ...TRANSCRIPT_CAPTURE_EVENTS].includes(c.event)
|
|
383
389
|
|| typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
|
|
384
390
|
|| !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
|
|
391
|
+
if (c.event === "Record" && (c.agent !== "claude" || c.subagent === true || !recordCapture(c))) return undefined;
|
|
385
392
|
const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
|
|
386
393
|
...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }),
|
|
387
394
|
...(c.session === undefined ? {} : { session: c.session }), ...(c.subagent === undefined ? {} : { subagent: c.subagent }) });
|
|
@@ -447,6 +454,13 @@ function retireClaim(base: string, file: OwnedFile, preserve: boolean, batch?: Q
|
|
|
447
454
|
return true;
|
|
448
455
|
}
|
|
449
456
|
|
|
457
|
+
/** Reuse the existing body-free policy quarantine; refused Records must not monopolize replay slots. */
|
|
458
|
+
function retireRecordPolicy(base: string, file: OwnedFile): boolean {
|
|
459
|
+
if (!owns(file)) return false;
|
|
460
|
+
appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "record_policy" }]);
|
|
461
|
+
return retireClaim(base, file, true);
|
|
462
|
+
}
|
|
463
|
+
|
|
450
464
|
const RETIRE_AFTER_MS = 7 * 24 * 3600_000, MISSING_CWD_AFTER_MS = 3600_000;
|
|
451
465
|
interface MaintenanceBudget { files: number; bytes: number }
|
|
452
466
|
const maintenanceBudget = (): MaintenanceBudget => ({ files: DRAIN_FILES, bytes: DRAIN_BYTES });
|
|
@@ -689,23 +703,35 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
689
703
|
}
|
|
690
704
|
|
|
691
705
|
/** Transcript text is screened in full before caps, then shares the ordinary durable spool. */
|
|
692
|
-
export async function stageTranscriptCapture(input: CaptureFields, options: Options, receiptIdentity?: string): Promise<PendingHookCapture | undefined> {
|
|
693
|
-
if (options.policy?.host || belowFloor(options.base) || input.agent !== "claude"
|
|
706
|
+
export async function stageTranscriptCapture(input: CaptureFields, options: Options, receiptIdentity?: string, onRecordDrop?: (reason: RecordDropReason) => void): Promise<PendingHookCapture | undefined> {
|
|
707
|
+
if (options.policy?.host || belowFloor(options.base) || !TRANSCRIPT_CAPTURE_EVENTS.includes(input.event) || input.agent !== "claude" && !(input.agent === "jcode" && (input.event === "Title" || input.event === "Compact"))) return;
|
|
694
708
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base);
|
|
695
709
|
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
696
|
-
|
|
710
|
+
const record = input.event === "Record";
|
|
711
|
+
if (!record && !await policy.screen({ cwd, input })) return;
|
|
697
712
|
const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config;
|
|
713
|
+
if (record && (configured.records === false || input.subagent === true)) return;
|
|
714
|
+
if (record) {
|
|
715
|
+
if (typeof input.at !== "string" || !Number.isFinite(Date.parse(input.at))) { onRecordDrop?.("record_timestamp"); return; }
|
|
716
|
+
if (stripPrivateSpans(input.excerpt ?? "") !== input.excerpt) { onRecordDrop?.("record_policy"); return; }
|
|
717
|
+
const line = recordCapture(input);
|
|
718
|
+
if (!line) { onRecordDrop?.("record_invalid"); return; }
|
|
719
|
+
if (stripPrivateSpans(line.line) !== line.line
|
|
720
|
+
|| !await policy.screen({ cwd, input, paths: line.anchors.map(anchor => anchor.split("#")[0]!) })) { onRecordDrop?.("record_policy"); return; }
|
|
721
|
+
if (Buffer.byteLength(line.line) > Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT)) { onRecordDrop?.("record_length"); return; }
|
|
722
|
+
}
|
|
698
723
|
const capture = normalizeCaptureFields({ ...input, excerpt: clipCaptureUtf8(stripPrivateSpans(input.excerpt ?? ""), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT)) }, undefined,
|
|
699
724
|
text => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))),
|
|
700
725
|
{ excerptBytes: configured.excerptBytes, paths: configured.capturePaths, denyPaths: configured.denyPaths });
|
|
701
|
-
if (capture.excerpt
|
|
726
|
+
if (record && (capture.excerpt !== input.excerpt || JSON.stringify(capture.paths) !== JSON.stringify(input.paths ?? []))) { onRecordDrop?.("record_policy"); return; }
|
|
727
|
+
if (capture.excerpt === "[secret-screened]" || !await policy.screen({ cwd, capture })) { if (record) onRecordDrop?.("record_policy"); return; }
|
|
702
728
|
return publishCapture(input.event, capture, { ...options, cwd, base }, hash(receiptIdentity === undefined ? JSON.stringify(capture) : JSON.stringify([capture, receiptIdentity])).slice(0, 32));
|
|
703
729
|
}
|
|
704
730
|
|
|
705
731
|
function publishCapture(event: string, capture: NormalizedCaptureFields, options: Options, nonce = randomBytes(16).toString("hex")): PendingHookCapture {
|
|
706
732
|
const { cwd, base } = options;
|
|
707
733
|
const scope = hookCaptureScope(cwd, base) ?? null;
|
|
708
|
-
const v3 = capture.agent !== "claude" ||
|
|
734
|
+
const v3 = capture.agent !== "claude" || TRANSCRIPT_CAPTURE_EVENTS.includes(event);
|
|
709
735
|
const record: Record = { schema: FAMILY, format: v3 ? FORMAT_V3 : FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
|
|
710
736
|
if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
|
|
711
737
|
const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
|
|
@@ -715,7 +741,7 @@ function publishCapture(event: string, capture: NormalizedCaptureFields, options
|
|
|
715
741
|
const withinShare = (rows: ReturnType<typeof entries>, maximum: number) => rows.filter(row => namePattern.exec(row.name)?.[2] === cwdHash).length < maximum / 4;
|
|
716
742
|
const name = `${v3 ? PREFIX_V3 : PREFIX}${cwdHash}-${scope ?? "0".repeat(64)}-${nonce}.json`;
|
|
717
743
|
let activeInventory: ReturnType<typeof entries> | undefined, reserveInventory: ReturnType<typeof entries> | undefined;
|
|
718
|
-
if (
|
|
744
|
+
if (TRANSCRIPT_CAPTURE_EVENTS.includes(event)) {
|
|
719
745
|
// Transcript cursor recovery can repeat a deterministic publication. An already
|
|
720
746
|
// durable identical body needs no new slot, including when a drainer owns it.
|
|
721
747
|
for (const capacity of [false, true]) {
|
|
@@ -856,7 +882,13 @@ export async function drainHookCaptures(options: Options & { project: string; ex
|
|
|
856
882
|
...(c.session === undefined ? {} : { session: c.session }), ...(c.subagent === undefined ? {} : { subagent: c.subagent }) };
|
|
857
883
|
if (!await policy.screen(value) || !await policy.screen(input)) { retireClaim(base, claim, true); blocked++; continue; }
|
|
858
884
|
if (!owns(claim) || hookCaptureScope(cwd, base) !== stamp || value.scope === legacyStamp && captureScope(cwd, base, false, undefined, true) !== legacyStamp) { blocked++; continue; }
|
|
859
|
-
|
|
885
|
+
try {
|
|
886
|
+
append(input, hash(record.bytes));
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
if (!(error instanceof RecordNormalizationRefusal) || input.event !== "Record") throw error;
|
|
890
|
+
retireRecordPolicy(base, claim); blocked++; continue;
|
|
891
|
+
}
|
|
860
892
|
if (owns(claim)) { rmSync(claim.file); captured.push(name); }
|
|
861
893
|
else blocked++;
|
|
862
894
|
} catch { blocked++; } // Transient failure keeps the unique claim for a later idempotent retry.
|
|
@@ -875,6 +907,7 @@ export const hookSpoolReplayInternals = {
|
|
|
875
907
|
owns: (file: OwnedFile) => owns(file),
|
|
876
908
|
decoded: (file: OwnedFile) => decoded(file),
|
|
877
909
|
belowFloor: (base: string) => belowFloor(base),
|
|
910
|
+
retireRecordPolicy: (base: string, file: OwnedFile) => retireRecordPolicy(base, file),
|
|
878
911
|
/** Claim under a caller-chosen deterministic suffix (#217 decision B: hash(plan digest, publication)), never a random one. */
|
|
879
912
|
claimAs(file: OwnedFile, name: string, suffix: string): OwnedFile | undefined {
|
|
880
913
|
const match = namePattern.exec(name);
|
package/src/hook-transcript.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import { parseRecordLines, RECORD_INPUT_BYTES, type ParsedRecordTurn, type RecordDropReason } from "./hook-record.ts";
|
|
2
|
+
import { stripPrivateSpans } from "./memory/private-spans.ts";
|
|
1
3
|
import { assertFormatFloor } from "./memory/formats.ts";
|
|
2
4
|
import { Database } from "bun:sqlite";
|
|
3
5
|
import { createHash } from "node:crypto";
|
|
4
6
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
5
7
|
import { join } from "node:path";
|
|
6
8
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
7
|
-
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
9
|
+
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
8
10
|
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
9
11
|
import { HookSpoolFull, stageTranscriptCapture, type PendingHookCapture } from "./hook-spool.ts";
|
|
10
12
|
import { appendMetrics } from "./memory/metrics.ts";
|
|
11
13
|
import type { HookTranscriptProgress } from "./hook-contract.ts";
|
|
12
14
|
|
|
13
|
-
export const HOOK_TRANSCRIPT_BYTES =
|
|
15
|
+
export const HOOK_TRANSCRIPT_BYTES = RECORD_INPUT_BYTES;
|
|
14
16
|
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
15
17
|
type SummaryEvent = "Title" | "Recap" | "Compact";
|
|
16
18
|
interface Cursor { offset: number; dev: string; ino: string; size: number; title: string; at: string; readStart: number; readAt: string; skipping: number }
|
|
@@ -40,6 +42,25 @@ export function transcriptSummary(line: string): { event: SummaryEvent; text: st
|
|
|
40
42
|
return { event, text: visibleText, ...(timestamp(row.timestamp) ? { at: timestamp(row.timestamp)! } : {}), ...(row.isSidechain === true || typeof row.agentId === "string" && row.agentId.length > 0 ? { subagent: true as const } : {}) };
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/** Only Claude main-session assistant text is a record source. Check the marker before parsing JSON. */
|
|
46
|
+
export function transcriptRecords(line: string, shouldStop?: () => boolean): { messageId?: string; at?: string; parsed: ParsedRecordTurn } | undefined {
|
|
47
|
+
if (!line.includes("amem:")) return;
|
|
48
|
+
const row = object(JSON.parse(line));
|
|
49
|
+
if (!row || row.type !== "assistant" || row.isSidechain === true || typeof row.agentId === "string" && row.agentId.length > 0) return;
|
|
50
|
+
const message = object(row.message);
|
|
51
|
+
if (!message || message.role !== undefined && message.role !== "assistant") return;
|
|
52
|
+
const content = message.content;
|
|
53
|
+
const text = typeof content === "string" ? content : Array.isArray(content)
|
|
54
|
+
? content.flatMap(part => { const value = object(part); return value?.type === "text" && typeof value.text === "string" ? [value.text] : []; }).join("\n") : undefined;
|
|
55
|
+
if (text === undefined || !stripPrivateSpans(text).includes("amem:")) return;
|
|
56
|
+
const messageId = typeof message.id === "string" && message.id.length > 0 && message.id.length <= 256 && message.id.trim() === message.id
|
|
57
|
+
&& !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(message.id) ? message.id : undefined;
|
|
58
|
+
if (!messageId) return { parsed: { records: [], drops: { record_identity: 1 } } };
|
|
59
|
+
const at = timestamp(row.timestamp);
|
|
60
|
+
if (!at) return { parsed: { records: [], drops: { record_timestamp: 1 } } };
|
|
61
|
+
return { messageId, at, parsed: parseRecordLines(text, shouldStop) };
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/** Read only a top-level timestamp token, without parsing ordinary message bodies. */
|
|
44
65
|
function transcriptTimestamp(line: string): string | undefined {
|
|
45
66
|
if (!line.includes('"timestamp"')) return;
|
|
@@ -62,13 +83,13 @@ function transcriptTimestamp(line: string): string | undefined {
|
|
|
62
83
|
return at;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
function privateDirectory(dir: string): void {
|
|
86
|
+
export function privateDirectory(dir: string): void {
|
|
66
87
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
88
|
const stat = lstatSync(dir);
|
|
68
89
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("transcript cursor unavailable");
|
|
69
90
|
chmodSync(dir, 0o700);
|
|
70
91
|
}
|
|
71
|
-
function cursorDatabase(file: string, dev: string, ino: string, size: number): Database {
|
|
92
|
+
export function cursorDatabase(file: string, dev: string, ino: string, size: number): Database {
|
|
72
93
|
const fd = openSync(file, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
73
94
|
try {
|
|
74
95
|
const stat = fstatSync(fd), named = lstatSync(file);
|
|
@@ -103,6 +124,8 @@ export async function scanHookTranscriptTail(input: unknown, options: Options):
|
|
|
103
124
|
if (value.cwd !== undefined && (typeof value.cwd !== "string" || realPath(value.cwd) !== cwd)) return;
|
|
104
125
|
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
105
126
|
if (!await policy.screen(value)) return;
|
|
127
|
+
const recordsEnabled = loadAmemConfig(base, repoConfigRoot(cwd)).config.records !== false
|
|
128
|
+
&& !(typeof value.agent_id === "string" && value.agent_id.length > 0);
|
|
106
129
|
const session = hash(`amem-session\0${value.session_id}`).slice(0, 16);
|
|
107
130
|
if (lstatSync(value.transcript_path).isSymbolicLink()) throw new Error("transcript unavailable");
|
|
108
131
|
const transcript = realPath(value.transcript_path);
|
|
@@ -133,6 +156,11 @@ export async function scanHookTranscriptTail(input: unknown, options: Options):
|
|
|
133
156
|
const after = fstatSync(fd), live = lstatSync(value.transcript_path);
|
|
134
157
|
if (length !== bytes.length || after.size < stat.size || live.dev !== stat.dev || live.ino !== stat.ino || realPath(value.transcript_path) !== transcript) return;
|
|
135
158
|
let start = 0, stopReason: HookTranscriptProgress["stopReason"];
|
|
159
|
+
// ponytail: the two-record cap is per native message.id within this scan only; no durable count.
|
|
160
|
+
// A crash after staging but before cursor save has the summaries' duplicate window.
|
|
161
|
+
// Upgrade to a msgid+ordinal receipt if the AC6 live week demonstrates duplicates.
|
|
162
|
+
const recordCounts = new Map<string, number>(), recordDrops: Partial<Record<RecordDropReason, number>> = {};
|
|
163
|
+
const dropRecord = (reason: RecordDropReason, count = 1) => { recordDrops[reason] = (recordDrops[reason] ?? 0) + count; };
|
|
136
164
|
const workStart = performance.now();
|
|
137
165
|
// The caller's deadline is read live (a caller may move it); the work budget only adds an earlier end after setup.
|
|
138
166
|
const due = () => performance.now() >= options.deadline || options.workBudgetMs !== undefined && performance.now() - workStart >= options.workBudgetMs;
|
|
@@ -155,6 +183,36 @@ export async function scanHookTranscriptTail(input: unknown, options: Options):
|
|
|
155
183
|
let line: string;
|
|
156
184
|
try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, newline)); }
|
|
157
185
|
catch { start = newline + 1; continue; }
|
|
186
|
+
let records: ReturnType<typeof transcriptRecords>;
|
|
187
|
+
if (recordsEnabled) try { records = transcriptRecords(line, due); } catch { /* Malformed native JSON is not a record source. */ }
|
|
188
|
+
// Privacy flags and policy metadata belong to the source row, not just its extracted text.
|
|
189
|
+
if (records && !await policy.screen(JSON.parse(line))) { dropRecord("record_policy", Math.max(1, records.parsed.records.length)); records = undefined; }
|
|
190
|
+
let recordPublication: PendingHookCapture | undefined;
|
|
191
|
+
if (records) {
|
|
192
|
+
for (const [reason, count] of Object.entries(records.parsed.drops)) dropRecord(reason as RecordDropReason, count!);
|
|
193
|
+
if (records.parsed.incomplete) { stopReason = "deadline"; break; }
|
|
194
|
+
if (records.messageId && records.at) for (const record of records.parsed.records) {
|
|
195
|
+
const count = recordCounts.get(records.messageId) ?? 0;
|
|
196
|
+
if (count >= 2) { dropRecord("record_count"); continue; }
|
|
197
|
+
recordCounts.set(records.messageId, count + 1);
|
|
198
|
+
if (due()) { stopReason = "deadline"; break; }
|
|
199
|
+
const publish = () => stageTranscriptCapture({ agent: "claude", event: "Record", excerpt: record.line, paths: record.anchors,
|
|
200
|
+
at: records!.at, session }, { cwd, base, policy: options.policy, deferAtCapacity: true }, undefined, dropRecord);
|
|
201
|
+
try {
|
|
202
|
+
let staged: PendingHookCapture | undefined;
|
|
203
|
+
try { staged = await publish(); }
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!(error instanceof HookSpoolFull) || due() || !options.onBackpressure || !await options.onBackpressure() || due()) throw error;
|
|
206
|
+
staged = await publish();
|
|
207
|
+
}
|
|
208
|
+
if (staged) recordPublication = staged;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof HookSpoolFull) { stopReason = "spool_full"; break; }
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (stopReason) break;
|
|
215
|
+
}
|
|
158
216
|
let summary: ReturnType<typeof transcriptSummary>;
|
|
159
217
|
try { summary = transcriptSummary(line); } catch { /* Malformed native JSON is not capture. */ }
|
|
160
218
|
if (summary && !await policy.screen(JSON.parse(line))) summary = undefined;
|
|
@@ -192,9 +250,15 @@ export async function scanHookTranscriptTail(input: unknown, options: Options):
|
|
|
192
250
|
// Carry timestamps without parsing ordinary messages (including across invocation boundaries).
|
|
193
251
|
const at = transcriptTimestamp(line); if (at) cursor.at = at;
|
|
194
252
|
start = newline + 1;
|
|
253
|
+
// Stage the whole row before optional replay. A slow replay must not restart
|
|
254
|
+
// its first Record forever; the remaining durable publications replay later.
|
|
255
|
+
if (recordPublication && !due()) await options.onPublication?.(recordPublication);
|
|
195
256
|
}
|
|
196
257
|
if (!stopReason && start < length && due()) stopReason = "deadline";
|
|
197
258
|
if (!stopReason && cursor.offset + start < stat.size && length === HOOK_TRANSCRIPT_BYTES) stopReason = "cap";
|
|
259
|
+
if (Object.keys(recordDrops).length) appendMetrics(base, Object.entries(recordDrops).map(([reason, count]) => ({
|
|
260
|
+
at: new Date().toISOString(), kind: "hook", captureDrops: count!, captureDropReason: reason as RecordDropReason,
|
|
261
|
+
})));
|
|
198
262
|
assertFormatFloor(base, "hook-spool");
|
|
199
263
|
db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,title=?,at=?,skipping=? WHERE id=1").run(cursor.offset + start, String(stat.dev), String(stat.ino), stat.size, cursor.title, cursor.at, cursor.skipping);
|
|
200
264
|
db.run("COMMIT");
|
package/src/hooks.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { RECORD_INSTRUCTION } from "./hook-record.ts";
|
|
1
3
|
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { resolveHookProjectAsync, type HookProjectReceipt } from "./hook-project.ts";
|
|
3
5
|
import { hookProcessSignature, type HookNativeUsage } from "./hook-native.ts";
|
|
@@ -6,7 +8,7 @@ import { combineRssSources, countedRssSources, emptyRssSources, hookRssReading,
|
|
|
6
8
|
import { HookWorkerRss } from "./hook-rss-wire.ts";
|
|
7
9
|
import { appendMetrics, captureDropSummary } from "./memory/metrics.ts";
|
|
8
10
|
import { stageHookCapture, drainHookCaptures, hookCaptureScope, quarantineHookScope, HookSpoolFull, type HookAgent, type PendingHookCapture } from "./hook-spool.ts";
|
|
9
|
-
import { jcodeHookCapture } from "./hook-jcode.ts";
|
|
11
|
+
import { jcodeHookCapture, jcodeJournalTarget } from "./hook-jcode.ts";
|
|
10
12
|
import { memoryHome } from "./memory/home.ts";
|
|
11
13
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
12
14
|
import { createStandalonePolicy, type MemoryPolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
@@ -58,6 +60,12 @@ function joinedContext(context: string, notice: string, maximum: number): string
|
|
|
58
60
|
return Buffer.byteLength(fallback) <= maximum ? fallback : "";
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
function instructionCost(context: string): { recordInstructionBytes: number; recordInstructionEstimatedTokens: number } {
|
|
64
|
+
const bytes = context.startsWith(`${RECORD_INSTRUCTION}\n`) ? Buffer.byteLength(RECORD_INSTRUCTION) + 1 : 0;
|
|
65
|
+
// Approximation only: the UTF-8 byte count is exact; tokens depend on the host tokenizer.
|
|
66
|
+
return { recordInstructionBytes: bytes, recordInstructionEstimatedTokens: Math.ceil(bytes / 4) };
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
async function before<T>(work: Promise<T>, deadline: number): Promise<T | undefined> {
|
|
62
70
|
const safe = work.catch(() => undefined);
|
|
63
71
|
const remaining = deadline - performance.now();
|
|
@@ -227,13 +235,17 @@ async function hookWorker(options: HookOptions, base: string, deadline: number,
|
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
/** SessionStart shared pieces: the numeric capture-loss notice and the native byte ceiling. */
|
|
230
|
-
function sessionNoticeAndLimit(base: string, cwd: string, options: HookOptions, currentDropped: boolean): { dropNotice: string; contextLimit: number } {
|
|
238
|
+
function sessionNoticeAndLimit(base: string, cwd: string, options: HookOptions, currentDropped: boolean, header = unavailable): { dropNotice: string; contextLimit: number; instruction: string } {
|
|
231
239
|
const losses = captureDropSummary(base);
|
|
232
240
|
if (currentDropped) losses.spool_full = (losses.spool_full ?? 0) + 1;
|
|
233
|
-
|
|
234
|
-
const
|
|
241
|
+
// Record parsing refusals remain observable metrics, but are not lost captures.
|
|
242
|
+
const dropNotice = Object.entries(losses).filter(([reason]) => !reason.startsWith("record_")).map(([reason, count]) => `capture dropped: ${reason.replaceAll("_", " ")} (${count})`).join("; ");
|
|
243
|
+
const config = loadAmemConfig(base, repoConfigRoot(cwd)).config;
|
|
244
|
+
const configuredBrief = config.briefBytes;
|
|
235
245
|
const contextLimit = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, Number.isFinite(options.maxTokens) ? Math.floor(options.maxTokens!) : HOOK_CONTEXT_BYTES));
|
|
236
|
-
|
|
246
|
+
const prefix = `${RECORD_INSTRUCTION}\n`;
|
|
247
|
+
const instruction = config.records !== false && Buffer.byteLength(prefix) + Buffer.byteLength(header) + Buffer.byteLength(dropNotice) + (dropNotice ? 1 : 0) <= contextLimit ? prefix : "";
|
|
248
|
+
return { dropNotice, contextLimit: contextLimit - Buffer.byteLength(instruction), instruction };
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
/** SessionStart spends one Bun boot on context. Capture remains durable/pending;
|
|
@@ -249,8 +261,9 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
249
261
|
if (remaining() <= 0) { result.dropped = !pending; return result; }
|
|
250
262
|
if (sessionSource(input) === "resume") {
|
|
251
263
|
try {
|
|
252
|
-
const { dropNotice, contextLimit } = sessionNoticeAndLimit(base, cwd, options, !pending);
|
|
253
|
-
|
|
264
|
+
const { dropNotice, contextLimit, instruction } = sessionNoticeAndLimit(base, cwd, options, !pending, RESUME_HEADER);
|
|
265
|
+
const context = joinedContext(RESUME_HEADER, dropNotice, contextLimit);
|
|
266
|
+
result.context = context ? instruction + context : "";
|
|
254
267
|
result.valid = result.context.length > 0;
|
|
255
268
|
} catch { /* fail open: no context */ }
|
|
256
269
|
result.dropped = !pending;
|
|
@@ -278,12 +291,13 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
|
|
|
278
291
|
if (remaining() <= 0) return result;
|
|
279
292
|
// The current SessionStart is durable in its bounded reserve. All replay and
|
|
280
293
|
// retirement belong to a later non-SessionStart hook, never this brief deadline.
|
|
281
|
-
const { dropNotice, contextLimit } = sessionNoticeAndLimit(base, cwd, options, !pending);
|
|
294
|
+
const { dropNotice, contextLimit, instruction } = sessionNoticeAndLimit(base, cwd, options, !pending);
|
|
282
295
|
const brief = await sessionStartBrief({ ...options, maxTokens: Math.max(1, contextLimit - Buffer.byteLength(dropNotice) - (dropNotice ? 1 : 0)), cwd, base, project, nativeSession: { signature }, projectFromCwd: true, timeRemaining: remaining });
|
|
283
296
|
result.pending = !!pending && !result.captured;
|
|
284
297
|
result.dropped = !pending;
|
|
285
298
|
if (hookCaptureScope(cwd, base) !== scope || !await createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)).screen({ context: brief.context })) return result;
|
|
286
|
-
|
|
299
|
+
const context = joinedContext(brief.context, dropNotice, contextLimit);
|
|
300
|
+
result.context = context ? instruction + context : "";
|
|
287
301
|
result.valid = result.context.length > 0;
|
|
288
302
|
return result;
|
|
289
303
|
} catch { result.dropped = !pending; return result; }
|
|
@@ -333,6 +347,7 @@ export async function runHook(event: string, input: unknown, options: HookOption
|
|
|
333
347
|
let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
|
|
334
348
|
let childSources = emptyRssSources();
|
|
335
349
|
let transcriptProgress: HookTranscriptProgress | undefined;
|
|
350
|
+
let emittedCost = instructionCost("");
|
|
336
351
|
try {
|
|
337
352
|
base = canonicalPath(options.base ?? memoryHome());
|
|
338
353
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
|
|
@@ -346,10 +361,11 @@ export async function runHook(event: string, input: unknown, options: HookOption
|
|
|
346
361
|
if (!result.valid) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
|
|
347
362
|
if (event !== "SessionStart") return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
|
|
348
363
|
if (!await publicBefore(policy, { context: result.context }, deadline)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false };
|
|
364
|
+
emittedCost = instructionCost(result.context);
|
|
349
365
|
return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: false, output: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } } };
|
|
350
366
|
} catch { return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true }; }
|
|
351
367
|
finally {
|
|
352
|
-
if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
|
|
368
|
+
if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started, ...emittedCost,
|
|
353
369
|
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
354
370
|
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
|
|
355
371
|
}
|
|
@@ -363,11 +379,19 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
363
379
|
let childRssKB = 0, childRawRssKB = 0, dropped = false;
|
|
364
380
|
let childSources = emptyRssSources();
|
|
365
381
|
let transcriptProgress: HookTranscriptProgress | undefined;
|
|
382
|
+
let emittedCost = instructionCost("");
|
|
366
383
|
try {
|
|
367
384
|
if (args.length !== 1) return "";
|
|
368
385
|
if (args[0] === "jcode") {
|
|
369
386
|
// #203: jcode observers have no stdin and discard stdout; the event and its cwd arrive in the environment.
|
|
370
387
|
const jcode = jcodeHookCapture(process.env);
|
|
388
|
+
// #216: title and compaction summaries from the session journal's meta, before the turn's own capture so its drain commits them.
|
|
389
|
+
// Bounded (256 KiB, 40 ms work, never past the handler deadline) and fail open: a fault here never drops ordinary capture.
|
|
390
|
+
const target = jcodeJournalTarget(process.env, homedir());
|
|
391
|
+
if (target) try {
|
|
392
|
+
const { scanJcodeJournal } = await import("./hook-jcode-journal.ts");
|
|
393
|
+
transcriptProgress = await scanJcodeJournal(target, { base: canonicalPath(memoryHome()), workBudgetMs: 40, deadline: startedAt + deadlineMs - 50 }) ?? transcriptProgress;
|
|
394
|
+
} catch { transcriptProgress = { stopReason: "unavailable" }; }
|
|
371
395
|
if (!jcode) return "";
|
|
372
396
|
const result = await captureAndWork(jcode.event, jcode.input, { cwd: jcode.cwd, agent: "jcode" }, canonicalPath(memoryHome()), startedAt + deadlineMs - 3);
|
|
373
397
|
if (result) { childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true; }
|
|
@@ -392,6 +416,7 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
392
416
|
childRssKB = result.rssKB; childRawRssKB = result.rawRssKB; childSources = result.rssSources ?? emptyRssSources(); dropped = "dropped" in result && result.dropped === true;
|
|
393
417
|
transcriptProgress = "transcript" in result ? result.transcript : undefined;
|
|
394
418
|
if (!result.valid || args[0] !== "SessionStart") return "";
|
|
419
|
+
emittedCost = instructionCost(result.context);
|
|
395
420
|
return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.context } });
|
|
396
421
|
}
|
|
397
422
|
return "";
|
|
@@ -400,7 +425,7 @@ export async function hookMain(args: readonly string[], startedAt = performance.
|
|
|
400
425
|
await cleanup?.();
|
|
401
426
|
try {
|
|
402
427
|
const self = hookRssReading();
|
|
403
|
-
appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt,
|
|
428
|
+
appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt, ...emittedCost,
|
|
404
429
|
rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
|
|
405
430
|
...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
|
|
406
431
|
} catch { /* Invalid input and unavailable homes still fail open. */ }
|
package/src/index.ts
CHANGED
|
@@ -78,3 +78,7 @@ export { readSyncPlacementWriterRoots } from "./memory/sync-placement-writer-roo
|
|
|
78
78
|
export { readEffectiveSyncPlacementWriters, readEffectiveSyncPlacementWriterRoots } from "./memory/sync-placement-effective-writers.ts";
|
|
79
79
|
export type { EffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
|
|
80
80
|
export { readSetupWriterRoots, SetupWriterRootsError } from "./setup.ts";
|
|
81
|
+
|
|
82
|
+
export { parseRecordLines, RECORD_INSTRUCTION, type ParsedRecord, type ParsedRecordTurn, type RecordKind, type RecordDropReason } from "./hook-record.ts";
|
|
83
|
+
export { createPlacementAttestation, PlacementWriterContextError } from "./sync-placement-writer-context.ts";
|
|
84
|
+
export type { PlacementWriterRootCategory, PlacementWriterContextPhase } from "./sync-placement-writer-context.ts";
|
|
@@ -4,6 +4,15 @@ import { stripPrivateSpans } from "./private-spans.ts";
|
|
|
4
4
|
|
|
5
5
|
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
6
6
|
export const CAPTURE_MAX_PATHS = 32;
|
|
7
|
+
/** Claude transcript-only events use the existing format-3 transport, never native hook argv. */
|
|
8
|
+
export const TRANSCRIPT_CAPTURE_EVENTS: readonly string[] = ["Title", "Recap", "Compact", "Record"];
|
|
9
|
+
/** A whole-Record policy refusal, distinct from transient storage failures. No receipt or row was written. */
|
|
10
|
+
export class RecordNormalizationRefusal extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Record capture would be changed by current caps or screening; check amem.config.json or CaptureStore options, then stage a complete Record that fits the current policy");
|
|
13
|
+
this.name = "RecordNormalizationRefusal";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
7
16
|
export const TOOL_INPUT_LINE_BYTES = 512;
|
|
8
17
|
const TOOL_INPUT_ORDER = ["command", "pattern", "query", "url", "description", "prompt", "skill", "subject"] as const;
|
|
9
18
|
const TOOL_INPUT_OMIT = new Set(["content", "old_string", "new_string", "edits"]);
|
|
@@ -66,7 +75,7 @@ export function normalizeCaptureFields(input: CaptureFields, now: () => number =
|
|
|
66
75
|
const at = new Date(input.at === undefined ? now() : Date.parse(input.at)).toISOString();
|
|
67
76
|
const tool = str(input.tool, 64) ? input.tool! : null;
|
|
68
77
|
const status = str(input.status, 64) ? input.status! : null;
|
|
69
|
-
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p, caps.denyPaths ? [...caps.denyPaths] : [])).slice(0, maxPaths).map((p) => p.slice(0, 400));
|
|
78
|
+
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(input.event === "Record" ? p.split("#")[0]! : p, caps.denyPaths ? [...caps.denyPaths] : [])).slice(0, maxPaths).map((p) => p.slice(0, 400));
|
|
70
79
|
// `<private>` spans go first: they are never screened, counted or stored. The cap applies to what is left.
|
|
71
80
|
let excerpt = stripPrivateSpans(input.excerpt ?? "").slice(0, maxExcerpt);
|
|
72
81
|
if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
|