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.
- package/README.md +76 -212
- package/extensions/active-context.ts +457 -362
- package/extensions/evidence.js +1 -5
- package/extensions/index.js +8 -3
- package/extensions/json.ts +23 -1
- package/extensions/lib/canonical.ts +34 -0
- package/extensions/lib/curation.ts +141 -9
- package/extensions/lib/editor-ui.ts +133 -25
- package/extensions/lib/folding.ts +181 -17
- package/extensions/lib/instrumentation.ts +0 -6
- package/extensions/lib/measurement.ts +84 -4
- package/extensions/lib/persistence.ts +181 -9
- package/extensions/lib/policy.ts +80 -28
- package/extensions/lib/scheduling.ts +105 -18
- package/extensions/lib/selection.ts +112 -39
- package/extensions/lib/tool-surface.ts +6 -4
- package/extensions/lib/transcript.ts +65 -14
- package/extensions/settings.ts +123 -54
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
stableStringify,
|
|
11
11
|
} from "../json.ts";
|
|
12
12
|
import {
|
|
13
|
+
contentText,
|
|
13
14
|
boundedUtf8,
|
|
14
15
|
bytes,
|
|
15
16
|
clone,
|
|
@@ -71,7 +72,9 @@ import {
|
|
|
71
72
|
chapterUnits,
|
|
72
73
|
unpinnedStaleFolds,
|
|
73
74
|
deterministicChapterCandidateBrief,
|
|
75
|
+
toolClipHead,
|
|
74
76
|
deterministicConsolidationBrief,
|
|
77
|
+
oneLine,
|
|
75
78
|
NO_FOLD_KINDS,
|
|
76
79
|
partsForRange,
|
|
77
80
|
resultCall,
|
|
@@ -79,6 +82,7 @@ import {
|
|
|
79
82
|
selectAutomaticRefold,
|
|
80
83
|
selectAutomaticToolBatch,
|
|
81
84
|
} from "./selection.ts";
|
|
85
|
+
import { staleSpanMatureEnd } from "./transcript.ts";
|
|
82
86
|
|
|
83
87
|
export function selectAutomaticChapter(
|
|
84
88
|
snapshot: ActiveContextSnapshot,
|
|
@@ -87,19 +91,44 @@ export function selectAutomaticChapter(
|
|
|
87
91
|
): FoldCandidate | null {
|
|
88
92
|
const units = chapterUnits(snapshot);
|
|
89
93
|
const allowedChildren = NO_FOLD_KINDS;
|
|
94
|
+
// The maturity floor: a stale span may end only where the batch lane has already had
|
|
95
|
+
// its chance, so a chapter can never claim a tool batch one turn before it qualifies.
|
|
96
|
+
const matureEnd = staleSpanMatureEnd(snapshot.messages);
|
|
90
97
|
for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) {
|
|
91
98
|
const first = units[unitIndex];
|
|
99
|
+
if (first.end > matureEnd) break;
|
|
92
100
|
let best: FoldCandidate | null = null;
|
|
93
101
|
for (let endIndex = unitIndex; endIndex < units.length; endIndex += 1) {
|
|
94
102
|
const unit = units[endIndex];
|
|
103
|
+
if (unit.end > matureEnd) break;
|
|
95
104
|
if (endIndex > unitIndex && unit.start !== units[endIndex - 1].end) break;
|
|
96
105
|
const coherentSegment = endIndex > unitIndex || first.end - first.start > 1;
|
|
97
106
|
if (!coherentSegment) continue;
|
|
107
|
+
// A DISQUALIFIED RANGE DISQUALIFIES EVERY LARGER ONE, so the walk stops here rather
|
|
108
|
+
// than rediscovering the same obstruction at every remaining endIndex (issue #2,
|
|
109
|
+
// 2026-08-28). The inner loop only ever EXTENDS the range to the right, and all
|
|
110
|
+
// three conditions below are monotone under that extension: an unmapped index or an
|
|
111
|
+
// overlapping root stays inside a larger range, a claimed ref stays claimed because
|
|
112
|
+
// `claimed` is fixed for the whole walk, and a protected ref stays protected. Only
|
|
113
|
+
// `size > MAX_FOLD_SPAN_CHARS` bounded this loop, and these three `continue`s skipped
|
|
114
|
+
// the sizing that would have hit it, so an obstruction near the start of the window
|
|
115
|
+
// let the walk run to the end of `units` for every start: O(units^2) iterations doing
|
|
116
|
+
// O(span) work each. Instrumented on a 549KB window, ONE context event ran 457,986
|
|
117
|
+
// inner iterations and allocated 98,811,691 FoldPart objects, of which 456,724
|
|
118
|
+
// iterations (99.7 percent) died on the `claimed` check and only 72 ever reached the
|
|
119
|
+
// sizing. That is 42 seconds of CPU at 824KB, ungated by occupancy, on every event.
|
|
120
|
+
// With the walk stopped at the first obstruction the same event costs 377ms.
|
|
121
|
+
//
|
|
122
|
+
// This is 00dfeb3's defect one layer out. That commit memoized `orderedRoots` to kill
|
|
123
|
+
// the O(roots x folds) rebuild INSIDE each iteration and its own comment named "a walk
|
|
124
|
+
// that is quadratic in units" as the standing cost; this is that walk. Nothing about
|
|
125
|
+
// WHICH candidate is selected changes: every iteration this skips would have been
|
|
126
|
+
// skipped by the same condition.
|
|
98
127
|
const parts = partsForRange(snapshot, state, first.start, unit.end - 1, allowedChildren);
|
|
99
|
-
if (!parts || parts.some((part) => part.kind === "fold" && state.expanded.includes(part.foldId)))
|
|
128
|
+
if (!parts || parts.some((part) => part.kind === "fold" && state.expanded.includes(part.foldId))) break;
|
|
100
129
|
const refs = candidateSourceRefs(parts, state);
|
|
101
|
-
if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref))))
|
|
102
|
-
if (refsProtected(refs, state, snapshot))
|
|
130
|
+
if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) break;
|
|
131
|
+
if (refsProtected(refs, state, snapshot)) break;
|
|
103
132
|
const size = bytes(encodedFoldSource(snapshot, state, parts, "chapter"));
|
|
104
133
|
const biteSized = size <= MAX_FOLD_SPAN_CHARS || endIndex === unitIndex;
|
|
105
134
|
// ACCUMULATE UNTIL OVER, never cut to hit the number. The walk grows the span one
|
|
@@ -414,13 +443,43 @@ export async function prepareFold(input: {
|
|
|
414
443
|
let brief: string;
|
|
415
444
|
let provenance: BriefProvenance;
|
|
416
445
|
if (input.brief !== undefined) {
|
|
417
|
-
const
|
|
418
|
-
input.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
446
|
+
const declaredKind = typeof input.briefProvenance === "object"
|
|
447
|
+
? input.briefProvenance.kind
|
|
448
|
+
: input.briefProvenance ?? "supplied";
|
|
449
|
+
if (declaredKind === "supplied") {
|
|
450
|
+
// A SUPPLIED BRIEF AUGMENTS THE HEAD, NEVER REPLACES IT (rep 7 of
|
|
451
|
+
// sol-20260823-live: replacement deleted the fact-carrying identification and 74
|
|
452
|
+
// of 95 peeks recovered values the run's own briefs dropped). The agent's clause
|
|
453
|
+
// is judged against its own reserve so the refusal names the budget the agent
|
|
454
|
+
// actually has, and the deterministic head keeps the remainder of the one policy
|
|
455
|
+
// cap. Only the agent's OWN words take this path: a deterministic brief arriving
|
|
456
|
+
// through the same door IS the head, and a re-prepared augmented brief is already
|
|
457
|
+
// composed, so each passes through under the whole-brief cap instead.
|
|
458
|
+
const complaint = briefContractComplaint(
|
|
459
|
+
input.brief.trim(), snapshot.policy.agentBriefReserve, snapshot.toolName);
|
|
460
|
+
if (complaint !== null) throw new Error(`Supplied brief rejected. ${complaint}`);
|
|
461
|
+
const clause = input.brief.trim();
|
|
462
|
+
const head = candidate.kind === "consolidation"
|
|
463
|
+
? deterministicConsolidationBrief(candidate, state, snapshot.toolName, snapshot)
|
|
464
|
+
: deterministicChapterCandidateBrief(snapshot, candidate);
|
|
465
|
+
if (head) {
|
|
466
|
+
const seam = " · Agent: ";
|
|
467
|
+
const headRoom = Math.max(0, snapshot.policy.maxBriefChars - seam.length - clause.length);
|
|
468
|
+
brief = `${oneLine(head, headRoom)}${seam}${clause}`;
|
|
469
|
+
provenance = { kind: "augmented" };
|
|
470
|
+
} else {
|
|
471
|
+
brief = clause;
|
|
472
|
+
provenance = { kind: "supplied" };
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
const complaint = briefContractComplaint(
|
|
476
|
+
input.brief.trim(), snapshot.policy.maxBriefChars, snapshot.toolName);
|
|
477
|
+
if (complaint !== null) throw new Error(`Supplied brief rejected. ${complaint}`);
|
|
478
|
+
brief = input.brief.trim();
|
|
479
|
+
provenance = typeof input.briefProvenance === "object"
|
|
480
|
+
? clone(input.briefProvenance)
|
|
481
|
+
: { kind: declaredKind } as BriefProvenance;
|
|
482
|
+
}
|
|
424
483
|
} else {
|
|
425
484
|
brief = candidate.kind === "consolidation"
|
|
426
485
|
? deterministicConsolidationBrief(candidate, state, snapshot.toolName, snapshot)
|
|
@@ -806,6 +865,13 @@ export function pinnedPeekMass(
|
|
|
806
865
|
state: ActiveContextState,
|
|
807
866
|
): { bytes: number; results: number } {
|
|
808
867
|
const explicitProtected = explicitProtectedKeys(state);
|
|
868
|
+
// ITS TWO SIBLINGS ALREADY RETURN HERE (2026-08-28). `protectedStaleMass` and
|
|
869
|
+
// `explicitProtectedMass` both open with this guard; this one did the whole quadratic walk
|
|
870
|
+
// and then returned zero, because every increment in its final loop is gated on
|
|
871
|
+
// `explicitProtected.has(...)`. So the guard is exact, not an approximation. It is not
|
|
872
|
+
// hypothetical either: `state.protected` is empty in all 10,521 persisted state records
|
|
873
|
+
// across 36 sealed runs, the corpus carrying no `context.pin` event at all.
|
|
874
|
+
if (!explicitProtected.size) return { bytes: 0, results: 0 };
|
|
809
875
|
const folded = new Set<string>();
|
|
810
876
|
for (const fold of state.folds) {
|
|
811
877
|
for (const ref of flattenFoldRefs(fold, state)) folded.add(objectRefKey(ref));
|
|
@@ -859,10 +925,55 @@ export function projectActiveContext(
|
|
|
859
925
|
index += 1;
|
|
860
926
|
}
|
|
861
927
|
}
|
|
928
|
+
applyToolClips(output, state, snapshot);
|
|
862
929
|
assertProjectionPreservesToolLinkage(snapshot.messages, output);
|
|
863
930
|
return output;
|
|
864
931
|
}
|
|
865
932
|
|
|
933
|
+
// THE TOOL-CALL DIET'S VIEW (2026-08-24). A clipped result renders in place as its
|
|
934
|
+
// identified head with the cut stated and the recovery named, matched BY CALL ID and
|
|
935
|
+
// identity-preserving on role, call id, tool name, error flag and timestamp, exactly
|
|
936
|
+
// the ephemeral-peek withdrawal's shape one mechanism over. Clips are only ever ADDED
|
|
937
|
+
// inside a commit transaction, and a commit already rewrites the projection, so a
|
|
938
|
+
// non-commit pass with unchanged clips renders byte-identically and the frozen prefix
|
|
939
|
+
// never moves. Every reader shares this pass because every reader shares
|
|
940
|
+
// projectActiveContext: the live projection, the persistence readers and the
|
|
941
|
+
// attribution lens all see the same clipped view, and the full bytes stay in the
|
|
942
|
+
// transcript behind the entry id the marker names.
|
|
943
|
+
function applyToolClips(
|
|
944
|
+
output: unknown[],
|
|
945
|
+
state: ActiveContextState,
|
|
946
|
+
snapshot: ActiveContextSnapshot,
|
|
947
|
+
): void {
|
|
948
|
+
if (!state.clips?.length) return;
|
|
949
|
+
const byCallId = new Map(state.clips.map((clip) => [clip.callId, clip]));
|
|
950
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
951
|
+
const message = output[index] as Record<string, unknown> | null;
|
|
952
|
+
if (!message || (message as { role?: unknown }).role !== "toolResult") continue;
|
|
953
|
+
const callId = (message as { toolCallId?: unknown }).toolCallId;
|
|
954
|
+
if (typeof callId !== "string") continue;
|
|
955
|
+
const clip = byCallId.get(callId);
|
|
956
|
+
if (!clip) continue;
|
|
957
|
+
const details = (message as { details?: unknown }).details;
|
|
958
|
+
if (details && typeof (details as { projection?: unknown }).projection === "string") continue;
|
|
959
|
+
const full = contentText(message);
|
|
960
|
+
if (full.length <= snapshot.policy.toolClipFloorChars) continue;
|
|
961
|
+
const head = toolClipHead(full, snapshot.policy.toolClipHeadChars);
|
|
962
|
+
const hidden = full.length - head.length;
|
|
963
|
+
if (hidden <= 0) continue;
|
|
964
|
+
output[index] = {
|
|
965
|
+
role: "toolResult",
|
|
966
|
+
toolCallId: callId,
|
|
967
|
+
toolName: (message as { toolName?: unknown }).toolName,
|
|
968
|
+
content: [{ type: "text", text: `${head}\n... [${hidden} more chars clipped; ` +
|
|
969
|
+
`{"action":"peek","id":"${clip.entryId}"} returns the full result]` }],
|
|
970
|
+
isError: (message as { isError?: unknown }).isError ?? false,
|
|
971
|
+
details: { projection: "tool-clip" },
|
|
972
|
+
timestamp: (message as { timestamp?: unknown }).timestamp,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
866
977
|
export function foldStatusRow(fold: ActiveFold, state: ActiveContextState, snapshot: ActiveContextSnapshot): Record<string, unknown> {
|
|
867
978
|
const navigation = foldNavigation(fold, state, snapshot);
|
|
868
979
|
const interval = foldInterval(fold, state, snapshot);
|
|
@@ -1299,11 +1410,26 @@ export function peekFoldSource(input: {
|
|
|
1299
1410
|
const source = stableStringify(messages);
|
|
1300
1411
|
const sourceBytes = bytes(source);
|
|
1301
1412
|
const offset = Math.min(Math.max(0, input.offset ?? 0), sourceBytes);
|
|
1302
|
-
|
|
1413
|
+
// The widest read anyone may ask for is the policy's own source cap, the same ceiling
|
|
1414
|
+
// the `wider` hint has always offered; an unclamped `bytes` was the one door left to a
|
|
1415
|
+
// single result larger than any fold the runtime would cut.
|
|
1416
|
+
const budget = Math.min(
|
|
1417
|
+
input.maximumBytes ?? PEEK_DEFAULT_MAX_BYTES,
|
|
1418
|
+
ACTIVE_CONTEXT_POLICY.maxSourceChars,
|
|
1419
|
+
);
|
|
1420
|
+
// ONE BUDGET GOVERNS THE WHOLE RESULT (sol-20260826-full2, pifold rep 1). The byte
|
|
1421
|
+
// bound used to govern `source` alone while the descendant index rode free, so peeking
|
|
1422
|
+
// the root of a deep consolidation tree returned 320 nested briefs, 423KB against a
|
|
1423
|
+
// stated 16KB bound, durably into a window already near its budget; that one result is
|
|
1424
|
+
// what the projection fence then aborted thirty-five requests over. The index seats
|
|
1425
|
+
// first, into at most half the budget, because a parent's index is the point read; the
|
|
1426
|
+
// source view takes everything the seated rows leave.
|
|
1427
|
+
const catalog = boundedDescendantIndex(fold, input.state, Math.floor(budget / 2));
|
|
1428
|
+
const sourceBudget = budget - catalog.seatedBytes;
|
|
1303
1429
|
const window = offset > 0 ? utf8Slice(source, offset) : source;
|
|
1304
1430
|
const view = offset > 0
|
|
1305
|
-
? { text: boundedUtf8(window,
|
|
1306
|
-
: boundedHeadTail(window,
|
|
1431
|
+
? { text: boundedUtf8(window, sourceBudget), omittedBytes: 0, contiguous: true }
|
|
1432
|
+
: boundedHeadTail(window, sourceBudget);
|
|
1307
1433
|
const returned = view.text;
|
|
1308
1434
|
const returnedBytes = bytes(returned) - bytes(headTailMarker(view.omittedBytes));
|
|
1309
1435
|
const nextOffset = view.contiguous && offset + returnedBytes < sourceBytes
|
|
@@ -1311,7 +1437,7 @@ export function peekFoldSource(input: {
|
|
|
1311
1437
|
: null;
|
|
1312
1438
|
const truncated = view.omittedBytes > 0 || nextOffset !== null;
|
|
1313
1439
|
const children = childFoldIds(fold);
|
|
1314
|
-
const index =
|
|
1440
|
+
const index = catalog.rows;
|
|
1315
1441
|
return {
|
|
1316
1442
|
version: 1,
|
|
1317
1443
|
action: "peek",
|
|
@@ -1330,25 +1456,31 @@ export function peekFoldSource(input: {
|
|
|
1330
1456
|
truncated,
|
|
1331
1457
|
view: offset > 0 ? "slice" : (view.omittedBytes > 0 ? "index" : "complete"),
|
|
1332
1458
|
...(index.length ? { index } : {}),
|
|
1459
|
+
...(catalog.omitted ? { indexOmitted: catalog.omitted } : {}),
|
|
1333
1460
|
children,
|
|
1334
1461
|
narrower: {
|
|
1335
1462
|
...(nextOffset === null ? {} : { slice: { action: "peek", id: fold.id, offset: nextOffset, bytes: returnedBytes } }),
|
|
1336
1463
|
...(children.length ? { child: { action: "peek", id: children[0] } } : {}),
|
|
1337
1464
|
},
|
|
1338
|
-
...(truncated
|
|
1465
|
+
...(truncated || catalog.omitted
|
|
1339
1466
|
? { wider: { action: "peek", id: fold.id, bytes: Math.min(sourceBytes, ACTIVE_CONTEXT_POLICY.maxSourceChars) } }
|
|
1340
1467
|
: {}),
|
|
1341
1468
|
lifetime: "these bytes stay in the window exactly as returned, like any other tool result, and " +
|
|
1342
1469
|
`nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s stored source, so the ` +
|
|
1343
1470
|
`next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
|
|
1344
1471
|
"same verbatim bytes, or pin this result to keep the copy raw.",
|
|
1345
|
-
note: truncated
|
|
1472
|
+
note: (truncated
|
|
1346
1473
|
? `Bounded read: ${returnedBytes} of ${sourceBytes} exact source bytes, ${view.omittedBytes} omitted ` +
|
|
1347
1474
|
`from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
|
|
1348
1475
|
: children.length
|
|
1349
1476
|
? `Complete stored span, one level: raw entries exactly, and ${children.length} child fold(s) still ` +
|
|
1350
1477
|
"placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
|
|
1351
|
-
: "Complete exact source; the fold stayed collapsed and no projection changed."
|
|
1478
|
+
: "Complete exact source; the fold stayed collapsed and no projection changed.") +
|
|
1479
|
+
(catalog.omitted
|
|
1480
|
+
? ` The nested index seats the shallowest ${index.length} of ${index.length + catalog.omitted} ` +
|
|
1481
|
+
"descendant briefs inside the byte budget; the deeper rows are one hop away by peeking a child id, " +
|
|
1482
|
+
"or widen with bytes."
|
|
1483
|
+
: ""),
|
|
1352
1484
|
source: returned,
|
|
1353
1485
|
...(truncated
|
|
1354
1486
|
? {
|
|
@@ -1388,6 +1520,38 @@ export function boundedHeadTail(
|
|
|
1388
1520
|
};
|
|
1389
1521
|
}
|
|
1390
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* The descendant index, seated inside a byte budget: shallowest rows first, whole rows
|
|
1525
|
+
* only, the omitted counted rather than dropped. Depth order is the navigational order,
|
|
1526
|
+
* because a row's own children are reachable by peeking it, so the rows worth seating
|
|
1527
|
+
* first are the ones every deeper row is reachable THROUGH. Walk order breaks depth
|
|
1528
|
+
* ties, which keeps siblings in span order. The seating stops at the first row that
|
|
1529
|
+
* does not fit, so what is omitted is always a contiguous deep tail of the depth order
|
|
1530
|
+
* and the note can state it as one count.
|
|
1531
|
+
*/
|
|
1532
|
+
export function boundedDescendantIndex(
|
|
1533
|
+
fold: ActiveFold,
|
|
1534
|
+
state: ActiveContextState,
|
|
1535
|
+
budget: number,
|
|
1536
|
+
): { rows: Array<Record<string, unknown>>; omitted: number; seatedBytes: number } {
|
|
1537
|
+
const walked = descendantIndexRows(fold, state)
|
|
1538
|
+
.map((row, walk) => ({ row, walk }))
|
|
1539
|
+
.sort((left, right) =>
|
|
1540
|
+
Number(left.row.depth) - Number(right.row.depth) || left.walk - right.walk);
|
|
1541
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
1542
|
+
// Counted as the array actually serializes, brackets and joining commas included, so
|
|
1543
|
+
// "seated bytes" is the bytes the result carries and not an under-read of them.
|
|
1544
|
+
let seatedBytes = 2;
|
|
1545
|
+
for (const { row } of walked) {
|
|
1546
|
+
const cost = bytes(stableStringify(row)) + (rows.length ? 1 : 0);
|
|
1547
|
+
if (seatedBytes + cost > budget) break;
|
|
1548
|
+
seatedBytes += cost;
|
|
1549
|
+
rows.push(row);
|
|
1550
|
+
}
|
|
1551
|
+
if (!rows.length) seatedBytes = 0;
|
|
1552
|
+
return { rows, omitted: walked.length - rows.length, seatedBytes };
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1391
1555
|
export function descendantIndexRows(
|
|
1392
1556
|
fold: ActiveFold,
|
|
1393
1557
|
state: ActiveContextState,
|
|
@@ -87,17 +87,11 @@ export type ContextEventKind =
|
|
|
87
87
|
| "context.pin"
|
|
88
88
|
| "context.capacity"
|
|
89
89
|
| "context.receipt"
|
|
90
|
-
| "context.lastcall"
|
|
91
|
-
| "context.response"
|
|
92
|
-
| "context.suggestion"
|
|
93
|
-
| "context.outcome"
|
|
94
|
-
| "context.notice"
|
|
95
90
|
| "context.frontier"
|
|
96
91
|
| "context.commit"
|
|
97
92
|
| "context.fold"
|
|
98
93
|
| "context.brief"
|
|
99
94
|
| "context.absorb"
|
|
100
|
-
| "context.split"
|
|
101
95
|
| "context.recovery"
|
|
102
96
|
| "context.suspend"
|
|
103
97
|
| "context.rollback"
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "./canonical.ts";
|
|
16
16
|
import {
|
|
17
17
|
childFoldIds,
|
|
18
|
-
|
|
18
|
+
assignFoldParents,
|
|
19
19
|
flattenFoldRefs,
|
|
20
20
|
foldMap,
|
|
21
21
|
parseActiveContextState,
|
|
@@ -270,11 +270,42 @@ export function foldInterval(fold: ActiveFold, state: ActiveContextState, snapsh
|
|
|
270
270
|
return indices ? { start: indices[0], end: indices.at(-1)! } : null;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
export
|
|
274
|
-
|
|
273
|
+
export type OrderedRoot = { fold: ActiveFold; start: number; end: number };
|
|
274
|
+
|
|
275
|
+
// ROOT INTERVALS ARE DERIVED ONCE PER (STATE, SNAPSHOT) PAIR (2026-08-24). This derivation
|
|
276
|
+
// is O(roots x folds) on its own, because `foldInterval` calls `flattenFoldRefs`, which
|
|
277
|
+
// rebuilds `foldMap` for every root it walks. `partsForRange` opens with it, and
|
|
278
|
+
// `selectAutomaticChapter` calls `partsForRange` from a walk that is quadratic in units,
|
|
279
|
+
// so the cost was O(units^2 x roots x folds) for an answer that cannot change inside a
|
|
280
|
+
// pass. Replaying sealed sol-20260823-live rep 3 at 125 folds, ONE frontier walk made
|
|
281
|
+
// 12,333 calls and spent 22.5 seconds, and 12,328 of them returned null without reading a
|
|
282
|
+
// root, because the automatic chapter passes an EMPTY allowed-child set and any overlap
|
|
283
|
+
// refuses. At 140 folds the walk was 98.8 percent of the runtime's whole per-request cost;
|
|
284
|
+
// the per-turn gap between a response and the next projection ran 3s at zero folds to 164s
|
|
285
|
+
// past 150, and five per-turn call sites each pay a walk.
|
|
286
|
+
//
|
|
287
|
+
// The memo is gate 113's rule and gate 121's rule at a third address: kept against the
|
|
288
|
+
// state and snapshot OBJECTS rather than their contents, because both are REPLACED on
|
|
289
|
+
// every change rather than mutated, so a replaced one misses and derives again instead of
|
|
290
|
+
// serving something stale. It hands every caller the SAME array, which is safe only while
|
|
291
|
+
// no reading mutates it; gate 149 drives every call site against a frozen one so a reading
|
|
292
|
+
// that starts mutating throws rather than quietly poisoning the memo.
|
|
293
|
+
export const ORDERED_ROOTS = new WeakMap<ActiveContextState, WeakMap<ActiveContextSnapshot, OrderedRoot[]>>();
|
|
294
|
+
|
|
295
|
+
export function orderedRoots(state: ActiveContextState, snapshot: ActiveContextSnapshot): OrderedRoot[] {
|
|
296
|
+
let bySnapshot = ORDERED_ROOTS.get(state);
|
|
297
|
+
if (!bySnapshot) {
|
|
298
|
+
bySnapshot = new WeakMap<ActiveContextSnapshot, OrderedRoot[]>();
|
|
299
|
+
ORDERED_ROOTS.set(state, bySnapshot);
|
|
300
|
+
}
|
|
301
|
+
const memoized = bySnapshot.get(snapshot);
|
|
302
|
+
if (memoized) return memoized;
|
|
303
|
+
const roots = rootFolds(state).flatMap((fold) => {
|
|
275
304
|
const interval = foldInterval(fold, state, snapshot);
|
|
276
305
|
return interval ? [{ fold, ...interval }] : [];
|
|
277
306
|
}).sort((left, right) => left.start - right.start);
|
|
307
|
+
bySnapshot.set(snapshot, roots);
|
|
308
|
+
return roots;
|
|
278
309
|
}
|
|
279
310
|
|
|
280
311
|
export function visibleCollapsedRoots(state: ActiveContextState, snapshot: ActiveContextSnapshot) {
|
|
@@ -317,7 +348,9 @@ export function persistenceProjection(state: ActiveContextState, snapshot: Activ
|
|
|
317
348
|
if (projected.prepared && projected.prepared.sourceRefs.some((ref) => !mapped.has(objectRefKey(ref)))) {
|
|
318
349
|
delete projected.prepared;
|
|
319
350
|
}
|
|
320
|
-
|
|
351
|
+
// The parse on the next line validates this forest, so `assignFoldParents` is the whole
|
|
352
|
+
// job here; `deriveFoldParents` would walk it twice.
|
|
353
|
+
projected.folds = assignFoldParents(projected.folds);
|
|
321
354
|
return parseActiveContextState(projected, state.sessionId);
|
|
322
355
|
}
|
|
323
356
|
|
|
@@ -537,3 +570,50 @@ export function parseNativeCompactionCompletion(value: unknown, expectedSessionI
|
|
|
537
570
|
}
|
|
538
571
|
return { ...(clone(value) as unknown as NativeCompactionCompletionReceipt), decision };
|
|
539
572
|
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* AN IMAGE IS NOT TEXT, AND MEASURING IT AS TEXT KILLS SESSIONS (2026-08-28).
|
|
576
|
+
*
|
|
577
|
+
* The projection is sized by serializing it and counting UTF-8 bytes, which is right for
|
|
578
|
+
* prose and catastrophically wrong for an image: a content block carries its pixels as
|
|
579
|
+
* base64, so a screenshot lands as several hundred thousand characters that the estimator
|
|
580
|
+
* then divides by a chars-per-token ratio learned from PROSE. A live session read three
|
|
581
|
+
* PNGs of 364,801, 631,045 and 827,513 bytes; the projection went 636,687 chars to
|
|
582
|
+
* 1,268,275 in one step and the estimate went 110,687 tokens to 220,883, while the
|
|
583
|
+
* provider's own count for that same request came back 111,837. Every token of the
|
|
584
|
+
* 109,046 difference was base64 counted as if it were sentences.
|
|
585
|
+
*
|
|
586
|
+
* The calibration cannot learn its way out, and that is the part worth understanding.
|
|
587
|
+
* `projectionCharsPerToken` takes the MINIMUM ratio over its window so the estimate never
|
|
588
|
+
* under-reads. An image pushes the true ratio UP, so the minimum discards exactly the
|
|
589
|
+
* sample that would have corrected it and keeps dividing base64 by the prose ratio. The
|
|
590
|
+
* conservative rule that protects against under-reading is what locks in the over-read.
|
|
591
|
+
*
|
|
592
|
+
* So image payload is removed from the character count and replaced by a flat per-image
|
|
593
|
+
* token cost. The count cannot be exact across providers, and it does not need to be: it
|
|
594
|
+
* needs to be the right order of magnitude instead of wrong by seventy times, and the
|
|
595
|
+
* provider anchor corrects the remainder the moment a real measurement lands.
|
|
596
|
+
*/
|
|
597
|
+
export function imageMass(projected: unknown[]): { base64Chars: number; images: number } {
|
|
598
|
+
let base64Chars = 0;
|
|
599
|
+
let images = 0;
|
|
600
|
+
for (const message of projected) {
|
|
601
|
+
const content = ownValue(message, "content");
|
|
602
|
+
const blocks = denseOwnArrayValues(content);
|
|
603
|
+
if (!blocks) continue;
|
|
604
|
+
for (const block of blocks) {
|
|
605
|
+
if (ownValue(block, "type") !== "image") continue;
|
|
606
|
+
images += 1;
|
|
607
|
+
// Two shapes reach here: the flat `data` a Pi tool result carries, and the nested
|
|
608
|
+
// `source.data` of a provider-native block. Both are counted; neither is required.
|
|
609
|
+
const direct = ownValue(block, "data");
|
|
610
|
+
const nested = ownValue(ownValue(block, "source"), "data");
|
|
611
|
+
const payload = typeof direct === "string" ? direct
|
|
612
|
+
: typeof nested === "string" ? nested : "";
|
|
613
|
+
// The serialization counts the JSON-escaped string, and base64 has no character
|
|
614
|
+
// JSON escapes, so its byte length in the serialized form is its own length.
|
|
615
|
+
base64Chars += bytes(payload);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { base64Chars, images };
|
|
619
|
+
}
|