pi-fold 1.0.2 → 1.0.4

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.
@@ -28,9 +28,7 @@ import {
28
28
  orderedRoots,
29
29
  refsInOrder,
30
30
  refsProtected,
31
- toolFoldCadence,
32
31
  toolRefsProtected,
33
- visibleCollapsedRoots,
34
32
  } from "./measurement.ts";
35
33
  import {
36
34
  childFoldIds,
@@ -39,6 +37,7 @@ import {
39
37
  foldBrief,
40
38
  foldIdFor,
41
39
  foldMap,
40
+ MAX_ACTIVE_PROTECTED,
42
41
  normalizedPart,
43
42
  protectionSha256,
44
43
  topologySha256,
@@ -48,7 +47,6 @@ import {
48
47
  ACTIVE_CONTEXT_POLICY,
49
48
  activeContextBrand,
50
49
  activeContextSource,
51
- CONSOLIDATION_WIDTH_THRESHOLD,
52
50
  CONTEXT_STATUS_RESPONSE_BYTES,
53
51
  EXPAND_LEASE_GENERATIONS,
54
52
  MAX_EXPAND_LEASES,
@@ -56,6 +54,7 @@ import {
56
54
  PEEK_DEFAULT_MAX_BYTES,
57
55
  PEEK_HEAD_SHARE,
58
56
  STATUS_DIET_SUGGESTIONS,
57
+ zoneBytes,
59
58
  } from "./policy.ts";
60
59
  import type {
61
60
  ActiveContextSnapshot,
@@ -72,12 +71,15 @@ import {
72
71
  automaticToolBrief,
73
72
  candidateSourceRefs,
74
73
  chapterUnits,
74
+ unpinnedStaleFolds,
75
75
  deterministicChapterCandidateBrief,
76
+ deterministicConsolidationBrief,
77
+ NO_FOLD_KINDS,
76
78
  partsForRange,
77
79
  resultCall,
78
- selectAutomaticConsolidation,
80
+ selectAutomaticConsolidations,
79
81
  selectAutomaticRefold,
80
- selectAutomaticToolForRung,
82
+ selectAutomaticToolBatch,
81
83
  } from "./selection.ts";
82
84
 
83
85
  export function selectAutomaticChapter(
@@ -86,8 +88,17 @@ export function selectAutomaticChapter(
86
88
  maximumSourceRefs: number = snapshot.policy.maxFoldSourceRefs,
87
89
  claimed: ReadonlySet<string> = new Set<string>(),
88
90
  ): FoldCandidate | null {
91
+ // Every structurally closed unit is a candidate. What keeps a chapter off the newest
92
+ // material is `refsProtected` below -- the fresh byte tail and the agent's pins -- and
93
+ // what keeps it off the running excursion's own evidence is the guard adjudicated at
94
+ // the commit. There is no position filter here: a unit is composable or it is not.
89
95
  const units = chapterUnits(snapshot);
90
- const allowedChildren = new Set<FoldKind>(["tool-result"]);
96
+ // An automatic chapter is raw material, always. Letting a chapter swallow placeholders
97
+ // once the root count reached `consolidateAfter` was the old approximation of
98
+ // consolidation, and the count law replaces it outright: a placeholder gets a parent
99
+ // when the count says it must, under a fold that says what it is, not as a side effect
100
+ // of which chapter happened to be composable that pass (Shane 2026-08-10).
101
+ const allowedChildren = NO_FOLD_KINDS;
91
102
  for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) {
92
103
  const first = units[unitIndex];
93
104
  let best: FoldCandidate | null = null;
@@ -124,6 +135,46 @@ export function selectAutomaticChapter(
124
135
  return null;
125
136
  }
126
137
 
138
+ /**
139
+ * ONE automatic selection law for stale material.
140
+ *
141
+ * There is no chapter rung and no consolidation rung any more, each with its own
142
+ * trigger: automation proposes the stalest eligible SPAN, and what the span contains
143
+ * decides the fold's kind. Raw narrative folds as a chapter; a parent the root count
144
+ * requires folds as a consolidation. Nothing here is pressure-scaled: the epoch's own
145
+ * commit trigger decides WHEN, and this decides WHAT.
146
+ *
147
+ * One candidate, because that is what a single inline rung can apply. The count law
148
+ * itself is not rationed this way: the commit epoch takes EVERY group the count requires
149
+ * in one pass, and this is the leftover route by which the stalest of them can also ride
150
+ * an epoch's own rewrite.
151
+ */
152
+ export function selectAutomaticStaleSpan(
153
+ snapshot: ActiveContextSnapshot,
154
+ state: ActiveContextState,
155
+ claimed: ReadonlySet<string> = new Set<string>(),
156
+ ): FoldCandidate | null {
157
+ const chapter = selectAutomaticChapter(snapshot, state, snapshot.policy.maxFoldSourceRefs, claimed);
158
+ const folds = selectAutomaticConsolidations(snapshot, state)[0] ?? null;
159
+ if (!chapter || !folds) return chapter ?? folds;
160
+ const at = (candidate: FoldCandidate): number =>
161
+ exactMapped(snapshot, candidate.sourceRefs[0])?.index ?? Number.MAX_SAFE_INTEGER;
162
+ return at(folds) < at(chapter) ? folds : chapter;
163
+ }
164
+
165
+ /**
166
+ * What the commit epoch proposes: a completed read-only tool batch if one is stale, and
167
+ * otherwise the stalest span the one law composes. This is the whole automatic path.
168
+ */
169
+ export function selectAutomaticSpan(
170
+ snapshot: ActiveContextSnapshot,
171
+ state: ActiveContextState,
172
+ claimed: ReadonlySet<string> = new Set<string>(),
173
+ ): FoldCandidate | null {
174
+ return selectAutomaticToolBatch(snapshot, state, claimed)[0] ??
175
+ selectAutomaticStaleSpan(snapshot, state, claimed);
176
+ }
177
+
127
178
  export type AutomaticRungSelection =
128
179
  | { kind: "prepared-chapter"; candidate: FoldCandidate }
129
180
  | { kind: "tool"; candidate: FoldCandidate }
@@ -133,7 +184,6 @@ export type AutomaticRungSelection =
133
184
  | { kind: "chapter-prepare"; candidate: FoldCandidate };
134
185
 
135
186
  export interface AutomaticRungSelectionOptions {
136
- waiveToolCadence?: boolean;
137
187
  toolOnly?: boolean;
138
188
  summarizerAvailable?: boolean;
139
189
  failedPreparationIds?: ReadonlySet<string>;
@@ -169,14 +219,16 @@ export function selectAutomaticRung(
169
219
  const candidate = selectAutomaticChapter(snapshot, state, snapshot.policy.maxFoldSourceRefs, claimed);
170
220
  return candidate ? { kind: "prepared-chapter", candidate } : null;
171
221
  }
172
- const tool = selectAutomaticToolForRung(snapshot, state, ratio, options.waiveToolCadence, claimed);
222
+ const tool = selectAutomaticToolBatch(snapshot, state, claimed)[0] ?? null;
173
223
  if (tool) return { kind: "tool", candidate: tool };
174
224
  if (options.toolOnly || !Number.isFinite(ratio)) return null;
175
- const refold = selectAutomaticRefold(snapshot, state, ratio, claimedFoldIds);
225
+ const refold = selectAutomaticRefold(snapshot, state, claimedFoldIds);
176
226
  if (refold) return { kind: "refold", foldId: refold };
177
- const consolidation = selectAutomaticConsolidation(snapshot, state, ratio, claimed);
178
- if (consolidation) return { kind: "consolidation", candidate: consolidation };
179
- const chapter = selectAutomaticChapter(snapshot, state, snapshot.policy.maxFoldSourceRefs, claimed);
227
+ // One law decides the span; only the CHAPTER kind still has a preparation path in
228
+ // front of it, because only a chapter brief is worth a model call.
229
+ const span = selectAutomaticStaleSpan(snapshot, state, claimed);
230
+ if (span?.kind === "consolidation") return { kind: "consolidation", candidate: span };
231
+ const chapter = span;
180
232
  if (!chapter) return null;
181
233
  const preparationFailed = options.failedPreparationIds?.has(
182
234
  automaticPreparationId(chapter, state),
@@ -207,10 +259,11 @@ export function foldCandidatesDetail(
207
259
  } = {},
208
260
  ): Record<string, unknown> {
209
261
  const measuredRatio = ratio !== null && Number.isFinite(ratio) ? ratio : Number.NaN;
210
- const tool = selectAutomaticToolForRung(snapshot, state, measuredRatio);
211
- const refold = selectAutomaticRefold(snapshot, state, measuredRatio);
212
- const consolidation = selectAutomaticConsolidation(snapshot, state, measuredRatio);
213
- const chapter = selectAutomaticChapter(snapshot, state);
262
+ const tool = selectAutomaticToolBatch(snapshot, state)[0] ?? null;
263
+ const refold = selectAutomaticRefold(snapshot, state);
264
+ const staleSpan = selectAutomaticStaleSpan(snapshot, state);
265
+ const consolidation = staleSpan?.kind === "consolidation" ? staleSpan : null;
266
+ const chapter = staleSpan?.kind === "chapter" ? staleSpan : null;
214
267
  const selection = selectAutomaticRung(snapshot, state, measuredRatio, {
215
268
  summarizerAvailable: options.summarizerAvailable,
216
269
  failedPreparationIds: options.failedPreparationIds,
@@ -243,13 +296,17 @@ export function foldCandidatesDetail(
243
296
  } : null,
244
297
  wouldFireNow,
245
298
  blockedBy,
246
- cadence: {
247
- tokensSinceToolFold: state.tokensSinceToolFold,
248
- cadenceNeed: toolFoldCadence(snapshot.contextWindow),
299
+ // The one position that still cuts anything, plus the count law read as the
300
+ // arithmetic it is: the roots it counts, the width it divides by, and how many
301
+ // parents that count owes right now.
302
+ zones: {
303
+ freshBoundary: snapshot.freshBoundary,
304
+ budgetTokens: snapshot.budgetTokens,
249
305
  },
250
306
  width: {
251
- visibleRoots: visibleCollapsedRoots(state, snapshot).length,
252
- threshold: CONSOLIDATION_WIDTH_THRESHOLD,
307
+ visibleRoots: unpinnedStaleFolds(snapshot, state).length,
308
+ threshold: snapshot.thresholds.consolidateAfter,
309
+ groupsDue: selectAutomaticConsolidations(snapshot, state).length,
253
310
  },
254
311
  };
255
312
  }
@@ -312,6 +369,69 @@ export function encodedFoldSource(
312
369
  }));
313
370
  }
314
371
 
372
+ /**
373
+ * What a generator reads to brief a PARENT: its children at depth one.
374
+ *
375
+ * `encodedFoldSource` renders a parent's children through `renderFold`, which yields their
376
+ * placeholders, so a parent's source was the children's briefs. Briefing that produces a
377
+ * summary of summaries, and by the second rung the material itself is three descriptions
378
+ * away. So each child is opened here instead: its own brief for orientation, then its
379
+ * contents, with ITS children left as placeholders. Depth one is the whole rule. It bounds
380
+ * the read at one level per rung whatever the ladder's height, and it is the same view an
381
+ * agent gets from expanding the parent, so a brief written from it describes what expanding
382
+ * would actually return.
383
+ *
384
+ * When the opened children do not fit, the LARGEST collapses back to brief-only first, and
385
+ * again until the rest fit. Size is what decides, not position: dropping in span order
386
+ * would spend the whole budget on an early giant and close every child behind it, while
387
+ * taking the giant out first buys back the most room for the fewest subjects lost. A
388
+ * collapsed child still appears, in span order, carrying its brief and saying it is
389
+ * collapsed, so the generator can tell a child it read from a child it only heard about.
390
+ */
391
+ export function consolidationSourceText(
392
+ snapshot: ActiveContextSnapshot,
393
+ state: ActiveContextState,
394
+ parts: FoldPart[],
395
+ ): string {
396
+ const byId = foldMap(state);
397
+ const entries = parts.map((part, index) => {
398
+ if (part.kind === "raw") {
399
+ const item = exactMapped(snapshot, part.ref);
400
+ if (!item) throw new Error(`Exact Pi evidence drift for ${part.ref.entryId}`);
401
+ return { index, foldId: null, kind: "raw" as const, brief: null, messages: [clone(item.message)] };
402
+ }
403
+ const child = byId.get(part.foldId);
404
+ if (!child) throw new Error(`Consolidation source lost child fold ${part.foldId}`);
405
+ const messages = renderFoldParts(child.parts, state, snapshot);
406
+ if (!messages) throw new Error(`Consolidation source projection drifted at ${child.id}`);
407
+ return { index, foldId: child.id, kind: child.kind, brief: foldBrief(child, state), messages };
408
+ });
409
+ const collapsed = new Set<number>();
410
+ const render = (): string => stableStringify(entries.map((entry) => ({
411
+ child: entry.index + 1,
412
+ of: entries.length,
413
+ ...(entry.foldId === null ? {} : { foldId: entry.foldId }),
414
+ kind: entry.kind,
415
+ ...(entry.brief === null ? {} : { brief: entry.brief }),
416
+ ...(collapsed.has(entry.index)
417
+ ? { collapsed: "brief only: this child did not fit expanded" }
418
+ : { contents: entry.messages }),
419
+ })));
420
+ // Largest first, and only as many as the budget forces: each collapse is a subject the
421
+ // brief must describe from one sentence instead of from its contents.
422
+ const bySize = entries
423
+ .filter((entry) => entry.foldId !== null)
424
+ .map((entry) => ({ index: entry.index, size: bytes(entry.messages) }))
425
+ .sort((a, b) => b.size - a.size || a.index - b.index);
426
+ let text = render();
427
+ for (const entry of bySize) {
428
+ if (bytes(text) <= snapshot.policy.maxSourceChars) break;
429
+ collapsed.add(entry.index);
430
+ text = render();
431
+ }
432
+ return text;
433
+ }
434
+
315
435
  export function boundedOrientation(
316
436
  snapshot: ActiveContextSnapshot,
317
437
  sourceRefs: EvidenceRef[],
@@ -339,6 +459,155 @@ export function boundedOrientation(
339
459
  return { beforeRefs: before, beforeText, afterRefs: after.refs, afterText: after.text };
340
460
  }
341
461
 
462
+ /**
463
+ * What is wrong with a generated brief, said in words its author can act on, or null.
464
+ *
465
+ * Length is a criterion we set, so a brief that misses it is answered rather than cut.
466
+ * Truncating a long brief silently loses whatever the generator put last, which on a group
467
+ * is the last children it covered; the cure is to hand the complaint back and let the
468
+ * generator spend the budget itself. Coverage cannot be checked the same way: no test tells
469
+ * whether a sentence stands for a child, so coverage is instructed in the request and
470
+ * defended by the deterministic index underneath, and what is checked here is the pair of
471
+ * failures that were silent before.
472
+ */
473
+ export function briefContractComplaint(
474
+ brief: string,
475
+ maxBriefChars: number,
476
+ toolName: string,
477
+ ): string | null {
478
+ if (!brief) return "You returned no brief text. Reply with the brief itself and nothing else.";
479
+ if (brief.length > maxBriefChars) {
480
+ return `Your brief was ${brief.length} characters and the limit is ${maxBriefChars}. ` +
481
+ "Rewrite it within the limit. Cut detail, not subjects: every subject you named must " +
482
+ "still be named in the shorter brief.";
483
+ }
484
+ if (!usefulBrief(brief, maxBriefChars, toolName)) {
485
+ return "Your brief carried no factual content once fold navigation lines were dropped. " +
486
+ "Name concrete things from the span: files, identifiers, decisions, results, errors.";
487
+ }
488
+ return null;
489
+ }
490
+
491
+ /**
492
+ * One brief from the generator, with one chance to cure a criterion it missed.
493
+ *
494
+ * The no-retry rule this lane was built with holds for what it was written about: a
495
+ * generator that fails on a span fails on it again, and looping would spend the session's
496
+ * calls proving it. A contract complaint is the other case. The request stated a limit, the
497
+ * answer missed it, and the complaint names exactly what to change, so the second ask is
498
+ * new information rather than the same ask twice. It is bounded at one, and only a
499
+ * complaint earns it: transport faults, timeouts and attribution drift still fail on the
500
+ * first answer. Both attempts sit inside the caller's timeout, so a brief costs no more
501
+ * wall clock than it did.
502
+ */
503
+ export async function generatedBrief(input: {
504
+ summarize: (request: Record<string, unknown>, ctx?: unknown) => Promise<Record<string, unknown>>;
505
+ request: Record<string, unknown>;
506
+ ctx?: unknown;
507
+ maxBriefChars: number;
508
+ toolName: string;
509
+ }): Promise<{ brief: string; provenance: BriefProvenance; cured: boolean }> {
510
+ let complaint: string | null = null;
511
+ for (let attempt = 0; attempt < 2; attempt += 1) {
512
+ const result = await input.summarize(
513
+ complaint === null ? input.request : { ...input.request, cure: complaint },
514
+ input.ctx,
515
+ );
516
+ const brief = typeof result?.brief === "string" ? result.brief.trim() : "";
517
+ const provenance = generatorAttribution(result);
518
+ complaint = briefContractComplaint(brief, input.maxBriefChars, input.toolName);
519
+ if (complaint === null) return { brief, provenance, cured: attempt > 0 };
520
+ }
521
+ throw new Error(`Model context brief usefulness contract drift after one cure: ${complaint}`);
522
+ }
523
+
524
+ /**
525
+ * Attribution is the harness's own wiring, not the generator's judgment, so it is never
526
+ * handed back to be cured: a summarizer that cannot say which model wrote a brief is broken
527
+ * in a way a second ask cannot mend. Shared by the single and batched paths so the two
528
+ * cannot drift.
529
+ */
530
+ function generatorAttribution(result: Record<string, unknown> | null | undefined): BriefProvenance {
531
+ const digest = result?.launchContractDigest;
532
+ if (typeof result?.provider !== "string" || !result.provider ||
533
+ typeof result?.model !== "string" || !result.model ||
534
+ typeof result?.effort !== "string" || !result.effort || result.toolCalls !== 0 ||
535
+ (digest !== undefined && (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)))) {
536
+ throw new Error("Model context brief attribution, zero-tool, or digest contract drift");
537
+ }
538
+ return {
539
+ kind: "model",
540
+ provider: result.provider,
541
+ model: result.model,
542
+ effort: result.effort,
543
+ ...(typeof digest === "string" ? { launchContractDigest: digest } : {}),
544
+ };
545
+ }
546
+
547
+ /**
548
+ * Many briefs from one call, with one chance to cure the ones that missed.
549
+ *
550
+ * The lane used to send one request per fold, two at a time, which meant most folds never
551
+ * reached a generator at all: the 2026-08-11 rep committed 87 folds and applied 14 model
552
+ * briefs, the rest keeping the deterministic sentence because the queue was full. A commit
553
+ * folds its spans together, so they are briefed together, and coverage stops competing with
554
+ * throughput.
555
+ *
556
+ * The cure is per-span and the retry carries only the spans that failed, so one bad brief
557
+ * does not make the model rewrite eleven good ones. A span still failing after its cure
558
+ * keeps its deterministic brief and is named in the error, which is the same outcome a
559
+ * single-span failure has always had; the difference is that its neighbours still land.
560
+ */
561
+ export async function generatedBriefs(input: {
562
+ summarize: (request: Record<string, unknown>, ctx?: unknown) => Promise<Record<string, unknown>>;
563
+ request: Record<string, unknown>;
564
+ spans: Array<Record<string, unknown>>;
565
+ ctx?: unknown;
566
+ maxBriefChars: number;
567
+ toolName: string;
568
+ }): Promise<{
569
+ briefs: Array<{ brief: string; provenance: BriefProvenance } | null>;
570
+ cured: number;
571
+ complaints: string[];
572
+ }> {
573
+ const settled = new Array<{ brief: string; provenance: BriefProvenance } | null>(input.spans.length)
574
+ .fill(null);
575
+ let pending = input.spans.map((span, index) => ({ span, index }));
576
+ let cured = 0;
577
+ let complaints: string[] = [];
578
+ for (let attempt = 0; attempt < 2 && pending.length; attempt += 1) {
579
+ const asked = pending;
580
+ const result = await input.summarize({
581
+ ...input.request,
582
+ spans: asked.map((item) => item.span),
583
+ ...(complaints.length
584
+ ? {
585
+ cure: asked.map((item, at) =>
586
+ `Brief ${at + 1}: ${complaints[at] ?? "did not meet the stated criteria."}`).join(" "),
587
+ }
588
+ : {}),
589
+ }, input.ctx);
590
+ const provenance = generatorAttribution(result);
591
+ const written = Array.isArray(result?.briefs) ? result.briefs : [];
592
+ const failed: typeof pending = [];
593
+ const next: string[] = [];
594
+ for (let at = 0; at < asked.length; at += 1) {
595
+ const brief = typeof written[at] === "string" ? (written[at] as string).trim() : "";
596
+ const complaint = briefContractComplaint(brief, input.maxBriefChars, input.toolName);
597
+ if (complaint === null) {
598
+ settled[asked[at].index] = { brief, provenance };
599
+ if (attempt > 0) cured += 1;
600
+ continue;
601
+ }
602
+ failed.push(asked[at]);
603
+ next.push(complaint);
604
+ }
605
+ pending = failed;
606
+ complaints = next;
607
+ }
608
+ return { briefs: settled, cured, complaints };
609
+ }
610
+
342
611
  export async function prepareFold(input: {
343
612
  candidate: FoldCandidate;
344
613
  snapshot: ActiveContextSnapshot;
@@ -366,6 +635,10 @@ export async function prepareFold(input: {
366
635
  throw new Error("Fold source is not exact, stale, and unprotected");
367
636
  }
368
637
  const sourceText = encodedFoldSource(snapshot, state, candidate.parts, candidate.kind);
638
+ const groupChildren = candidate.parts.filter((part) => part.kind === "fold").length;
639
+ const readableSource = groupChildren
640
+ ? consolidationSourceText(snapshot, state, candidate.parts)
641
+ : sourceText;
369
642
  const orientation = boundedOrientation(snapshot, candidate.sourceRefs);
370
643
  const sourceSha256 = sha256Text(sourceText);
371
644
  const beforeSha256 = sha256Text(orientation.beforeText);
@@ -396,7 +669,7 @@ export async function prepareFold(input: {
396
669
  input.signal?.addEventListener("abort", relayAbort, { once: true });
397
670
  let timeout: ReturnType<typeof setTimeout> | undefined;
398
671
  try {
399
- if (bytes(sourceText) > snapshot.policy.maxSourceChars) {
672
+ if (bytes(readableSource) > snapshot.policy.maxSourceChars) {
400
673
  throw new Error("Fold source exceeds the bounded model-summary request");
401
674
  }
402
675
  const timed = new Promise<never>((_resolve, reject) => {
@@ -405,39 +678,31 @@ export async function prepareFold(input: {
405
678
  reject(new Error(`Context brief exceeded ${snapshot.policy.briefTimeoutMs}ms`));
406
679
  }, snapshot.policy.briefTimeoutMs);
407
680
  });
408
- const result = await Promise.race([input.summarize({
409
- candidateId,
410
- sourceRefs: clone(candidate.sourceRefs),
411
- sourceText,
412
- sourceSha256,
413
- beforeRefs: clone(orientation.beforeRefs),
414
- beforeText: orientation.beforeText,
415
- beforeSha256,
416
- afterRefs: clone(orientation.afterRefs),
417
- afterText: orientation.afterText,
418
- afterSha256,
419
- maxBriefChars: snapshot.policy.maxBriefChars,
420
- signal: controller.signal,
421
- }, input.ctx), timed]);
422
- const generated = typeof result?.brief === "string" ? result.brief.trim() : "";
423
- const digest = result?.launchContractDigest;
424
- if (!usefulBrief(generated, snapshot.policy.maxBriefChars, snapshot.toolName) ||
425
- typeof result?.provider !== "string" || !result.provider ||
426
- typeof result?.model !== "string" || !result.model ||
427
- typeof result?.effort !== "string" || !result.effort || result.toolCalls !== 0 ||
428
- (digest !== undefined && (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)))) {
429
- throw new Error("Model context brief attribution, zero-tool, digest, or usefulness contract drift");
430
- }
431
- modelBrief = {
432
- brief: generated,
433
- provenance: {
434
- kind: "model",
435
- provider: result.provider,
436
- model: result.model,
437
- effort: result.effort,
438
- ...(typeof digest === "string" ? { launchContractDigest: digest } : {}),
681
+ const generated = await Promise.race([generatedBrief({
682
+ summarize: input.summarize,
683
+ request: {
684
+ candidateId,
685
+ sourceRefs: clone(candidate.sourceRefs),
686
+ // What the generator READS, which for a group is its children opened one level
687
+ // rather than the placeholders `sourceText` renders. The fold's own identity
688
+ // stays `sourceSha256` above: what a span IS does not change with how it is read.
689
+ sourceText: readableSource,
690
+ sourceSha256: sha256Text(readableSource),
691
+ children: groupChildren,
692
+ beforeRefs: clone(orientation.beforeRefs),
693
+ beforeText: orientation.beforeText,
694
+ beforeSha256,
695
+ afterRefs: clone(orientation.afterRefs),
696
+ afterText: orientation.afterText,
697
+ afterSha256,
698
+ maxBriefChars: snapshot.policy.maxBriefChars,
699
+ signal: controller.signal,
439
700
  },
440
- };
701
+ ctx: input.ctx,
702
+ maxBriefChars: snapshot.policy.maxBriefChars,
703
+ toolName: snapshot.toolName,
704
+ }), timed]);
705
+ modelBrief = { brief: generated.brief, provenance: generated.provenance };
441
706
  } catch (error) {
442
707
  if (input.signal?.aborted) throw error;
443
708
  input.onSummarizerFailure?.(error);
@@ -450,7 +715,11 @@ export async function prepareFold(input: {
450
715
  brief = modelBrief.brief;
451
716
  provenance = modelBrief.provenance;
452
717
  } else {
453
- brief = deterministicChapterCandidateBrief(snapshot, candidate);
718
+ // By kind, not by assumption: a group that loses its generator falls back to the
719
+ // index of its children, and only a chapter falls back to a chapter's brief.
720
+ brief = candidate.kind === "consolidation"
721
+ ? deterministicConsolidationBrief(candidate, state)
722
+ : deterministicChapterCandidateBrief(snapshot, candidate);
454
723
  provenance = { kind: "deterministic" };
455
724
  }
456
725
  }
@@ -706,6 +975,14 @@ export function protectEvidence(
706
975
  });
707
976
  const byKey = new Map(state.protected.map((ref) => [objectRefKey(ref), ref]));
708
977
  for (const ref of refs) protect ? byKey.set(objectRefKey(ref), ref) : byKey.delete(objectRefKey(ref));
978
+ // Atomic: the whole resolved set pins or nothing does. A partial pin that reports
979
+ // success would leave part of the requested span silently reclaimable.
980
+ if (byKey.size > MAX_ACTIVE_PROTECTED) {
981
+ throw new Error(
982
+ `Protecting ${ids.join(", ")} would hold ${byKey.size} refs, over the ${MAX_ACTIVE_PROTECTED}-ref cap; ` +
983
+ "unprotect something first",
984
+ );
985
+ }
709
986
  return clearPrepared({
710
987
  ...state,
711
988
  revision: state.revision + 1,
@@ -1114,7 +1391,7 @@ export function activeContextStatus(
1114
1391
  endId: eligibleSourceIds.at(-1),
1115
1392
  action: { action: "fold", ids: eligibleEndpoints, brief: "<factual brief, at most 1000 characters>" },
1116
1393
  } : null,
1117
- rawTailMinimumBytes: snapshot.policy.freshBytes,
1394
+ rawTailMinimumBytes: zoneBytes(snapshot.thresholds.freshTail, snapshot.budgetTokens),
1118
1395
  currentTurnRequiresBoundary: false,
1119
1396
  actions: {
1120
1397
  status: { action: "status", offset, limit },
@@ -1274,15 +1551,16 @@ export function projectionSlateCandidates(
1274
1551
  });
1275
1552
  }
1276
1553
 
1277
- export function recoverFoldMessages(input: {
1554
+ export interface FoldSourceRequest {
1278
1555
  foldId: string;
1279
1556
  state: ActiveContextState;
1280
1557
  entries: Array<Record<string, unknown>>;
1281
1558
  sessionId: string;
1282
1559
  projectEntry?: (entry: Record<string, unknown>) => unknown[];
1283
- }): unknown[] {
1284
- const fold = input.state.folds.find((item) => item.id === input.foldId);
1285
- if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
1560
+ }
1561
+
1562
+ /** One exact message per ref identity and digest, read off the immutable session entries. */
1563
+ function exactSessionMessages(input: FoldSourceRequest): Map<string, unknown> {
1286
1564
  const projectEntry = input.projectEntry ?? sessionEntryMessages;
1287
1565
  const exact = new Map<string, unknown>();
1288
1566
  for (const entry of input.entries) {
@@ -1292,20 +1570,81 @@ export function recoverFoldMessages(input: {
1292
1570
  exact.set(`${objectRefKey(ref)}:${ref.sha256}`, message);
1293
1571
  }
1294
1572
  }
1295
- return flattenFoldRefs(fold, input.state).map((ref) => {
1296
- if (ref.sessionId !== input.sessionId) throw new Error(`Fold source ${ref.entryId} belongs to another session`);
1297
- const message = exact.get(`${objectRefKey(ref)}:${ref.sha256}`);
1298
- if (!message || evidenceSha256(message) !== ref.sha256) {
1299
- throw new Error(`Exact recovery failed for ${ref.entryId}`);
1300
- }
1301
- return clone(message);
1573
+ return exact;
1574
+ }
1575
+
1576
+ function exactMessageFor(
1577
+ exact: Map<string, unknown>,
1578
+ ref: EvidenceRef,
1579
+ sessionId: string,
1580
+ ): unknown {
1581
+ if (ref.sessionId !== sessionId) throw new Error(`Fold source ${ref.entryId} belongs to another session`);
1582
+ const message = exact.get(`${objectRefKey(ref)}:${ref.sha256}`);
1583
+ if (!message || evidenceSha256(message) !== ref.sha256) {
1584
+ throw new Error(`Exact recovery failed for ${ref.entryId}`);
1585
+ }
1586
+ return clone(message);
1587
+ }
1588
+
1589
+ /**
1590
+ * One fold's ENTIRE original source, at every depth.
1591
+ *
1592
+ * This is the rescue read: restoration and the recovery audit both need the bytes as the
1593
+ * session first wrote them, with nothing standing in for anything. Retrieval inside a
1594
+ * session is the other function, and it serves one level.
1595
+ */
1596
+ export function recoverFoldMessages(input: FoldSourceRequest): unknown[] {
1597
+ const fold = input.state.folds.find((item) => item.id === input.foldId);
1598
+ if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
1599
+ const exact = exactSessionMessages(input);
1600
+ return flattenFoldRefs(fold, input.state).map((ref) => exactMessageFor(exact, ref, input.sessionId));
1601
+ }
1602
+
1603
+ /** A child fold as its parent's stored span holds it: the placeholder, not the bytes under it. */
1604
+ export function storedChildPlaceholder(child: ActiveFold, state: ActiveContextState): Record<string, unknown> {
1605
+ return {
1606
+ placeholder: "fold",
1607
+ id: child.id,
1608
+ kind: child.kind,
1609
+ brief: foldBrief(child, state),
1610
+ sourceSha256: child.sourceSha256,
1611
+ sourceBytes: child.sourceChars,
1612
+ children: childFoldIds(child).length,
1613
+ peek: { action: "peek", id: child.id },
1614
+ };
1615
+ }
1616
+
1617
+ /**
1618
+ * One fold's stored span, AS STORED: one level.
1619
+ *
1620
+ * A fold's span is what it took in, and what it took in was already whatever the window
1621
+ * held: raw entries, and placeholders for folds that were there first. So the honest read
1622
+ * of a parent is that same span, children still placeheld. Reading it as the original
1623
+ * bytes at every depth would hand back a whole session's transcript for one call and
1624
+ * undo the nesting the fold performed.
1625
+ *
1626
+ * The verbatim floor is untouched by this. A leaf's stored span IS its original bytes, so
1627
+ * the bytes are always exactly one peek away from any id the read hands back.
1628
+ */
1629
+ export function foldStoredSpan(input: FoldSourceRequest): unknown[] {
1630
+ const fold = input.state.folds.find((item) => item.id === input.foldId);
1631
+ if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
1632
+ const exact = exactSessionMessages(input);
1633
+ const byId = foldMap(input.state);
1634
+ return fold.parts.map((part) => {
1635
+ if (part.kind === "raw") return exactMessageFor(exact, part.ref, input.sessionId);
1636
+ const child = byId.get(part.foldId);
1637
+ if (!child) throw new Error(`Missing active-context child ${part.foldId}`);
1638
+ return storedChildPlaceholder(child, input.state);
1302
1639
  });
1303
1640
  }
1304
1641
 
1305
1642
  /**
1306
- * Ephemeral point read of one fold's exact source at any depth. It recovers the same
1307
- * SHA-256-verified messages expansion restores, bounded for one model call, and changes no
1308
- * projection: the fold stays collapsed and the durable state is untouched.
1643
+ * Ephemeral point read of one fold's stored span. It returns the same SHA-256-verified
1644
+ * span expansion restores, ONE level: raw entries verbatim, child folds still placeheld.
1645
+ * It is bounded for one model call and changes no projection: the fold stays collapsed and
1646
+ * the durable state is untouched. Any id in the result peeks in one hop, so the verbatim
1647
+ * floor is a hop away at every depth rather than a payload nobody asked for.
1309
1648
  */
1310
1649
  export function peekFoldSource(input: {
1311
1650
  foldId: string;
@@ -1321,7 +1660,7 @@ export function peekFoldSource(input: {
1321
1660
  }): Record<string, unknown> {
1322
1661
  const fold = input.state.folds.find((item) => item.id === input.foldId);
1323
1662
  if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
1324
- const messages = recoverFoldMessages({
1663
+ const messages = foldStoredSpan({
1325
1664
  foldId: input.foldId,
1326
1665
  state: input.state,
1327
1666
  entries: input.entries,
@@ -1377,12 +1716,17 @@ export function peekFoldSource(input: {
1377
1716
  ...(truncated
1378
1717
  ? { wider: { action: "peek", id: fold.id, bytes: Math.min(sourceBytes, ACTIVE_CONTEXT_POLICY.maxChapterChars) } }
1379
1718
  : {}),
1380
- lifetime: "these bytes stay in the window exactly as returned, like any other tool " +
1381
- "result, until the ladder folds them at a commit. Nothing rewrites this result in place.",
1719
+ lifetime: "these bytes stay in the window exactly as returned, like any other tool result, and " +
1720
+ `nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s stored source, so the ` +
1721
+ `next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
1722
+ "same verbatim bytes, or pin this result to keep the copy raw.",
1382
1723
  note: truncated
1383
1724
  ? `Bounded read: ${returnedBytes} of ${sourceBytes} exact source bytes, ${view.omittedBytes} omitted ` +
1384
1725
  `from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
1385
- : "Complete exact source; the fold stayed collapsed and no projection changed.",
1726
+ : children.length
1727
+ ? `Complete stored span, one level: raw entries exactly, and ${children.length} child fold(s) still ` +
1728
+ "placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
1729
+ : "Complete exact source; the fold stayed collapsed and no projection changed.",
1386
1730
  source: returned,
1387
1731
  // Serialized AFTER the source on purpose: a reader that just consumed a bounded slice
1388
1732
  // decides its next action at the end of the payload, and a notice buried before ten