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