pi-fold 0.1.1 → 1.0.1

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,6 +28,7 @@ import {
28
28
  ACTIVE_CONTEXT_POLICY,
29
29
  CONSOLIDATION_WIDTH_THRESHOLD,
30
30
  DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
31
+ MAX_FOLD_SPAN_CHARS,
31
32
  } from "./policy.ts";
32
33
  import type {
33
34
  ActiveContextSnapshot,
@@ -41,6 +42,7 @@ import type {
41
42
  } from "./policy.ts";
42
43
  import {
43
44
  chapterSegments,
45
+ currentTurnBoundary,
44
46
  scanTurnToolBatches,
45
47
  structurallyClosedChapterUnits,
46
48
  terminalAssistant,
@@ -87,6 +89,7 @@ export function resultCallIndex(snapshot: ActiveContextSnapshot, allowConsumedIn
87
89
  allowConsumedIncomplete,
88
90
  snapshot.toolName,
89
91
  snapshot.readOnlyTools,
92
+ snapshot.readOnlyContextActions,
90
93
  );
91
94
  if (!validated) continue;
92
95
  const batches = new Map<number, string[]>();
@@ -149,6 +152,85 @@ export function toolCallArguments(snapshot: ActiveContextSnapshot, assistantInde
149
152
  return calls.get(id);
150
153
  }
151
154
 
155
+ /**
156
+ * Anchor budgets for a stage-identified brief. They exist to keep the composed brief
157
+ * inside the hard 1200-character brief cap with the generic prose trimmed away: an
158
+ * argument list, a leading label from the result head, and the result TAIL, which is
159
+ * where a staged chain puts its keys and a report puts its conclusion.
160
+ */
161
+ export const IDENTIFIED_BRIEF_ARGUMENT_CHARS = 240;
162
+ export const IDENTIFIED_BRIEF_VALUE_CHARS = 96;
163
+ export const IDENTIFIED_BRIEF_HEAD_CHARS = 160;
164
+ export const IDENTIFIED_BRIEF_TAIL_CHARS = 120;
165
+ export const IDENTIFIED_BRIEF_CALLS_CHARS = 400;
166
+
167
+ /** Every scalar argument of a call, in stable key order, bounded per value and in total. */
168
+ export function identifiedCallArguments(
169
+ args: unknown,
170
+ factual: (value: string, maximum: number) => string,
171
+ ): string {
172
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
173
+ const pairs: string[] = [];
174
+ for (const key of Object.keys(args as Record<string, unknown>).sort()) {
175
+ const value = ownValue(args, key);
176
+ const text = typeof value === "string"
177
+ ? value
178
+ : (typeof value === "number" || typeof value === "boolean" ? String(value) : "");
179
+ if (!text.trim()) continue;
180
+ pairs.push(`${factual(key, 40)}=${factual(text, IDENTIFIED_BRIEF_VALUE_CHARS)}`);
181
+ }
182
+ return oneLine(pairs.join(", "), IDENTIFIED_BRIEF_ARGUMENT_CHARS);
183
+ }
184
+
185
+ /** The last characters of a result, which is the region a truncated read never shows. */
186
+ export function identifiedResultTail(
187
+ message: unknown,
188
+ factual: (value: string, maximum: number) => string,
189
+ ): string {
190
+ const text = String(contentText(message) ?? "").replace(/\s+/g, " ").trim();
191
+ if (!text) return "";
192
+ return factual(text.slice(Math.max(0, text.length - IDENTIFIED_BRIEF_TAIL_CHARS)), IDENTIFIED_BRIEF_TAIL_CHARS);
193
+ }
194
+
195
+ /**
196
+ * The stage-identified brief. The generic prose is trimmed to a few words so the
197
+ * character budget buys ANCHORS instead: which call this was, with which arguments,
198
+ * what the result opened with, and what it ENDED with. The tail anchor is the point
199
+ * of the whole change: a reader deciding which fold holds a chain key can only tell
200
+ * from the tail, and a generic sentence never carried one.
201
+ */
202
+ export function stageIdentifiedToolBrief(input: {
203
+ snapshot: ActiveContextSnapshot;
204
+ refs: EvidenceRef[];
205
+ calls: ResultCall[];
206
+ factualValue: (value: string, maximum: number) => string;
207
+ factualToolName: (name: string) => string;
208
+ }): string | null {
209
+ const { snapshot, refs, calls, factualValue, factualToolName } = input;
210
+ const messages = refs.map((ref) => exactMapped(snapshot, ref)?.message ?? null);
211
+ if (messages.some((message) => message === null)) return null;
212
+ const signatures = calls.map((call) => {
213
+ const args = identifiedCallArguments(
214
+ toolCallArguments(snapshot, call.assistantIndex, call.id),
215
+ factualValue,
216
+ );
217
+ return args ? `${factualToolName(call.name)}(${args})` : factualToolName(call.name);
218
+ });
219
+ const label = factualValue(
220
+ String(contentText(messages[0]) ?? "").split(/\r?\n/).find((line) => line.trim()) ?? "",
221
+ IDENTIFIED_BRIEF_HEAD_CHARS,
222
+ );
223
+ const tail = identifiedResultTail(messages.at(-1), factualValue);
224
+ const composed = [
225
+ `Read ${oneLine(signatures.join("; "), IDENTIFIED_BRIEF_CALLS_CHARS)}`,
226
+ label ? `opens "${label}"` : "",
227
+ tail ? `ends "${tail}"` : "",
228
+ calls.length > 1 ? `${calls.length} exact results here` : "exact source here",
229
+ ].filter(Boolean).join(" · ");
230
+ const bounded = oneLine(composed, ACTIVE_CONTEXT_POLICY.maxBriefChars);
231
+ return usefulBrief(bounded, ACTIVE_CONTEXT_POLICY.maxBriefChars, snapshot.toolName) ? bounded : null;
232
+ }
233
+
152
234
  export function automaticToolBrief(snapshot: ActiveContextSnapshot, candidate: FoldCandidate): string {
153
235
  const refs = candidate.kind === "tool-result" && candidate.parts.every((part) => part.kind === "raw")
154
236
  ? candidate.parts.map((part) => (part as Extract<FoldPart, { kind: "raw" }>).ref)
@@ -163,16 +245,21 @@ export function automaticToolBrief(snapshot: ActiveContextSnapshot, candidate: F
163
245
  throw new Error("Automatic tool brief crossed a validated assistant batch");
164
246
  }
165
247
  const first = calls[0];
166
- const factualBriefValue = (value: string): string => value
248
+ const factualValue = (value: string, maximum: number): string => value
167
249
  .replace(new RegExp(snapshot.toolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), "active-context service")
168
250
  .replace(/[\u0000-\u001f\u007f]+/g, " ")
169
251
  .replace(/\s+/g, " ")
170
252
  .trim()
171
- .slice(0, 120);
253
+ .slice(0, maximum);
254
+ const factualBriefValue = (value: string): string => factualValue(value, 120);
172
255
  const factualToolName = (name: string): string => name.toLowerCase() === snapshot.toolName.toLowerCase()
173
256
  ? "active-context status inspection"
174
257
  : factualBriefValue(name);
175
258
  const args = toolCallArguments(snapshot, first.assistantIndex, first.id);
259
+ // Exact anchors before generic prose: one sentence per tool made every fold of that
260
+ // tool carry the SAME brief, so no index could answer which fold holds a given stage.
261
+ const identified = stageIdentifiedToolBrief({ snapshot, refs, calls, factualValue, factualToolName });
262
+ if (identified) return identified;
176
263
  const targets: string[] = [];
177
264
  for (const key of ["path", "address", "query", "url", "action", "id"]) {
178
265
  const value = ownValue(args, key);
@@ -252,14 +339,18 @@ export function selectAutomaticConsolidation(
252
339
  snapshot: ActiveContextSnapshot,
253
340
  state: ActiveContextState,
254
341
  ratio: number,
342
+ claimed: ReadonlySet<string> = new Set<string>(),
255
343
  ): FoldCandidate | null {
256
344
  const visibleRoots = visibleCollapsedRoots(state, snapshot);
257
345
  const widthEligible = visibleRoots.length > CONSOLIDATION_WIDTH_THRESHOLD;
258
346
  const pressureEligible = Number.isFinite(ratio) && ratio >= snapshot.policy.consolidationRatio;
259
347
  if (!widthEligible && !pressureEligible) return null;
260
- const roots = visibleRoots.filter(({ fold }) =>
261
- (fold.kind === "chapter" || fold.kind === "consolidation") &&
262
- !refsProtected(flattenFoldRefs(fold, state), state, snapshot));
348
+ const roots = visibleRoots.filter(({ fold }) => {
349
+ if (fold.kind !== "chapter" && fold.kind !== "consolidation") return false;
350
+ const refs = flattenFoldRefs(fold, state);
351
+ if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) return false;
352
+ return !refsProtected(refs, state, snapshot);
353
+ });
263
354
  const candidateFor = (selected: typeof roots): FoldCandidate | null => {
264
355
  const parts: FoldPart[] = selected.map(({ fold }) => ({ kind: "fold", foldId: fold.id }));
265
356
  const sourceRefs = candidateSourceRefs(parts, state);
@@ -301,14 +392,20 @@ export function selectAutomaticConsolidation(
301
392
  return finish();
302
393
  }
303
394
 
395
+ /**
396
+ * The stalest eligible completed read-only batch. `claimed` lets a caller exclude
397
+ * evidence already spoken for by something that is not yet a fold (a pending mark),
398
+ * so repeated calls walk forward instead of returning the same batch.
399
+ */
304
400
  export function selectAutomaticToolBatch(
305
401
  snapshot: ActiveContextSnapshot,
306
402
  state: ActiveContextState,
307
403
  ratio: number,
404
+ claimed: ReadonlySet<string> = new Set<string>(),
308
405
  ): FoldCandidate[] {
309
406
  if (!Number.isFinite(ratio) || ratio < snapshot.policy.toolFoldRatio) return [];
310
- const owned = new Set(state.folds.flatMap((fold) => fold.parts.flatMap((part) =>
311
- part.kind === "raw" ? [objectRefKey(part.ref)] : [])));
407
+ const owned = new Set([...claimed, ...state.folds.flatMap((fold) => fold.parts.flatMap((part) =>
408
+ part.kind === "raw" ? [objectRefKey(part.ref)] : []))]);
312
409
  const groups = new Map<number, Array<{ item: MappedMessage; call: NonNullable<ReturnType<typeof resultCall>> }>>();
313
410
  for (const item of snapshot.mapped) {
314
411
  if (messageRole(item.message) !== "toolResult") continue;
@@ -342,9 +439,11 @@ export function selectAutomaticRefold(
342
439
  snapshot: ActiveContextSnapshot,
343
440
  state: ActiveContextState,
344
441
  ratio: number,
442
+ claimedFoldIds: ReadonlySet<string> = new Set<string>(),
345
443
  ): string | null {
346
444
  if (!Number.isFinite(ratio) || ratio < snapshot.policy.refoldRatio) return null;
347
445
  const candidates = state.expanded.flatMap((id) => {
446
+ if (claimedFoldIds.has(id)) return [];
348
447
  const fold = state.folds.find((item) => item.id === id);
349
448
  const interval = fold ? foldInterval(fold, state, snapshot) : null;
350
449
  const protectedSource = fold && (fold.kind === "tool-result"
@@ -360,11 +459,12 @@ export function selectAutomaticToolForRung(
360
459
  state: ActiveContextState,
361
460
  ratio: number,
362
461
  cadenceWaived = false,
462
+ claimed: ReadonlySet<string> = new Set<string>(),
363
463
  ): FoldCandidate | null {
364
464
  const cadenceSatisfied = state.tokensSinceToolFold >= toolFoldCadence(snapshot.contextWindow);
365
465
  if (!cadenceWaived && (!Number.isFinite(ratio) ||
366
466
  (ratio < snapshot.policy.toolFoldRatio && !cadenceSatisfied))) return null;
367
- return selectAutomaticToolBatch(snapshot, state, 1)[0] ?? null;
467
+ return selectAutomaticToolBatch(snapshot, state, 1, claimed)[0] ?? null;
368
468
  }
369
469
 
370
470
  export function resolveFoldInputIds(
@@ -396,11 +496,336 @@ export function chapterRangeIsUnitAligned(snapshot: ActiveContextSnapshot, start
396
496
  return units.every((unit, index) => index === 0 || unit.start === units[index - 1].end);
397
497
  }
398
498
 
499
+ /**
500
+ * Bite-sized folds.
501
+ *
502
+ * A fold is only navigable if reading one back is cheap. Measured 2026-08-06 (rep 6):
503
+ * one 60,432-byte chapter fold hid the fact the run needed in its tail, and every peek
504
+ * of it was either truncated short of the answer or too expensive to widen. So a span
505
+ * whose content exceeds the cap becomes SEQUENTIAL folds, each with its own brief,
506
+ * split only at structurally closed unit boundaries -- never mid-entry.
507
+ *
508
+ * A single unit that alone exceeds the cap is returned whole: there is no valid
509
+ * interior boundary, and refusing to fold it would leave the biggest span in the
510
+ * window as the one thing nothing may reclaim. The caller reports the split.
511
+ */
512
+ export function splitCandidateBySize(
513
+ snapshot: ActiveContextSnapshot,
514
+ state: ActiveContextState,
515
+ candidate: FoldCandidate,
516
+ maxChars: number = MAX_FOLD_SPAN_CHARS,
517
+ ): FoldCandidate[] {
518
+ // A tool-result fold owns exactly one validated assistant batch and a consolidation
519
+ // owns whole child folds; neither has an interior boundary to split on.
520
+ if (candidate.kind !== "chapter") return [candidate];
521
+ const refs = candidate.sourceRefs.length ? candidate.sourceRefs : candidateSourceRefs(candidate.parts, state);
522
+ const indices = refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
523
+ if (!indices.length || indices.some((index) => index < 0)) return [candidate];
524
+ const start = Math.min(...indices);
525
+ const end = Math.max(...indices);
526
+ if (spanBytes(snapshot, start, end + 1) <= maxChars) return [candidate];
527
+ const units = chapterUnits(snapshot).filter((unit) => unit.start >= start && unit.end <= end + 1);
528
+ if (units.length < 2) return [candidate];
529
+ const split: FoldCandidate[] = [];
530
+ const emit = (from: number, to: number): boolean => {
531
+ const parts = partsForRange(snapshot, state, from, to, new Set<FoldKind>(["tool-result"]));
532
+ if (!parts) return false;
533
+ split.push({ kind: "chapter", parts, sourceRefs: candidateSourceRefs(parts, state) });
534
+ return true;
535
+ };
536
+ let runStart = units[0].start;
537
+ let runBytes = 0;
538
+ for (const unit of units) {
539
+ const size = spanBytes(snapshot, unit.start, unit.end);
540
+ if (runBytes > 0 && runBytes + size > maxChars) {
541
+ if (!emit(runStart, unit.start - 1)) return [candidate];
542
+ runStart = unit.start;
543
+ runBytes = 0;
544
+ }
545
+ runBytes += size;
546
+ }
547
+ if (runBytes > 0 && !emit(runStart, units.at(-1)!.end - 1)) return [candidate];
548
+ return split.length ? split : [candidate];
549
+ }
550
+
551
+ /** Exact serialized size of a candidate's whole span, folds and raw entries alike. */
552
+ export function candidateSpanChars(
553
+ snapshot: ActiveContextSnapshot,
554
+ state: ActiveContextState,
555
+ candidate: FoldCandidate,
556
+ ): number {
557
+ const refs = candidate.sourceRefs.length ? candidate.sourceRefs : candidateSourceRefs(candidate.parts, state);
558
+ const indices = refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1).filter((index) => index >= 0);
559
+ if (!indices.length) return 0;
560
+ return spanBytes(snapshot, Math.min(...indices), Math.max(...indices) + 1);
561
+ }
562
+
563
+ /** Exact serialized size of a half-open mapped message range. */
564
+ export function spanBytes(snapshot: ActiveContextSnapshot, start: number, end: number): number {
565
+ return bytes(snapshot.messages.slice(start, end));
566
+ }
567
+
568
+ /** One auto-snap the runtime applied to a requested span, reported never inferred. */
569
+ export interface SpanCorrection {
570
+ from: string[];
571
+ to: string[];
572
+ reason: string;
573
+ }
574
+
575
+ export interface SnappedFoldSpan {
576
+ candidate: FoldCandidate;
577
+ corrections: SpanCorrection[];
578
+ }
579
+
580
+ /**
581
+ * Resolve a requested span, correcting it where a correction exists.
582
+ *
583
+ * A span the agent got slightly wrong -- crossing a fold it forgot about, reaching
584
+ * into the turn still in flight, landing mid-unit -- is a span it MEANT, and refusing
585
+ * it teaches the agent that curating is a coin flip. So an invalid span SNAPS to the
586
+ * nearest valid edge and the correction is stated in the result. Rejection is reserved
587
+ * for a request with no valid interpretation at all, and it says exactly what failed.
588
+ */
589
+ export function snapFoldCandidate(
590
+ snapshot: ActiveContextSnapshot,
591
+ state: ActiveContextState,
592
+ ids: string[],
593
+ options: { allowProtected?: boolean } = {},
594
+ ): SnappedFoldSpan {
595
+ let directError: Error;
596
+ try {
597
+ return { candidate: manualFoldCandidate(snapshot, state, ids, options), corrections: [] };
598
+ } catch (error) {
599
+ directError = error instanceof Error ? error : new Error(String(error));
600
+ }
601
+ // Nearest first, then the other readings of the same intent. A span that cuts into one
602
+ // fold has exactly two endpoint corrections -- absorb it, or step past it -- and which
603
+ // one survives depends on structure the caller cannot see. A span that cuts into folds
604
+ // on BOTH sides has neither: every endpoint correction still leaves a whole chapter
605
+ // inside the span, and a chapter may not swallow one.
606
+ const alternatives = snapSpanAlternatives(snapshot, state, ids);
607
+ let lastError: Error = directError;
608
+ for (const snapped of alternatives) {
609
+ try {
610
+ return {
611
+ candidate: manualFoldCandidate(snapshot, state, snapped.ids, options),
612
+ corrections: snapped.corrections,
613
+ };
614
+ } catch (error) {
615
+ lastError = error instanceof Error ? error : new Error(String(error));
616
+ }
617
+ }
618
+ // A suggestion the caller cannot act on is worse than no suggestion: never name a
619
+ // "nearest valid span" this same validation just refused. Nothing snapped, so the
620
+ // refusal is the direct one, stated once.
621
+ throw new Error(
622
+ `${directError.message}. No corrected reading of that span is constructible`,
623
+ { cause: lastError },
624
+ );
625
+ }
626
+
627
+ /**
628
+ * Every constructible reading of a requested span, nearest intent first.
629
+ *
630
+ * The three endpoint modes move the EDGES; the fourth reading moves the FRAME. A span
631
+ * cutting into folds at both ends has no endpoint correction that yields a foldable
632
+ * chapter -- each one still contains a whole chapter, which `partsForRange` refuses --
633
+ * so the only faithful reading left is the whole folds themselves: a consolidation.
634
+ */
635
+ export function snapSpanAlternatives(
636
+ snapshot: ActiveContextSnapshot,
637
+ state: ActiveContextState,
638
+ ids: string[],
639
+ ): Array<{ ids: string[]; corrections: SpanCorrection[] }> {
640
+ const seen = new Set<string>();
641
+ const readings = [
642
+ ...(["nearest", "exclude", "absorb"] as const)
643
+ .map((mode) => snapSpanIds(snapshot, state, ids, mode)),
644
+ snapSpanToWholeFolds(snapshot, state, ids),
645
+ ];
646
+ return readings.flatMap((snapped) => {
647
+ if (!snapped) return [];
648
+ const key = snapped.ids.join("\u0000");
649
+ if (seen.has(key)) return [];
650
+ seen.add(key);
651
+ return [snapped];
652
+ });
653
+ }
654
+
655
+ /**
656
+ * The requested span read as the whole folds it cuts into.
657
+ *
658
+ * Constructible only when the outward snap lands on a contiguous tiling of two or more
659
+ * foldable roots: then the span the agent named IS those folds, and consolidating them
660
+ * is the one reading that both covers what was asked for and validates.
661
+ */
662
+ export function snapSpanToWholeFolds(
663
+ snapshot: ActiveContextSnapshot,
664
+ state: ActiveContextState,
665
+ ids: string[],
666
+ ): { ids: string[]; corrections: SpanCorrection[] } | null {
667
+ let outward: ReturnType<typeof snapToFoldBoundaries>;
668
+ try { outward = snapToFoldBoundaries(snapshot, state, ids); }
669
+ catch { return null; }
670
+ if (!outward.corrections.length) return null;
671
+ const tiling = orderedRoots(state, snapshot)
672
+ .filter((root) => root.start >= outward.start && root.end <= outward.end);
673
+ if (tiling.length < 2) return null;
674
+ if (tiling[0].start !== outward.start || tiling.at(-1)!.end !== outward.end) return null;
675
+ if (tiling.some((root, index) => index > 0 && root.start !== tiling[index - 1].end + 1)) return null;
676
+ if (tiling.some((root) => root.fold.kind === "tool-result")) return null;
677
+ return {
678
+ ids: tiling.map((root) => root.fold.id),
679
+ corrections: [{
680
+ ...outward.corrections[0],
681
+ reason: `span cut into ${tiling.length} folds it may not re-fold; corrected outward to their whole ` +
682
+ `boundaries and read as a consolidation of ${tiling.map((root) => root.fold.id).join(", ")}`,
683
+ }],
684
+ };
685
+ }
686
+
687
+ /**
688
+ * The correction itself: pull the endpoints out of any fold they cut through, out of
689
+ * the turn still in flight, and onto structurally closed unit boundaries.
690
+ */
691
+ export function snapSpanIds(
692
+ snapshot: ActiveContextSnapshot,
693
+ state: ActiveContextState,
694
+ ids: string[],
695
+ /** How an endpoint inside a fold is read: by distance, or always one way. */
696
+ mode: "nearest" | "absorb" | "exclude" = "nearest",
697
+ ): { ids: string[]; corrections: SpanCorrection[] } | null {
698
+ let resolved: Array<{ start: number; end: number; fold?: ActiveFold }>;
699
+ // An unknown id has no nearest valid edge: there is nothing to snap TO.
700
+ try { resolved = resolveFoldInputIds(snapshot, state, ids); }
701
+ catch { return null; }
702
+ const requested = [resolved[0].start, resolved.at(-1)!.end] as [number, number];
703
+ let [start, end] = requested;
704
+ const corrections: SpanCorrection[] = [];
705
+ const note = (reason: string): void => {
706
+ corrections.push({
707
+ from: [entryIdAt(snapshot, requested[0]), entryIdAt(snapshot, requested[1])],
708
+ to: [entryIdAt(snapshot, start), entryIdAt(snapshot, end)],
709
+ reason,
710
+ });
711
+ };
712
+ for (const root of orderedRoots(state, snapshot)) {
713
+ if (start > root.start && start <= root.end) {
714
+ const absorb = mode === "absorb" ||
715
+ (mode === "nearest" && start - root.start <= root.end + 1 - start);
716
+ start = absorb ? root.start : root.end + 1;
717
+ note(`span started inside ${root.fold.id}; corrected to its ` +
718
+ `${absorb ? "start" : "far"} boundary`);
719
+ }
720
+ if (end >= root.start && end < root.end) {
721
+ const absorb = mode === "absorb" ||
722
+ (mode === "nearest" && root.end - end <= end - (root.start - 1));
723
+ end = absorb ? root.end : root.start - 1;
724
+ note(`span ended inside ${root.fold.id}; corrected to its ` +
725
+ `${absorb ? "end" : "near"} boundary`);
726
+ }
727
+ }
728
+ const boundary = currentTurnBoundary(snapshot);
729
+ if (boundary >= 0 && end > boundary) {
730
+ end = boundary;
731
+ note("span reached into the turn still in flight; corrected back to the last closed turn");
732
+ }
733
+ const units = chapterUnits(snapshot);
734
+ const startUnit = units.find((unit) => start >= unit.start && start < unit.end);
735
+ if (startUnit && startUnit.start !== start) {
736
+ start = startUnit.start;
737
+ note("span started mid-unit; corrected to the start of its closed user/assistant/tool unit");
738
+ }
739
+ const endUnit = units.find((unit) => end >= unit.start && end < unit.end);
740
+ if (endUnit && endUnit.end - 1 !== end) {
741
+ end = endUnit.end - 1;
742
+ note("span ended mid-unit; corrected to the end of its closed user/assistant/tool unit");
743
+ }
744
+ // A chapter spans at most a few closed turns. A longer request is a span the agent
745
+ // meant, so it is CLAMPED to what one fold may hold rather than refused; the
746
+ // remainder stays raw and is the agent's to fold next.
747
+ const covered = units.filter((unit) => unit.start >= start && unit.end <= end + 1);
748
+ if (covered.length) {
749
+ const turns: number[] = [];
750
+ let clamped = end;
751
+ for (const unit of covered) {
752
+ if (!turns.includes(unit.turnStart)) {
753
+ if (turns.length >= snapshot.policy.maxChapterTurns) break;
754
+ turns.push(unit.turnStart);
755
+ }
756
+ clamped = unit.end - 1;
757
+ }
758
+ if (clamped < end) {
759
+ end = clamped;
760
+ note(`span covered more than the ${snapshot.policy.maxChapterTurns}-turn chapter limit; ` +
761
+ "corrected to the last turn that fits");
762
+ }
763
+ }
764
+ if (!corrections.length || end < start ||
765
+ !snapshot.mapped[start]?.ref || !snapshot.mapped[end]?.ref) return null;
766
+ return { ids: [entryIdAt(snapshot, start), entryIdAt(snapshot, end)], corrections };
767
+ }
768
+
769
+ /**
770
+ * Snap a span OUTWARD to whole-fold boundaries.
771
+ *
772
+ * The re-boundary verb operates on folds, so a span that cuts one in half has exactly
773
+ * one sane reading: the agent meant that fold. Outward rather than nearest, because
774
+ * losing a fold the agent did not name is the surprising outcome and re-cutting a
775
+ * slightly larger span is not.
776
+ */
777
+ export function snapToFoldBoundaries(
778
+ snapshot: ActiveContextSnapshot,
779
+ state: ActiveContextState,
780
+ ids: string[],
781
+ ): { start: number; end: number; ids: string[]; covered: ActiveFold[]; corrections: SpanCorrection[] } {
782
+ const resolved = resolveFoldInputIds(snapshot, state, ids);
783
+ const requested: [number, number] = [resolved[0].start, resolved.at(-1)!.end];
784
+ let [start, end] = requested;
785
+ const corrections: SpanCorrection[] = [];
786
+ // Fixed point: absorbing one fold can pull the span across the next.
787
+ for (let pass = 0; pass < state.folds.length + 1; pass += 1) {
788
+ let moved = false;
789
+ for (const root of orderedRoots(state, snapshot)) {
790
+ if (root.end < start || root.start > end) continue;
791
+ if (root.start < start) { start = root.start; moved = true; }
792
+ if (root.end > end) { end = root.end; moved = true; }
793
+ }
794
+ if (!moved) break;
795
+ }
796
+ if (start !== requested[0] || end !== requested[1]) {
797
+ corrections.push({
798
+ from: [entryIdAt(snapshot, requested[0]), entryIdAt(snapshot, requested[1])],
799
+ to: [entryIdAt(snapshot, start), entryIdAt(snapshot, end)],
800
+ reason: "span partially covered one or more folds; corrected outward to their whole boundaries",
801
+ });
802
+ }
803
+ const covered = orderedRoots(state, snapshot)
804
+ .filter((root) => root.start >= start && root.end <= end)
805
+ .map((root) => root.fold);
806
+ return { start, end, ids: [entryIdAt(snapshot, start), entryIdAt(snapshot, end)], covered, corrections };
807
+ }
808
+
809
+ export function entryIdAt(snapshot: ActiveContextSnapshot, index: number): string {
810
+ return snapshot.mapped[index]?.ref?.entryId ?? `position-${index}`;
811
+ }
812
+
399
813
  export function manualFoldCandidate(
400
814
  snapshot: ActiveContextSnapshot,
401
815
  state: ActiveContextState,
402
816
  ids: string[],
817
+ /**
818
+ * Accept a span that is still fresh or protected. Only a caller that will DEFER the
819
+ * fold may pass this: it relaxes eligibility, never structure, so contiguity, unit
820
+ * alignment and batch completeness are still enforced and the commit still refuses
821
+ * to apply a mark whose span is protected at commit time.
822
+ */
823
+ options: { allowProtected?: boolean } = {},
403
824
  ): FoldCandidate {
825
+ const blockedTool = (refs: EvidenceRef[]): boolean =>
826
+ !options.allowProtected && toolRefsProtected(refs, state, snapshot);
827
+ const blocked = (refs: EvidenceRef[]): boolean =>
828
+ !options.allowProtected && refsProtected(refs, state, snapshot);
404
829
  const bounded = (candidate: FoldCandidate): FoldCandidate => {
405
830
  if (candidate.sourceRefs.length > snapshot.policy.maxFoldSourceRefs) {
406
831
  throw new Error(`Folds may include at most ${snapshot.policy.maxFoldSourceRefs} exact source references`);
@@ -419,10 +844,10 @@ export function manualFoldCandidate(
419
844
  calls.length === first.batch.length &&
420
845
  new Set(calls.map((call) => call!.id)).size === first.batch.length &&
421
846
  first.batch.every((id) => calls.some((call) => call!.id === id));
422
- if (completeBatch && !toolRefsProtected(refs, state, snapshot)) {
847
+ if (completeBatch && !blockedTool(refs)) {
423
848
  return bounded({ kind: "tool-result", parts: refs.map((ref) => ({ kind: "raw", ref })), sourceRefs: refs });
424
849
  }
425
- if (one && first && first.batch.length === 1 && !toolRefsProtected(refs, state, snapshot)) {
850
+ if (one && first && first.batch.length === 1 && !blockedTool(refs)) {
426
851
  return bounded({ kind: "tool-result", parts: [{ kind: "raw", ref: refs[0] }], sourceRefs: refs });
427
852
  }
428
853
  }
@@ -431,7 +856,7 @@ export function manualFoldCandidate(
431
856
  if (exactFolds && selected.length >= 2) {
432
857
  const parts: FoldPart[] = selected.map((item) => ({ kind: "fold", foldId: item.fold!.id }));
433
858
  const refs = candidateSourceRefs(parts, state);
434
- if (refsProtected(refs, state, snapshot)) throw new Error("Manual consolidation contains protected evidence");
859
+ if (blocked(refs)) throw new Error("Manual consolidation contains protected evidence");
435
860
  return bounded({ kind: "consolidation", parts, sourceRefs: refs });
436
861
  }
437
862
  if (!chapterRangeIsUnitAligned(snapshot, start, end)) {
@@ -440,7 +865,7 @@ export function manualFoldCandidate(
440
865
  const parts = partsForRange(snapshot, state, start, end, new Set<FoldKind>(["tool-result"]));
441
866
  if (!parts) throw new Error("Chapter fold would partially overlap or swallow an existing chapter");
442
867
  const refs = candidateSourceRefs(parts, state);
443
- if (refsProtected(refs, state, snapshot)) throw new Error("Manual chapter contains fresh, unfinished, unmatched, unmapped, or protected evidence");
868
+ if (blocked(refs)) throw new Error("Manual chapter contains fresh, unfinished, unmatched, unmapped, or protected evidence");
444
869
  return bounded({ kind: "chapter", parts, sourceRefs: refs });
445
870
  }
446
871
 
@@ -13,7 +13,10 @@ import {
13
13
  refsProtected,
14
14
  toolRefsProtected,
15
15
  } from "./measurement.ts";
16
- import { flattenFoldRefs } from "./persistence.ts";
16
+ import {
17
+ flattenFoldRefs,
18
+ foldBrief,
19
+ } from "./persistence.ts";
17
20
  import {
18
21
  SURFACING_CHAR_BUDGET,
19
22
  SURFACING_COOLDOWN_ORDINALS,
@@ -191,15 +194,29 @@ export function surfacingLog(state: Pick<ActiveContextState, "surfacing">): Surf
191
194
  return state.surfacing ? clone(state.surfacing) : [];
192
195
  }
193
196
 
197
+ /**
198
+ * Property ORDER is load-bearing: the state digest is a stable stringify, so a
199
+ * `surfacing` key assigned onto a state that did not already carry one would land
200
+ * after `pendingMarks`/`advisory`/`prepared` and replay as digest drift. Rebuild the
201
+ * tail of the record in the same order `parseActiveContextState` produces.
202
+ */
194
203
  export function withSurfacingLog(
195
204
  state: ActiveContextState,
196
205
  log: readonly SurfacingRecord[],
197
206
  ): ActiveContextState {
198
207
  const bounded = log.slice(Math.max(0, log.length - SURFACING_MAX_LOG_RECORDS));
199
- const next = { ...state };
200
- if (bounded.length) next.surfacing = clone(bounded) as SurfacingRecord[];
201
- else delete next.surfacing;
202
- return next;
208
+ const head = { ...state };
209
+ delete head.surfacing;
210
+ delete head.pendingMarks;
211
+ delete head.advisory;
212
+ delete head.prepared;
213
+ return {
214
+ ...head,
215
+ ...(bounded.length ? { surfacing: clone(bounded) as SurfacingRecord[] } : {}),
216
+ ...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
217
+ ...(state.advisory ? { advisory: clone(state.advisory) } : {}),
218
+ ...(state.prepared ? { prepared: clone(state.prepared) } : {}),
219
+ };
203
220
  }
204
221
 
205
222
  /** A shown suggestion the agent never peeked or expanded inside the outcome window is a reject. */
@@ -245,7 +262,7 @@ export function foldBriefCandidates(input: SuggestionSourceInput): SurfacingCand
245
262
  return [{
246
263
  source: SURFACING_SOURCE_ID,
247
264
  id: fold.id,
248
- text: fold.brief,
265
+ text: foldBrief(fold, state),
249
266
  route: `${toolName} {"action":"expand","id":"${fold.id}"}`,
250
267
  alternateRoute: `${toolName} {"action":"peek","id":"${fold.id}"}`,
251
268
  position: interval.end,