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.
@@ -46,6 +46,7 @@ import {
46
46
  explicitProtectedMass,
47
47
  budgetOccupancy,
48
48
  hardFenceRatio,
49
+ imageMass,
49
50
  latestProviderContextMeasurement,
50
51
  orderedRoots,
51
52
  parseNativeCompactionCompletion,
@@ -91,7 +92,9 @@ import {
91
92
  activeContextSource,
92
93
  ACTIVE_CONTEXT_POLICY,
93
94
  ACTIVE_CONTEXT_TOOL_ACTIONS,
94
- UNBRIEFED_FOLDS_BEFORE_NOTICE,
95
+ resolveNoticeLeadShare,
96
+ EDITOR_DETAIL_PAGE_CHARS,
97
+ EDITOR_ROW_PREVIEW_CHARS,
95
98
  contextBrand,
96
99
  DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
97
100
  DEFAULT_ACTIVE_CONTEXT_COMMAND_NAMES,
@@ -105,6 +108,7 @@ import {
105
108
  resolveThresholds,
106
109
  servingBudgetTokens,
107
110
  ESTIMATED_BYTES_PER_TOKEN,
111
+ IMAGE_ESTIMATED_TOKENS,
108
112
  entryTypeNamespace,
109
113
  MAX_FOLD_SPAN_CHARS,
110
114
  DEFAULT_TOOL_FOLD_THRESHOLD,
@@ -116,10 +120,6 @@ import {
116
120
  RETIRED_TOOL_ACTIONS,
117
121
  AUTO_FOLD_BLACKLIST_DEFAULT,
118
122
  USER_RESCUE_MAX_SOURCE_CHARS,
119
- MEMORY_BODY_CHARS,
120
- MEMORY_KEY_CHARS,
121
- MEMORY_KEYS_MAX,
122
- WORKING_MEMORY_TOOL_ACTIONS,
123
123
  } from "./lib/policy.ts";
124
124
  import type {
125
125
  ActiveContextSnapshot,
@@ -131,7 +131,6 @@ import type {
131
131
  FoldKind,
132
132
  FoldRecordEntry,
133
133
  PreparedFold,
134
- WorkingMemoryEntry,
135
134
  } from "./lib/policy.ts";
136
135
  import {
137
136
  absorbWedgeMarks,
@@ -140,7 +139,7 @@ import {
140
139
  import {
141
140
  contextReceipt,
142
141
  foldNoticeText,
143
- memoryTocText,
142
+ foldStatusText,
144
143
  receiptBlockText,
145
144
  withReceipt,
146
145
  } from "./lib/curation.ts";
@@ -174,6 +173,7 @@ import {
174
173
  } from "./lib/scheduling.ts";
175
174
  import {
176
175
  manualFoldCandidate,
176
+ oneLine,
177
177
  peekedSourceFoldIds,
178
178
  snapFoldCandidate,
179
179
  snapToFoldBoundaries,
@@ -197,7 +197,9 @@ import type {
197
197
  } from "./lib/instrumentation.ts";
198
198
  import { buildActiveContextCommands, buildActiveContextTool } from "./lib/tool-surface.ts";
199
199
  import { buildFoldEditorData, FoldEditorView } from "./lib/editor-ui.ts";
200
+ import { publishLiveSettings } from "./lib/live-settings.ts";
200
201
  import {
202
+ entryText,
201
203
  mapActiveContext,
202
204
  } from "./lib/transcript.ts";
203
205
  import {
@@ -209,12 +211,13 @@ import {
209
211
  unansweredToolCalls,
210
212
  } from "./lib/rollback.ts";
211
213
 
212
- export type ProjectionReadingBasis = "anchored" | "rewritten" | "unmeasured";
214
+ export type ProjectionReadingBasis = "anchored" | "rewritten" | "unmeasured" | "refused";
213
215
 
214
216
  export * from "./lib/canonical.ts";
215
217
  export * from "./lib/curation.ts";
216
218
  export * from "./lib/folding.ts";
217
219
  export * from "./lib/instrumentation.ts";
220
+ export * from "./lib/live-settings.ts";
218
221
  export * from "./lib/measurement.ts";
219
222
  export * from "./lib/persistence.ts";
220
223
  export * from "./lib/policy.ts";
@@ -223,6 +226,47 @@ export * from "./lib/scheduling.ts";
223
226
  export * from "./lib/selection.ts";
224
227
  export * from "./lib/transcript.ts";
225
228
 
229
+ /**
230
+ * The two independent scalars, resolved in ONE place because they are resolved TWICE:
231
+ * once from the registration options and once from every /fold-settings edit that
232
+ * reaches the running session. Absent means the package default in both directions,
233
+ * which is the settings file's own law, and the refusal text is the same sentence
234
+ * wherever the bad value came from.
235
+ */
236
+ /**
237
+ * ONE ENTRY AS ONE ROW: whitespace collapsed, bounded, and the cut STATED.
238
+ *
239
+ * Shared by the three places /fold-editor builds an entry row, which each carried their
240
+ * own inline reading of `content` until 2026-08-30 and each got it wrong the same way:
241
+ * `content.find((part) => part.type === "text")` sliced to 300 characters, so a message
242
+ * whose first block was not text rendered as an empty row and everything past the first
243
+ * block was unreachable at any depth. `entryText` accounts for every block and
244
+ * `entryDetail` serves the rest; this is the summary that sits on the row.
245
+ */
246
+ function rowPreview(message: unknown): string {
247
+ return oneLine(entryText(message), EDITOR_ROW_PREVIEW_CHARS);
248
+ }
249
+
250
+ function resolvedPreCommitNotice(value: unknown): boolean {
251
+ if (value !== undefined && typeof value !== "boolean") {
252
+ throw new Error("preCommitNotice must be a boolean: true states the window's status to " +
253
+ "the agent once per approach to a commit, false ships every fold with the runtime's " +
254
+ "deterministic brief and puts no carrier in the projection at all");
255
+ }
256
+ return value ?? false;
257
+ }
258
+
259
+ function resolvedToolFoldThreshold(value: unknown): number {
260
+ const resolved = value ?? DEFAULT_TOOL_FOLD_THRESHOLD;
261
+ if (typeof resolved !== "number" || !Number.isFinite(resolved) ||
262
+ resolved < 0 || resolved >= 1) {
263
+ throw new Error("toolFoldThreshold must be a share in [0, 1): it names the oldest " +
264
+ "fraction of the projected window whose tool results are clipped in view at each " +
265
+ "commit, and 0 turns the diet off");
266
+ }
267
+ return resolved;
268
+ }
269
+
226
270
  export function registerActiveContext(pi: any, options: {
227
271
  toolName?: string;
228
272
  toolLabel?: string;
@@ -239,21 +283,21 @@ export function registerActiveContext(pi: any, options: {
239
283
  * [0, 1), where 0 turns the diet off; 0.50 is what every fold repetition from 3 onward
240
284
  * ran, so the default is the value that was measured rather than the absence of one. */
241
285
  toolFoldThreshold?: number;
242
- /** THE INVITATION SWITCH (public since 2026-08-27; DEFAULT FALSE since 2026-08-28,
243
- * Shane, on the sol-20260826-full2 verdict). false is the deterministic shape the
244
- * campaign measured and the one it recommends: no carrier ever invites a brief and
245
- * every fold goes out with the runtime's own words, which drew 14, 14, 9 and 8 correct
246
- * against the invited condition's 3. true restores the standing invitation for the
247
- * agent to improve a brief. Either way the agent verbs stay on the tool, so an agent
248
- * that finds a fold can still annotate what it reads. */
249
- postFoldNotice?: boolean;
250
- /** THE WORKING MEMORY (Shane, 2026-08-26): a session-scoped ordered dictionary the
251
- * agent maintains beside the fold index. `remember` writes or removes an entry,
252
- * `recall` reads bodies on demand, and the projection carries one table of contents
253
- * that refreshes at each commit. Off when absent: the digest channel earned its
254
- * place in a measured gap (running tallies lost 0/6 against native's 6/6 while the
255
- * index won everything else), and it must stay separable from the index it is not. */
256
- workingMemory?: boolean;
286
+ /** THE NOTICE SWITCH (public since 2026-08-27 as postFoldNotice; DEFAULT FALSE since
287
+ * 2026-08-28, Shane, on the sol-20260826-full2 verdict; RESHAPED from a brief
288
+ * solicitation into a status statement on 2026-08-30). false is the deterministic
289
+ * shape the campaign measured: no carrier reaches the projection at all and every fold
290
+ * goes out with the runtime's own words, which drew 14, 14, 9 and 8 correct against
291
+ * the soliciting condition's 3. true states the window's status once per approach to a
292
+ * commit: where the commit fires, how far away it is, what is staged, what that frees,
293
+ * what is pinned and what the pin costs, with the verbs listed and no argument made
294
+ * for any of them. Either way the agent verbs stay on the tool. */
295
+ preCommitNotice?: boolean;
296
+ /** HOW FAR BEFORE THE COMMIT THE NOTICE SPEAKS (2026-08-30, Shane), as a share of the
297
+ * serving budget below maxTarget; default DEFAULT_NOTICE_LEAD_SHARE 0.10. A DISTANCE
298
+ * rather than a point, so it tracks a band that moves. Read only when preCommitNotice
299
+ * is true. */
300
+ noticeLeadShare?: number;
257
301
  }): {
258
302
  projectionCandidates: (ctx: any) => Array<Record<string, unknown>>;
259
303
  } {
@@ -262,23 +306,27 @@ export function registerActiveContext(pi: any, options: {
262
306
  const brandNoun = options.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN;
263
307
  const entryTypePrefix = options.entryTypePrefix ?? DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX;
264
308
  const blacklistAutoFoldTools = options.blacklistAutoFoldTools ?? AUTO_FOLD_BLACKLIST_DEFAULT;
265
- if (options.postFoldNotice !== undefined && typeof options.postFoldNotice !== "boolean") {
266
- throw new Error("postFoldNotice must be a boolean: true keeps the post-fold invitation " +
267
- "notice, false ships every fold with the runtime's deterministic brief and no " +
268
- "carrier inviting the agent to improve it");
269
- }
270
- const postFoldNotice = options.postFoldNotice ?? false;
271
- if (options.workingMemory !== undefined && typeof options.workingMemory !== "boolean") {
272
- throw new Error("workingMemory must be a boolean: it enables the session-scoped " +
273
- "working-memory dictionary and its two actions, remember and recall");
274
- }
275
- const workingMemory = options.workingMemory === true;
276
- const toolFoldThreshold = options.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD;
277
- if (typeof toolFoldThreshold !== "number" || !Number.isFinite(toolFoldThreshold) ||
278
- toolFoldThreshold < 0 || toolFoldThreshold >= 1) {
279
- throw new Error("toolFoldThreshold must be a share in [0, 1): it names the oldest " +
280
- "fraction of the projected window whose tool results are clipped in view at each " +
281
- "commit, and 0 turns the diet off");
309
+ // THESE THREE ARE THE SETTINGS SURFACE, AND THEY ARE NOT CONSTANTS (2026-08-29). Each
310
+ // was resolved once here and read from the closure ever after, so a person editing
311
+ // /fold-settings changed the file and nothing else while the session kept the values
312
+ // pi booted with. They are `let` so the live applier published below can replace them,
313
+ // and every reader takes the variable rather than a copy, so a swap reaches the next
314
+ // snapshot, the next commit decision and the status line without anything else moving.
315
+ let preCommitNotice = resolvedPreCommitNotice(options.preCommitNotice);
316
+ let noticeLeadShare = resolveNoticeLeadShare(options.noticeLeadShare);
317
+ let toolFoldThreshold = resolvedToolFoldThreshold(options.toolFoldThreshold);
318
+ // THE RENAMED OPTION IS REFUSED BY NAME, not ignored (2026-08-30). postFoldNotice was
319
+ // public for three days and named a carrier that no longer exists in that shape: it
320
+ // fired on a fold count, after the cut, and asked for briefs. Silently accepting the
321
+ // old name would run the new mechanism under a description of the old one, and
322
+ // silently DROPPING it would turn a deployment that deliberately chose the invitation
323
+ // into a silent one without saying so.
324
+ if (Object.hasOwn(options, "postFoldNotice")) {
325
+ throw new Error("postFoldNotice is now preCommitNotice, and the carrier it switches has " +
326
+ "changed: it fires on occupancy approaching the commit rather than on a count of " +
327
+ "unbriefed folds, and it STATES the window's status rather than asking for briefs. " +
328
+ "The default is still false. noticeLeadShare sets how far ahead of the commit it " +
329
+ "speaks");
282
330
  }
283
331
  for (const removed of ["foldScheduling", "foldPeekResults", "toolActions"]) {
284
332
  if (Object.hasOwn(options, removed)) {
@@ -338,14 +386,13 @@ export function registerActiveContext(pi: any, options: {
338
386
  commandNames.status === commandNames.fold) {
339
387
  throw new Error("Active-context command names must be distinct kebab-case strings");
340
388
  }
341
- const thresholds = resolveThresholds(options.thresholds);
389
+ let thresholds = resolveThresholds(options.thresholds);
342
390
  assertThresholdsServable(thresholds, providerInputBudget ?? servingBudgetTokens(DEFAULT_CONTEXT_WINDOW));
343
391
  const stateEntryType = `${entryTypePrefix}-state`;
344
392
  const foldRecordEntryType = `${entryTypePrefix}-fold-record`;
345
393
  const milestoneProjectionType = `${entryTypePrefix}-milestone`;
346
394
  const advisoryProjectionType = `${entryTypePrefix}-advisory`;
347
395
  const foldNoticeProjectionType = `${entryTypePrefix}-fold-notice`;
348
- const memoryTocProjectionType = `${entryTypePrefix}-memory-toc`;
349
396
  const receiptProjectionType = `${entryTypePrefix}-receipts`;
350
397
  const curationProjectionType = `${entryTypePrefix}-curation`;
351
398
  const entryNamespace = entryTypeNamespace(entryTypePrefix);
@@ -353,9 +400,7 @@ export function registerActiveContext(pi: any, options: {
353
400
  const nativeReceiptEntryType = `${entryNamespace}-native-compaction-receipt`;
354
401
  const contextEventEntryType = `${entryNamespace}-context-event`;
355
402
  const nativeDecisionEntryType = `${entryNamespace}-native-compaction-decision`;
356
- const allowedToolActions: readonly ActiveContextToolAction[] = workingMemory
357
- ? [...ACTIVE_CONTEXT_TOOL_ACTIONS, ...WORKING_MEMORY_TOOL_ACTIONS]
358
- : ACTIVE_CONTEXT_TOOL_ACTIONS;
403
+ const allowedToolActions: readonly ActiveContextToolAction[] = ACTIVE_CONTEXT_TOOL_ACTIONS;
359
404
  const allowedToolActionSet = new Set<string>(allowedToolActions);
360
405
 
361
406
  type AutomaticFailureState = {
@@ -382,6 +427,9 @@ export function registerActiveContext(pi: any, options: {
382
427
  bandTopMeasurement: null as unknown,
383
428
  /** The snapshot-and-state key whose frontier scan came back empty; see advanceFoldFrontier. */
384
429
  frontierEmptyKey: null as string | null,
430
+ /** The commit count the pre-commit notice last spoke at; null while it is re-armed.
431
+ * One notice per approach, and a landed commit begins the next approach. */
432
+ noticeLatch: null as number | null,
385
433
  pendingManual: false,
386
434
  preparing: null as { id: string; controller: AbortController; promise: Promise<void> } | null,
387
435
  lastPreparationError: null as string | null,
@@ -401,6 +449,7 @@ export function registerActiveContext(pi: any, options: {
401
449
  lastProviderMeasurement: null as ProviderContextMeasurement | null,
402
450
  descriptorWindow: null as number | null,
403
451
  lastProjectedChars: null as number | null,
452
+ lastProjectedImages: 0,
404
453
  projectionCalibrations: [] as Array<{ chars: number; tokens: number }>,
405
454
  lastProjectedEstimate: null as number | null,
406
455
  lastProjectedEstimateCalibrated: false,
@@ -409,11 +458,22 @@ export function registerActiveContext(pi: any, options: {
409
458
  projectionAnchor: null as {
410
459
  tokens: number;
411
460
  chars: number;
461
+ /** `chars` less the base64 of any image, which is what the delta is taken on. */
462
+ textChars: number;
463
+ /** Images inside the anchored prefix, each worth IMAGE_ESTIMATED_TOKENS. */
464
+ images: number;
412
465
  head: string;
413
466
  messageSha256: string;
414
467
  sessionId: string;
415
468
  generation: number;
416
469
  } | null,
470
+ // A REFUSED ANCHOR IS NOT AN ABSENT ONE (2026-08-30). The plausibility floor below
471
+ // rejects a provider count the projected TEXT cannot explain, which is the earliest
472
+ // and cheapest evidence that what we project is not what reaches the wire. It set
473
+ // `projectionAnchor = null` and said nothing, and the reading then called itself
474
+ // "unmeasured", the same word a session uses before its first provider response.
475
+ // Those are opposite states: one has no number yet, the other has one it disbelieves.
476
+ anchorRefusal: null as { consecutive: number } | null,
417
477
  estimatorErrors: [] as number[],
418
478
  inflowSteps: [] as number[],
419
479
  wallInflowSteps: [] as number[],
@@ -428,6 +488,10 @@ export function registerActiveContext(pi: any, options: {
428
488
  ledger: emptyLedger(),
429
489
  previousDigests: null as string[] | null,
430
490
  previousText: null as string | null,
491
+ // The image mass of `previousText`, kept beside it because the provider anchor is
492
+ // built from that text and has no access to the array it came from.
493
+ previousImages: 0,
494
+ previousImageChars: 0,
431
495
  lastChange: "append" as ProjectionChange,
432
496
  lastPreservedShare: null as number | null,
433
497
  sinceHandoff: [] as Array<{
@@ -473,6 +537,8 @@ export function registerActiveContext(pi: any, options: {
473
537
  const curation = {
474
538
  receipts: [] as ContextReceipt[],
475
539
  contextCalls: 0,
540
+ /** Receipts delivered this session, monotonic; the pre-commit notice's re-arm signal. */
541
+ commitsDelivered: 0,
476
542
  wallEpisodeOpen: false,
477
543
  recoveryAttempts: 0,
478
544
  pendingRejection: null as { status: number; ordinal: number } | null,
@@ -490,12 +556,6 @@ export function registerActiveContext(pi: any, options: {
490
556
  pendingOverflow: null as { at: number; entryId: string | null } | null,
491
557
  };
492
558
 
493
- const advisory = {
494
- hardFenceNoticeKey: null as string | null,
495
- hardFenceReleaseSessionId: null as string | null,
496
- hardFenceReleasedProjectionKeys: new Set<string>(),
497
- };
498
-
499
559
  // EPHEMERAL PEEK (Build 4b): tool call ids whose peek asked for one-read
500
560
  // visibility. In-memory ON PURPOSE: a restart forgets the registry and the
501
561
  // result stays durable, which is the safe default, and the durable entry
@@ -606,6 +666,10 @@ export function registerActiveContext(pi: any, options: {
606
666
 
607
667
  const deliverReceipt = (receipt: ContextReceipt): void => {
608
668
  curation.receipts = withReceipt(curation.receipts, receipt);
669
+ // MONOTONIC, unlike `curation.receipts.length`, which saturates at MAX_CONTEXT_RECEIPTS
670
+ // and would stop distinguishing one approach from the next once the session is long
671
+ // enough to matter. This is the only counter the notice latch reads.
672
+ curation.commitsDelivered += 1;
609
673
  emit("context.receipt", {
610
674
  receipt_kind: receipt.kind,
611
675
  trigger: receipt.trigger,
@@ -632,18 +696,125 @@ export function registerActiveContext(pi: any, options: {
632
696
  lifecycle.generation === expectedGeneration && persistence.state?.sessionId === sessionId &&
633
697
  (!ctx || contextSessionMatches(ctx, sessionId));
634
698
 
699
+ /**
700
+ * THE ONE LINE THAT IS ALWAYS ON SCREEN, so it answers the question a person actually
701
+ * has: how full am I, and is anything about to happen.
702
+ *
703
+ * It used to read `pi_fold_context folds: 3 · provider 187234/272000`. That led with the
704
+ * AGENT TOOL'S NAME, which a person cannot call and does not care about, and then spent
705
+ * its remaining width on two raw counts they would have to divide themselves. The brand
706
+ * leads now, occupancy is a percentage of the serving budget, and the commit point is
707
+ * named only when it is close enough to matter.
708
+ */
635
709
  const updateStatus = (ctx: any): void => {
636
710
  try {
637
711
  const roots = persistence.state && lifecycle.latestSnapshot ? orderedRoots(persistence.state, lifecycle.latestSnapshot).length : 0;
638
- const prepared = persistence.state?.prepared ? " · brief ready" : ladder.preparing ? " · briefing" : "";
639
- const usage = measurements.lastProviderMeasurement
640
- ? ` · provider ${measurements.lastProviderMeasurement.tokens}/${measurements.lastProviderMeasurement.contextWindow}`
641
- : " · provider usage unmeasured";
642
- const suspended = ladder.automaticFailure ? " · automatic suspended" : "";
643
- ctx.ui?.setStatus?.(entryTypePrefix, `${toolName} folds: ${roots}${prepared}${usage}${suspended}`);
712
+ const staged = persistence.state ? pendingMarks(persistence.state).length : 0;
713
+ const capacity = currentCapacity(ctx);
714
+ const used = measurements.lastProviderMeasurement?.tokens ?? null;
715
+ const share = used !== null && capacity.budgetTokens > 0 ? used / capacity.budgetTokens : null;
716
+ const parts: string[] = [];
717
+ // FAILURE LEADS. This line is cut to whatever width the host gives it, so the last
718
+ // segment is the first one a narrow terminal removes, and folding having stopped is
719
+ // the one state on this line a person has to act on. It was appended last.
720
+ if (ladder.automaticFailure) parts.push("FOLDING STOPPED");
721
+ // Unmeasured is stated, never guessed: an estimate rendered as a fact is the habit
722
+ // that cost a live session, and this line is the most-read surface there is.
723
+ parts.push(share === null ? "not measured" : `${Math.round(share * 100)}% full`);
724
+ // THE LIVE BAND, NOT THE LAST SNAPSHOT'S (2026-08-29). This read the threshold off
725
+ // `lifecycle.latestSnapshot`, which is derived per context event, so a band changed
726
+ // in /fold-settings went on being announced at its old value until the next event
727
+ // rebuilt a snapshot. The number this line owes a person is the one that will fire,
728
+ // and that is the runtime's own variable.
729
+ const trigger = thresholds.maxTarget;
730
+ // WHEN, not just how full. "68% full" only answers the second question a reader has
731
+ // if they already remember where the trigger sits; naming it answers both.
732
+ //
733
+ // NOT WHILE STOPPED, though: the line rendered "COMMIT DUE" and "FOLDING STOPPED"
734
+ // in one breath, promising the thing it was announcing would not happen.
735
+ // DUE MEANS ABOUT TO HAPPEN, NOT MERELY OVER THE LINE (2026-08-30). This read
736
+ // `share >= trigger`, which is true of the BAND and says nothing about the
737
+ // DECISION. A band-top commit runs ONCE per provider count, so once it has run
738
+ // against the current one, nothing further will happen until a new count arrives,
739
+ // whatever it applied. Live session 01a052b1 sat on `COMMIT DUE` through 48
740
+ // consecutive evaluations that all declined for a recorded reason, and a person
741
+ // reasonably read a line promising an imminent commit as a runtime that had hung.
742
+ //
743
+ // The latch is the whole test and no new state is kept: it already holds the
744
+ // measurement the last band-top commit ran against, for the cache reason documented
745
+ // at its own site, and "a commit has already been weighed against this count" is
746
+ // exactly what a reader needs. Held is lower case because it is not urgent; DUE
747
+ // keeps its shout because it is the one that is about to cost a rewrite.
748
+ if (share !== null && !ladder.automaticFailure) {
749
+ const weighed = ladder.bandTopMeasurement !== null &&
750
+ ladder.bandTopMeasurement === measurements.lastProviderMeasurement;
751
+ parts.push(share < trigger
752
+ ? `commit at ${Math.round(trigger * 100)}%`
753
+ : weighed ? "commit held" : "COMMIT DUE");
754
+ }
755
+ if (staged > 0) parts.push(`${staged} staged`);
756
+ if (roots > 0) parts.push(`${roots} fold${roots === 1 ? "" : "s"}`);
757
+ ctx.ui?.setStatus?.(entryTypePrefix, `${brandNoun} ${parts.join(" · ")}`);
644
758
  } catch { }
645
759
  };
646
760
 
761
+ /**
762
+ * WHAT A PERSON SAVES IN /fold-settings REACHES THIS SESSION (2026-08-29).
763
+ *
764
+ * The screen writes the file and then hands the whole file here, so an absent field
765
+ * means the package default exactly as it does on disk and at registration; all four
766
+ * values are resolved through the same functions the options went through, which is
767
+ * what stops a value meaning one thing at boot and another at an edit.
768
+ *
769
+ * ATOMIC: every value is validated before any is assigned, so a refusal leaves the
770
+ * session on the settings it already had rather than half-moved. It throws the way
771
+ * registration throws, and the caller decides what that means; the settings screen
772
+ * catches it, because a screen may not take the session down with it.
773
+ *
774
+ * The memo is dropped because `authoritativeSnapshotFor` keys on session, entries and
775
+ * messages, none of which move when a threshold does, so a cached snapshot would serve
776
+ * the old band to the next reader that asked.
777
+ */
778
+ publishLiveSettings(pi, (settings, ctx) => {
779
+ const nextThresholds = resolveThresholds(settings?.thresholds);
780
+ assertThresholdsServable(nextThresholds,
781
+ providerInputBudget ?? servingBudgetTokens(DEFAULT_CONTEXT_WINDOW));
782
+ const nextToolFoldThreshold = resolvedToolFoldThreshold(settings?.toolFoldThreshold);
783
+ const nextPreCommitNotice = resolvedPreCommitNotice(settings?.preCommitNotice);
784
+ const nextNoticeLeadShare = resolveNoticeLeadShare(settings?.noticeLeadShare);
785
+ const changed: string[] = [];
786
+ for (const field of ["maxTarget", "minTarget", "consolidateAfter", "minFoldChars"] as const) {
787
+ if (nextThresholds[field] !== thresholds[field]) changed.push(field);
788
+ }
789
+ if (nextToolFoldThreshold !== toolFoldThreshold) changed.push("toolFoldThreshold");
790
+ if (nextPreCommitNotice !== preCommitNotice) changed.push("preCommitNotice");
791
+ if (nextNoticeLeadShare !== noticeLeadShare) changed.push("noticeLeadShare");
792
+ // A SAVE THAT MOVED NOTHING IS NOT AN EVENT. The screen saves every keystroke that
793
+ // lands, including a row stepped back to where it started.
794
+ if (!changed.length) return;
795
+ thresholds = nextThresholds;
796
+ toolFoldThreshold = nextToolFoldThreshold;
797
+ preCommitNotice = nextPreCommitNotice;
798
+ noticeLeadShare = nextNoticeLeadShare;
799
+ dropDerivationMemo();
800
+ // THE STREAM CARRIES IT, because from here on the session's commits fire at a point
801
+ // no earlier record explains, and an archive reading this run later has no other way
802
+ // to know the band moved under it.
803
+ emit("context.settings", {
804
+ changed,
805
+ max_target: thresholds.maxTarget,
806
+ min_target: thresholds.minTarget,
807
+ consolidate_after: thresholds.consolidateAfter,
808
+ min_fold_chars: thresholds.minFoldChars,
809
+ tool_fold_threshold: toolFoldThreshold,
810
+ pre_commit_notice: preCommitNotice,
811
+ notice_lead_share: noticeLeadShare,
812
+ });
813
+ // The person is standing in the settings screen, so the line they will look at next
814
+ // is the status line. Without this it keeps the old number until a context event.
815
+ if (ctx) { try { updateStatus(ctx); } catch { } }
816
+ });
817
+
647
818
  const budgetWindowFor = (ctx: any): number | null => {
648
819
  measurements.descriptorWindow = contextWindowFor(ctx);
649
820
  return providerInputBudget ?? measurements.descriptorWindow;
@@ -767,6 +938,10 @@ export function registerActiveContext(pi: any, options: {
767
938
  measurements.lastProviderMeasurement = null;
768
939
  measurements.wallInflowSteps.length = 0;
769
940
  measurements.projectionAnchor = null;
941
+ // A RESET IS NOT A RESTORATION. The refusal is dropped without a record because the
942
+ // session it described is gone; emitting "restored" here would report a recovery that
943
+ // never happened.
944
+ measurements.anchorRefusal = null;
770
945
  measurements.lastProjectedEstimateBasis = "unmeasured";
771
946
  ladder.pendingManual = false;
772
947
  if (!preserveThresholdDecision) nativeCompaction.lastThresholdDecision = null;
@@ -779,6 +954,8 @@ export function registerActiveContext(pi: any, options: {
779
954
  instrumentation.ledger = emptyLedger();
780
955
  instrumentation.previousDigests = null;
781
956
  instrumentation.previousText = null;
957
+ instrumentation.previousImages = 0;
958
+ instrumentation.previousImageChars = 0;
782
959
  instrumentation.requests = 0;
783
960
  instrumentation.lastMutationRequest = 0;
784
961
  instrumentation.lastMutationTokens = null;
@@ -793,16 +970,11 @@ export function registerActiveContext(pi: any, options: {
793
970
  curation.lastRecovery = null;
794
971
  ladder.lastAutomaticAction = null;
795
972
  ladder.automaticFailure = null;
796
- advisory.hardFenceNoticeKey = null;
797
973
  measurements.providerMeasurementReceipts.clear();
798
974
  measurements.providerMeasurementRevisionByMessageSha.clear();
799
975
  measurements.providerMeasurementByMessageSha.clear();
800
976
  measurements.providerMeasurementAnchorByMessageSha.clear();
801
977
  const sessionId = ctx.sessionManager.getSessionId();
802
- if (advisory.hardFenceReleaseSessionId !== sessionId) {
803
- advisory.hardFenceReleaseSessionId = sessionId;
804
- advisory.hardFenceReleasedProjectionKeys.clear();
805
- }
806
978
  let restored: ActiveContextState | null = null;
807
979
  let restoreError: unknown = null;
808
980
  let restoredPersistence: MaterializedStatePersistence | null = null;
@@ -1318,54 +1490,6 @@ export function registerActiveContext(pi: any, options: {
1318
1490
  safeNotify(ctx, `Automatic context management suspended: ${message}`, "warning");
1319
1491
  };
1320
1492
 
1321
- const abortUnsafeHardContext = (
1322
- snapshot: ActiveContextSnapshot | null,
1323
- ctx: any,
1324
- allowUnmeasuredRevisionRelease = false,
1325
- ): boolean => {
1326
- if (measurements.latestRatio === null || measurements.latestRatio < hardFenceRatio(snapshot ?? undefined, ctx) || !persistence.state) return false;
1327
- const measuredRevision = measurements.lastProviderMeasurement
1328
- ? measurements.providerMeasurementRevisionByMessageSha.get(measurements.lastProviderMeasurement.messageSha256)
1329
- : undefined;
1330
- if (allowUnmeasuredRevisionRelease && !ladder.automaticFailure &&
1331
- measuredRevision !== undefined && measurements.lastProviderMeasurement &&
1332
- !durableProviderMeasurementMatches(measurements.lastProviderMeasurement)) {
1333
- const releaseKey = sha256Value({
1334
- sessionId: persistence.state.sessionId,
1335
- topologySha256: topologySha256(persistence.state),
1336
- protectionSha256: protectionSha256(persistence.state),
1337
- measuredRevision,
1338
- providerMessageSha256: measurements.lastProviderMeasurement?.messageSha256 ?? null,
1339
- });
1340
- if (!advisory.hardFenceReleasedProjectionKeys.has(releaseKey) &&
1341
- advisory.hardFenceReleasedProjectionKeys.size < 4_096) {
1342
- advisory.hardFenceReleasedProjectionKeys.add(releaseKey);
1343
- return false;
1344
- }
1345
- }
1346
- const key = ladder.automaticFailure?.key ?? sha256Value({
1347
- sessionId: snapshot?.sessionId ?? persistence.state.sessionId,
1348
- revision: persistence.state.revision,
1349
- providerMessageSha256: measurements.lastProviderMeasurement?.messageSha256 ?? null,
1350
- phase: "hard-provider-fence",
1351
- });
1352
- ladder.pendingContextNote = `Provider context reached the hard ${brandNoun} fence without a newly committed lossless fold. ` +
1353
- "The provider request was aborted before transmission; run /compact or make an explicit bounded context fold.";
1354
- if (advisory.hardFenceNoticeKey !== key) {
1355
- advisory.hardFenceNoticeKey = key;
1356
- safeNotify(
1357
- ctx,
1358
- "Provider request aborted at the hard context fence; run /compact or make an explicit bounded context fold.",
1359
- "error",
1360
- );
1361
- }
1362
- if (typeof ctx.abort !== "function") {
1363
- throw new Error(`Pi hard-fence abort capability is unavailable at ratio ${measurements.latestRatio}`);
1364
- }
1365
- ctx.abort();
1366
- return true;
1367
- };
1368
-
1369
1493
  const PROJECTION_CALIBRATION_MIN_CHARS = 20_000;
1370
1494
  const PROJECTION_CALIBRATION_MIN_TOKENS = 5_000;
1371
1495
  const PROJECTION_CHARS_PER_TOKEN_FLOOR = 2;
@@ -1385,6 +1509,12 @@ export function registerActiveContext(pi: any, options: {
1385
1509
  }
1386
1510
  const chars = measurements.lastProjectedChars;
1387
1511
  if (chars === null || chars < PROJECTION_CALIBRATION_MIN_CHARS) return;
1512
+ // NEVER LEARN THE RATIO FROM A WINDOW HOLDING AN IMAGE. chars/token is a fact about
1513
+ // prose; a projection carrying base64 has a far higher true ratio, and because
1514
+ // projectionCharsPerToken takes the MINIMUM over the window such a sample is either
1515
+ // discarded (leaving the over-read in place) or, once images dominate, poisons the
1516
+ // ratio for the text around them. The clean rule is to calibrate on text alone.
1517
+ if (measurements.lastProjectedImages > 0) return;
1388
1518
  if (!Number.isFinite(measurement.tokens) || measurement.tokens < PROJECTION_CALIBRATION_MIN_TOKENS) return;
1389
1519
  if (measurements.lastProjectedEstimate !== null && measurements.lastProjectedEstimateCalibrated &&
1390
1520
  measurement.tokens > 0) {
@@ -1410,6 +1540,41 @@ export function registerActiveContext(pi: any, options: {
1410
1540
  );
1411
1541
  };
1412
1542
 
1543
+ /**
1544
+ * THE REFUSAL SPEAKS ONCE PER EPISODE, NOT ONCE PER PASS (2026-08-30).
1545
+ *
1546
+ * Every request in a divergent stretch refuses the anchor for the same reason, and a
1547
+ * record per pass would be 48 identical lines in the one session that found this. The
1548
+ * edge is what carries information: entering the state, and leaving it with a count of
1549
+ * what it cost. Same shape as `context.suspend`, which repeats only when its message
1550
+ * changes rather than once per pass.
1551
+ */
1552
+ const noteAnchorRefusal = (
1553
+ measurement: ProviderContextMeasurement,
1554
+ textChars: number,
1555
+ images: number,
1556
+ ): void => {
1557
+ const previous = measurements.anchorRefusal;
1558
+ measurements.anchorRefusal = { consecutive: (previous?.consecutive ?? 0) + 1 };
1559
+ if (previous) return;
1560
+ emit("context.anchor", {
1561
+ state: "refused",
1562
+ reason: "text-cannot-explain-count",
1563
+ text_chars: textChars,
1564
+ images,
1565
+ provider_tokens: measurement.tokens,
1566
+ chars_per_token: measurement.tokens > 0 ? textChars / measurement.tokens : null,
1567
+ floor: PROJECTION_CHARS_PER_TOKEN_FLOOR,
1568
+ });
1569
+ };
1570
+
1571
+ const clearAnchorRefusal = (): void => {
1572
+ const refusal = measurements.anchorRefusal;
1573
+ measurements.anchorRefusal = null;
1574
+ if (!refusal) return;
1575
+ emit("context.anchor", { state: "restored", refused_requests: refusal.consecutive });
1576
+ };
1577
+
1413
1578
  const noteProviderProjectionAnchor = (measurement: ProviderContextMeasurement): void => {
1414
1579
  if (measurements.projectionAnchor?.messageSha256 === measurement.messageSha256) return;
1415
1580
  const text = instrumentation.previousText;
@@ -1418,13 +1583,32 @@ export function registerActiveContext(pi: any, options: {
1418
1583
  return;
1419
1584
  }
1420
1585
  const chars = bytes(text);
1421
- if (!(measurement.tokens > 0) || chars / measurement.tokens < PROJECTION_CHARS_PER_TOKEN_FLOOR) {
1586
+ // The anchor is differenced against TEXT characters, so it must store them. The mass
1587
+ // belongs to the same `previousText` this anchor is built from, recorded by
1588
+ // noteProjection when that text was produced.
1589
+ const textChars = Math.max(0, chars - instrumentation.previousImageChars);
1590
+ const images = instrumentation.previousImages;
1591
+ // The floor test reads TEXT against tokens: an image-heavy projection has a raw ratio
1592
+ // far above any prose floor and would sail through a test that is meant to reject a
1593
+ // measurement the text cannot explain.
1594
+ if (!(measurement.tokens > 0) ||
1595
+ (measurement.tokens > images * IMAGE_ESTIMATED_TOKENS &&
1596
+ textChars / (measurement.tokens - images * IMAGE_ESTIMATED_TOKENS) <
1597
+ PROJECTION_CHARS_PER_TOKEN_FLOOR)) {
1422
1598
  measurements.projectionAnchor = null;
1599
+ // ONLY A COUNT WE DISBELIEVE IS A REFUSAL. A zero or absent count is simply nothing
1600
+ // to anchor on and stays silent; the floor failing on a positive count is the state
1601
+ // worth announcing, because it means the provider is billing for mass our own
1602
+ // projection does not contain.
1603
+ if (measurement.tokens > 0) noteAnchorRefusal(measurement, textChars, images);
1423
1604
  return;
1424
1605
  }
1606
+ clearAnchorRefusal();
1425
1607
  measurements.projectionAnchor = {
1426
1608
  tokens: measurement.tokens,
1427
1609
  chars,
1610
+ textChars,
1611
+ images,
1428
1612
  head: text.slice(0, -1),
1429
1613
  messageSha256: measurement.messageSha256,
1430
1614
  sessionId: persistence.state.sessionId,
@@ -1446,6 +1630,9 @@ export function registerActiveContext(pi: any, options: {
1446
1630
  tokens: number;
1447
1631
  basis: ProjectionReadingBasis;
1448
1632
  chars: number;
1633
+ /** The image-corrected size of THIS projection, independent of any anchor. */
1634
+ sizeTokens: number;
1635
+ images: number;
1449
1636
  anchorTokens: number | null;
1450
1637
  deltaChars: number | null;
1451
1638
  text: string;
@@ -1453,21 +1640,43 @@ export function registerActiveContext(pi: any, options: {
1453
1640
  const text = serialized ?? stableStringify(projected);
1454
1641
  const chars = Buffer.byteLength(text, "utf8");
1455
1642
  const charsPerToken = projectionCharsPerToken();
1456
- const estimate = Math.ceil(chars / charsPerToken);
1643
+ // AN IMAGE IS NOT TEXT (2026-08-28, see imageMass). Its base64 leaves the character
1644
+ // count and comes back as a flat per-image cost, because dividing base64 by a ratio
1645
+ // learned from prose read one screenshot as 110,000 tokens against a true 1,500 and
1646
+ // aborted a session that was less than half full.
1647
+ const mass = imageMass(projected);
1648
+ const textChars = Math.max(0, chars - mass.base64Chars);
1649
+ const imageTokens = mass.images * IMAGE_ESTIMATED_TOKENS;
1650
+ const estimate = Math.ceil(textChars / charsPerToken) + imageTokens;
1457
1651
  const anchor = measurements.projectionAnchor;
1458
- if (!anchor || anchor.generation !== lifecycle.generation ||
1652
+ // REFUSED AND UNMEASURED READ THE SAME NUMBER AND MEAN OPPOSITE THINGS. Both fall back
1653
+ // to the character estimate, so the tokens are identical and only the BASIS separates
1654
+ // them: "unmeasured" is a session that has not been told yet, "refused" is one that was
1655
+ // told something its own projection cannot account for. Reading 48 consecutive
1656
+ // "unmeasured" projections as a quiet session is what hid a 19x divergence.
1657
+ if (!anchor) {
1658
+ const basis: ProjectionReadingBasis = measurements.anchorRefusal ? "refused" : "unmeasured";
1659
+ return { tokens: estimate, basis, chars, sizeTokens: estimate, images: mass.images, anchorTokens: null, deltaChars: null, text };
1660
+ }
1661
+ if (anchor.generation !== lifecycle.generation ||
1459
1662
  anchor.sessionId !== persistence.state?.sessionId) {
1460
- return { tokens: estimate, basis: "unmeasured", chars, anchorTokens: null, deltaChars: null, text };
1663
+ return { tokens: estimate, basis: "unmeasured", chars, sizeTokens: estimate, images: mass.images, anchorTokens: null, deltaChars: null, text };
1461
1664
  }
1462
1665
  const separator = text.length > anchor.head.length ? text[anchor.head.length] : "";
1463
1666
  if (!text.startsWith(anchor.head) || (separator !== "," && separator !== "]")) {
1464
- return { tokens: estimate, basis: "rewritten", chars, anchorTokens: anchor.tokens, deltaChars: null, text };
1667
+ return { tokens: estimate, basis: "rewritten", chars, sizeTokens: estimate, images: mass.images, anchorTokens: anchor.tokens, deltaChars: null, text };
1465
1668
  }
1466
- const deltaChars = chars - anchor.chars;
1669
+ // The delta is taken on TEXT characters and images separately, so an image arriving
1670
+ // after the anchor costs its own flat price rather than its base64 length.
1671
+ const deltaChars = textChars - anchor.textChars;
1672
+ const deltaImages = mass.images - anchor.images;
1467
1673
  return {
1468
- tokens: Math.max(0, anchor.tokens + Math.ceil(deltaChars / charsPerToken)),
1674
+ tokens: Math.max(0, anchor.tokens + Math.ceil(deltaChars / charsPerToken) +
1675
+ deltaImages * IMAGE_ESTIMATED_TOKENS),
1469
1676
  basis: "anchored",
1470
1677
  chars,
1678
+ sizeTokens: estimate,
1679
+ images: mass.images,
1471
1680
  anchorTokens: anchor.tokens,
1472
1681
  deltaChars,
1473
1682
  text,
@@ -1517,7 +1726,7 @@ export function registerActiveContext(pi: any, options: {
1517
1726
  : Number.POSITIVE_INFINITY;
1518
1727
  const reading = projectedTokenReading(projected, serialized);
1519
1728
  const tokens = reading.tokens;
1520
- const sizeTokens = Math.ceil(reading.chars / projectionCharsPerToken());
1729
+ const sizeTokens = reading.sizeTokens;
1521
1730
  const marginTokens = Number.isFinite(budgetTokens)
1522
1731
  ? projectionMarginTokens(tokens, capacity.window)
1523
1732
  : 0;
@@ -1533,41 +1742,6 @@ export function registerActiveContext(pi: any, options: {
1533
1742
  };
1534
1743
  };
1535
1744
 
1536
- const abortOverBudgetProjection = (
1537
- tokens: number,
1538
- budgetTokens: number,
1539
- ctx: any,
1540
- recoveryAttempts = 0,
1541
- ): void => {
1542
- const key = sha256Value({
1543
- sessionId: persistence.state?.sessionId ?? null,
1544
- revision: persistence.state?.revision ?? null,
1545
- tokens,
1546
- budgetTokens,
1547
- phase: "projection-budget-fence",
1548
- });
1549
- ladder.pendingContextNote =
1550
- `The ${brandNoun} projection estimates ${tokens} tokens against a ${budgetTokens}-token serving budget` +
1551
- (recoveryAttempts
1552
- ? `, after ${recoveryAttempts} recovery attempt(s) that folded everything foldable. The remaining ` +
1553
- "inflow does not fit at any folding depth, which is an impossibility rather than a recoverable state."
1554
- : ".") +
1555
- " The provider request was aborted before transmission; run /compact or make an explicit bounded context fold.";
1556
- if (advisory.hardFenceNoticeKey !== key) {
1557
- advisory.hardFenceNoticeKey = key;
1558
- safeNotify(
1559
- ctx,
1560
- "Provider request aborted: the projection exceeds the serving budget. " +
1561
- "Run /compact or make an explicit bounded context fold.",
1562
- "error",
1563
- );
1564
- }
1565
- if (typeof ctx.abort !== "function") {
1566
- throw new Error(`Pi projection-budget abort capability is unavailable at ${tokens} estimated tokens`);
1567
- }
1568
- ctx.abort();
1569
- };
1570
-
1571
1745
  // THE BAND TOP COMMITS ON ITS OWN (Shane, 2026-08-22).
1572
1746
  //
1573
1747
  // `maxTarget` is what the settings panel calls the commit trigger, and until now it
@@ -1666,9 +1840,9 @@ export function registerActiveContext(pi: any, options: {
1666
1840
  };
1667
1841
 
1668
1842
  /**
1669
- * Tell the agent what was cut, once there is enough of it to be worth a turn.
1843
+ * Tell the agent where the window stands, once per approach to a commit.
1670
1844
  *
1671
- * It is built from LIVE state, so it names what is unbriefed at the pass it speaks on,
1845
+ * It is built from LIVE state, so it names what is staged at the pass it speaks on,
1672
1846
  * and it is admitted ONCE per frozen projection, so a pass that adds material leaves it
1673
1847
  * buried at its own index rather than moving it to the tail. Those two together mean it
1674
1848
  * is a snapshot rather than a running tally: cuts staged after it spoke wait for the
@@ -1678,19 +1852,54 @@ export function registerActiveContext(pi: any, options: {
1678
1852
  * epoch early. What it must never do is ask twice for a brief the agent has written,
1679
1853
  * and it does not: the rebuild reads the marks, not its own last text.
1680
1854
  */
1681
- const appendFoldNotice = (projected: unknown[], snapshot: ActiveContextSnapshot): unknown[] => {
1855
+ const appendFoldNotice = (
1856
+ projected: unknown[],
1857
+ snapshot: ActiveContextSnapshot,
1858
+ bodyBytes: number,
1859
+ ): unknown[] => {
1682
1860
  // The deterministic experiment condition: cuts stage and commits land exactly as
1683
- // ever, but nothing invites a brief, so briefs stay the runtime's own.
1684
- if (!postFoldNotice) return projected;
1861
+ // ever, but no carrier reaches the projection at all.
1862
+ if (!preCommitNotice) return projected;
1685
1863
  if (!persistence.state) return projected;
1686
1864
  const held = pendingMarks(persistence.state);
1687
- const unbriefed = held.filter((mark) =>
1688
- mark.briefProvenance?.kind !== "supplied" && mark.briefProvenance?.kind !== "augmented");
1689
- // BATCHED, AND APPENDED ONCE. A notice per cut is noise, and noise is how guidance
1690
- // gets ignored; the frontier cuts often and this speaks only when a batch has built
1691
- // up. `carrierAdmitted` is what makes it an APPEND rather than a rewrite: once the
1692
- // carrier is in the frozen projection it is not pushed again, so raw material
1693
- // arriving afterwards lands after it and the prefix in front of it never moves.
1865
+ const pinnedMass = explicitProtectedMass(snapshot, persistence.state);
1866
+ // NOTHING TO REPORT IS NOT AN OCCASION. With no staged fold and no pin there is no
1867
+ // status: the commit that is coming will apply nothing, and a notice saying so is a
1868
+ // turn's attention spent on the absence of news.
1869
+ if (!held.length && !pinnedMass.refs) return projected;
1870
+ const capacity = servingCapacity(snapshot.contextWindow);
1871
+ if (!(capacity.budgetTokens > 0)) return projected;
1872
+ // THE TRIGGER IS OCCUPANCY, AND IT NAMES ITS OWN BASIS. The provider's count is
1873
+ // ground truth; the estimate is what there is before the first response lands, and
1874
+ // this runtime's standing law is that an estimate MAY SCHEDULE WORK and may not veto
1875
+ // a request. Appending a notice is scheduling. `bodyBytes` is the serialization this
1876
+ // pass already performed for the freeze predicate, so reading a share here adds no
1877
+ // second pass over the window (gate 155's law).
1878
+ const measured = capacity.usedTokens !== null;
1879
+ const usedTokens = measured
1880
+ ? capacity.usedTokens!
1881
+ : Math.ceil(bodyBytes / projectionCharsPerToken());
1882
+ const share = usedTokens / capacity.budgetTokens;
1883
+ // A DISTANCE BELOW THE TRIGGER, floored at zero so a lead wider than the trigger
1884
+ // means "from the first measurement" rather than a negative point nothing crosses.
1885
+ const firePoint = Math.max(0, thresholds.maxTarget - noticeLeadShare);
1886
+ // EDGE TRIGGERED, ONE NOTICE PER APPROACH. A window sits in the band for many passes,
1887
+ // and the freeze breaks whenever the prefix changes, so `carrierAdmitted` alone would
1888
+ // let this re-admit all the way up the band. Two things re-arm it and nothing else:
1889
+ // falling back below the fire point, and a commit landing, which is the event that
1890
+ // ends one approach and begins the next. Without the second, a commit that lands
1891
+ // above the fire point would leave the latch shut for the rest of the session.
1892
+ if (share < firePoint) {
1893
+ ladder.noticeLatch = null;
1894
+ return projected;
1895
+ }
1896
+ if (ladder.noticeLatch !== null && ladder.noticeLatch === curation.commitsDelivered) {
1897
+ return projected;
1898
+ }
1899
+ // APPENDED ONCE PER FROZEN PROJECTION. `carrierAdmitted` is what makes it an APPEND
1900
+ // rather than a rewrite: once the carrier is in the frozen projection it is not
1901
+ // pushed again, so raw material arriving afterwards lands after it and the prefix in
1902
+ // front of it never moves.
1694
1903
  //
1695
1904
  // The first cut of this withdrew the carrier and re-pushed it at the tail every pass,
1696
1905
  // the way the steward band had to because a band must be able to stand down. That is
@@ -1699,23 +1908,42 @@ export function registerActiveContext(pi: any, options: {
1699
1908
  // gates 110 and 111 read the whole projection as rewritten and dropped the occupancy
1700
1909
  // anchor back to the estimate. A carrier that moves on a quiet pass costs the cache,
1701
1910
  // which is the one thing this design exists to avoid.
1702
- if (unbriefed.length < UNBRIEFED_FOLDS_BEFORE_NOTICE) return projected;
1703
1911
  if (!carrierAdmitted("fold-notice")) return projected;
1912
+ const briefed = (mark: Record<string, unknown>): boolean =>
1913
+ (mark as any).briefProvenance?.kind === "supplied" ||
1914
+ (mark as any).briefProvenance?.kind === "augmented";
1915
+ const staged = held.map((mark) => ({
1916
+ id: mark.id as string,
1917
+ kind: mark.kind as string,
1918
+ tokens: estimatedTokens(markFreedBytes(snapshot, persistence.state!, mark)),
1919
+ brief: typeof mark.brief === "string" ? mark.brief : undefined,
1920
+ briefed: briefed(mark),
1921
+ }));
1922
+ // THE SHARE IS MEASURED THE WAY THE CAP MEASURES IT. The notice states the pinned
1923
+ // share against MAX_PINNED_SHARE, and `applyProtectionChange` refuses past that cap
1924
+ // on `explicitProtectedMass`, so quoting the stale-only figure `windowClaims` reports
1925
+ // would put a number beside a cap it is not compared against.
1704
1926
  const text = foldNoticeText({
1705
- unbriefed: unbriefed.map((mark) => ({
1706
- id: mark.id,
1707
- kind: mark.kind,
1708
- tokens: estimatedTokens(markFreedBytes(snapshot, persistence.state!, mark)),
1709
- brief: typeof mark.brief === "string" ? mark.brief : undefined,
1710
- })),
1711
- pending: held.length,
1927
+ staged,
1928
+ freedTokens: staged.reduce((total, fold) => total + fold.tokens, 0),
1929
+ briefedCount: staged.filter((fold) => fold.briefed).length,
1930
+ pinnedEntries: pinnedMass.refs,
1931
+ pinnedShare: estimatedTokens(pinnedMass.bytes) / capacity.budgetTokens,
1932
+ maxPinnedShare: MAX_PINNED_SHARE,
1933
+ headroomTokens: Math.max(0, thresholds.maxTarget * capacity.budgetTokens - usedTokens),
1934
+ commitAtShare: thresholds.maxTarget,
1935
+ measured,
1712
1936
  toolName,
1713
1937
  brandNoun,
1714
1938
  });
1939
+ ladder.noticeLatch = curation.commitsDelivered;
1715
1940
  emit("context.frontier", {
1716
1941
  cut: 0,
1717
1942
  pending_marks: held.length,
1718
- unbriefed: unbriefed.length,
1943
+ briefed: staged.filter((fold) => fold.briefed).length,
1944
+ occupancy_share: Number(share.toFixed(4)),
1945
+ fire_point_share: firePoint,
1946
+ measured,
1719
1947
  notice_chars: text.length,
1720
1948
  });
1721
1949
  projected.push({
@@ -1731,42 +1959,6 @@ export function registerActiveContext(pi: any, options: {
1731
1959
  return projected;
1732
1960
  };
1733
1961
 
1734
- /**
1735
- * THE TABLE OF CONTENTS IS ONE COPY PER COMMIT (Shane, 2026-08-26). It is admitted
1736
- * once per frozen projection, exactly as the fold notice above, and that admission
1737
- * rule IS the refresh law: between commits the copy stays buried at its index, so a
1738
- * quiet pass moves no byte and costs no occupancy anchor, and the tool result of every
1739
- * `remember` carries the live key list in the meantime. A commit rewrites the
1740
- * projection, the freeze drops, the body filter strips the old copy, and the next pass
1741
- * seats a fresh one at the tail with the latest keys. Nothing here re-renders in
1742
- * place, because a carrier that moves on a quiet pass is the exact defect the fold
1743
- * notice's comment records.
1744
- */
1745
- const appendMemoryToc = (projected: unknown[], snapshot: ActiveContextSnapshot): unknown[] => {
1746
- if (!workingMemory) return projected;
1747
- if (!persistence.state) return projected;
1748
- const held = persistence.state.memory ?? [];
1749
- if (!held.length) return projected;
1750
- if (!carrierAdmitted("memory-toc")) return projected;
1751
- const entries = [...held].sort((left, right) => right.ordinal - left.ordinal);
1752
- const text = memoryTocText({
1753
- entries: entries.map((entry) => ({ key: entry.key, chars: entry.body.length })),
1754
- toolName,
1755
- brandNoun,
1756
- });
1757
- projected.push({
1758
- role: "custom",
1759
- customType: memoryTocProjectionType,
1760
- content: text,
1761
- display: false,
1762
- details: { source: activeContextSource(entryTypePrefix), ephemeral: true },
1763
- timestamp: typeof ownValue(snapshot.messages.at(-1), "timestamp") === "number"
1764
- ? ownValue(snapshot.messages.at(-1), "timestamp")
1765
- : 0,
1766
- });
1767
- return projected;
1768
- };
1769
-
1770
1962
  const enforceBandTop = async (
1771
1963
  snapshot: ActiveContextSnapshot,
1772
1964
  ctx: any,
@@ -1805,10 +1997,10 @@ export function registerActiveContext(pi: any, options: {
1805
1997
  snapshot: ActiveContextSnapshot,
1806
1998
  projected: unknown[],
1807
1999
  ctx: any,
1808
- ): Promise<{ projected: unknown[]; aborted: boolean; text: string }> => {
2000
+ ): Promise<{ projected: unknown[]; text: string }> => {
1809
2001
  let measured = projectionExceedsBudget(projected, ctx);
1810
2002
  const rejected = curation.pendingRejection !== null;
1811
- if (!measured.crowded && !rejected) return { projected, aborted: false, text: measured.text };
2003
+ if (!measured.crowded && !rejected) return { projected, text: measured.text };
1812
2004
  const trigger = measured;
1813
2005
  let reduced = projected;
1814
2006
  let attempts = 0;
@@ -1912,11 +2104,24 @@ export function registerActiveContext(pi: any, options: {
1912
2104
  }));
1913
2105
  curation.pendingRejection = null;
1914
2106
  }
1915
- // `measured` is recomputed on `reduced` at every turn of the loop above, so its text is
1916
- // the serialization of the array both of these exits return.
1917
- if (!measured.over) return { projected: reduced, aborted: false, text: measured.text };
1918
- abortOverBudgetProjection(measured.tokens, measured.budgetTokens, ctx, attempts);
1919
- return { projected: reduced, aborted: true, text: measured.text };
2107
+ // THE RUNTIME COMMITS AND TRANSMITS; ONLY THE PROVIDER REFUSES (Shane, 2026-08-28).
2108
+ //
2109
+ // This used to abort the request when the projection still read over budget after the
2110
+ // loop. It was deleted because it let an ESTIMATE veto a request that ground truth
2111
+ // would have accepted: a live session read 220,883 tokens where the provider counted
2112
+ // 111,837, was refused before transmission, and could not be rescued, because the
2113
+ // commit before the refusal had applied 28 marks and the next applied 0. The advice it
2114
+ // printed, fold manually or run /compact, named the two things the mechanism had just
2115
+ // proven it could not do.
2116
+ //
2117
+ // maxTarget is a TRIGGER meaning "commit now", not a bound meaning "refuse to
2118
+ // proceed", and only the first is something an estimate can be right about. What
2119
+ // remains is the loop above, which commits everything it can reach; the request then
2120
+ // goes to the provider, and a genuine overflow is handled by the rejection path, which
2121
+ // branches back, folds and retries on the provider's own count rather than ours.
2122
+ // `measured` is recomputed on `reduced` at every turn of the loop, so its text is the
2123
+ // serialization of the array this returns.
2124
+ return { projected: reduced, text: measured.text };
1920
2125
  };
1921
2126
 
1922
2127
  const startPreparation = (snapshot: ActiveContextSnapshot, ratio: number | null, ctx: any): void => {
@@ -2071,7 +2276,7 @@ export function registerActiveContext(pi: any, options: {
2071
2276
  const customType = ownValue(message, "customType");
2072
2277
  return customType !== milestoneProjectionType && customType !== advisoryProjectionType &&
2073
2278
  customType !== receiptProjectionType && customType !== curationProjectionType &&
2074
- customType !== foldNoticeProjectionType && customType !== memoryTocProjectionType;
2279
+ customType !== foldNoticeProjectionType;
2075
2280
  });
2076
2281
  const withdrawn = withdrawConsumedEphemeralPeeks(body);
2077
2282
  if (withdrawn.size && freeze.body) {
@@ -2098,8 +2303,7 @@ export function registerActiveContext(pi: any, options: {
2098
2303
  freeze.body = body;
2099
2304
  freeze.bodyText = bodyText;
2100
2305
  appendReceipts(projected, snapshot);
2101
- appendFoldNotice(projected, snapshot);
2102
- appendMemoryToc(projected, snapshot);
2306
+ appendFoldNotice(projected, snapshot, bodyText.length);
2103
2307
  return holdFrozen(projected);
2104
2308
  };
2105
2309
  const noteProjection = (projected: unknown[], serialized?: string): void => {
@@ -2792,6 +2996,7 @@ export function registerActiveContext(pi: any, options: {
2792
2996
  measurements.lastProviderMeasurement = null;
2793
2997
  measurements.wallInflowSteps.length = 0;
2794
2998
  measurements.projectionAnchor = null;
2999
+ measurements.anchorRefusal = null;
2795
3000
  nativeCompaction.lastThresholdDecision = null;
2796
3001
  updateStatus(ctx);
2797
3002
  };
@@ -2888,11 +3093,6 @@ export function registerActiveContext(pi: any, options: {
2888
3093
  try { projected = projectWithAdvisory(snapshot); }
2889
3094
  catch (error) {
2890
3095
  suspendAutomatic(error, "projection", ctx);
2891
- abortUnsafeHardContext(snapshot, ctx);
2892
- return { messages: event.messages };
2893
- }
2894
- if (abortUnsafeHardContext(snapshot, ctx, true)) {
2895
- updateStatus(ctx);
2896
3096
  return { messages: event.messages };
2897
3097
  }
2898
3098
  // THE FRONTIER CUTS FIRST, and it costs the projection nothing to do so: a pending
@@ -2908,7 +3108,6 @@ export function registerActiveContext(pi: any, options: {
2908
3108
  try { projected = projectWithAdvisory(snapshot); }
2909
3109
  catch (error) {
2910
3110
  suspendAutomatic(error, "projection", ctx);
2911
- abortUnsafeHardContext(snapshot, ctx);
2912
3111
  return { messages: event.messages };
2913
3112
  }
2914
3113
  }
@@ -2916,14 +3115,13 @@ export function registerActiveContext(pi: any, options: {
2916
3115
  projected = budgeted.projected;
2917
3116
  const reading = projectedTokenReading(projected, budgeted.text);
2918
3117
  measurements.lastProjectedChars = reading.chars;
3118
+ measurements.lastProjectedImages = reading.images;
2919
3119
  measurements.lastProjectedEstimate = reading.tokens;
2920
3120
  measurements.lastProjectedEstimateBasis = reading.basis;
2921
- measurements.lastProjectedSizeTokens = Math.ceil(reading.chars / projectionCharsPerToken());
3121
+ // The size is the reading's own image-corrected figure. Recomputing it from raw
3122
+ // chars here is what fed base64 straight into the fence's own accounting.
3123
+ measurements.lastProjectedSizeTokens = reading.sizeTokens;
2922
3124
  measurements.lastProjectedEstimateCalibrated = measurements.projectionCalibrations.length > 0;
2923
- if (budgeted.aborted) {
2924
- updateStatus(ctx);
2925
- return { messages: projected };
2926
- }
2927
3125
  noteProjection(projected, budgeted.text);
2928
3126
  updateStatus(ctx);
2929
3127
  return { messages: projected };
@@ -2937,7 +3135,6 @@ export function registerActiveContext(pi: any, options: {
2937
3135
  if (!persistedSucceeded) ladder.boundaryFailure = lifecycle.latestSnapshotError;
2938
3136
  suspendAutomatic(error, persistedSucceeded ? "post-persist-projection" : "context", ctx);
2939
3137
  }
2940
- abortUnsafeHardContext(lifecycle.latestSnapshot, ctx);
2941
3138
  return { messages: event.messages };
2942
3139
  }
2943
3140
  };
@@ -3400,6 +3597,94 @@ export function registerActiveContext(pi: any, options: {
3400
3597
  return removed as Array<Record<string, unknown>>;
3401
3598
  };
3402
3599
 
3600
+ /**
3601
+ * THE DISSOLVE PATH, shared by the tool's single-id reboundary and the editor's re-cut
3602
+ * (2026-08-30). Returning a fold to raw is the first half of re-cutting it, and the
3603
+ * editor already has the second half: `m` on two raw entries stages the span the person
3604
+ * meant. Extracted rather than reimplemented, because the editor's own law is that
3605
+ * every edit is a skin over an existing validated path, and this one carries the nested
3606
+ * refusal, the brief and lease cleanup and the parent re-derivation with it.
3607
+ */
3608
+ const dissolveFold = async (
3609
+ snapshot: ActiveContextSnapshot,
3610
+ id: string,
3611
+ ctx: any,
3612
+ ): Promise<{ children: string[]; startId: string | null; endId: string | null }> => {
3613
+ const fold = requireActiveFold(snapshot, persistence.state!, id);
3614
+ if (fold.parentId) {
3615
+ throw new Error(
3616
+ `Fold ${id} is nested under ${fold.parentId}; dissolve ${fold.parentId} first so ${id} is a root`,
3617
+ );
3618
+ }
3619
+ const refs = flattenFoldRefs(fold, persistence.state!);
3620
+ const children = childFoldIds(fold);
3621
+ const briefs = { ...(persistence.state!.briefs ?? {}) };
3622
+ delete briefs[id];
3623
+ const next: ActiveContextState = {
3624
+ ...persistence.state!,
3625
+ revision: persistence.state!.revision + 1,
3626
+ folds: deriveFoldParents(persistence.state!.folds.filter((item) => item.id !== id)),
3627
+ expanded: persistence.state!.expanded.filter((expandedId) => expandedId !== id),
3628
+ ...(Object.keys(briefs).length ? { briefs } : {}),
3629
+ };
3630
+ if (!Object.keys(briefs).length) delete next.briefs;
3631
+ const leases = { ...next.leases };
3632
+ delete leases[id];
3633
+ await persistManual({ ...next, leases }, ctx);
3634
+ updateStatus(ctx);
3635
+ return { children, startId: refs[0]?.entryId ?? null, endId: refs.at(-1)?.entryId ?? null };
3636
+ };
3637
+
3638
+ /**
3639
+ * THE EXPANSION PATH, shared by the tool's expand/refold actions and the editor's
3640
+ * promote-to-context intent (2026-08-30).
3641
+ *
3642
+ * THIS IS THE ACT THE EDITOR'S OWN EXPANSION IS NOT. Opening a fold row in /fold-editor
3643
+ * moves a key in a Set the view owns: it reveals the fold to the READER and the model
3644
+ * never learns it happened. This restores the fold's raw bytes to the PROJECTION, which
3645
+ * costs the window and is what the lease exists to reclaim. Shane, 2026-08-30: the user
3646
+ * should "page through/expand for their own viewing purposes" while "setting an expanded
3647
+ * fold is a different choice". Two acts, two paths, and this is the one that spends.
3648
+ */
3649
+ const setFoldExpansion = async (
3650
+ snapshot: ActiveContextSnapshot,
3651
+ id: string,
3652
+ expand: boolean,
3653
+ ctx: any,
3654
+ ): Promise<unknown[]> => {
3655
+ let epochApplied: unknown[] = [];
3656
+ if (expand && pendingMarks(persistence.state!).length) {
3657
+ const result = await commitPendingMarks({
3658
+ snapshot,
3659
+ state: persistence.state!,
3660
+ generation: lifecycle.generation,
3661
+ retainIneligible: true,
3662
+ });
3663
+ persistence.state = result.state;
3664
+ epochApplied = result.applied;
3665
+ }
3666
+ const expanding = requireActiveFold(snapshot, persistence.state!, id);
3667
+ if (expand) {
3668
+ admit({
3669
+ action: "expand",
3670
+ ctx,
3671
+ foldId: id,
3672
+ requestedBytes: Math.max(0, expanding.sourceChars - expanding.placeholderChars),
3673
+ children: childFoldIds(expanding),
3674
+ });
3675
+ }
3676
+ let next = setFoldProjectionState(persistence.state!, id, expand ? "expanded" : "folded");
3677
+ if (expand) next = withExpandLease(next, id);
3678
+ else {
3679
+ const leases = { ...next.leases };
3680
+ delete leases[id];
3681
+ next = { ...next, leases };
3682
+ }
3683
+ await persistManual(next, ctx);
3684
+ updateStatus(ctx);
3685
+ return epochApplied;
3686
+ };
3687
+
3403
3688
  /**
3404
3689
  * THE PIN CHANGE PATH, shared by the tool's pin/unpin actions and the editor's
3405
3690
  * pin toggle: the pinned-share cap, protectEvidence, the canonical event, persist.
@@ -3884,36 +4169,15 @@ export function registerActiveContext(pi: any, options: {
3884
4169
  }
3885
4170
  const id = String(params.id ?? "").trim();
3886
4171
  if (!id) throw new Error("reboundary requires id, or ids naming the span to re-cut");
3887
- const fold = requireActiveFold(snapshot, persistence.state, id);
3888
- if (fold.parentId) {
3889
- throw new Error(
3890
- `Fold ${id} is nested under ${fold.parentId}; dissolve ${fold.parentId} first so ${id} is a root`,
3891
- );
3892
- }
3893
- const refs = flattenFoldRefs(fold, persistence.state);
3894
- const children = childFoldIds(fold);
3895
- const briefs = { ...(persistence.state.briefs ?? {}) };
3896
- delete briefs[id];
3897
- const next: ActiveContextState = {
3898
- ...persistence.state,
3899
- revision: persistence.state.revision + 1,
3900
- folds: deriveFoldParents(persistence.state.folds.filter((item) => item.id !== id)),
3901
- expanded: persistence.state.expanded.filter((expandedId) => expandedId !== id),
3902
- ...(Object.keys(briefs).length ? { briefs } : {}),
3903
- };
3904
- if (!Object.keys(briefs).length) delete next.briefs;
3905
- const leases = { ...next.leases };
3906
- delete leases[id];
3907
- await persistManual({ ...next, leases }, ctx);
3908
- updateStatus(ctx);
4172
+ const dissolved = await dissolveFold(snapshot, id, ctx);
3909
4173
  return toolPayload({
3910
4174
  version: 1,
3911
4175
  action,
3912
4176
  id,
3913
4177
  dissolved: true,
3914
- releasedChildren: children,
3915
- startId: refs[0]?.entryId ?? null,
3916
- endId: refs.at(-1)?.entryId ?? null,
4178
+ releasedChildren: dissolved.children,
4179
+ startId: dissolved.startId,
4180
+ endId: dissolved.endId,
3917
4181
  durableRevision: persistence.state.revision,
3918
4182
  activation: `the span ${id} held is raw again and its boundary is yours to re-cut; ` +
3919
4183
  "fold the endpoints you meant, or two sub-spans to split it. No evidence moved.",
@@ -3922,36 +4186,7 @@ export function registerActiveContext(pi: any, options: {
3922
4186
  if (action === "expand" || action === "refold") {
3923
4187
  const id = String(params.id ?? "").trim();
3924
4188
  if (!id) throw new Error(`${action} requires id`);
3925
- let epochApplied: unknown[] = [];
3926
- if (action === "expand" && pendingMarks(persistence.state).length) {
3927
- const result = await commitPendingMarks({
3928
- snapshot,
3929
- state: persistence.state,
3930
- generation: lifecycle.generation,
3931
- retainIneligible: true,
3932
- });
3933
- persistence.state = result.state;
3934
- epochApplied = result.applied;
3935
- }
3936
- const expanding = requireActiveFold(snapshot, persistence.state, id);
3937
- if (action === "expand") {
3938
- admit({
3939
- action: "expand",
3940
- ctx,
3941
- foldId: id,
3942
- requestedBytes: Math.max(0, expanding.sourceChars - expanding.placeholderChars),
3943
- children: childFoldIds(expanding),
3944
- });
3945
- }
3946
- let next = setFoldProjectionState(persistence.state, id, action === "expand" ? "expanded" : "folded");
3947
- if (action === "expand") next = withExpandLease(next, id);
3948
- else {
3949
- const leases = { ...next.leases };
3950
- delete leases[id];
3951
- next = { ...next, leases };
3952
- }
3953
- await persistManual(next, ctx);
3954
- updateStatus(ctx);
4189
+ const epochApplied = await setFoldExpansion(snapshot, id, action === "expand", ctx);
3955
4190
  return toolPayload({
3956
4191
  version: 1,
3957
4192
  action,
@@ -4081,102 +4316,6 @@ export function registerActiveContext(pi: any, options: {
4081
4316
  "reaches it only when the commit folds this span. Nothing in your context moved.",
4082
4317
  });
4083
4318
  }
4084
- if (action === "remember") {
4085
- // THE DIGEST'S OWN CHANNEL. A working-memory write is durable state outside the
4086
- // projection, so like `brief` it moves no bytes and costs no prefix cache: the
4087
- // table of contents the projection carries refreshes at the next commit, and this
4088
- // result carries the live key list in the meantime.
4089
- const key = String(params.key ?? "").trim();
4090
- if (!key) throw new Error("remember needs a key");
4091
- if (key.length > MEMORY_KEY_CHARS) {
4092
- throw new Error(`Working-memory keys have a hard ${MEMORY_KEY_CHARS}-character ` +
4093
- "maximum: the key is a table-of-contents line, not the note. Put the detail " +
4094
- "in the body.");
4095
- }
4096
- const body = String(params.body ?? "").trim();
4097
- if (body.length > MEMORY_BODY_CHARS) {
4098
- throw new Error(`Working-memory bodies have a hard ${MEMORY_BODY_CHARS}-character ` +
4099
- `maximum and this one is ${body.length}. Split the entry, or keep the part ` +
4100
- "that changes and let the transcript carry the rest: folded material stays " +
4101
- "losslessly recoverable by peek.");
4102
- }
4103
- const held = persistence.state!.memory ?? [];
4104
- const existing = held.find((entry) => entry.key === key);
4105
- let nextMemory: WorkingMemoryEntry[];
4106
- if (!body) {
4107
- if (!existing) {
4108
- throw new Error(`No working-memory entry '${key}' to remove. ${held.length
4109
- ? `The keys are: ${held.map((entry) => entry.key).join(", ")}.`
4110
- : "Working memory is empty."}`);
4111
- }
4112
- nextMemory = held.filter((entry) => entry.key !== key);
4113
- } else {
4114
- if (!existing && held.length >= MEMORY_KEYS_MAX) {
4115
- throw new Error(`Working memory holds its maximum of ${MEMORY_KEYS_MAX} ` +
4116
- "entries. Merge two entries, or remove one whose facts no longer matter " +
4117
- `(${toolName} {"action":"remember","key":"<key>","body":""}), then write ` +
4118
- "this one.");
4119
- }
4120
- const ordinal = held.reduce((max, entry) => Math.max(max, entry.ordinal), 0) + 1;
4121
- nextMemory = existing
4122
- ? held.map((entry) => entry.key === key ? { key, body, ordinal } : entry)
4123
- : [...held, { key, body, ordinal }];
4124
- }
4125
- const nextState = {
4126
- ...persistence.state!,
4127
- revision: persistence.state!.revision + 1,
4128
- ...(nextMemory.length ? { memory: nextMemory } : {}),
4129
- };
4130
- if (!nextMemory.length) delete (nextState as Partial<ActiveContextState>).memory;
4131
- persistence.state = nextState;
4132
- await persistManual(nextState, ctx);
4133
- updateStatus(ctx);
4134
- const toc = [...nextMemory].sort((left, right) => right.ordinal - left.ordinal)
4135
- .map((entry) => ({ key: entry.key, chars: entry.body.length }));
4136
- return toolPayload({
4137
- version: 1,
4138
- action,
4139
- key,
4140
- ...(body ? { chars: body.length } : { removed: true }),
4141
- argumentsSha256: executionArgumentsSha256,
4142
- durableRevision: persistence.state!.revision,
4143
- entries: toc,
4144
- activation: body
4145
- ? "durable immediately, and it costs your window nothing: the body lives in " +
4146
- "state, this list is the live view, and the table of contents in your " +
4147
- "context refreshes at the next commit."
4148
- : "removed. The table of contents in your context refreshes at the next commit.",
4149
- });
4150
- }
4151
- if (action === "recall") {
4152
- const held = persistence.state!.memory ?? [];
4153
- if (!held.length) {
4154
- throw new Error(`Working memory is empty. Write an entry with ${toolName} ` +
4155
- '{"action":"remember","key":"<key>","body":"..."}.');
4156
- }
4157
- const requested = params.keys === undefined && params.key === undefined
4158
- ? null
4159
- : (denseOwnArrayValues(params.keys) ?? [params.key ?? params.keys])
4160
- .map((value) => String(value ?? "").trim()).filter(Boolean);
4161
- const entries = [...held].sort((left, right) => right.ordinal - left.ordinal);
4162
- const chosen = requested === null
4163
- ? entries
4164
- : entries.filter((entry) => requested.includes(entry.key));
4165
- if (requested !== null) {
4166
- const missing = requested.filter((key) => !held.some((entry) => entry.key === key));
4167
- if (missing.length) {
4168
- throw new Error(`No working-memory entr${missing.length === 1 ? "y" : "ies"} ` +
4169
- `${missing.join(", ")}. The keys are: ` +
4170
- `${entries.map((entry) => entry.key).join(", ")}.`);
4171
- }
4172
- }
4173
- return toolPayload({
4174
- version: 1,
4175
- action,
4176
- argumentsSha256: executionArgumentsSha256,
4177
- entries: chosen.map((entry) => ({ key: entry.key, chars: entry.body.length, body: entry.body })),
4178
- });
4179
- }
4180
4319
  throw new Error(`Unknown ${toolName} action '${action}'`);
4181
4320
  };
4182
4321
  const toolHandler = async (
@@ -4232,12 +4371,24 @@ export function registerActiveContext(pi: any, options: {
4232
4371
  try {
4233
4372
  const snapshot = authoritativeSnapshotFor(ctx);
4234
4373
  const status = activeContextStatus(snapshot, persistence.state, 0, 40);
4235
- safeNotify(
4236
- ctx,
4237
- `Active context: ${status.totalFolds} fold(s), roots ${(status.roots as string[]).join(", ") || "none"}. ` +
4238
- `Use ${toolName} status for exact recursive actions.`,
4239
- "info",
4240
- );
4374
+ const capacity = currentCapacity(ctx);
4375
+ const staged = pendingMarks(persistence.state);
4376
+ safeNotify(ctx, foldStatusText({
4377
+ brand: brandNoun,
4378
+ usedTokens: measurements.lastProviderMeasurement?.tokens ?? null,
4379
+ budgetTokens: capacity.budgetTokens,
4380
+ commitAtShare: snapshot.thresholds.maxTarget,
4381
+ aimShare: snapshot.thresholds.minTarget,
4382
+ roots: (status.roots as string[]).length,
4383
+ totalFolds: Number(status.totalFolds ?? 0),
4384
+ stagedMarks: staged.length,
4385
+ stagedTokens: staged.reduce((total, mark) =>
4386
+ total + estimatedTokens(markFreedBytes(snapshot, persistence.state!, mark)), 0),
4387
+ pinned: persistence.state.protected.length,
4388
+ suspended: ladder.automaticFailure?.message ?? null,
4389
+ foldCommand: `/${commandNames.fold}`,
4390
+ editorCommand: "/fold-editor",
4391
+ }), "info");
4241
4392
  } catch (error) {
4242
4393
  safeNotify(ctx, `Active-context status unavailable; native Pi context is unchanged: ${String(error)}`, "warning");
4243
4394
  }
@@ -4366,6 +4517,37 @@ export function registerActiveContext(pi: any, options: {
4366
4517
  tokens: estimatedTokens(spanBytes(snapshot, Math.min(from, to), Math.max(from, to) + 1)),
4367
4518
  });
4368
4519
 
4520
+ /**
4521
+ * THE READER'S OWN DEPTH, AND IT COSTS THE AGENT NOTHING (2026-08-30).
4522
+ *
4523
+ * A row preview is bounded at EDITOR_ROW_PREVIEW_CHARS because it is a SUMMARY, and
4524
+ * the editor used to have nothing behind it: the whole surface stopped at 300
4525
+ * characters of the first text block. This is the way past that. It is an INJECTED
4526
+ * accessor, like `spanCost`, so the view still holds no runtime reference and still
4527
+ * computes nothing it was not handed, and it is a pure READ: it moves no bytes,
4528
+ * writes no state and never touches the projection, so paging an entry to its end
4529
+ * changes nothing the agent will ever see. That separation is the whole point. A
4530
+ * person auditing the window needs to read further than the model is being shown,
4531
+ * and expanding a fold INTO the model's window is a different act with a different
4532
+ * verb (`expand`) and a different cost.
4533
+ *
4534
+ * The cut is STATED rather than silent, which is gate 136's law carried to a surface
4535
+ * a person reads: `total` and `hasMore` travel with the slice.
4536
+ */
4537
+ const entryDetail = (entryId: string, offset = 0, chars = EDITOR_DETAIL_PAGE_CHARS) => {
4538
+ const item = snapshot.mapped.find((candidate) => candidate.ref?.entryId === entryId);
4539
+ if (!item) return null;
4540
+ const whole = entryText(item.message);
4541
+ const from = Math.max(0, Math.min(offset, whole.length));
4542
+ const width = Math.max(1, Math.min(chars, EDITOR_DETAIL_PAGE_CHARS));
4543
+ return {
4544
+ text: whole.slice(from, from + width),
4545
+ offset: from,
4546
+ total: whole.length,
4547
+ hasMore: from + width < whole.length,
4548
+ };
4549
+ };
4550
+
4369
4551
  // Marks move no bytes until the commit, so window geometry cannot change while
4370
4552
  // the editor holds it open; only pending state grows. Rebuilding against the SAME
4371
4553
  // snapshot with fresh state keeps PROPOSED rows honest without re-deriving indices.
@@ -4377,12 +4559,15 @@ export function registerActiveContext(pi: any, options: {
4377
4559
  : null;
4378
4560
  const accounting = markAccounting(snapshot, state);
4379
4561
  return {
4380
- title: `pi-fold context editor · ${snapshot.sessionId.slice(0, 8)}`,
4562
+ // NO SESSION ID. Eight hex characters of a value a person never types, on the
4563
+ // title line of the surface, is the same defect the rows below it just lost.
4564
+ title: `${brandNoun} window`,
4381
4565
  occupancy: {
4382
4566
  usedTokens: capacity.usedTokens,
4383
4567
  budgetTokens: capacity.budgetTokens,
4384
4568
  commitOccupancy: thresholds.maxTarget,
4385
4569
  commitDue: occupancy !== null && occupancy >= thresholds.maxTarget,
4570
+ suspended: Boolean(ladder.automaticFailure),
4386
4571
  },
4387
4572
  blocks: buildFoldEditorData(snapshot, state, {
4388
4573
  foldRows: () => (state.folds ?? []).map((fold: any) => {
@@ -4392,14 +4577,16 @@ export function registerActiveContext(pi: any, options: {
4392
4577
  const item = exactMapped(snapshot, ref);
4393
4578
  const message = item?.message as Record<string, unknown> | undefined;
4394
4579
  const role = String(item?.ref?.role ?? message?.role ?? "entry");
4395
- const content = message?.content;
4396
- let preview = "";
4397
- if (typeof content === "string") preview = content.slice(0, 300);
4398
- else if (Array.isArray(content)) {
4399
- const firstText = content.find((part) => part?.type === "text");
4400
- preview = typeof firstText?.text === "string" ? firstText.text.slice(0, 300) : "";
4401
- }
4402
- return { id: ref.entryId, role, preview: preview.replace(/\s+/g, " ") };
4580
+ // A FOLD'S ENTRY IS A MARK POINT AND A PIN POINT TOO (2026-08-30). It
4581
+ // carried no index and no entry id the view could act on, so `p` was
4582
+ // inert on every row inside an expanded fold while working one row over
4583
+ // in a raw gap, for no reason a reader could see.
4584
+ return {
4585
+ id: ref.entryId,
4586
+ role,
4587
+ preview: rowPreview(item?.message),
4588
+ ...(item ? { index: item.index } : {}),
4589
+ };
4403
4590
  });
4404
4591
  return {
4405
4592
  id: String(row.id),
@@ -4408,6 +4595,16 @@ export function registerActiveContext(pi: any, options: {
4408
4595
  sourceCount: Number(row.sourceCount ?? 0),
4409
4596
  startPosition: Number(row.startPosition),
4410
4597
  endPosition: Number(row.endPosition),
4598
+ // WHAT THE AGENT IS ACTUALLY LOOKING AT (2026-08-30). A fold the agent has
4599
+ // expanded is standing RAW in its window, and the editor rendered it
4600
+ // identically to one that is still a placeholder, so the one surface built
4601
+ // to show a person the model's window could not show them this about it.
4602
+ expandedInContext: (state.expanded ?? []).includes(String(row.id)),
4603
+ // WHERE THE BRIEF CAME FROM. Given the campaign verdict on agent-written
4604
+ // briefs, which sentence is the runtime's and which is the model's is the
4605
+ // fact a person auditing this window most needs, and it was not on the
4606
+ // surface at all.
4607
+ provenance: String((fold as any).briefProvenance?.kind ?? "deterministic"),
4411
4608
  entries,
4412
4609
  };
4413
4610
  }),
@@ -4433,21 +4630,12 @@ export function registerActiveContext(pi: any, options: {
4433
4630
  if (!item) continue;
4434
4631
  const message = item.message as Record<string, unknown> | undefined;
4435
4632
  const role = String(item.ref?.role ?? message?.role ?? "entry");
4436
- const content = message?.content;
4437
- // 300 chars, same as fold entries: the view shows 48 on the row and
4438
- // deepens to 240 on Enter, so a 48-char preview made detail a no-op.
4439
- let preview = "";
4440
- if (typeof content === "string") preview = content.slice(0, 300);
4441
- else if (Array.isArray(content)) {
4442
- const firstText = content.find((part) => part?.type === "text");
4443
- preview = typeof firstText?.text === "string" ? firstText.text.slice(0, 300) : "";
4444
- }
4445
4633
  // THE INDEX IS THE MARK POINT: raw entries carry their mapped position so
4446
4634
  // the view can price a span and name its two boundary ids.
4447
4635
  out.push({
4448
4636
  id: item.ref?.entryId ?? String(index),
4449
4637
  role,
4450
- preview: preview.replace(/\s+/g, " "),
4638
+ preview: rowPreview(message),
4451
4639
  ...(item.ref?.entryId ? { index } : {}),
4452
4640
  });
4453
4641
  }
@@ -4463,6 +4651,9 @@ export function registerActiveContext(pi: any, options: {
4463
4651
  freedTokens: accounting.freedTokens,
4464
4652
  },
4465
4653
  pinned: state.protected.map((ref) => ref.entryId),
4654
+ // THE FOLDS THE AGENT HAS STANDING RAW IN ITS WINDOW, so the editor can say which
4655
+ // rows are placeholders and which are already costing the projection.
4656
+ expandedInContext: [...(state.expanded ?? [])],
4466
4657
  };
4467
4658
  };
4468
4659
 
@@ -4477,6 +4668,35 @@ export function registerActiveContext(pi: any, options: {
4477
4668
  };
4478
4669
  const editorActions = {
4479
4670
  spanCost,
4671
+ entryDetail,
4672
+ // THE READER'S EXPANSION AND THE WINDOW'S ARE DIFFERENT ACTS, and this is the second
4673
+ // one: it restores the fold's raw bytes to the model's projection and takes the
4674
+ // lease that reclaims them at the next commit. The view's own open/close key never
4675
+ // reaches here.
4676
+ onSetExpanded: async (foldId: string, expand: boolean): Promise<void> => {
4677
+ await runQueued(async () => {
4678
+ await setFoldExpansion(authoritativeSnapshotFor(ctx), foldId, expand, ctx);
4679
+ });
4680
+ safeNotify(ctx, expand
4681
+ ? "Expanded in the model's window: its exact bytes are back in the projection, " +
4682
+ "reclaimed at the next commit unless pinned."
4683
+ : "Refolded: the placeholder stands where the bytes were, still peek-recoverable.",
4684
+ "info");
4685
+ refreshAfter();
4686
+ },
4687
+ // RE-CUT IS DISSOLVE PLUS MARK, and the editor already has the second half: `m` on
4688
+ // two raw entries stages the span the person meant. This is the first half, through
4689
+ // the tool's own validated path.
4690
+ onDissolveFold: async (foldId: string): Promise<void> => {
4691
+ let released: string[] = [];
4692
+ await runQueued(async () => {
4693
+ released = (await dissolveFold(authoritativeSnapshotFor(ctx), foldId, ctx)).children;
4694
+ });
4695
+ safeNotify(ctx,
4696
+ `The span is raw again${released.length ? `, releasing ${released.length} nested fold(s)` : ""}; ` +
4697
+ "no evidence moved. Mark the boundary you meant with m.", "info");
4698
+ refreshAfter();
4699
+ },
4480
4700
  onStageMark: async (fromId: string, toId: string, brief?: string): Promise<void> => {
4481
4701
  await runQueued(async () => {
4482
4702
  const fresh = authoritativeSnapshotFor(ctx);
@@ -4501,11 +4721,24 @@ export function registerActiveContext(pi: any, options: {
4501
4721
  `Withdrew ${removed.length} staged mark(s); no context bytes moved.`, "info");
4502
4722
  refreshAfter();
4503
4723
  },
4504
- onTogglePin: async (entryId: string): Promise<void> => {
4505
- const pinnedAlready = persistence.state!.protected
4506
- .some((ref: any) => ref.entryId === entryId);
4724
+ onTogglePin: async (id: string): Promise<void> => {
4725
+ // A TOGGLE HAS TO ASK THE SAME QUESTION THE PIN PATH ANSWERS (2026-08-30). This
4726
+ // read `protected.some((ref) => ref.entryId === id)`, which is only ever true for
4727
+ // an ENTRY id, and `protectEvidence` has always taken a FOLD id too, holding
4728
+ // every ref underneath it. So the moment the editor started offering p on a fold
4729
+ // row, the second press pinned the same refs again instead of releasing them: the
4730
+ // question was asked about an id that is never in the set being searched.
4731
+ const state = persistence.state!;
4732
+ const fold = state.folds.find((item: any) => item.id === id);
4733
+ const held = new Set(state.protected.map((ref: any) => objectRefKey(ref)));
4734
+ const refs = fold
4735
+ ? flattenFoldRefs(fold, state)
4736
+ : state.protected.filter((ref: any) => ref.entryId === id);
4737
+ const pinnedAlready = fold
4738
+ ? refs.length > 0 && refs.every((ref: any) => held.has(objectRefKey(ref)))
4739
+ : refs.length > 0;
4507
4740
  await runQueued(async () => {
4508
- await applyProtectionChange(authoritativeSnapshotFor(ctx), [entryId], !pinnedAlready, ctx);
4741
+ await applyProtectionChange(authoritativeSnapshotFor(ctx), [id], !pinnedAlready, ctx);
4509
4742
  });
4510
4743
  safeNotify(ctx, pinnedAlready
4511
4744
  ? "Unpinned: the released entries rejoin the next commit's candidates."