pi-fold 3.1.0 → 4.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 +26 -28
- package/extensions/active-context.ts +645 -412
- package/extensions/index.js +11 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +208 -74
- package/extensions/lib/editor-ui.ts +595 -66
- package/extensions/lib/folding.ts +20 -2
- package/extensions/lib/instrumentation.ts +3 -1
- package/extensions/lib/live-settings.ts +73 -0
- package/extensions/lib/measurement.ts +47 -0
- package/extensions/lib/persistence.ts +42 -39
- package/extensions/lib/policy.ts +72 -51
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/tool-surface.ts +2 -32
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +200 -32
- package/package.json +5 -5
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
refsProtected,
|
|
32
32
|
} from "./measurement.ts";
|
|
33
33
|
import {
|
|
34
|
+
assertFoldForestValid,
|
|
34
35
|
childFoldIds,
|
|
35
36
|
clearPrepared,
|
|
36
37
|
flattenFoldRefs,
|
|
@@ -908,7 +909,7 @@ export function projectActiveContext(
|
|
|
908
909
|
snapshot: ActiveContextSnapshot,
|
|
909
910
|
state: ActiveContextState,
|
|
910
911
|
): unknown[] {
|
|
911
|
-
|
|
912
|
+
assertFoldForestValid(state.folds);
|
|
912
913
|
const replacements = new Map<number, { end: number; messages: unknown[] }>();
|
|
913
914
|
for (const root of orderedRoots(state, snapshot)) {
|
|
914
915
|
const rendered = renderFold(root.fold, state, snapshot);
|
|
@@ -921,7 +922,24 @@ export function projectActiveContext(
|
|
|
921
922
|
output.push(...replacement.messages);
|
|
922
923
|
index = replacement.end + 1;
|
|
923
924
|
} else {
|
|
924
|
-
|
|
925
|
+
// A PASS-THROUGH MESSAGE IS SHARED, NOT COPIED (gate 158, 2026-08-29). This used to
|
|
926
|
+
// push `clone(...)`, which was in the first commit of the runtime and never load
|
|
927
|
+
// bearing: `snapshot.messages` is ALREADY `clone(input.eventMessages)`
|
|
928
|
+
// (transcript.ts:531), against an array Pi itself structuredClones before it calls
|
|
929
|
+
// any handler, so the copy was a third generation guarding objects the host never
|
|
930
|
+
// sees. It cost 54.3ms of a 298.5ms projection, 18 percent, on a sealed 3,364
|
|
931
|
+
// message session, and it is the only remaining per-message cost of that size.
|
|
932
|
+
//
|
|
933
|
+
// What makes sharing indistinguishable from copying: NOTHING writes a field into a
|
|
934
|
+
// projected message at any depth. Every rewrite builds a fresh object and replaces
|
|
935
|
+
// an array SLOT, here in `applyToolClips` and in `withdrawConsumedEphemeralPeeks`
|
|
936
|
+
// and `carryWithdrawalIntoFreeze` one layer up, so a shared element is never the
|
|
937
|
+
// thing that gets edited. Gate 158 pins that by deep-freezing `snapshot.messages`
|
|
938
|
+
// and driving the readers against it, so a future in-place write throws in the
|
|
939
|
+
// suite instead of silently corrupting every later projection through the frozen
|
|
940
|
+
// prefix. Folded spans keep their own copies: line 796 and `renderFoldParts` still
|
|
941
|
+
// clone, because those objects are built for the projection rather than read from it.
|
|
942
|
+
output.push(snapshot.messages[index]);
|
|
925
943
|
index += 1;
|
|
926
944
|
}
|
|
927
945
|
}
|
|
@@ -94,10 +94,12 @@ export type ContextEventKind =
|
|
|
94
94
|
| "context.absorb"
|
|
95
95
|
| "context.recovery"
|
|
96
96
|
| "context.suspend"
|
|
97
|
+
| "context.settings"
|
|
97
98
|
| "context.rollback"
|
|
98
99
|
| "context.projection"
|
|
99
100
|
| "context.usage"
|
|
100
|
-
| "context.prefix"
|
|
101
|
+
| "context.prefix"
|
|
102
|
+
| "context.anchor";
|
|
101
103
|
|
|
102
104
|
export function prefixDivergence(
|
|
103
105
|
previous: string | null,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// THE CHANNEL FROM /fold-settings TO THE RUNNING RUNTIME.
|
|
2
|
+
//
|
|
3
|
+
// THE NEED, from a live session (2026-08-29). /fold-settings wrote the file and nothing
|
|
4
|
+
// else: `registerPiFold` resolves thresholds, toolFoldThreshold and the notice settings
|
|
5
|
+
// ONCE at registration, so a session kept the settings pi booted with while the screen showed
|
|
6
|
+
// what the person had just chosen. Shane's terminal held both at once, "Start folding at
|
|
7
|
+
// 40%" on the screen over "commit at 90%" on the status line, and neither surface said
|
|
8
|
+
// why. The file was the durable truth and the running session was not reading it.
|
|
9
|
+
//
|
|
10
|
+
// The two halves are registered independently, `registerFoldSettings(pi)` and
|
|
11
|
+
// `registerPiFold(pi, settings)`, in either order, and neither returns a handle the other
|
|
12
|
+
// can hold. What they do share is the HOST OBJECT, so that is the channel: the runtime
|
|
13
|
+
// hangs an applier off `pi` under a registry symbol, and the settings screen looks for it
|
|
14
|
+
// when a person saves an edit, which is long after both registrations have run. Late
|
|
15
|
+
// binding is what makes the order irrelevant.
|
|
16
|
+
//
|
|
17
|
+
// A MODULE-LEVEL SLOT WAS THE OTHER CANDIDATE AND IT IS WORSE: the applier would then be
|
|
18
|
+
// process-global, every gate that registers a runtime would leave one standing, and a
|
|
19
|
+
// screen in one host could push settings into a runtime in another. Keeping it on the
|
|
20
|
+
// host means an unregistered host simply has no applier, which is a state the screen can
|
|
21
|
+
// read and state honestly rather than a state it has to guess at.
|
|
22
|
+
//
|
|
23
|
+
// Symbol.for, not a fresh Symbol: the global registry keys by string, so a package loaded
|
|
24
|
+
// twice through different module caches (jiti in the suite, pi's own loader in the
|
|
25
|
+
// deployment) still agrees on the property.
|
|
26
|
+
|
|
27
|
+
import type { ActiveContextThresholds } from "./policy.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the settings screen hands the runtime: exactly the settings FILE's shape, where an
|
|
31
|
+
* absent field means the package default rather than "leave it alone". The runtime
|
|
32
|
+
* resolves it the same way it resolves its registration options, through one code path,
|
|
33
|
+
* so a value cannot mean one thing at boot and another at an edit.
|
|
34
|
+
*/
|
|
35
|
+
export interface LiveFoldSettings {
|
|
36
|
+
thresholds?: ActiveContextThresholds;
|
|
37
|
+
toolFoldThreshold?: number;
|
|
38
|
+
preCommitNotice?: boolean;
|
|
39
|
+
noticeLeadShare?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Refuses by throwing, exactly as registration does, and leaves the runtime untouched. */
|
|
43
|
+
export type LiveSettingsApplier = (settings: LiveFoldSettings, ctx?: unknown) => void;
|
|
44
|
+
|
|
45
|
+
const LIVE_SETTINGS = Symbol.for("pi-fold.live-settings");
|
|
46
|
+
|
|
47
|
+
export function publishLiveSettings(host: any, apply: LiveSettingsApplier): void {
|
|
48
|
+
// A HOST THAT REFUSES THE PROPERTY IS NOT A FAILURE. Nothing about folding depends on
|
|
49
|
+
// this channel: without it the file is still written and the next start still reads
|
|
50
|
+
// it, which is the behaviour every build before this one had.
|
|
51
|
+
try { host[LIVE_SETTINGS] = apply; } catch { }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether an edit saved now would reach a running runtime. The settings screen states
|
|
55
|
+
* which of the two it is instead of promising the one it cannot deliver. */
|
|
56
|
+
export function liveSettingsReachable(host: any): boolean {
|
|
57
|
+
try { return typeof host?.[LIVE_SETTINGS] === "function"; }
|
|
58
|
+
catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Push settings into the runtime registered on this host. Returns false when there is
|
|
63
|
+
* none, and lets the applier's own refusal THROW: the caller decides what a refusal
|
|
64
|
+
* means, and swallowing it here would leave a screen believing it had applied a value
|
|
65
|
+
* the runtime rejected.
|
|
66
|
+
*/
|
|
67
|
+
export function applyLiveSettings(host: any, settings: LiveFoldSettings, ctx?: unknown): boolean {
|
|
68
|
+
let apply: LiveSettingsApplier | undefined;
|
|
69
|
+
try { apply = host?.[LIVE_SETTINGS]; } catch { return false; }
|
|
70
|
+
if (typeof apply !== "function") return false;
|
|
71
|
+
apply(settings, ctx);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
@@ -570,3 +570,50 @@ export function parseNativeCompactionCompletion(value: unknown, expectedSessionI
|
|
|
570
570
|
}
|
|
571
571
|
return { ...(clone(value) as unknown as NativeCompactionCompletionReceipt), decision };
|
|
572
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
|
+
}
|
|
@@ -22,9 +22,6 @@ import {
|
|
|
22
22
|
EXPAND_LEASE_GENERATIONS,
|
|
23
23
|
MAX_EXPAND_LEASES,
|
|
24
24
|
MAX_PENDING_MARKS,
|
|
25
|
-
MEMORY_BODY_CHARS,
|
|
26
|
-
MEMORY_KEY_CHARS,
|
|
27
|
-
MEMORY_KEYS_MAX,
|
|
28
25
|
} from "./policy.ts";
|
|
29
26
|
import type {
|
|
30
27
|
ActiveContextCheckpointV2,
|
|
@@ -36,7 +33,6 @@ import type {
|
|
|
36
33
|
BriefOverride,
|
|
37
34
|
BriefProvenance,
|
|
38
35
|
ToolClip,
|
|
39
|
-
WorkingMemoryEntry,
|
|
40
36
|
FoldKind,
|
|
41
37
|
FoldPart,
|
|
42
38
|
FoldRecordEntry,
|
|
@@ -315,6 +311,43 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
|
315
311
|
return values;
|
|
316
312
|
}
|
|
317
313
|
|
|
314
|
+
// A FOREST THAT VALIDATED ONCE IS NOT VALIDATED AGAIN (gate 160, 2026-08-29). Profiling
|
|
315
|
+
// twelve projections of a sealed 681-fold session put 51.2 percent of ALL self time on one
|
|
316
|
+
// call path, and caller attribution found every serialization sample on it:
|
|
317
|
+
//
|
|
318
|
+
// projectActiveContext -> validateFoldForest -> sha256Value -> stableStringify -> safeJson
|
|
319
|
+
//
|
|
320
|
+
// The cost is this function's closing digest-drift loop, which flattens every fold's refs
|
|
321
|
+
// (for a consolidation parent, the union of all descendants) and hashes the array. The
|
|
322
|
+
// projection opens with `validateFoldForest(state.folds)` on EVERY context event and throws
|
|
323
|
+
// the result away: it wants the throw, not the normalized array. So it paid a full forest
|
|
324
|
+
// rehash per event to re-prove something it had already proven.
|
|
325
|
+
//
|
|
326
|
+
// THIS WRAPPER IS THE ONLY THING THAT CHANGES. `validateFoldForest` is untouched, still
|
|
327
|
+
// clones, still normalizes, still returns; the three callers that CONSUME its return value
|
|
328
|
+
// (parseActiveContextState, stateFromFoldRefs, commitPreparedFold) still call it directly
|
|
329
|
+
// and are unaffected. Each of those hands it a freshly built array anyway, so a memo could
|
|
330
|
+
// never have hit there; the projection is the one caller that reads the same array again
|
|
331
|
+
// and again, which is exactly why it is the one that pays.
|
|
332
|
+
//
|
|
333
|
+
// KEYED ON THE INPUT ARRAY, and sound because a state is rebuilt immutably: a changed
|
|
334
|
+
// forest is always a new array, and nothing anywhere mutates a folds array in place (no
|
|
335
|
+
// push, splice, sort, reverse or slot assignment on any `.folds` in extensions/). Nothing
|
|
336
|
+
// is shared out, because nothing is returned, so this carries none of gate 158's aliasing
|
|
337
|
+
// question.
|
|
338
|
+
//
|
|
339
|
+
// THE ONE COST, stated rather than hidden: a folds array mutated IN PLACE keeps its first
|
|
340
|
+
// verdict and a corruption introduced that way would go unreported. Gate 160 asserts that
|
|
341
|
+
// cost directly rather than pretending it away, and still requires a corrupt NEW forest to
|
|
342
|
+
// raise on first sight.
|
|
343
|
+
const VALIDATED_FORESTS = new WeakSet<readonly ActiveFold[]>();
|
|
344
|
+
|
|
345
|
+
export function assertFoldForestValid(folds: ActiveFold[]): void {
|
|
346
|
+
if (VALIDATED_FORESTS.has(folds)) return;
|
|
347
|
+
validateFoldForest(folds);
|
|
348
|
+
VALIDATED_FORESTS.add(folds);
|
|
349
|
+
}
|
|
350
|
+
|
|
318
351
|
export function parseActiveContextState(
|
|
319
352
|
value: unknown,
|
|
320
353
|
sessionId: string,
|
|
@@ -330,6 +363,10 @@ export function parseActiveContextState(
|
|
|
330
363
|
const hasPinnedPeeks = recordLike && Object.prototype.hasOwnProperty.call(value, "pinnedPeeks");
|
|
331
364
|
const hasBriefs = recordLike && Object.prototype.hasOwnProperty.call(value, "briefs");
|
|
332
365
|
const hasClips = recordLike && Object.prototype.hasOwnProperty.call(value, "clips");
|
|
366
|
+
// Read and dropped, never stored (2026-08-28): the working memory is deleted, and a
|
|
367
|
+
// sealed state that carries it must still load. Tolerating the key while discarding the
|
|
368
|
+
// value is the legacy-rider treatment, not the retired-field refusal: sol-20260826-full2
|
|
369
|
+
// reps 4 and 6 ran with it, and a campaign this package still cites has to stay readable.
|
|
333
370
|
const hasMemory = recordLike && Object.prototype.hasOwnProperty.call(value, "memory");
|
|
334
371
|
// Read and dropped, never stored: see legacyRiderStateSha256. A v1 state has no digest
|
|
335
372
|
// to reproduce, so here the tolerance is the whole of it.
|
|
@@ -377,7 +414,6 @@ export function parseActiveContextState(
|
|
|
377
414
|
}
|
|
378
415
|
const briefs = hasBriefs ? parseBriefOverrides(ownValue(value, "briefs"), ids) : {};
|
|
379
416
|
const clips = hasClips ? parseToolClips(ownValue(value, "clips")) : [];
|
|
380
|
-
const memory = hasMemory ? parseWorkingMemory(ownValue(value, "memory")) : [];
|
|
381
417
|
const source = clone(value) as unknown as ActiveContextState;
|
|
382
418
|
return {
|
|
383
419
|
version: 1,
|
|
@@ -393,7 +429,6 @@ export function parseActiveContextState(
|
|
|
393
429
|
...(marks.length ? { pendingMarks: marks } : {}),
|
|
394
430
|
...(Object.keys(briefs).length ? { briefs } : {}),
|
|
395
431
|
...(clips.length ? { clips } : {}),
|
|
396
|
-
...(memory.length ? { memory } : {}),
|
|
397
432
|
...(hasAdvisory
|
|
398
433
|
? { advisory: clone(source.advisory!) }
|
|
399
434
|
: defaultAdvisory ? { advisory: { highWater: 0, delivered: {} } } : {}),
|
|
@@ -418,30 +453,6 @@ function parseToolClips(value: unknown): ToolClip[] {
|
|
|
418
453
|
return clips;
|
|
419
454
|
}
|
|
420
455
|
|
|
421
|
-
function parseWorkingMemory(value: unknown): WorkingMemoryEntry[] {
|
|
422
|
-
const entries = denseOwnArrayValues(value);
|
|
423
|
-
if (!entries) throw new Error("Invalid active-context working memory");
|
|
424
|
-
const memory = entries.map((entry) => {
|
|
425
|
-
const key = ownValue(entry, "key");
|
|
426
|
-
const body = ownValue(entry, "body");
|
|
427
|
-
const ordinal = ownValue(entry, "ordinal");
|
|
428
|
-
if (!exactRecord(entry, ["key", "body", "ordinal"]) ||
|
|
429
|
-
typeof key !== "string" || !key || key.length > MEMORY_KEY_CHARS ||
|
|
430
|
-
typeof body !== "string" || !body || body.length > MEMORY_BODY_CHARS ||
|
|
431
|
-
!Number.isSafeInteger(ordinal) || Number(ordinal) < 0) {
|
|
432
|
-
throw new Error("Invalid active-context working-memory entry");
|
|
433
|
-
}
|
|
434
|
-
return { key, body, ordinal: Number(ordinal) };
|
|
435
|
-
});
|
|
436
|
-
if (memory.length > MEMORY_KEYS_MAX) {
|
|
437
|
-
throw new Error(`Invalid active-context working memory: over ${MEMORY_KEYS_MAX} entries`);
|
|
438
|
-
}
|
|
439
|
-
if (new Set(memory.map((entry) => entry.key)).size !== memory.length) {
|
|
440
|
-
throw new Error("Invalid active-context working memory: duplicate key");
|
|
441
|
-
}
|
|
442
|
-
return memory;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
456
|
export function clearPrepared(state: ActiveContextState): ActiveContextState {
|
|
446
457
|
const next = { ...state };
|
|
447
458
|
delete next.prepared;
|
|
@@ -557,7 +568,6 @@ export function sameStateProjection(left: ActiveContextState, right: ActiveConte
|
|
|
557
568
|
if (normalizedState.pendingMarks && normalizedState.pendingMarks.length === 0) delete normalizedState.pendingMarks;
|
|
558
569
|
if (normalizedState.briefs && !Object.keys(normalizedState.briefs).length) delete normalizedState.briefs;
|
|
559
570
|
if (normalizedState.clips && normalizedState.clips.length === 0) delete normalizedState.clips;
|
|
560
|
-
if (normalizedState.memory && normalizedState.memory.length === 0) delete normalizedState.memory;
|
|
561
571
|
return normalizedState;
|
|
562
572
|
};
|
|
563
573
|
return stableStringify(normalized(left)) === stableStringify(normalized(right));
|
|
@@ -711,7 +721,6 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
711
721
|
);
|
|
712
722
|
if (hasWireClips) parseToolClips(ownValue(value, "clips"));
|
|
713
723
|
if (hasAddClips) parseToolClips(ownValue(value, "addClips"));
|
|
714
|
-
if (hasWireMemory) parseWorkingMemory(ownValue(value, "memory"));
|
|
715
724
|
if (hasClipBase) {
|
|
716
725
|
const base = ownValue(value, "clipBase");
|
|
717
726
|
if (!Number.isSafeInteger(base) || Number(base) < 0) throw new Error("Invalid active-context clip base");
|
|
@@ -837,7 +846,7 @@ export function stateFromFoldRefs(
|
|
|
837
846
|
wire: Pick<
|
|
838
847
|
ActiveContextCheckpointV2,
|
|
839
848
|
"sessionId" | "revision" | "expanded" | "protected" | "prepared" | "advisory" |
|
|
840
|
-
"tokensSinceToolFold" | "leases" | "pendingMarks" | "briefs" | "clips"
|
|
849
|
+
"tokensSinceToolFold" | "leases" | "pendingMarks" | "briefs" | "clips"
|
|
841
850
|
>,
|
|
842
851
|
refs: FoldRecordRef[],
|
|
843
852
|
records: Map<string, FoldRecordEntry>,
|
|
@@ -862,7 +871,6 @@ export function stateFromFoldRefs(
|
|
|
862
871
|
...(wire.pendingMarks?.length ? { pendingMarks: clone(wire.pendingMarks) } : {}),
|
|
863
872
|
...(wire.briefs && Object.keys(wire.briefs).length ? { briefs: clone(wire.briefs) } : {}),
|
|
864
873
|
...(wire.clips?.length ? { clips: clone(wire.clips) } : {}),
|
|
865
|
-
...(wire.memory?.length ? { memory: clone(wire.memory) } : {}),
|
|
866
874
|
...(wire.advisory === undefined ? {} : { advisory: clone(wire.advisory) }),
|
|
867
875
|
...(wire.prepared === null || wire.prepared === undefined ? {} : { prepared: clone(wire.prepared) }),
|
|
868
876
|
};
|
|
@@ -1082,7 +1090,6 @@ export function materializeStatePersistence(
|
|
|
1082
1090
|
briefs: Object.keys(briefs).length ? briefs : undefined,
|
|
1083
1091
|
pendingMarks: marks.length ? marks : undefined,
|
|
1084
1092
|
clips: clips.length ? clips : undefined,
|
|
1085
|
-
memory: wire.memory?.length ? clone(wire.memory) : undefined,
|
|
1086
1093
|
},
|
|
1087
1094
|
[...byId.values()],
|
|
1088
1095
|
records,
|
|
@@ -1143,7 +1150,6 @@ export function makeStateCheckpoint(state: ActiveContextState): ActiveContextChe
|
|
|
1143
1150
|
...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
|
|
1144
1151
|
...(state.briefs && Object.keys(state.briefs).length ? { briefs: clone(state.briefs) } : {}),
|
|
1145
1152
|
...(state.clips?.length ? { clips: clone(state.clips) } : {}),
|
|
1146
|
-
...(state.memory?.length ? { memory: clone(state.memory) } : {}),
|
|
1147
1153
|
...(state.advisory ? { advisory: clone(state.advisory) } : {}),
|
|
1148
1154
|
stateSha256: semanticStateSha256(state),
|
|
1149
1155
|
}, state.sessionId) as ActiveContextCheckpointV2;
|
|
@@ -1234,9 +1240,6 @@ export function makeStateDelta(previous: ActiveContextState, next: ActiveContext
|
|
|
1234
1240
|
...(Object.keys(addBriefs).length ? { addBriefs } : {}),
|
|
1235
1241
|
...(removeBriefIds.length ? { removeBriefIds } : {}),
|
|
1236
1242
|
...(clipsTravel ? { clipBase: clipBase, ...(addClips.length ? { addClips: clone(addClips) } : {}) } : {}),
|
|
1237
|
-
// The memory travels whole when it exists: bounded at MEMORY_KEYS_MAX entries and
|
|
1238
|
-
// edited in place, so the change encodings above have nothing to save here.
|
|
1239
|
-
...(next.memory?.length ? { memory: clone(next.memory) } : {}),
|
|
1240
1243
|
...(next.advisory ? { advisory: clone(next.advisory) } : {}),
|
|
1241
1244
|
stateSha256: semanticStateSha256(next),
|
|
1242
1245
|
}, next.sessionId) as ActiveContextDeltaV2;
|
package/extensions/lib/policy.ts
CHANGED
|
@@ -34,14 +34,37 @@ export const ACTIVE_CONTEXT_STATE_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_P
|
|
|
34
34
|
export const ACTIVE_CONTEXT_FOLD_RECORD_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX}-fold-record`;
|
|
35
35
|
export const PROVIDER_CONTEXT_MEASUREMENT_ENTRY = `${DEFAULT_ENTRY_NAMESPACE}-provider-context-measurement`;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* THE NOTICE'S DEFAULT LEAD, as a share of the serving budget below the commit trigger
|
|
38
|
+
* (Shane, 2026-08-22: "around 10% from the commit threshold"; built 2026-08-30).
|
|
38
39
|
*
|
|
39
|
-
* A
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* A DISTANCE, NOT A POINT. Expressed as how far BELOW maxTarget the notice speaks, so it
|
|
41
|
+
* tracks a band that moves: a deployment that drops its trigger to 0.40 gets its notice
|
|
42
|
+
* at 0.30 without touching this, where an absolute 0.70 would have put the warning after
|
|
43
|
+
* the thing it warns about. The fire point is `maxTarget - noticeLeadShare`, floored at
|
|
44
|
+
* zero, so a lead wider than the trigger means "speak from the first measurement".
|
|
45
|
+
*
|
|
46
|
+
* IT REPLACED A COUNT. Until 2026-08-30 the notice fired at UNBRIEFED_FOLDS_BEFORE_NOTICE
|
|
47
|
+
* = 3 unbriefed pending folds, which is a batching size and not an occasion: the frontier
|
|
48
|
+
* cuts continuously and ungated by occupancy, so three folds stand within a few turns of
|
|
49
|
+
* a session's start and the notice spoke a long way from any commit. The share is the
|
|
50
|
+
* occasion, and the edge latch is what batches it: one notice per approach.
|
|
43
51
|
*/
|
|
44
|
-
export const
|
|
52
|
+
export const DEFAULT_NOTICE_LEAD_SHARE = 0.10;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The lead is a share of the serving budget, so it is bounded the way every other share
|
|
56
|
+
* on this surface is: strictly inside 0 and 1. Zero would mean the notice fires exactly
|
|
57
|
+
* at the trigger, which is the same pass the commit fires on and therefore never a
|
|
58
|
+
* warning; 1 and above is not a share.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveNoticeLeadShare(value: unknown): number {
|
|
61
|
+
if (value === undefined) return DEFAULT_NOTICE_LEAD_SHARE;
|
|
62
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 1) {
|
|
63
|
+
throw new Error("noticeLeadShare must be a share above 0 and below 1: how far below the " +
|
|
64
|
+
"commit trigger, as a fraction of the serving budget, the notice speaks");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
45
68
|
|
|
46
69
|
/**
|
|
47
70
|
* How many spans the frontier cuts on one projection pass.
|
|
@@ -91,9 +114,7 @@ export const RETIRED_TOOL_ACTIONS: Readonly<Record<string, string>> = Object.fre
|
|
|
91
114
|
"peek already carries losslessly. Write the brief you want when you mark the span, or " +
|
|
92
115
|
"use reboundary to re-cut it.",
|
|
93
116
|
});
|
|
94
|
-
export type ActiveContextToolAction =
|
|
95
|
-
| typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number]
|
|
96
|
-
| typeof WORKING_MEMORY_TOOL_ACTIONS[number];
|
|
117
|
+
export type ActiveContextToolAction = typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number];
|
|
97
118
|
export const USER_RESCUE_MAX_SOURCE_CHARS = 512_000;
|
|
98
119
|
export const DEFAULT_CONTEXT_WINDOW = 272_000;
|
|
99
120
|
export const EXPAND_LEASE_GENERATIONS = 8;
|
|
@@ -263,6 +284,22 @@ export function servingBudgetTokens(window: number): number {
|
|
|
263
284
|
|
|
264
285
|
export const MAX_PENDING_MARKS = 256;
|
|
265
286
|
export const ESTIMATED_BYTES_PER_TOKEN = 4;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* What one image in the window is worth, in tokens, for estimation only.
|
|
290
|
+
*
|
|
291
|
+
* A provider prices an image from its DIMENSIONS, not its byte size: Anthropic bills
|
|
292
|
+
* about width x height / 750, which puts a 2000x563 screenshot near 1,500 tokens, and a
|
|
293
|
+
* large image caps close to this number once the long edge is scaled to the 1568px limit.
|
|
294
|
+
* The exact figure differs per provider and cannot be known from the base64 alone.
|
|
295
|
+
*
|
|
296
|
+
* It does not need to be exact. The value it replaces was the image's base64 length
|
|
297
|
+
* divided by a prose chars-per-token ratio, which read one screenshot as 110,000 tokens
|
|
298
|
+
* against a true cost near 1,500. Being within an order of magnitude ends the failure;
|
|
299
|
+
* the provider anchor corrects the rest as soon as a real measurement lands. A constant
|
|
300
|
+
* rather than an option, because nobody can tune this better than the anchor can.
|
|
301
|
+
*/
|
|
302
|
+
export const IMAGE_ESTIMATED_TOKENS = 1_600;
|
|
266
303
|
export const ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES = 240;
|
|
267
304
|
|
|
268
305
|
export const PEEK_MIN_SLICE_BYTES = 1_024;
|
|
@@ -273,15 +310,35 @@ export const MAX_CONTEXT_RECEIPTS = 3;
|
|
|
273
310
|
export const CONTEXT_RECEIPT_BLOCK_BYTES = 900;
|
|
274
311
|
|
|
275
312
|
/**
|
|
276
|
-
* The
|
|
313
|
+
* The pre-commit notice's bound, and its own rather than the receipt block's.
|
|
277
314
|
*
|
|
278
|
-
* It is larger because it carries a variable-length list the receipt does not:
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
315
|
+
* It is larger because it carries a variable-length list the receipt does not: the fixed
|
|
316
|
+
* status and verb block plus one entry per staged fold, and the pending set grows past
|
|
317
|
+
* MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than a commit lands.
|
|
318
|
+
* 1,200 seated the old instruction and roughly eight folds; past the bound the list gives
|
|
319
|
+
* way and states how many it could not name.
|
|
283
320
|
*/
|
|
284
321
|
export const FOLD_NOTICE_BYTES = 2_400;
|
|
322
|
+
/**
|
|
323
|
+
* WHAT ONE /fold-editor ROW SUMMARIZES, and what one page of its detail serves
|
|
324
|
+
* (2026-08-30).
|
|
325
|
+
*
|
|
326
|
+
* The editor's whole reading of a message was 300 characters of its first text block,
|
|
327
|
+
* with a detail row that sliced to 480 and therefore could never bind. Two numbers replace
|
|
328
|
+
* it because they answer two different questions. EDITOR_ROW_PREVIEW_CHARS is a SUMMARY
|
|
329
|
+
* on a line that also carries a role, an id and a badge, so it is generous enough that a
|
|
330
|
+
* reader can tell two rows apart and no more. EDITOR_DETAIL_PAGE_CHARS is a READ, served
|
|
331
|
+
* a page at a time by an injected accessor with `total` and `hasMore` travelling beside
|
|
332
|
+
* it, so depth is bounded by the reader's patience rather than by a constant: there is no
|
|
333
|
+
* ceiling on what the editor can reach, only on what it hands over at once.
|
|
334
|
+
*
|
|
335
|
+
* THIS IS A READER'S BOUND, NOT A WINDOW'S. Nothing here reaches the projection: paging
|
|
336
|
+
* an entry to its end in the editor moves no bytes and the model never learns it
|
|
337
|
+
* happened. `maxSourceChars` 200,000 is the bound on a read that DOES reach the agent.
|
|
338
|
+
*/
|
|
339
|
+
export const EDITOR_ROW_PREVIEW_CHARS = 240;
|
|
340
|
+
export const EDITOR_DETAIL_PAGE_CHARS = 4_000;
|
|
341
|
+
|
|
285
342
|
export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
|
|
286
343
|
export const MAX_UNMARKED_CANDIDATES = 3;
|
|
287
344
|
|
|
@@ -310,34 +367,6 @@ export function peekIsEphemeral(params: Record<string, unknown> | undefined | nu
|
|
|
310
367
|
|
|
311
368
|
export const PEEK_READ_ONLY_CONTEXT_ACTIONS: ReadonlySet<string> = new Set(["status", "peek", "recall"]);
|
|
312
369
|
|
|
313
|
-
/**
|
|
314
|
-
* THE WORKING MEMORY (Shane, 2026-08-26). A session-scoped ordered dictionary the agent
|
|
315
|
-
* maintains beside the fold index: keys list in a table of contents the projection
|
|
316
|
-
* carries, bodies live in state and are read on demand with `recall`. It exists because
|
|
317
|
-
* the campaign measured a structural gap, not a tuning one: a maintained digest (running
|
|
318
|
-
* tallies, current totals) is a different operation from a lossless index, and blending
|
|
319
|
-
* digest prose into the index's briefs was measured making BOTH worse (sol-20260826-full2
|
|
320
|
-
* rep 4: nine confident wrong end-block answers against the deterministic arm's zero).
|
|
321
|
-
* The digest gets its own channel and the index stays mechanical.
|
|
322
|
-
*
|
|
323
|
-
* Three constants, not knobs. The key cap keeps the table of contents a table of
|
|
324
|
-
* contents; the body cap is deliberately larger than a brief because a digest is
|
|
325
|
-
* maintained prose, not an index row; the entry cap bounds the whole store at a size
|
|
326
|
-
* whose table of contents stays one glance.
|
|
327
|
-
*/
|
|
328
|
-
export const MEMORY_KEY_CHARS = 64;
|
|
329
|
-
export const MEMORY_BODY_CHARS = 4_000;
|
|
330
|
-
export const MEMORY_KEYS_MAX = 24;
|
|
331
|
-
|
|
332
|
-
export const WORKING_MEMORY_TOOL_ACTIONS = Object.freeze(["remember", "recall"] as const);
|
|
333
|
-
|
|
334
|
-
export interface WorkingMemoryEntry {
|
|
335
|
-
key: string;
|
|
336
|
-
body: string;
|
|
337
|
-
/** Write counter at last update; the table of contents lists freshest first. */
|
|
338
|
-
ordinal: number;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
370
|
/** "user" is a mark laid down by the human through /fold-editor; it stages and
|
|
342
371
|
* commits through the same validated path as an agent mark and counts toward the
|
|
343
372
|
* commit's coverage the same way. */
|
|
@@ -457,8 +486,6 @@ export interface ActiveContextState {
|
|
|
457
486
|
armed?: { milestone: AdvisoryMilestone; threshold: number; scheduleKey: string };
|
|
458
487
|
};
|
|
459
488
|
rider?: { epoch: number; text: string };
|
|
460
|
-
/** The working memory's entries, in write order; absent when empty. */
|
|
461
|
-
memory?: WorkingMemoryEntry[];
|
|
462
489
|
}
|
|
463
490
|
|
|
464
491
|
export interface FoldRecordRef {
|
|
@@ -488,7 +515,6 @@ export interface ActiveContextCheckpointV2 {
|
|
|
488
515
|
pendingMarks?: PendingMark[];
|
|
489
516
|
briefs?: Record<string, BriefOverride>;
|
|
490
517
|
clips?: ToolClip[];
|
|
491
|
-
memory?: WorkingMemoryEntry[];
|
|
492
518
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
493
519
|
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
494
520
|
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
@@ -530,11 +556,6 @@ export interface ActiveContextDeltaV2 {
|
|
|
530
556
|
clips?: ToolClip[];
|
|
531
557
|
clipBase?: number;
|
|
532
558
|
addClips?: ToolClip[];
|
|
533
|
-
// `memory` on a delta is the WHOLE array. It is bounded by MEMORY_KEYS_MAX entries and
|
|
534
|
-
// edited in place by key, so a change form would have to restate the key order the way
|
|
535
|
-
// the marks do while saving at most a handful of bounded bodies: the whole array is the
|
|
536
|
-
// cheaper wire. Absent means empty, by the same discriminator rule as the marks.
|
|
537
|
-
memory?: WorkingMemoryEntry[];
|
|
538
559
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
539
560
|
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
540
561
|
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { EvidenceRef } from "../json.ts";
|
|
2
|
+
import { boundedSubject, oneLine, seatSubjects, toolClipHead } from "./brief-text.ts";
|
|
3
|
+
export { oneLine, seatSubjects, toolClipHead };
|
|
2
4
|
import {
|
|
3
5
|
denseOwnArrayValues,
|
|
4
6
|
objectRefKey,
|
|
@@ -397,13 +399,6 @@ function assistantNoteText(message: unknown): string {
|
|
|
397
399
|
// "content continues in the exact source", and a reader never mistakes a cut
|
|
398
400
|
// value for a complete one. oneLine collapses whitespace then delegates;
|
|
399
401
|
// factualValue sanitizes then delegates through oneLine. No other site slices.
|
|
400
|
-
function boundedSubject(text: string, budget: number): string {
|
|
401
|
-
if (text.length <= budget) return text;
|
|
402
|
-
let kept = text.slice(0, Math.max(0, budget - 3)).trimEnd();
|
|
403
|
-
const finalCode = kept.charCodeAt(kept.length - 1);
|
|
404
|
-
if (finalCode >= 0xd800 && finalCode <= 0xdbff) kept = kept.slice(0, -1);
|
|
405
|
-
return `${kept}...`;
|
|
406
|
-
}
|
|
407
402
|
|
|
408
403
|
export function deterministicConsolidationBrief(
|
|
409
404
|
candidate: FoldCandidate,
|
|
@@ -467,29 +462,16 @@ export function deterministicConsolidationBrief(
|
|
|
467
462
|
const noteCount = notes.length;
|
|
468
463
|
const subjects = [...childSubjects, ...notes];
|
|
469
464
|
if (!subjects.length) return "Grouped completed context covering no readable folds.";
|
|
470
|
-
const separator = " | ";
|
|
471
465
|
const lead = noteCount
|
|
472
466
|
? `Grouped completed context covering ${foldCount} folds and ${noteCount} agent notes: `
|
|
473
467
|
: `Grouped completed context covering ${subjects.length} folds: `;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const budget = room - tail.length - separator.length * (named - 1);
|
|
482
|
-
const order = subjects.slice(0, named).map((text, index) => ({ text, index })).sort((a, b) =>
|
|
483
|
-
a.text.length - b.text.length || a.index - b.index);
|
|
484
|
-
const bounded = new Array<string>(named);
|
|
485
|
-
let left = budget;
|
|
486
|
-
for (let taken = 0; taken < order.length; taken += 1) {
|
|
487
|
-
const owed = Math.max(1, Math.floor(left / (order.length - taken)));
|
|
488
|
-
const kept = boundedSubject(order[taken].text, owed);
|
|
489
|
-
bounded[order[taken].index] = kept;
|
|
490
|
-
left -= kept.length;
|
|
491
|
-
}
|
|
492
|
-
return `${lead}${bounded.join(separator)}${tail}.`.replace(/\s+/g, " ").trim();
|
|
468
|
+
// With notes in the division, what the count-slice drops may be a note rather
|
|
469
|
+
// than a fold, so the tail only claims folds when folds are all it holds.
|
|
470
|
+
return seatSubjects(subjects, lead, {
|
|
471
|
+
total: ACTIVE_CONTEXT_POLICY.maxBriefChars,
|
|
472
|
+
minSubjectChars: MIN_SUBJECT_CHARS,
|
|
473
|
+
omittedNoun: noteCount ? "in this group" : "folds in this group",
|
|
474
|
+
});
|
|
493
475
|
}
|
|
494
476
|
|
|
495
477
|
export const ALL_FOLD_KINDS: ReadonlySet<FoldKind> =
|
|
@@ -1106,23 +1088,6 @@ export function manualFoldCandidate(
|
|
|
1106
1088
|
// blank line is kept whole up to the cap; a result that is bulk from line one keeps its
|
|
1107
1089
|
// first cap's worth. The caller states the cut, per gate 136: the marker rides beside
|
|
1108
1090
|
// 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
|
-
|
|
1123
|
-
export function oneLine(value: string, maximum: number): string {
|
|
1124
|
-
return boundedSubject(value.replace(/\s+/g, " ").trim(), maximum);
|
|
1125
|
-
}
|
|
1126
1091
|
|
|
1127
1092
|
export function deterministicChapterBrief(
|
|
1128
1093
|
refs: EvidenceRef[],
|