@staix/agent-mem 0.22.0 → 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.
@@ -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
+ }
@@ -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
- const result = store!.append(captureInput(decoded.value.capture as never, record.project!), { receipt: hash(decoded.bytes) });
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 { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type CaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
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) => ["Title", "Recap", "Compact"].includes(event);
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" && !["Title", "Recap", "Compact"].includes(c.event)) === v3 || ![...HOOK_EVENTS, "Title", "Recap", "Compact"].includes(c.event)
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" || !["Title", "Recap", "Compact"].includes(input.event)) return;
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
- if (!await policy.screen({ cwd, input })) return;
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 === "[secret-screened]" || !await policy.screen({ cwd, capture })) return;
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" || ["Title", "Recap", "Compact"].includes(event);
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 (["Title", "Recap", "Compact"].includes(event)) {
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
- append(input, hash(record.bytes));
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);
@@ -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 = 256 * 1024;
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
- const dropNotice = Object.entries(losses).map(([reason, count]) => `capture dropped: ${reason.replaceAll("_", " ")} (${count})`).join("; ");
234
- const configuredBrief = loadAmemConfig(base, repoConfigRoot(cwd)).config.briefBytes;
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
- return { dropNotice, contextLimit };
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
- result.context = joinedContext(RESUME_HEADER, dropNotice, contextLimit);
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
- result.context = joinedContext(brief.context, dropNotice, contextLimit);
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]";