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.
@@ -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
- validateFoldForest(state.folds);
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
- output.push(clone(snapshot.messages[index]));
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" | "memory"
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;
@@ -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
- * How many unbriefed pending folds stand before the runtime says so.
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 CONSTANT, not a threshold the deployment can turn (Shane's KISS rule): it is a
40
- * batching size, and the only thing a knob here would buy is a deployment that never
41
- * speaks. Three is the smallest batch that reads as a batch rather than as a running
42
- * commentary on every cut.
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 UNBRIEFED_FOLDS_BEFORE_NOTICE = 3;
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 post-fold notice's bound, and its own rather than the receipt block's.
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: three
279
- * fixed sentences of instruction plus one entry per unbriefed fold, and the pending set
280
- * grows past MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than the
281
- * agent answers. 1,200 seats the instruction and roughly eight folds; past that the list
282
- * gives way and states how many it could not name.
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
- const room = ACTIVE_CONTEXT_POLICY.maxBriefChars - lead.length - 1;
475
- const named = Math.max(1, Math.min(subjects.length,
476
- Math.floor((room + separator.length) / (MIN_SUBJECT_CHARS + separator.length))));
477
- const omitted = subjects.length - named;
478
- // With notes in the division, what the slice drops may be a note rather than
479
- // a fold, so the tail only claims folds when folds are all it holds.
480
- const tail = omitted ? `${separator}${omitted} more ${noteCount ? "in this group" : "folds in this group"}` : "";
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[],