pi-fold 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -211
- package/extensions/active-context.ts +483 -244
- package/extensions/evidence.js +1 -5
- package/extensions/index.js +4 -3
- package/extensions/json.ts +23 -1
- package/extensions/lib/canonical.ts +34 -0
- package/extensions/lib/curation.ts +46 -9
- package/extensions/lib/folding.ts +181 -17
- package/extensions/lib/instrumentation.ts +0 -6
- package/extensions/lib/measurement.ts +37 -4
- package/extensions/lib/persistence.ts +212 -9
- package/extensions/lib/policy.ts +103 -29
- package/extensions/lib/scheduling.ts +105 -18
- package/extensions/lib/selection.ts +112 -39
- package/extensions/lib/tool-surface.ts +37 -6
- package/extensions/lib/transcript.ts +65 -14
- package/extensions/settings.ts +11 -36
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 +
|
|
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 && !
|
|
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 && !
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
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 =
|
|
1107
|
-
const shown =
|
|
1108
|
-
|
|
1109
|
-
|
|
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(
|
|
1113
|
-
for (let kept =
|
|
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,8 @@
|
|
|
7
7
|
* nothing: the next model call that reads a result, a natural boundary in the work.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { MEMORY_BODY_CHARS, MEMORY_KEY_CHARS, MEMORY_KEYS_MAX } from "./policy.ts";
|
|
11
|
+
|
|
10
12
|
type ToolHandler = (
|
|
11
13
|
toolCallId: string,
|
|
12
14
|
params: Record<string, unknown>,
|
|
@@ -29,7 +31,7 @@ export function buildActiveContextTool(input: {
|
|
|
29
31
|
handler: ToolHandler;
|
|
30
32
|
}) {
|
|
31
33
|
const peekGuidance = input.allowedActions.includes("peek")
|
|
32
|
-
? ` Peek is the point read: it returns one fold's index view --
|
|
34
|
+
? ` 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
35
|
: "";
|
|
34
36
|
const correctionGuidance = input.allowedActions.includes("reboundary")
|
|
35
37
|
? " 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."
|
|
@@ -43,13 +45,16 @@ export function buildActiveContextTool(input: {
|
|
|
43
45
|
const recoveryGuidance = input.allowedActions.includes("expand")
|
|
44
46
|
? " A fold brief is an index entry, not the source: when a question or task depends on material that is folded, peek or expand that fold and answer from the exact bytes, never from memory of them. Recalling folded detail without reopening it is how specifics get misremembered."
|
|
45
47
|
: "";
|
|
48
|
+
const memoryGuidance = input.allowedActions.includes("remember")
|
|
49
|
+
? ` WORKING MEMORY: a session-scoped dictionary you maintain beside the fold index. remember with key and body writes or updates an entry (${MEMORY_KEY_CHARS}-character keys, ${MEMORY_BODY_CHARS}-character bodies, ${MEMORY_KEYS_MAX} entries); an empty body removes it; recall with keys reads bodies back, or everything when keys is omitted. The bodies live outside your window: only a one-glance table of contents rides your context, refreshed at each commit, so writing an entry costs your window almost nothing. Keep entries current as the work moves: when a fact an entry records changes, or new information lands that belongs in one, update it then rather than at the end.`
|
|
50
|
+
: "";
|
|
46
51
|
const standingGuidance = " Folding is automatic, lossless and recoverable: the runtime folds stale spans on its own when the window needs room, nothing is ever discarded, and every folded span keeps its exact source. You are never asked to make room and no fold is ever announced in advance. Work normally and let it happen.";
|
|
47
52
|
return {
|
|
48
53
|
name: input.name,
|
|
49
54
|
label: input.label,
|
|
50
55
|
description: input.fullSurface
|
|
51
|
-
? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
|
|
52
|
-
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
|
|
56
|
+
? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
|
|
57
|
+
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
|
|
53
58
|
parameters: {
|
|
54
59
|
type: "object",
|
|
55
60
|
additionalProperties: false,
|
|
@@ -62,9 +67,10 @@ export function buildActiveContextTool(input: {
|
|
|
62
67
|
type: "string",
|
|
63
68
|
minLength: 1,
|
|
64
69
|
maxLength: input.maxBriefChars,
|
|
65
|
-
description: "Brief only: what
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
description: "Brief only: what you will want back from this pending fold that " +
|
|
71
|
+
"its automatic brief cannot know. Your sentence is kept BESIDE the automatic " +
|
|
72
|
+
`brief, never instead of it, at most ${input.maxBriefChars} characters. Free ` +
|
|
73
|
+
"to write, because the fold it names is not in your window yet.",
|
|
68
74
|
},
|
|
69
75
|
offset: { type: "integer", minimum: 0 },
|
|
70
76
|
ephemeral: {
|
|
@@ -83,6 +89,31 @@ export function buildActiveContextTool(input: {
|
|
|
83
89
|
},
|
|
84
90
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
85
91
|
detail: { type: "string", enum: [...input.statusDetails] },
|
|
92
|
+
...(input.allowedActions.includes("remember")
|
|
93
|
+
? {
|
|
94
|
+
key: {
|
|
95
|
+
type: "string",
|
|
96
|
+
minLength: 1,
|
|
97
|
+
maxLength: MEMORY_KEY_CHARS,
|
|
98
|
+
description: "remember only: the working-memory entry to write, update or " +
|
|
99
|
+
"(with an empty body) remove.",
|
|
100
|
+
},
|
|
101
|
+
body: {
|
|
102
|
+
type: "string",
|
|
103
|
+
maxLength: MEMORY_BODY_CHARS,
|
|
104
|
+
description: "remember only: the entry's full text, replacing what the key " +
|
|
105
|
+
"held. Empty removes the entry.",
|
|
106
|
+
},
|
|
107
|
+
keys: {
|
|
108
|
+
type: "array",
|
|
109
|
+
minItems: 1,
|
|
110
|
+
maxItems: MEMORY_KEYS_MAX,
|
|
111
|
+
items: { type: "string", minLength: 1 },
|
|
112
|
+
description: "recall only: the working-memory entries to read. Omit to read " +
|
|
113
|
+
"every entry.",
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
86
117
|
},
|
|
87
118
|
},
|
|
88
119
|
execute: input.handler,
|
|
@@ -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
|
|
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;
|