pi-fold 3.0.0 → 3.1.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.
@@ -562,10 +562,6 @@ function mcpServerForTool(toolName, fallback) {
562
562
  : fallback;
563
563
  }
564
564
 
565
- function conventionalMcpTool(toolName) {
566
- return mcpServerForTool(toolName, null) !== null;
567
- }
568
-
569
565
  export function registerEvidenceIngestion(pi, {
570
566
  entryTypePrefix = DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
571
567
  } = {}) {
@@ -626,7 +622,7 @@ export function registerEvidenceIngestion(pi, {
626
622
  return projectEvidenceText(text, descriptor, "utf8-head", truncation ? { truncation } : undefined);
627
623
  }
628
624
 
629
- if (conventionalMcpTool(toolName)) {
625
+ if (mcpServerForTool(toolName, null) !== null) {
630
626
  const envelope = eventEnvelope(event);
631
627
  const serialized = `${stableStringify(envelope)}\n`;
632
628
  if (utf8Bytes(serialized) <= TOOL_RESULT_PROJECTION_BYTES) return;
@@ -10,7 +10,8 @@ export {
10
10
  } from "./settings.ts";
11
11
 
12
12
  const PUBLIC_OPTIONS = Object.freeze([
13
- "thresholds", "providerInputBudget", "blacklistAutoFoldTools",
13
+ "thresholds", "providerInputBudget", "blacklistAutoFoldTools", "toolFoldThreshold",
14
+ "workingMemory", "postFoldNotice",
14
15
  ]);
15
16
 
16
17
  const GENERATOR_DELETED = "the model brief generator is deleted (2026-08-14): across 15 sealed " +
@@ -27,8 +28,8 @@ const REFUSED_OPTIONS = Object.freeze({
27
28
  guidance: "deleted 2026-08-23: it switched the copy that taught the agent to " +
28
29
  "CHOOSE SPANS, and the agent does not choose spans. The runtime cuts at the frontier and " +
29
30
  "the agent annotates what it cut, so the guidance it switched is replaced wholesale by " +
30
- "the post-fold notice, which is not optional: a fold the agent is never told about is one " +
31
- "it can never brief",
31
+ "the post-fold notice. That notice has its own option, postFoldNotice, which defaults " +
32
+ "false: the deterministic shape is what the campaign measured",
32
33
  toolName: "the deployment identity is hardwired to pi-fold: the tool is pi_fold_context",
33
34
  toolLabel: "the deployment identity is hardwired to pi-fold",
34
35
  brandNoun: "the deployment identity is hardwired to pi-fold",
@@ -194,8 +194,30 @@ export function isObjectRef(value: unknown): value is EvidenceRef {
194
194
  typeof sha256 === "string" && reflectApply(regexpTest, sha256Pattern, [sha256]);
195
195
  }
196
196
 
197
+ // THE HOT PATH (issue #2, 2026-08-28). This is the most-called function in the package:
198
+ // every mark, span, claim, eligibility and measurement pass keys its refs through it,
199
+ // and a reporter profiling a 467KB window found the serializer under it at the top of a
200
+ // V8 profile with the process pegged at 80 percent for forty minutes. The general
201
+ // serializer earns its cost on arbitrary values, checking for proxies, cycles, accessors
202
+ // and non-plain prototypes; none of that is in question for three strings off a ref.
203
+ //
204
+ // The output is IDENTICAL, not merely equivalent, and that is the whole design of this
205
+ // shape: safeJson emits object keys in own-property order rather than sorted, so the
206
+ // literal below is the same byte sequence its object form produced, and string escaping
207
+ // is native JSON.stringify on both paths. It matters because these keys order
208
+ // `protectionSha256`'s sort and reach `branchSha256`'s missing-digest, so a format that
209
+ // merely round-trips would move digests that sealed sessions carry. Anything that is not
210
+ // three strings falls through to the serializer, which owns every remaining case.
211
+ // Measured 8.5x, 4.27us to 0.50us per call, over 2,000 refs. Gate 150 pins the identity.
197
212
  export function objectRefKey(ref: EvidenceRef): string {
198
- return stableStringify({ sessionId: ref.sessionId, entryId: ref.entryId, role: ref.role });
213
+ const sessionId = ref.sessionId;
214
+ const entryId = ref.entryId;
215
+ const role = ref.role;
216
+ if (typeof sessionId === "string" && typeof entryId === "string" && typeof role === "string") {
217
+ return `{"sessionId":${jsonStringify(sessionId)},"entryId":${jsonStringify(entryId)},` +
218
+ `"role":${jsonStringify(role)}}`;
219
+ }
220
+ return stableStringify({ sessionId, entryId, role });
199
221
  }
200
222
 
201
223
  export function sameObjectIdentity(left: EvidenceRef, right: EvidenceRef): boolean {
@@ -15,6 +15,40 @@ export function bytes(value: unknown): number {
15
15
  return Buffer.byteLength(typeof value === "string" ? value : stableStringify(value), "utf8");
16
16
  }
17
17
 
18
+ /**
19
+ * Does `heldText`, the serialization of the first `held` elements, stand as a prefix of
20
+ * `bodyText`, the serialization of the whole array?
21
+ *
22
+ * ARRAY SERIALIZATION IS PREFIX-FREE BY CONSTRUCTION, which is the whole reason this can be
23
+ * answered from a string the caller already has instead of by serializing a slice. `safeJson`
24
+ * emits `[`, then the elements separated by `,`, then `]`: no whitespace, no trailing comma.
25
+ * So the serialization of the first `held` elements is exactly the head of the whole one with
26
+ * `]` in place of the `,` that follows element `held - 1`.
27
+ *
28
+ * The two ends break that rule and are branched on rather than argued away. At `held` 0 the
29
+ * prefix is `[]` and there is no comma to match, so testing `"[," ` would be nonsense. At
30
+ * `held === length` there is no following element and the two strings are simply equal.
31
+ *
32
+ * The comma is the reason this is not a bare `startsWith`, and it is worth being exact about
33
+ * when it earns its place. JSON's object and string forms SELF-TERMINATE, with `}` and a
34
+ * closing quote, so for the message arrays this has one caller for, a bare `startsWith` would
35
+ * happen to agree. Numbers do not self-terminate: `[1]` is a leading substring of `[12]` and
36
+ * a bare `startsWith` accepts it. The separator is what makes the predicate correct for any
37
+ * array rather than for today's caller, and a false positive in that caller splices a stale
38
+ * head in front of a live tail. Gate 154 drives the numeric case for exactly this reason.
39
+ */
40
+ export function serializedPrefixHolds(
41
+ bodyText: string,
42
+ heldText: string | null | undefined,
43
+ held: number,
44
+ length: number,
45
+ ): boolean {
46
+ if (heldText === null || heldText === undefined || length < held) return false;
47
+ if (held === 0) return heldText === "[]";
48
+ if (held === length) return bodyText === heldText;
49
+ return bodyText.startsWith(`${heldText.slice(0, -1)},`);
50
+ }
51
+
18
52
  export function boundedUtf8(text: string, maximumBytes: number): string {
19
53
  const buffer = Buffer.from(text, "utf8");
20
54
  if (buffer.length <= maximumBytes) return text;
@@ -1,4 +1,5 @@
1
1
  import { boundReceiptText } from "./measurement.ts";
2
+ import { oneLine } from "./selection.ts";
2
3
  import {
3
4
  contextBrand,
4
5
  CONTEXT_RECEIPT_BLOCK_BYTES,
@@ -22,7 +23,7 @@ import {
22
23
  * worth a turn's attention, and it is ephemeral, so it never accumulates.
23
24
  */
24
25
  export function foldNoticeText(input: {
25
- unbriefed: ReadonlyArray<{ id: string; kind: string; tokens: number }>;
26
+ unbriefed: ReadonlyArray<{ id: string; kind: string; tokens: number; brief?: string }>;
26
27
  pending: number;
27
28
  toolName: string;
28
29
  brandNoun?: string;
@@ -49,8 +50,15 @@ export function foldNoticeText(input: {
49
50
  ].join("\n");
50
51
  // WHAT IT COULD NOT NAME, IT COUNTS (gate 136's law, and gate 115's shape one level
51
52
  // out): a list that stops early and says nothing reads as a complete list.
53
+ // EACH ROW IDENTIFIES ITS SPAN. The pending mark carries the deterministic brief the
54
+ // runtime cut it with, and a notice that withholds it makes the agent guess: the
55
+ // dogfooded 1M session answered "I need to brief three pending folds. What are they?
56
+ // Likely: ..." from memory, and a wrong guess writes an accurate-sounding brief onto
57
+ // the wrong fold. The head is the identification; the agent adds what it cannot know.
58
+ const NOTICE_ROW_CHARS = 160;
52
59
  const entries = input.unbriefed
53
- .map((fold) => `${fold.id} (${fold.kind}, ~${fold.tokens} tokens)`);
60
+ .map((fold) => `${fold.id} (${fold.kind}, ~${fold.tokens} tokens)` +
61
+ (fold.brief ? `: ${oneLine(fold.brief, NOTICE_ROW_CHARS)}` : ""));
54
62
  const overflowText = (seated: number) =>
55
63
  `${input.toolName} {"action":"status"} lists the other ${input.unbriefed.length - seated}.`;
56
64
  let seated = entries.length;
@@ -65,6 +73,42 @@ export function foldNoticeText(input: {
65
73
  `[${brand} folds] Pending folds are waiting for briefs; the list is unavailable this pass.`);
66
74
  }
67
75
 
76
+ /**
77
+ * THE WORKING MEMORY'S TABLE OF CONTENTS (Shane, 2026-08-26). One copy rides the
78
+ * projection, refreshed at each commit by the carrier freeze: keys only, freshest first,
79
+ * because the bodies live in state and are read on demand. The instruction is fixed and
80
+ * the key list is the variable part, exactly as the fold notice above, and a list that
81
+ * stops early counts what it dropped (gate 136's law). It shares FOLD_NOTICE_BYTES: both
82
+ * are one-glance carriers and a second bound would be a knob with no second job.
83
+ */
84
+ export function memoryTocText(input: {
85
+ entries: ReadonlyArray<{ key: string; chars: number }>;
86
+ toolName: string;
87
+ brandNoun?: string;
88
+ }): string {
89
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
90
+ const head = [
91
+ `[${brand} memory] Your working memory holds ${input.entries.length} ` +
92
+ `entr${input.entries.length === 1 ? "y" : "ies"}, freshest first.`,
93
+ `Read any of them with ${input.toolName} {"action":"recall","keys":["<key>"]}. ` +
94
+ `Update an entry whose facts have changed, or add one, with ${input.toolName} ` +
95
+ '{"action":"remember","key":"<key>","body":"..."}; an empty body removes the entry.',
96
+ ].join("\n");
97
+ const rows = input.entries.map((entry) => `${entry.key} (${entry.chars} chars)`);
98
+ const overflowText = (seated: number) =>
99
+ `${input.toolName} {"action":"recall"} lists the other ${input.entries.length - seated}.`;
100
+ let seated = rows.length;
101
+ let list = `Entries: ${rows.join("; ")}.`;
102
+ while (seated > 0 && Buffer.byteLength(`${head}\n${list}`, "utf8") > FOLD_NOTICE_BYTES) {
103
+ seated -= 1;
104
+ list = seated === 0
105
+ ? overflowText(0)
106
+ : `Entries: ${rows.slice(0, seated).join("; ")}. ${overflowText(seated)}`;
107
+ }
108
+ return boundReceiptText(`${head}\n${list}`, FOLD_NOTICE_BYTES,
109
+ `[${brand} memory] Working memory has entries; the list is unavailable this pass.`);
110
+ }
111
+
68
112
  export interface ContextReceipt {
69
113
  kind: string;
70
114
  ordinal: number;
@@ -76,8 +120,6 @@ export interface ContextReceipt {
76
120
  occupancyAfter: number | null;
77
121
  spansFolded: number;
78
122
  toolResultsFolded: number;
79
- splitFolds: number;
80
- splitFromChars: number;
81
123
  absorbedWedges: number;
82
124
  recovered: boolean;
83
125
  protectedBytes: number;
@@ -96,8 +138,6 @@ export function contextReceipt(input: Partial<ContextReceipt> & { kind: string;
96
138
  occupancyAfter: input.occupancyAfter ?? null,
97
139
  spansFolded: input.spansFolded ?? 0,
98
140
  toolResultsFolded: input.toolResultsFolded ?? 0,
99
- splitFolds: input.splitFolds ?? 0,
100
- splitFromChars: input.splitFromChars ?? 0,
101
141
  absorbedWedges: input.absorbedWedges ?? 0,
102
142
  recovered: input.recovered ?? false,
103
143
  protectedBytes: input.protectedBytes ?? 0,
@@ -128,9 +168,6 @@ export function receiptLine(receipt: ContextReceipt): string {
128
168
  `about ${receipt.freedTokens} tokens freed.`,
129
169
  occupancy,
130
170
  folded,
131
- receipt.splitFolds
132
- ? `A ${receipt.splitFromChars}-char span was split into ${receipt.splitFolds} bite-sized folds.`
133
- : "",
134
171
  receipt.absorbedWedges
135
172
  ? `${receipt.absorbedWedges} short stale span(s) wedged between folds were absorbed into their ` +
136
173
  "later neighbour rather than left as crumbs."
@@ -10,6 +10,7 @@ import {
10
10
  stableStringify,
11
11
  } from "../json.ts";
12
12
  import {
13
+ contentText,
13
14
  boundedUtf8,
14
15
  bytes,
15
16
  clone,
@@ -71,7 +72,9 @@ import {
71
72
  chapterUnits,
72
73
  unpinnedStaleFolds,
73
74
  deterministicChapterCandidateBrief,
75
+ toolClipHead,
74
76
  deterministicConsolidationBrief,
77
+ oneLine,
75
78
  NO_FOLD_KINDS,
76
79
  partsForRange,
77
80
  resultCall,
@@ -79,6 +82,7 @@ import {
79
82
  selectAutomaticRefold,
80
83
  selectAutomaticToolBatch,
81
84
  } from "./selection.ts";
85
+ import { staleSpanMatureEnd } from "./transcript.ts";
82
86
 
83
87
  export function selectAutomaticChapter(
84
88
  snapshot: ActiveContextSnapshot,
@@ -87,19 +91,44 @@ export function selectAutomaticChapter(
87
91
  ): FoldCandidate | null {
88
92
  const units = chapterUnits(snapshot);
89
93
  const allowedChildren = NO_FOLD_KINDS;
94
+ // The maturity floor: a stale span may end only where the batch lane has already had
95
+ // its chance, so a chapter can never claim a tool batch one turn before it qualifies.
96
+ const matureEnd = staleSpanMatureEnd(snapshot.messages);
90
97
  for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) {
91
98
  const first = units[unitIndex];
99
+ if (first.end > matureEnd) break;
92
100
  let best: FoldCandidate | null = null;
93
101
  for (let endIndex = unitIndex; endIndex < units.length; endIndex += 1) {
94
102
  const unit = units[endIndex];
103
+ if (unit.end > matureEnd) break;
95
104
  if (endIndex > unitIndex && unit.start !== units[endIndex - 1].end) break;
96
105
  const coherentSegment = endIndex > unitIndex || first.end - first.start > 1;
97
106
  if (!coherentSegment) continue;
107
+ // A DISQUALIFIED RANGE DISQUALIFIES EVERY LARGER ONE, so the walk stops here rather
108
+ // than rediscovering the same obstruction at every remaining endIndex (issue #2,
109
+ // 2026-08-28). The inner loop only ever EXTENDS the range to the right, and all
110
+ // three conditions below are monotone under that extension: an unmapped index or an
111
+ // overlapping root stays inside a larger range, a claimed ref stays claimed because
112
+ // `claimed` is fixed for the whole walk, and a protected ref stays protected. Only
113
+ // `size > MAX_FOLD_SPAN_CHARS` bounded this loop, and these three `continue`s skipped
114
+ // the sizing that would have hit it, so an obstruction near the start of the window
115
+ // let the walk run to the end of `units` for every start: O(units^2) iterations doing
116
+ // O(span) work each. Instrumented on a 549KB window, ONE context event ran 457,986
117
+ // inner iterations and allocated 98,811,691 FoldPart objects, of which 456,724
118
+ // iterations (99.7 percent) died on the `claimed` check and only 72 ever reached the
119
+ // sizing. That is 42 seconds of CPU at 824KB, ungated by occupancy, on every event.
120
+ // With the walk stopped at the first obstruction the same event costs 377ms.
121
+ //
122
+ // This is 00dfeb3's defect one layer out. That commit memoized `orderedRoots` to kill
123
+ // the O(roots x folds) rebuild INSIDE each iteration and its own comment named "a walk
124
+ // that is quadratic in units" as the standing cost; this is that walk. Nothing about
125
+ // WHICH candidate is selected changes: every iteration this skips would have been
126
+ // skipped by the same condition.
98
127
  const parts = partsForRange(snapshot, state, first.start, unit.end - 1, allowedChildren);
99
- if (!parts || parts.some((part) => part.kind === "fold" && state.expanded.includes(part.foldId))) continue;
128
+ if (!parts || parts.some((part) => part.kind === "fold" && state.expanded.includes(part.foldId))) break;
100
129
  const refs = candidateSourceRefs(parts, state);
101
- if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) continue;
102
- if (refsProtected(refs, state, snapshot)) continue;
130
+ if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) break;
131
+ if (refsProtected(refs, state, snapshot)) break;
103
132
  const size = bytes(encodedFoldSource(snapshot, state, parts, "chapter"));
104
133
  const biteSized = size <= MAX_FOLD_SPAN_CHARS || endIndex === unitIndex;
105
134
  // ACCUMULATE UNTIL OVER, never cut to hit the number. The walk grows the span one
@@ -414,13 +443,43 @@ export async function prepareFold(input: {
414
443
  let brief: string;
415
444
  let provenance: BriefProvenance;
416
445
  if (input.brief !== undefined) {
417
- const complaint = briefContractComplaint(
418
- input.brief.trim(), snapshot.policy.maxBriefChars, snapshot.toolName);
419
- if (complaint !== null) throw new Error(`Supplied brief rejected. ${complaint}`);
420
- brief = input.brief.trim();
421
- provenance = typeof input.briefProvenance === "object"
422
- ? clone(input.briefProvenance)
423
- : { kind: input.briefProvenance ?? "supplied" };
446
+ const declaredKind = typeof input.briefProvenance === "object"
447
+ ? input.briefProvenance.kind
448
+ : input.briefProvenance ?? "supplied";
449
+ if (declaredKind === "supplied") {
450
+ // A SUPPLIED BRIEF AUGMENTS THE HEAD, NEVER REPLACES IT (rep 7 of
451
+ // sol-20260823-live: replacement deleted the fact-carrying identification and 74
452
+ // of 95 peeks recovered values the run's own briefs dropped). The agent's clause
453
+ // is judged against its own reserve so the refusal names the budget the agent
454
+ // actually has, and the deterministic head keeps the remainder of the one policy
455
+ // cap. Only the agent's OWN words take this path: a deterministic brief arriving
456
+ // through the same door IS the head, and a re-prepared augmented brief is already
457
+ // composed, so each passes through under the whole-brief cap instead.
458
+ const complaint = briefContractComplaint(
459
+ input.brief.trim(), snapshot.policy.agentBriefReserve, snapshot.toolName);
460
+ if (complaint !== null) throw new Error(`Supplied brief rejected. ${complaint}`);
461
+ const clause = input.brief.trim();
462
+ const head = candidate.kind === "consolidation"
463
+ ? deterministicConsolidationBrief(candidate, state, snapshot.toolName, snapshot)
464
+ : deterministicChapterCandidateBrief(snapshot, candidate);
465
+ if (head) {
466
+ const seam = " · Agent: ";
467
+ const headRoom = Math.max(0, snapshot.policy.maxBriefChars - seam.length - clause.length);
468
+ brief = `${oneLine(head, headRoom)}${seam}${clause}`;
469
+ provenance = { kind: "augmented" };
470
+ } else {
471
+ brief = clause;
472
+ provenance = { kind: "supplied" };
473
+ }
474
+ } else {
475
+ const complaint = briefContractComplaint(
476
+ input.brief.trim(), snapshot.policy.maxBriefChars, snapshot.toolName);
477
+ if (complaint !== null) throw new Error(`Supplied brief rejected. ${complaint}`);
478
+ brief = input.brief.trim();
479
+ provenance = typeof input.briefProvenance === "object"
480
+ ? clone(input.briefProvenance)
481
+ : { kind: declaredKind } as BriefProvenance;
482
+ }
424
483
  } else {
425
484
  brief = candidate.kind === "consolidation"
426
485
  ? deterministicConsolidationBrief(candidate, state, snapshot.toolName, snapshot)
@@ -806,6 +865,13 @@ export function pinnedPeekMass(
806
865
  state: ActiveContextState,
807
866
  ): { bytes: number; results: number } {
808
867
  const explicitProtected = explicitProtectedKeys(state);
868
+ // ITS TWO SIBLINGS ALREADY RETURN HERE (2026-08-28). `protectedStaleMass` and
869
+ // `explicitProtectedMass` both open with this guard; this one did the whole quadratic walk
870
+ // and then returned zero, because every increment in its final loop is gated on
871
+ // `explicitProtected.has(...)`. So the guard is exact, not an approximation. It is not
872
+ // hypothetical either: `state.protected` is empty in all 10,521 persisted state records
873
+ // across 36 sealed runs, the corpus carrying no `context.pin` event at all.
874
+ if (!explicitProtected.size) return { bytes: 0, results: 0 };
809
875
  const folded = new Set<string>();
810
876
  for (const fold of state.folds) {
811
877
  for (const ref of flattenFoldRefs(fold, state)) folded.add(objectRefKey(ref));
@@ -859,10 +925,55 @@ export function projectActiveContext(
859
925
  index += 1;
860
926
  }
861
927
  }
928
+ applyToolClips(output, state, snapshot);
862
929
  assertProjectionPreservesToolLinkage(snapshot.messages, output);
863
930
  return output;
864
931
  }
865
932
 
933
+ // THE TOOL-CALL DIET'S VIEW (2026-08-24). A clipped result renders in place as its
934
+ // identified head with the cut stated and the recovery named, matched BY CALL ID and
935
+ // identity-preserving on role, call id, tool name, error flag and timestamp, exactly
936
+ // the ephemeral-peek withdrawal's shape one mechanism over. Clips are only ever ADDED
937
+ // inside a commit transaction, and a commit already rewrites the projection, so a
938
+ // non-commit pass with unchanged clips renders byte-identically and the frozen prefix
939
+ // never moves. Every reader shares this pass because every reader shares
940
+ // projectActiveContext: the live projection, the persistence readers and the
941
+ // attribution lens all see the same clipped view, and the full bytes stay in the
942
+ // transcript behind the entry id the marker names.
943
+ function applyToolClips(
944
+ output: unknown[],
945
+ state: ActiveContextState,
946
+ snapshot: ActiveContextSnapshot,
947
+ ): void {
948
+ if (!state.clips?.length) return;
949
+ const byCallId = new Map(state.clips.map((clip) => [clip.callId, clip]));
950
+ for (let index = 0; index < output.length; index += 1) {
951
+ const message = output[index] as Record<string, unknown> | null;
952
+ if (!message || (message as { role?: unknown }).role !== "toolResult") continue;
953
+ const callId = (message as { toolCallId?: unknown }).toolCallId;
954
+ if (typeof callId !== "string") continue;
955
+ const clip = byCallId.get(callId);
956
+ if (!clip) continue;
957
+ const details = (message as { details?: unknown }).details;
958
+ if (details && typeof (details as { projection?: unknown }).projection === "string") continue;
959
+ const full = contentText(message);
960
+ if (full.length <= snapshot.policy.toolClipFloorChars) continue;
961
+ const head = toolClipHead(full, snapshot.policy.toolClipHeadChars);
962
+ const hidden = full.length - head.length;
963
+ if (hidden <= 0) continue;
964
+ output[index] = {
965
+ role: "toolResult",
966
+ toolCallId: callId,
967
+ toolName: (message as { toolName?: unknown }).toolName,
968
+ content: [{ type: "text", text: `${head}\n... [${hidden} more chars clipped; ` +
969
+ `{"action":"peek","id":"${clip.entryId}"} returns the full result]` }],
970
+ isError: (message as { isError?: unknown }).isError ?? false,
971
+ details: { projection: "tool-clip" },
972
+ timestamp: (message as { timestamp?: unknown }).timestamp,
973
+ };
974
+ }
975
+ }
976
+
866
977
  export function foldStatusRow(fold: ActiveFold, state: ActiveContextState, snapshot: ActiveContextSnapshot): Record<string, unknown> {
867
978
  const navigation = foldNavigation(fold, state, snapshot);
868
979
  const interval = foldInterval(fold, state, snapshot);
@@ -1299,11 +1410,26 @@ export function peekFoldSource(input: {
1299
1410
  const source = stableStringify(messages);
1300
1411
  const sourceBytes = bytes(source);
1301
1412
  const offset = Math.min(Math.max(0, input.offset ?? 0), sourceBytes);
1302
- const budget = input.maximumBytes ?? PEEK_DEFAULT_MAX_BYTES;
1413
+ // The widest read anyone may ask for is the policy's own source cap, the same ceiling
1414
+ // the `wider` hint has always offered; an unclamped `bytes` was the one door left to a
1415
+ // single result larger than any fold the runtime would cut.
1416
+ const budget = Math.min(
1417
+ input.maximumBytes ?? PEEK_DEFAULT_MAX_BYTES,
1418
+ ACTIVE_CONTEXT_POLICY.maxSourceChars,
1419
+ );
1420
+ // ONE BUDGET GOVERNS THE WHOLE RESULT (sol-20260826-full2, pifold rep 1). The byte
1421
+ // bound used to govern `source` alone while the descendant index rode free, so peeking
1422
+ // the root of a deep consolidation tree returned 320 nested briefs, 423KB against a
1423
+ // stated 16KB bound, durably into a window already near its budget; that one result is
1424
+ // what the projection fence then aborted thirty-five requests over. The index seats
1425
+ // first, into at most half the budget, because a parent's index is the point read; the
1426
+ // source view takes everything the seated rows leave.
1427
+ const catalog = boundedDescendantIndex(fold, input.state, Math.floor(budget / 2));
1428
+ const sourceBudget = budget - catalog.seatedBytes;
1303
1429
  const window = offset > 0 ? utf8Slice(source, offset) : source;
1304
1430
  const view = offset > 0
1305
- ? { text: boundedUtf8(window, budget), omittedBytes: 0, contiguous: true }
1306
- : boundedHeadTail(window, budget);
1431
+ ? { text: boundedUtf8(window, sourceBudget), omittedBytes: 0, contiguous: true }
1432
+ : boundedHeadTail(window, sourceBudget);
1307
1433
  const returned = view.text;
1308
1434
  const returnedBytes = bytes(returned) - bytes(headTailMarker(view.omittedBytes));
1309
1435
  const nextOffset = view.contiguous && offset + returnedBytes < sourceBytes
@@ -1311,7 +1437,7 @@ export function peekFoldSource(input: {
1311
1437
  : null;
1312
1438
  const truncated = view.omittedBytes > 0 || nextOffset !== null;
1313
1439
  const children = childFoldIds(fold);
1314
- const index = descendantIndexRows(fold, input.state);
1440
+ const index = catalog.rows;
1315
1441
  return {
1316
1442
  version: 1,
1317
1443
  action: "peek",
@@ -1330,25 +1456,31 @@ export function peekFoldSource(input: {
1330
1456
  truncated,
1331
1457
  view: offset > 0 ? "slice" : (view.omittedBytes > 0 ? "index" : "complete"),
1332
1458
  ...(index.length ? { index } : {}),
1459
+ ...(catalog.omitted ? { indexOmitted: catalog.omitted } : {}),
1333
1460
  children,
1334
1461
  narrower: {
1335
1462
  ...(nextOffset === null ? {} : { slice: { action: "peek", id: fold.id, offset: nextOffset, bytes: returnedBytes } }),
1336
1463
  ...(children.length ? { child: { action: "peek", id: children[0] } } : {}),
1337
1464
  },
1338
- ...(truncated
1465
+ ...(truncated || catalog.omitted
1339
1466
  ? { wider: { action: "peek", id: fold.id, bytes: Math.min(sourceBytes, ACTIVE_CONTEXT_POLICY.maxSourceChars) } }
1340
1467
  : {}),
1341
1468
  lifetime: "these bytes stay in the window exactly as returned, like any other tool result, and " +
1342
1469
  `nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s stored source, so the ` +
1343
1470
  `next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
1344
1471
  "same verbatim bytes, or pin this result to keep the copy raw.",
1345
- note: truncated
1472
+ note: (truncated
1346
1473
  ? `Bounded read: ${returnedBytes} of ${sourceBytes} exact source bytes, ${view.omittedBytes} omitted ` +
1347
1474
  `from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
1348
1475
  : children.length
1349
1476
  ? `Complete stored span, one level: raw entries exactly, and ${children.length} child fold(s) still ` +
1350
1477
  "placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
1351
- : "Complete exact source; the fold stayed collapsed and no projection changed.",
1478
+ : "Complete exact source; the fold stayed collapsed and no projection changed.") +
1479
+ (catalog.omitted
1480
+ ? ` The nested index seats the shallowest ${index.length} of ${index.length + catalog.omitted} ` +
1481
+ "descendant briefs inside the byte budget; the deeper rows are one hop away by peeking a child id, " +
1482
+ "or widen with bytes."
1483
+ : ""),
1352
1484
  source: returned,
1353
1485
  ...(truncated
1354
1486
  ? {
@@ -1388,6 +1520,38 @@ export function boundedHeadTail(
1388
1520
  };
1389
1521
  }
1390
1522
 
1523
+ /**
1524
+ * The descendant index, seated inside a byte budget: shallowest rows first, whole rows
1525
+ * only, the omitted counted rather than dropped. Depth order is the navigational order,
1526
+ * because a row's own children are reachable by peeking it, so the rows worth seating
1527
+ * first are the ones every deeper row is reachable THROUGH. Walk order breaks depth
1528
+ * ties, which keeps siblings in span order. The seating stops at the first row that
1529
+ * does not fit, so what is omitted is always a contiguous deep tail of the depth order
1530
+ * and the note can state it as one count.
1531
+ */
1532
+ export function boundedDescendantIndex(
1533
+ fold: ActiveFold,
1534
+ state: ActiveContextState,
1535
+ budget: number,
1536
+ ): { rows: Array<Record<string, unknown>>; omitted: number; seatedBytes: number } {
1537
+ const walked = descendantIndexRows(fold, state)
1538
+ .map((row, walk) => ({ row, walk }))
1539
+ .sort((left, right) =>
1540
+ Number(left.row.depth) - Number(right.row.depth) || left.walk - right.walk);
1541
+ const rows: Array<Record<string, unknown>> = [];
1542
+ // Counted as the array actually serializes, brackets and joining commas included, so
1543
+ // "seated bytes" is the bytes the result carries and not an under-read of them.
1544
+ let seatedBytes = 2;
1545
+ for (const { row } of walked) {
1546
+ const cost = bytes(stableStringify(row)) + (rows.length ? 1 : 0);
1547
+ if (seatedBytes + cost > budget) break;
1548
+ seatedBytes += cost;
1549
+ rows.push(row);
1550
+ }
1551
+ if (!rows.length) seatedBytes = 0;
1552
+ return { rows, omitted: walked.length - rows.length, seatedBytes };
1553
+ }
1554
+
1391
1555
  export function descendantIndexRows(
1392
1556
  fold: ActiveFold,
1393
1557
  state: ActiveContextState,
@@ -87,17 +87,11 @@ export type ContextEventKind =
87
87
  | "context.pin"
88
88
  | "context.capacity"
89
89
  | "context.receipt"
90
- | "context.lastcall"
91
- | "context.response"
92
- | "context.suggestion"
93
- | "context.outcome"
94
- | "context.notice"
95
90
  | "context.frontier"
96
91
  | "context.commit"
97
92
  | "context.fold"
98
93
  | "context.brief"
99
94
  | "context.absorb"
100
- | "context.split"
101
95
  | "context.recovery"
102
96
  | "context.suspend"
103
97
  | "context.rollback"
@@ -15,7 +15,7 @@ import {
15
15
  } from "./canonical.ts";
16
16
  import {
17
17
  childFoldIds,
18
- deriveFoldParents,
18
+ assignFoldParents,
19
19
  flattenFoldRefs,
20
20
  foldMap,
21
21
  parseActiveContextState,
@@ -270,11 +270,42 @@ export function foldInterval(fold: ActiveFold, state: ActiveContextState, snapsh
270
270
  return indices ? { start: indices[0], end: indices.at(-1)! } : null;
271
271
  }
272
272
 
273
- export function orderedRoots(state: ActiveContextState, snapshot: ActiveContextSnapshot): Array<{ fold: ActiveFold; start: number; end: number }> {
274
- return rootFolds(state).flatMap((fold) => {
273
+ export type OrderedRoot = { fold: ActiveFold; start: number; end: number };
274
+
275
+ // ROOT INTERVALS ARE DERIVED ONCE PER (STATE, SNAPSHOT) PAIR (2026-08-24). This derivation
276
+ // is O(roots x folds) on its own, because `foldInterval` calls `flattenFoldRefs`, which
277
+ // rebuilds `foldMap` for every root it walks. `partsForRange` opens with it, and
278
+ // `selectAutomaticChapter` calls `partsForRange` from a walk that is quadratic in units,
279
+ // so the cost was O(units^2 x roots x folds) for an answer that cannot change inside a
280
+ // pass. Replaying sealed sol-20260823-live rep 3 at 125 folds, ONE frontier walk made
281
+ // 12,333 calls and spent 22.5 seconds, and 12,328 of them returned null without reading a
282
+ // root, because the automatic chapter passes an EMPTY allowed-child set and any overlap
283
+ // refuses. At 140 folds the walk was 98.8 percent of the runtime's whole per-request cost;
284
+ // the per-turn gap between a response and the next projection ran 3s at zero folds to 164s
285
+ // past 150, and five per-turn call sites each pay a walk.
286
+ //
287
+ // The memo is gate 113's rule and gate 121's rule at a third address: kept against the
288
+ // state and snapshot OBJECTS rather than their contents, because both are REPLACED on
289
+ // every change rather than mutated, so a replaced one misses and derives again instead of
290
+ // serving something stale. It hands every caller the SAME array, which is safe only while
291
+ // no reading mutates it; gate 149 drives every call site against a frozen one so a reading
292
+ // that starts mutating throws rather than quietly poisoning the memo.
293
+ export const ORDERED_ROOTS = new WeakMap<ActiveContextState, WeakMap<ActiveContextSnapshot, OrderedRoot[]>>();
294
+
295
+ export function orderedRoots(state: ActiveContextState, snapshot: ActiveContextSnapshot): OrderedRoot[] {
296
+ let bySnapshot = ORDERED_ROOTS.get(state);
297
+ if (!bySnapshot) {
298
+ bySnapshot = new WeakMap<ActiveContextSnapshot, OrderedRoot[]>();
299
+ ORDERED_ROOTS.set(state, bySnapshot);
300
+ }
301
+ const memoized = bySnapshot.get(snapshot);
302
+ if (memoized) return memoized;
303
+ const roots = rootFolds(state).flatMap((fold) => {
275
304
  const interval = foldInterval(fold, state, snapshot);
276
305
  return interval ? [{ fold, ...interval }] : [];
277
306
  }).sort((left, right) => left.start - right.start);
307
+ bySnapshot.set(snapshot, roots);
308
+ return roots;
278
309
  }
279
310
 
280
311
  export function visibleCollapsedRoots(state: ActiveContextState, snapshot: ActiveContextSnapshot) {
@@ -317,7 +348,9 @@ export function persistenceProjection(state: ActiveContextState, snapshot: Activ
317
348
  if (projected.prepared && projected.prepared.sourceRefs.some((ref) => !mapped.has(objectRefKey(ref)))) {
318
349
  delete projected.prepared;
319
350
  }
320
- projected.folds = deriveFoldParents(projected.folds);
351
+ // The parse on the next line validates this forest, so `assignFoldParents` is the whole
352
+ // job here; `deriveFoldParents` would walk it twice.
353
+ projected.folds = assignFoldParents(projected.folds);
321
354
  return parseActiveContextState(projected, state.sessionId);
322
355
  }
323
356