pi-fold 3.0.0 → 4.0.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.
@@ -3,6 +3,7 @@ import type { EvidenceRef } from "../json.ts";
3
3
  import {
4
4
  bytes,
5
5
  clone,
6
+ contentText,
6
7
  messageRole,
7
8
  } from "./canonical.ts";
8
9
  import {
@@ -10,6 +11,7 @@ import {
10
11
  pinnedPeekMass,
11
12
  preparedMatchesCandidate,
12
13
  prepareFold,
14
+ projectActiveContext,
13
15
  renderFold,
14
16
  renderFoldParts,
15
17
  selectAutomaticChapter,
@@ -17,6 +19,7 @@ import {
17
19
  setFoldProjectionState,
18
20
  } from "./folding.ts";
19
21
  import type { AutomaticRungSelection } from "./folding.ts";
22
+ import { staleSpanMatureEnd } from "./transcript.ts";
20
23
  import {
21
24
  foldInterval,
22
25
  budgetOccupancy,
@@ -27,7 +30,6 @@ import {
27
30
  } from "./measurement.ts";
28
31
  import {
29
32
  childFoldIds,
30
- clearPrepared,
31
33
  flattenFoldRefs,
32
34
  foldIdFor,
33
35
  parsePendingMarks,
@@ -41,6 +43,7 @@ import {
41
43
  MAX_UNMARKED_CANDIDATES,
42
44
  } from "./policy.ts";
43
45
  import type {
46
+ ToolClip,
44
47
  ActiveContextSnapshot,
45
48
  ActiveContextState,
46
49
  FoldCandidate,
@@ -217,7 +220,14 @@ export function markEligibility(
217
220
  if (span.unresolved) return span.unresolved.pending ? "protected" : "unfulfillable";
218
221
  const refs = span.refs;
219
222
  if (!refs.length) return "unfulfillable";
220
- const mapped = new Set(snapshot.mapped.flatMap((item) => item.ref ? [objectRefKey(item.ref)] : []));
223
+ // THE MAPPED INDEX IS ALREADY DERIVED (2026-08-28). This built a fresh Set over the whole
224
+ // window to answer a membership question about the handful of refs in one mark's span, and
225
+ // `markAccounting` calls this once per pending mark while the commit fill calls
226
+ // `markAccounting` once per staged mark, so it was O(marks^2 x window). `mappedByKey` is
227
+ // gate 113's memo, keyed on the snapshot OBJECT, and `refsProtected` on the next line
228
+ // already depends on it being fresh: if it could serve stale, this function was already
229
+ // wrong. Measured 177.2ms to 41.6ms at the observed peak of 39 pending marks.
230
+ const mapped = mappedByKey(snapshot);
221
231
  if (refs.some((ref) => !mapped.has(objectRefKey(ref)))) return "unfulfillable";
222
232
  const blocked = refsProtected(refs, state, snapshot);
223
233
  return blocked ? "protected" : "eligible";
@@ -231,7 +241,6 @@ export interface MarkAccounting {
231
241
  freedBytes: number;
232
242
  freedTokens: number;
233
243
  freedBudgetShare: number;
234
- rewriteTokens: number;
235
244
  eligibleMarks: number;
236
245
  retainedMarks: number;
237
246
  eligibleFreedBytes: number;
@@ -246,13 +255,10 @@ export function markAccounting(
246
255
  state: ActiveContextState,
247
256
  ): MarkAccounting {
248
257
  const marks = pendingMarks(state);
249
- const indexByKey = new Map(snapshot.mapped.flatMap((item) =>
250
- item.ref ? [[objectRefKey(item.ref), item.index] as const] : []));
251
258
  let freedBytes = 0;
252
259
  let eligibleFreedBytes = 0;
253
260
  let eligibleMarks = 0;
254
261
  let retainedMarks = 0;
255
- let earliest = -1;
256
262
  for (const mark of marks) {
257
263
  const freed = markFreedBytes(snapshot, state, mark);
258
264
  freedBytes += freed;
@@ -261,15 +267,7 @@ export function markAccounting(
261
267
  eligibleMarks += 1;
262
268
  eligibleFreedBytes += freed;
263
269
  } else if (eligibility === "protected") retainedMarks += 1;
264
- for (const ref of markSpanRefs(state, mark).refs) {
265
- const index = indexByKey.get(objectRefKey(ref));
266
- if (index === undefined) continue;
267
- if (earliest < 0 || index < earliest) earliest = index;
268
- }
269
270
  }
270
- const rewriteBytes = earliest < 0
271
- ? 0
272
- : bytes(snapshot.messages.slice(earliest));
273
271
  const freedTokens = estimatedTokens(freedBytes);
274
272
  const eligibleFreedTokens = estimatedTokens(eligibleFreedBytes);
275
273
  const pinned = pinnedPeekMass(snapshot, state);
@@ -281,7 +279,6 @@ export function markAccounting(
281
279
  freedBytes,
282
280
  freedTokens,
283
281
  freedBudgetShare: snapshot.budgetTokens > 0 ? freedTokens / snapshot.budgetTokens : 0,
284
- rewriteTokens: estimatedTokens(rewriteBytes),
285
282
  eligibleMarks,
286
283
  retainedMarks,
287
284
  eligibleFreedBytes,
@@ -294,6 +291,40 @@ export function markAccounting(
294
291
  };
295
292
  }
296
293
 
294
+ /**
295
+ * What one commit would make the provider re-read: the marked tail, from the earliest
296
+ * marked message to the end of the window.
297
+ *
298
+ * IT IS DERIVED WHERE IT IS READ (2026-08-28). This was a field on `MarkAccounting`, which
299
+ * meant `bytes(snapshot.messages.slice(earliest))`, a serialization of the whole marked
300
+ * tail, ran on EVERY accounting call: the commit fill calls one per staged mark, and the
301
+ * `brief` handler calls one to read `pending` alone. Two readers want the number and both
302
+ * already hold the snapshot and the state, so the work moved to them. A memo could not have
303
+ * helped: `snapshot.messages` is `clone(input.eventMessages)` (transcript.ts:531), so its
304
+ * elements are fresh objects on every derivation and nothing keyed on them can ever hit.
305
+ *
306
+ * `schedulingStatus` still publishes `rewriteTokens` and now derives it here, so the status
307
+ * surface is unchanged. It is NOT a getter on the accounting object: `schedulingStatus`
308
+ * spreads the accounting, and a spread evaluates getters, which would put the cost straight
309
+ * back on every reader it was moved away from.
310
+ */
311
+ export function markRewriteTokens(
312
+ snapshot: ActiveContextSnapshot,
313
+ state: ActiveContextState,
314
+ ): number {
315
+ const indexByKey = new Map(snapshot.mapped.flatMap((item) =>
316
+ item.ref ? [[objectRefKey(item.ref), item.index] as const] : []));
317
+ let earliest = -1;
318
+ for (const mark of pendingMarks(state)) {
319
+ for (const ref of markSpanRefs(state, mark).refs) {
320
+ const index = indexByKey.get(objectRefKey(ref));
321
+ if (index === undefined) continue;
322
+ if (earliest < 0 || index < earliest) earliest = index;
323
+ }
324
+ }
325
+ return earliest < 0 ? 0 : estimatedTokens(bytes(snapshot.messages.slice(earliest)));
326
+ }
327
+
297
328
  export function epochCommitDue(snapshot: ActiveContextSnapshot, ratio: number | null): boolean {
298
329
  const occupancy = budgetOccupancy(snapshot, ratio);
299
330
  return occupancy !== null && occupancy >= snapshot.thresholds.maxTarget;
@@ -1083,9 +1114,6 @@ export async function commitPendingMarks(input: {
1083
1114
  generation: input.generation,
1084
1115
  });
1085
1116
  applied.push({ mark: "fold", id: mark.id, origin: mark.origin, foldId: prepared.id });
1086
- if (state.prepared && state.folds.some((fold) => fold.id === state.prepared!.id)) {
1087
- state = clearPrepared(state);
1088
- }
1089
1117
  } catch (error) {
1090
1118
  refused.push({
1091
1119
  mark: mark.mark,
@@ -1112,6 +1140,11 @@ export function schedulingStatus(input: {
1112
1140
  return {
1113
1141
  mode: "epoch",
1114
1142
  ...accounting,
1143
+ // The status surface keeps this field; the accounting no longer carries it. Status is a
1144
+ // read the agent or the human asks for, so paying for the tail serialization HERE is
1145
+ // the point of moving it: the commit fill calls `markAccounting` once per staged mark
1146
+ // and none of those calls wants this number.
1147
+ rewriteTokens: markRewriteTokens(input.snapshot, input.state),
1115
1148
  thresholds: { ...input.snapshot.thresholds },
1116
1149
  budgetTokens: input.snapshot.budgetTokens,
1117
1150
  commitDue: epochCommitDue(input.snapshot, input.ratio),
@@ -1131,3 +1164,57 @@ export function schedulingStatus(input: {
1131
1164
  })),
1132
1165
  };
1133
1166
  }
1167
+
1168
+ // THE TOOL-CALL DIET SELECTS AT COMMIT TIME ONLY (2026-08-24, Shane's ToolFoldThreshold).
1169
+ // The zone is the oldest `threshold` share of the projected window measured in bytes,
1170
+ // because "the tool calls prior to the X point" is a statement about what the provider
1171
+ // is being made to re-read, and the projection is exactly that. Eligibility mirrors the
1172
+ // fold lanes' own laws one view over: the result must be MATURE (past
1173
+ // staleSpanMatureEnd, the batch lane's consumption proof), unprotected (a pinned ref is
1174
+ // a durable hold), not blacklisted (a tool whose results must stay raw stays raw in
1175
+ // view too), bigger than the floor (small calls are cheap; the win is the multi-KB
1176
+ // result), not a rendering the projection already substituted, and not already clipped.
1177
+ // The additions are VIEW state: encodedFoldSource reads raw parts, so a later fold of a
1178
+ // clipped span still encodes the full bytes and losslessness is untouched.
1179
+ export function toolClipAdditions(input: {
1180
+ snapshot: ActiveContextSnapshot;
1181
+ state: ActiveContextState;
1182
+ threshold: number;
1183
+ blacklist: ReadonlySet<string>;
1184
+ }): ToolClip[] {
1185
+ const { snapshot, state, threshold, blacklist } = input;
1186
+ const projected = projectActiveContext(snapshot, state);
1187
+ const perMessage = projected.map((message) => bytes([message]));
1188
+ const total = perMessage.reduce((sum, size) => sum + size, 0);
1189
+ const zoneEnd = threshold * total;
1190
+ const matureEnd = staleSpanMatureEnd(snapshot.messages);
1191
+ const snapshotIndexByCallId = new Map<string, number>();
1192
+ for (let index = 0; index < snapshot.messages.length; index += 1) {
1193
+ const message = snapshot.messages[index] as { role?: unknown; toolCallId?: unknown };
1194
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
1195
+ snapshotIndexByCallId.set(message.toolCallId, index);
1196
+ }
1197
+ }
1198
+ const already = new Set((state.clips ?? []).map((clip) => clip.callId));
1199
+ const additions: ToolClip[] = [];
1200
+ let offset = 0;
1201
+ for (let index = 0; index < projected.length; index += 1) {
1202
+ const start = offset;
1203
+ offset += perMessage[index];
1204
+ if (start >= zoneEnd) break;
1205
+ const message = projected[index] as {
1206
+ role?: unknown; toolCallId?: unknown; toolName?: unknown; details?: unknown;
1207
+ };
1208
+ if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
1209
+ if (already.has(message.toolCallId)) continue;
1210
+ if (message.details && typeof (message.details as { projection?: unknown }).projection === "string") continue;
1211
+ if (typeof message.toolName === "string" && blacklist.has(message.toolName)) continue;
1212
+ const snapshotIndex = snapshotIndexByCallId.get(message.toolCallId);
1213
+ if (snapshotIndex === undefined || snapshotIndex >= matureEnd) continue;
1214
+ const ref = snapshot.mapped.find((item) => item.index === snapshotIndex)?.ref;
1215
+ if (!ref || refsProtected([ref], state, snapshot)) continue;
1216
+ if (contentText(message).length <= snapshot.policy.toolClipFloorChars) continue;
1217
+ additions.push({ callId: message.toolCallId, entryId: ref.entryId });
1218
+ }
1219
+ return additions;
1220
+ }
@@ -42,6 +42,7 @@ import {
42
42
  chapterSegments,
43
43
  scanTurnToolBatches,
44
44
  structurallyClosedChapterUnits,
45
+ deadAssistant,
45
46
  terminalAssistant,
46
47
  } from "./transcript.ts";
47
48
  import type { ChapterUnit } from "./transcript.ts";
@@ -102,7 +103,14 @@ export function resultCallIndex(snapshot: ActiveContextSnapshot, allowConsumedIn
102
103
  const message = snapshot.messages[index];
103
104
  if (messageRole(message) !== "assistant") continue;
104
105
  if (ownValue(message, "stopReason") === "toolUse") laterGenerations += 1;
105
- else if (complete && terminalAssistant(message)) laterGenerations = Math.max(laterGenerations, 1);
106
+ // A dead turn's own terminal grants the point a finished turn's does: the turn
107
+ // is only in completeTurns because a user message already stands after it, so
108
+ // no later response will ever consume this batch, and demanding one anyway is
109
+ // the hold that deadlocked sol-20260826-full2 (the 423KB peek copy behind an
110
+ // errored assistant, ineligible for thirty-five aborting passes).
111
+ else if (complete && (terminalAssistant(message) || deadAssistant(message))) {
112
+ laterGenerations = Math.max(laterGenerations, 1);
113
+ }
106
114
  }
107
115
  if ((complete && laterGenerations < 1) || (!complete && laterGenerations < 2)) continue;
108
116
  }
@@ -728,7 +736,7 @@ export function automaticToolBatches(
728
736
  if (ids.size !== expected.length || expected.some((id) => !ids.has(id)) ||
729
737
  refs.some((ref) => !ref || ref.role !== "toolResult")) continue;
730
738
  const exactRefs = refs as EvidenceRef[];
731
- const size = group.reduce((total, { item }) => total + bytes(item.message), 0);
739
+ const size = group.reduce((total, { item }) => total + messageBytes(item.message), 0);
732
740
  if (refsProtected(exactRefs, state, snapshot) || refsInOrder(snapshot, exactRefs) === null ||
733
741
  size < snapshot.thresholds.minFoldChars) continue;
734
742
  batches.push({ refs: exactRefs, indices: group.map(({ item }) => item.index), bytes: size });
@@ -736,6 +744,39 @@ export function automaticToolBatches(
736
744
  return batches;
737
745
  }
738
746
 
747
+ /**
748
+ * The serialized size of ONE transcript message, derived once per message object.
749
+ *
750
+ * `selectAutomaticSpan` tries the batch lane first, so this runs up to
751
+ * MAX_FRONTIER_CUTS_PER_PASS times per context event from `frontierMarks`, once per staged
752
+ * mark in a fill, and again from `unmarkedRemainder`. Everything else in
753
+ * `automaticToolBatches` was already memoized; this was not, and it ran BEFORE the
754
+ * eligibility filter, on every complete group including ones that go on to be refused.
755
+ *
756
+ * IT IS KEYED ON THE MESSAGE OBJECT, which is only useful because of where the object comes
757
+ * from: `snapshot.mapped[i].message` is `input.eventMessages[index]` VERBATIM
758
+ * (transcript.ts:494-508), so it is the same object across every snapshot derived from the
759
+ * same session, and the memo hits. `snapshot.messages` is `clone(input.eventMessages)`
760
+ * (transcript.ts:531), fresh objects on every derivation, so a caller reading THAT would
761
+ * never hit and should not be routed here.
762
+ *
763
+ * Deliberately NOT a wrapper on `bytes()`, which is also called on transient arrays
764
+ * (folding.ts, scheduling.ts) whose keys could never hit and would only grow the map.
765
+ *
766
+ * The accepted cost is gate 121's: a message mutated in place keeps its first size.
767
+ */
768
+ const MESSAGE_BYTES = new WeakMap<object, number>();
769
+
770
+ export function messageBytes(message: unknown): number {
771
+ const key = message && typeof message === "object" ? message as object : null;
772
+ if (!key) return bytes(message);
773
+ const memoized = MESSAGE_BYTES.get(key);
774
+ if (memoized !== undefined) return memoized;
775
+ const size = bytes(message);
776
+ MESSAGE_BYTES.set(key, size);
777
+ return size;
778
+ }
779
+
739
780
  export function selectAutomaticToolBatch(
740
781
  snapshot: ActiveContextSnapshot,
741
782
  state: ActiveContextState,
@@ -997,8 +1038,6 @@ export function manualFoldCandidate(
997
1038
  ids: string[],
998
1039
  options: { allowProtected?: boolean } = {},
999
1040
  ): FoldCandidate {
1000
- const blockedTool = (refs: EvidenceRef[]): boolean =>
1001
- !options.allowProtected && refsProtected(refs, state, snapshot);
1002
1041
  const blocked = (refs: EvidenceRef[]): boolean =>
1003
1042
  !options.allowProtected && refsProtected(refs, state, snapshot);
1004
1043
  const owners = directFoldOwners(state.folds);
@@ -1026,10 +1065,10 @@ export function manualFoldCandidate(
1026
1065
  calls.length === first.batch.length &&
1027
1066
  new Set(calls.map((call) => call!.id)).size === first.batch.length &&
1028
1067
  first.batch.every((id) => calls.some((call) => call!.id === id));
1029
- if (completeBatch && !blockedTool(refs)) {
1068
+ if (completeBatch && !blocked(refs)) {
1030
1069
  return bounded({ kind: "tool-result", parts: refs.map((ref) => ({ kind: "raw", ref })), sourceRefs: refs });
1031
1070
  }
1032
- if (one && first && first.batch.length === 1 && !blockedTool(refs)) {
1071
+ if (one && first && first.batch.length === 1 && !blocked(refs)) {
1033
1072
  return bounded({ kind: "tool-result", parts: [{ kind: "raw", ref: refs[0] }], sourceRefs: refs });
1034
1073
  }
1035
1074
  }
@@ -1061,6 +1100,26 @@ export function manualFoldCandidate(
1061
1100
  return bounded({ kind: "chapter", parts, sourceRefs: refs });
1062
1101
  }
1063
1102
 
1103
+ // THE TOOL-CLIP VIEW HEAD (2026-08-24). A clipped result keeps its opening verbatim,
1104
+ // not collapsed: the view stays a readable result head in place, unlike a brief's
1105
+ // one-line quote. The paragraph rule is gate 134's: an opening block terminated by a
1106
+ // blank line is kept whole up to the cap; a result that is bulk from line one keeps its
1107
+ // first cap's worth. The caller states the cut, per gate 136: the marker rides beside
1108
+ // the count of what it hides, never silently.
1109
+ export function toolClipHead(text: string, cap: number): string {
1110
+ const lines = String(text ?? "").split(/\r?\n/);
1111
+ const kept: string[] = [];
1112
+ for (const line of lines) {
1113
+ if (line.trim()) kept.push(line);
1114
+ else if (kept.length) break;
1115
+ }
1116
+ const paragraph = kept.join("\n");
1117
+ const head = paragraph.length > 0 && paragraph.length <= cap
1118
+ ? paragraph
1119
+ : String(text ?? "").slice(0, cap);
1120
+ return head;
1121
+ }
1122
+
1064
1123
  export function oneLine(value: string, maximum: number): string {
1065
1124
  return boundedSubject(value.replace(/\s+/g, " ").trim(), maximum);
1066
1125
  }
@@ -1073,44 +1132,58 @@ export function deterministicChapterBrief(
1073
1132
  if (refs.length !== messages.length || !refs.length) {
1074
1133
  throw new Error("Deterministic chapter brief requires aligned exact evidence");
1075
1134
  }
1076
- const firstUser = messages.find((message) => messageRole(message) === "user");
1077
- const toolCounts = new Map<string, number>();
1135
+ // EVERY ENTRY IDENTIFIED IN SPAN ORDER (Shane 2026-08-24). The count-style brief
1136
+ // ("User: ... \u00b7 Tools: 3\u00d7bash \u00b7 Assistant: No assistant text in this span")
1137
+ // collapsed to the same anonymous line on every residue span of the pull workload:
1138
+ // sol-20260823-live rep 3 committed 188 byte-identical placeholders of that shape and
1139
+ // the agent re-peeked 76 folds 209 times to learn what each one held. The span's own
1140
+ // calls are the index: each call named with its arguments and its result's opening
1141
+ // line, an errored result marked, the user's ask and every assistant note seated in
1142
+ // span order, and what the cap refuses COUNTED rather than dropped (the gate-135 law
1143
+ // carried forward: a value recorded once in an assistant note between two batches must
1144
+ // reach the brief). Cuts run through oneLine, so "..." always means the exact source
1145
+ // continues.
1146
+ const callNames = new Map<string, string>();
1147
+ const pieces: string[] = [];
1078
1148
  for (const message of messages) {
1079
- if (messageRole(message) !== "assistant") continue;
1080
- for (const part of denseOwnArrayValues(ownValue(message, "content")) ?? []) {
1081
- if (ownValue(part, "type") !== "toolCall") continue;
1082
- const name = ownValue(part, "name");
1083
- if (typeof name === "string" && name) toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
1149
+ const role = messageRole(message);
1150
+ if (role === "user") {
1151
+ const ask = contentText(message);
1152
+ if (ask.trim()) pieces.push(`User asked: ${oneLine(ask, 120)}`);
1153
+ } else if (role === "assistant") {
1154
+ for (const part of denseOwnArrayValues(ownValue(message, "content")) ?? []) {
1155
+ if (ownValue(part, "type") !== "toolCall") continue;
1156
+ const id = ownValue(part, "id");
1157
+ const name = ownValue(part, "name");
1158
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name) continue;
1159
+ let args = "{}";
1160
+ try {
1161
+ args = JSON.stringify(ownValue(part, "arguments") ?? {}) ?? "{}";
1162
+ } catch {
1163
+ args = "{unserializable}";
1164
+ }
1165
+ callNames.set(id, `${name}(${oneLine(args, 80)})`);
1166
+ }
1167
+ const note = assistantNoteText(message);
1168
+ if (note) pieces.push(`Noted: ${boundedSubject(note, CHAPTER_NOTE_CHARS)}`);
1169
+ } else if (role === "toolResult") {
1170
+ const id = ownValue(message, "toolCallId");
1171
+ const name = ownValue(message, "toolName");
1172
+ const head = (typeof id === "string" ? callNames.get(id) : undefined) ??
1173
+ (typeof name === "string" && name ? `${name}(...)` : "tool(...)");
1174
+ const errored = ownValue(message, "isError") === true ? " [errored]" : "";
1175
+ pieces.push(`${head}${errored} \u2192 "${oneLine(contentText(message), 90)}"`);
1084
1176
  }
1085
1177
  }
1086
- // EVERY assistant note in the span, not the first line of the first one
1087
- // (Shane 2026-08-14). A chapter is the first fold that hides its span, and a
1088
- // value the agent recorded once on a later turn died with the old single
1089
- // line: sol-20260814-traps rep 2 lost trace-a-02 and trace-d-05 to exactly
1090
- // that shape, an assistant message between two tool batches whose words
1091
- // reached zero briefs. Each note is the message's leading paragraph, capped
1092
- // per note, seated whole in span order until the brief's own bound refuses
1093
- // the next one, with the count of what did not seat stated rather than cut.
1094
- const notes = messages.flatMap((message) => {
1095
- if (messageRole(message) !== "assistant") return [];
1096
- const note = assistantNoteText(message);
1097
- return note ? [boundedSubject(note, CHAPTER_NOTE_CHARS)] : [];
1098
- });
1099
- const ask = oneLine(firstUser ? contentText(firstUser) : "No user ask in this span", 90);
1100
- const tools = oneLine([...toolCounts.entries()]
1101
- .sort(([left], [right]) => left.localeCompare(right))
1102
- .map(([name, count]) => `${count}×${name}`)
1103
- .join(" ") || "no tools", 500);
1104
- const separator = " | ";
1178
+ const separator = " \u00b7 ";
1105
1179
  const compose = (kept: number): string => {
1106
- const omitted = notes.length - kept;
1107
- const shown = kept ? notes.slice(0, kept).join(separator)
1108
- : (notes.length ? "" : "No assistant text in this span");
1109
- const tail = omitted ? `${kept ? separator : ""}${omitted} more notes in this span` : "";
1110
- return `User: ${ask} · Tools: ${tools} · Assistant: ${shown}${tail}`;
1180
+ const omitted = pieces.length - kept;
1181
+ const shown = pieces.slice(0, kept).join(separator);
1182
+ const tail = omitted ? `${kept ? separator : ""}${omitted} more entries in this span` : "";
1183
+ return `${shown}${tail}`;
1111
1184
  };
1112
- let composed = compose(notes.length);
1113
- for (let kept = notes.length; kept > 0 && composed.length > ACTIVE_CONTEXT_POLICY.maxBriefChars; kept -= 1) {
1185
+ let composed = compose(pieces.length);
1186
+ for (let kept = pieces.length; kept > 0 && composed.length > ACTIVE_CONTEXT_POLICY.maxBriefChars; kept -= 1) {
1114
1187
  composed = compose(kept - 1);
1115
1188
  }
1116
1189
  const escapeName = (name: string) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -7,6 +7,7 @@
7
7
  * nothing: the next model call that reads a result, a natural boundary in the work.
8
8
  */
9
9
 
10
+
10
11
  type ToolHandler = (
11
12
  toolCallId: string,
12
13
  params: Record<string, unknown>,
@@ -29,7 +30,7 @@ export function buildActiveContextTool(input: {
29
30
  handler: ToolHandler;
30
31
  }) {
31
32
  const peekGuidance = input.allowedActions.includes("peek")
32
- ? ` Peek is the point read: it returns one fold's index view -- every nested fold's brief in full, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back exactly, folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
33
+ ? ` Peek is the point read: it returns one fold's index view -- nested folds' briefs seated shallowest first inside the byte budget with the omitted rows counted, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back exactly, folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
33
34
  : "";
34
35
  const correctionGuidance = input.allowedActions.includes("reboundary")
35
36
  ? " Curation is correctable: reboundary with ids re-cuts a span into exactly one fold, which merges adjacent folds when the span covers several and splits one when the span sits inside it. Reboundary with a single id returns that fold's span to raw. A brief is written when you mark the span; a standing fold's brief is not rewritten, because rewriting it rewrites the projection at that fold's placeholder and costs the whole prefix cache from there on."
@@ -62,9 +63,10 @@ export function buildActiveContextTool(input: {
62
63
  type: "string",
63
64
  minLength: 1,
64
65
  maxLength: input.maxBriefChars,
65
- description: "Brief only: what this pending fold covers and what you will want " +
66
- `back from it, at most ${input.maxBriefChars} characters. Free to write, ` +
67
- "because the fold it names is not in your window yet.",
66
+ description: "Brief only: what you will want back from this pending fold that " +
67
+ "its automatic brief cannot know. Your sentence is kept BESIDE the automatic " +
68
+ `brief, never instead of it, at most ${input.maxBriefChars} characters. Free ` +
69
+ "to write, because the fold it names is not in your window yet.",
68
70
  },
69
71
  offset: { type: "integer", minimum: 0 },
70
72
  ephemeral: {
@@ -37,6 +37,17 @@ export function terminalAssistant(message: unknown): boolean {
37
37
  return stop === "stop" || stop === "length";
38
38
  }
39
39
 
40
+ /**
41
+ * An assistant stop that ends a turn by dying rather than by finishing. It is never
42
+ * terminal on its own: while it is the last message the turn may still be retried in
43
+ * place, so only a FOLLOWING user message proves the turn is over.
44
+ */
45
+ export function deadAssistant(message: unknown): boolean {
46
+ if (messageRole(message) !== "assistant") return false;
47
+ const stop = ownValue(message, "stopReason");
48
+ return stop === "error" || stop === "aborted";
49
+ }
50
+
40
51
  export function completeTurns(messages: unknown[]): CompleteTurn[] {
41
52
  const starts: number[] = [];
42
53
  for (let index = 0; index < messages.length; index += 1) if (messageRole(messages[index]) === "user") starts.push(index);
@@ -47,24 +58,26 @@ export function completeTurns(messages: unknown[]): CompleteTurn[] {
47
58
  let end = limit;
48
59
  while (end > start && (messageRole(messages[end - 1]) === "custom" ||
49
60
  messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
50
- if (end > start && terminalAssistant(messages[end - 1])) turns.push({ start, end });
61
+ if (end <= start) continue;
62
+ // A DEAD TURN IS A CLOSED TURN (sol-20260826-full2, pifold rep 1). A turn whose last
63
+ // word is an errored or aborted assistant, with a user message already standing after
64
+ // it, is over: no response for its material is ever coming, so holding its spans for
65
+ // a consumption proof holds them forever. That hold is what deadlocked a live run:
66
+ // the projection-budget fence aborted a request that would not fit, the abort put an
67
+ // errored assistant at the end of the turn holding the 423KB that made it not fit,
68
+ // the errored turn was invisible to every fold lane, and the next thirty-four
69
+ // requests aborted identically with the reclaimer finding nothing it was allowed to
70
+ // touch. The live tail keeps the strict rule: while the errored assistant is the
71
+ // last message the turn may still be retried in place, so death alone closes
72
+ // nothing.
73
+ if (terminalAssistant(messages[end - 1]) ||
74
+ (starts[cursor + 1] !== undefined && deadAssistant(messages[end - 1]))) {
75
+ turns.push({ start, end });
76
+ }
51
77
  }
52
78
  return turns;
53
79
  }
54
80
 
55
- export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn | null {
56
- if (messageRole(messages[0]) !== "compactionSummary") return null;
57
- let firstUser = -1;
58
- for (let index = 0; index < messages.length; index += 1) {
59
- if (messageRole(messages[index]) === "user") { firstUser = index; break; }
60
- }
61
- if (firstUser <= 1 || messageRole(messages[1]) !== "assistant") return null;
62
- let end = firstUser;
63
- while (end > 1 && (messageRole(messages[end - 1]) === "custom" ||
64
- messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
65
- return end > 1 && terminalAssistant(messages[end - 1]) ? { start: 1, end } : null;
66
- }
67
-
68
81
  export const READ_ONLY_CONTEXT_ACTION_ARGUMENTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
69
82
  status: Object.freeze(["action", "detail", "offset", "limit"]),
70
83
  // offset and bytes are the tool surface's own narrowing parameters: a paged or
@@ -327,6 +340,44 @@ export function unsafeChapterIndices(messages: unknown[]): Set<number> {
327
340
  * than a curation preference. The fresh-tail byte walk that used to sit here is deleted:
328
341
  * nothing decided on it, and stale-first ordering already leaves recent material last.
329
342
  */
343
+ /**
344
+ * THE STALE-SPAN MATURITY FLOOR (2026-08-24). The tool-batch lane proves consumption
345
+ * before it will index a result: two later generations in an open turn, one or the
346
+ * terminal assistant in a closed one (resultCallIndex). The chapter lane had no such
347
+ * proof, and on the pull-shaped workload the frontier cuts the moment a span crosses
348
+ * minFoldChars, which is always one turn before the same span's batch qualifies:
349
+ * sol-20260823-live rep 3 cut 219 chapters and ZERO tool folds over a transcript whose
350
+ * every span replays as a tool-result when aged, so every placeholder read "User: No
351
+ * user ask · Tools: 1×repo_stage · Assistant: No assistant text" and the agent
352
+ * re-peeked 76 folds 209 times to rebuild the index the brief should have carried. One
353
+ * floor, kind-agnostic, derived from the proof the batch lane already demands: a stale
354
+ * span may be cut only when it ends before the second-newest open generation, so by the
355
+ * time the chapter lane may touch material the tool lane has had its turn at it, and
356
+ * the tool-first preference in selectAutomaticSpan is reachable rather than starved.
357
+ * This is NOT gate 19/72's fresh-tail proportion returning: nothing here reads bytes or
358
+ * shares, and a closed session (terminal assistant) folds to its end exactly as before.
359
+ */
360
+ export function staleSpanMatureEnd(messages: unknown[]): number {
361
+ if (messages.length && terminalAssistant(messages[messages.length - 1])) return messages.length;
362
+ const generations: number[] = [];
363
+ for (let index = 0; index < messages.length; index += 1) {
364
+ const message = messages[index];
365
+ if (messageRole(message) === "assistant" && ownValue(message, "stopReason") === "toolUse") {
366
+ generations.push(index);
367
+ }
368
+ }
369
+ const generationFloor = generations.length < 2 ? 0 : generations[generations.length - 2];
370
+ // MATURITY REACHES THROUGH EVERY CLOSED TURN; the generation floor governs only the
371
+ // live one. A closed turn's material has had every chance it will ever get: a finished
372
+ // turn was consumed by its own terminal, and a dead one (completeTurns' rule) can never
373
+ // be consumed at all, which is exactly the state that wedged sol-20260826-full2. The
374
+ // generation count alone under-reads both, because it keeps demanding two responses
375
+ // AFTER material that no response will ever follow.
376
+ const turns = completeTurns(messages);
377
+ const closedEnd = turns.length ? turns[turns.length - 1].end : 0;
378
+ return Math.max(generationFloor, closedEnd);
379
+ }
380
+
330
381
  export function freshBoundary(messages: unknown[], turns: CompleteTurn[]): number {
331
382
  if (!messages.length) return 0;
332
383
  let unfinishedUser = messages.length;