pi-fold 4.1.1 → 4.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,10 +37,10 @@ In an interactive session one row sits directly above Pi's footer and shows the
37
37
 
38
38
  <picture>
39
39
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-dark.svg">
40
- <img alt="The fold bar: forty cells of the serving budget, oldest on the left, with ticks at the aim and the commit point. Staged spans, raw material and fold placeholders fill it in three colours, and the label reads 42%, commit at 80%, 12 staged, 118k to free, 3 folds hold 412k." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
40
+ <img alt="The fold bar: forty cells of the serving budget, oldest on the left, with ticks at the aim and the commit point. Staged spans, raw material and fold placeholders fill it in three colours, and the label reads 42%, commit at 80%, 12 staged, 3 folds hide 412k tokens." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
41
41
  </picture>
42
42
 
43
- The bar is the serving budget, forty cells wide, oldest material on the left. The two ticks are the band: the left one is `minTarget`, where a commit aims to land, and the right one is `maxTarget`, where the commit fires. The fill is the provider's own count of what is in the window, never an estimate, so before the first response the row says `not measured yet` and draws nothing. One cell is one fortieth of the budget, and the fill reads left to right in three colours: the placeholders of standing folds first, drawn as low slivers and small by design; then staged spans, the raw material the next commit collapses; then raw material the runtime has not claimed yet. The label repeats the percentage, names the commit point (or reads `COMMIT DUE` once the fill has crossed it and `commit held` once that count has been weighed), states how many spans are staged and what they free, and ends with what folding has already bought: the tokens the standing folds hide. If automatic folding ever stops, the row reads `FOLDING STOPPED` in place of the bar. On a truecolor terminal the three fill classes take three steps of Crameri's batlow colour map; elsewhere they use the theme's own colours.
43
+ The bar is the serving budget, forty cells wide, oldest material on the left. The two ticks are the band: the left one is `minTarget`, where a commit aims to land, and the right one is `maxTarget`, where the commit fires. The fill is the provider's own count of what is in the window, never an estimate, so before the first response the row says `not measured yet` and draws nothing. One cell is one fortieth of the budget, and the fill reads left to right in three colours: the placeholders of standing folds first, drawn as low slivers and small by design; then staged spans, the raw material the next commit collapses; then raw material the runtime has not claimed yet. The label repeats the percentage, names the commit point (or reads `COMMIT DUE` once the fill has crossed it and `commit held` once that count has been weighed), states how many spans are staged, and ends with what folding has already bought: the tokens the standing folds hide from the window. Every token figure prices a pasted image as one image rather than as its base64 text, the same way the occupancy estimate does. Right after a commit the percentage is still the provider's last count, so the label says it is from before the commit until the next response measures the window again. If automatic folding ever stops, the row reads `FOLDING STOPPED` in place of the bar. On a truecolor terminal the three fill classes take three steps of Crameri's batlow colour map; elsewhere they use the theme's own colours.
44
44
 
45
45
  ## Results
46
46
 
@@ -35,6 +35,7 @@ import {
35
35
  selectAutomaticRung,
36
36
  setFoldProjectionState,
37
37
  withExpandLease,
38
+ renderFoldParts,
38
39
  } from "./lib/folding.ts";
39
40
  import {
40
41
  admissionVerdict,
@@ -54,6 +55,7 @@ import {
54
55
  parseNativeCompactionDecision,
55
56
  parseProviderContextMeasurementReceipt,
56
57
  persistenceProjection,
58
+ pricedBytes,
57
59
  protectedStaleMass,
58
60
  providerContextMeasurement,
59
61
  providerTokens,
@@ -178,7 +180,6 @@ import {
178
180
  peekedSourceFoldIds,
179
181
  snapFoldCandidate,
180
182
  snapToFoldBoundaries,
181
- spanBytes,
182
183
  } from "./lib/selection.ts";
183
184
  import type { SpanCorrection } from "./lib/selection.ts";
184
185
  import {
@@ -224,6 +225,7 @@ export * from "./lib/persistence.ts";
224
225
  export * from "./lib/policy.ts";
225
226
  export * from "./lib/rollback.ts";
226
227
  export * from "./lib/scheduling.ts";
228
+ export * from "./lib/status-widget.ts";
227
229
  export * from "./lib/selection.ts";
228
230
  export * from "./lib/transcript.ts";
229
231
 
@@ -667,6 +669,12 @@ export function registerActiveContext(pi: any, options: {
667
669
 
668
670
  const deliverReceipt = (receipt: ContextReceipt): void => {
669
671
  curation.receipts = withReceipt(curation.receipts, receipt);
672
+ // THE BAR'S READING GOES STALE AT A COMMIT. Occupancy is the provider's last count,
673
+ // which does not move until the next response, so after a commit the bar went on
674
+ // showing the pre-commit number as if it were current (38% beside "10 folds hold
675
+ // 8.6M", Shane 2026-09-03). Remember which measurement the commit landed against;
676
+ // while it is still the latest, the bar says the number is from before the commit.
677
+ if (receipt.foldsCommitted > 0) foldBar.staleSince = measurements.lastProviderMeasurement;
670
678
  // MONOTONIC, unlike `curation.receipts.length`, which saturates at MAX_CONTEXT_RECEIPTS
671
679
  // and would stop distinguishing one approach from the next once the session is long
672
680
  // enough to matter. This is the only counter the notice latch reads.
@@ -718,6 +726,11 @@ export function registerActiveContext(pi: any, options: {
718
726
  model: null as FoldBarModel | null,
719
727
  installed: false,
720
728
  requestRender: null as (() => void) | null,
729
+ /** The provider measurement the last landed commit ran against; the reading is
730
+ * stale while it is still the latest one. */
731
+ staleSince: null as unknown,
732
+ /** Each standing fold's source priced by the image law, keyed by the fold object. */
733
+ pricedSource: new WeakMap<ActiveFold, number>(),
721
734
  };
722
735
  const installFoldBar = (ctx: any): void => {
723
736
  if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
@@ -734,18 +747,38 @@ export function registerActiveContext(pi: any, options: {
734
747
  };
735
748
  }, { placement: "belowEditor" });
736
749
  };
737
- const foldBarModel = (input: {
750
+ const foldBarModel = (ctx: any, input: {
738
751
  share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
739
752
  }): FoldBarModel => {
740
753
  const state = persistence.state;
741
- const snapshot = lifecycle.latestSnapshot;
754
+ // THE SAME SNAPSHOT /fold-status PRICES AGAINST. `markFreedBytes` answers ZERO, silently,
755
+ // for a span it cannot locate contiguously in the snapshot it is handed, and the last
756
+ // context event's snapshot can trail the branch the marks were cut from; the bar read
757
+ // "5 staged, 0 to free" beside a status command pricing the same marks at 41,877
758
+ // tokens (Shane, 2026-09-02, session 01a06272). The authoritative snapshot is memoized
759
+ // on the branch length, so reading it here costs nothing while nothing has changed.
760
+ let snapshot = lifecycle.latestSnapshot;
761
+ try { snapshot = authoritativeSnapshotFor(ctx); } catch { }
742
762
  let stagedTokens = 0;
743
763
  let placeholderChars = 0;
744
764
  let hiddenChars = 0;
745
765
  if (state && snapshot) {
746
766
  for (const mark of pendingMarks(state)) stagedTokens += estimatedTokens(markFreedBytes(snapshot, state, mark));
747
767
  for (const root of orderedRoots(state, snapshot)) placeholderChars += root.fold.placeholderChars;
748
- for (const fold of state.folds) hiddenChars += Math.max(0, fold.sourceChars - fold.placeholderChars);
768
+ // WHAT FOLDING BOUGHT, IN WINDOW TERMS. `sourceChars` is the transcript count, base64
769
+ // and all, and read as tokens it said "8.6M" over a 1M window. Each standing fold's
770
+ // source is priced by the image law once per fold OBJECT (folds are immutable and
771
+ // replaced, never mutated, so a WeakMap memo is exact) and summed over the whole
772
+ // forest, which is additive because a parent's source is its children's placeholders.
773
+ for (const fold of state.folds) {
774
+ let priced = foldBar.pricedSource.get(fold);
775
+ if (priced === undefined) {
776
+ const source = renderFoldParts(fold.parts, state, snapshot);
777
+ priced = source ? pricedBytes(source) : fold.sourceChars;
778
+ foldBar.pricedSource.set(fold, priced);
779
+ }
780
+ hiddenChars += Math.max(0, priced - fold.placeholderChars);
781
+ }
749
782
  }
750
783
  const budget = input.budgetTokens > 0 ? input.budgetTokens : 1;
751
784
  return {
@@ -760,6 +793,8 @@ export function registerActiveContext(pi: any, options: {
760
793
  folds: input.roots,
761
794
  hiddenTokens: estimatedTokens(hiddenChars),
762
795
  weighed: input.weighed,
796
+ staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
797
+ foldBar.staleSince === measurements.lastProviderMeasurement,
763
798
  stopped: ladder.automaticFailure?.message ?? null,
764
799
  };
765
800
  };
@@ -774,7 +809,7 @@ export function registerActiveContext(pi: any, options: {
774
809
  try {
775
810
  installFoldBar(ctx);
776
811
  if (foldBar.installed) {
777
- foldBar.model = foldBarModel({
812
+ foldBar.model = foldBarModel(ctx, {
778
813
  share, budgetTokens: capacity.budgetTokens, staged, roots,
779
814
  weighed: ladder.bandTopMeasurement !== null &&
780
815
  ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
@@ -2652,7 +2687,7 @@ export function registerActiveContext(pi: any, options: {
2652
2687
  });
2653
2688
  return null;
2654
2689
  }
2655
- const bytesBefore = bytes(projectActiveContext(snapshot, state));
2690
+ const bytesBefore = pricedBytes(projectActiveContext(snapshot, state));
2656
2691
  // THE COMMIT CUTS ONLY AS DEEP AS THE BUDGET ASKS (Shane, 2026-08-23). The frontier
2657
2692
  // stages as material arrives, so by now the pending set is most of the window; without
2658
2693
  // this bound the first commit would fold all of it. `freeingTarget` is a share of the
@@ -2735,7 +2770,7 @@ export function registerActiveContext(pi: any, options: {
2735
2770
  }
2736
2771
  }
2737
2772
  persistence.state = result.state;
2738
- const bytesAfter = bytes(projectActiveContext(snapshot, persistence.state));
2773
+ const bytesAfter = pricedBytes(projectActiveContext(snapshot, persistence.state));
2739
2774
  const freedBytes = Math.max(0, bytesBefore - bytesAfter);
2740
2775
  const pinHeld = protectedStaleMass(snapshot, result.state);
2741
2776
  const commitEvent = emit("context.commit", {
@@ -4499,7 +4534,11 @@ export function registerActiveContext(pi: any, options: {
4499
4534
  safeNotify(
4500
4535
  ctx,
4501
4536
  committed
4502
- ? `Committed ${ownValue(committed, "applied_marks") ?? 0} mark(s)${topUp ? " with automatic top-up" : ""}. ` +
4537
+ // THE EPOCH RECORD IS camelCase; `applied_marks` is the EVENT's spelling. Reading
4538
+ // the event's name off the record printed "Committed 0 mark(s)" after a /fold that
4539
+ // had just applied forty (Shane, 2026-09-03, session 01a06746: 378,216 tokens to
4540
+ // 97,577 with the notice saying nothing happened).
4541
+ ? `Committed ${ownValue(committed, "appliedMarks") ?? 0} mark(s)${topUp ? " with automatic top-up" : ""}. ` +
4503
4542
  "Exact source remains expandable."
4504
4543
  : "Nothing to commit: no staged mark could apply (pinned or already-folded spans hold).",
4505
4544
  "info",
@@ -4586,7 +4625,7 @@ export function registerActiveContext(pi: any, options: {
4586
4625
  // what the staged mark answers with after it.
4587
4626
  const spanCost = (from: number, to: number) => ({
4588
4627
  entries: Math.abs(to - from) + 1,
4589
- tokens: estimatedTokens(spanBytes(snapshot, Math.min(from, to), Math.max(from, to) + 1)),
4628
+ tokens: estimatedTokens(pricedBytes(snapshot.messages.slice(Math.min(from, to), Math.max(from, to) + 1))),
4590
4629
  });
4591
4630
 
4592
4631
  /**
@@ -23,6 +23,8 @@ import {
23
23
  import {
24
24
  ACTIVE_CONTEXT_POLICY,
25
25
  DEFAULT_CONTEXT_WINDOW,
26
+ ESTIMATED_BYTES_PER_TOKEN,
27
+ IMAGE_ESTIMATED_TOKENS,
26
28
  servingBudgetTokens,
27
29
  } from "./policy.ts";
28
30
  import type {
@@ -594,6 +596,25 @@ export function parseNativeCompactionCompletion(value: unknown, expectedSessionI
594
596
  * needs to be the right order of magnitude instead of wrong by seventy times, and the
595
597
  * provider anchor corrects the remainder the moment a real measurement lands.
596
598
  */
599
+ /**
600
+ * WHAT A SPAN COSTS THE WINDOW, in the byte unit every estimator already speaks.
601
+ *
602
+ * `bytes()` counts a message's serialized characters, and a pasted screenshot serializes
603
+ * as hundreds of thousands of base64 characters the provider prices as one image. The
604
+ * occupancy estimate has applied that law since gate 156; the pricing of what a fold
605
+ * FREES had not, so a session holding 83 screenshots (67.3M base64 characters against
606
+ * 1.5M of text) told its owner a commit had "5.8M to free" against a 1M window, and a
607
+ * commit that moved the window 280,000 tokens recorded 8.6M freed (Shane, 2026-09-03,
608
+ * session 01a06746). Text is counted as its bytes; each image is counted as the bytes
609
+ * that estimate to IMAGE_ESTIMATED_TOKENS, so `estimatedTokens(pricedBytes(m))` is the
610
+ * same reading gate 156 gives the projection. Every "frees N" figure derives from this.
611
+ */
612
+ export function pricedBytes(messages: unknown[]): number {
613
+ const images = imageMass(messages);
614
+ return Math.max(0, bytes(messages) - images.base64Chars) +
615
+ images.images * IMAGE_ESTIMATED_TOKENS * ESTIMATED_BYTES_PER_TOKEN;
616
+ }
617
+
597
618
  export function imageMass(projected: unknown[]): { base64Chars: number; images: number } {
598
619
  let base64Chars = 0;
599
620
  let images = 0;
@@ -27,6 +27,7 @@ import {
27
27
  orderedRoots,
28
28
  protectedStaleMass,
29
29
  refsProtected,
30
+ pricedBytes,
30
31
  } from "./measurement.ts";
31
32
  import {
32
33
  childFoldIds,
@@ -179,13 +180,15 @@ export function markFreedBytes(
179
180
  ...state,
180
181
  expanded: state.expanded.filter((id) => id !== mark.id),
181
182
  }, snapshot);
182
- return expanded && folded ? Math.max(0, bytes(expanded) - bytes(folded)) : 0;
183
+ return expanded && folded ? Math.max(0, pricedBytes(expanded) - pricedBytes(folded)) : 0;
183
184
  }
184
185
  const source = renderFoldParts(mark.parts, state, snapshot);
185
186
  if (!source) return 0;
186
187
  const placeholders = mark.kind === "tool-result" ? mark.parts.length : 1;
187
188
  const placeholder = placeholders * (mark.brief.length + ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES);
188
- return Math.max(0, bytes(source) - placeholder);
189
+ // PRICED, NOT COUNTED: a span holding a screenshot is worth one image to the
190
+ // window, not its base64 length (measurement.ts, pricedBytes).
191
+ return Math.max(0, pricedBytes(source) - placeholder);
189
192
  }
190
193
 
191
194
  export function markSpanStart(
@@ -55,6 +55,8 @@ export interface FoldBarModel {
55
55
  hiddenTokens: number;
56
56
  /** Whether a band-top commit has already been weighed against the current count. */
57
57
  weighed: boolean;
58
+ /** A commit landed after the count `share` reads; the number is from before it. */
59
+ staleAfterCommit: boolean;
58
60
  /** The suspension message when automatic folding has stopped, else null. */
59
61
  stopped: string | null;
60
62
  }
@@ -64,11 +66,33 @@ export interface FoldBarTheme {
64
66
  fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
65
67
  bold(text: string): string;
66
68
  getColorMode?(): "truecolor" | "256color";
69
+ getFgAnsi?(color: "text"): string;
70
+ name?: string;
67
71
  }
68
72
 
69
- // Crameri batlow10, classes 3, 6 and 8 (0-indexed). The two darkest classes are unusable
70
- // as fill on a dark terminal, and the lightest washes out on a light one.
71
- const BATLOW = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
73
+ // Crameri batlow10, three classes per background. Classes 3, 6 and 8 (0-indexed) read on
74
+ // a dark terminal; the lightest of them is 1.7:1 against white, so a light terminal steps
75
+ // two classes darker (2, 5 and 6), the same triple the README's light figure uses.
76
+ const BATLOW_ON_DARK = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
77
+ const BATLOW_ON_LIGHT = { raw: "#1C5A62", staged: "#9D892B", folded: "#D29343" } as const;
78
+
79
+ /**
80
+ * WHICH WAY THE BACKGROUND GOES, read off the theme's own text colour rather than its
81
+ * name: a theme whose body text is dark is drawn on a light background. pi hands the
82
+ * text colour out as a truecolor escape, and a theme that does not (256-colour mode,
83
+ * or a host without the accessor) falls back to the name, then to dark.
84
+ */
85
+ export function lightBackground(theme: FoldBarTheme): boolean {
86
+ try {
87
+ const ansi = theme.getFgAnsi?.("text") ?? "";
88
+ const match = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
89
+ if (match) {
90
+ const [r, g, b] = match.slice(1).map((v) => Number(v) / 255);
91
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5;
92
+ }
93
+ } catch { }
94
+ return /light/i.test(theme.name ?? "");
95
+ }
72
96
 
73
97
  const truecolor = (hex: string, text: string): string => {
74
98
  const r = parseInt(hex.slice(1, 3), 16);
@@ -138,6 +162,7 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
138
162
  width, theme.fg("dim", "..."));
139
163
  }
140
164
  const rich = theme.getColorMode?.() === "truecolor";
165
+ const BATLOW = lightBackground(theme) ? BATLOW_ON_LIGHT : BATLOW_ON_DARK;
141
166
  const ink: Record<Cell, (text: string) => string> = {
142
167
  raw: (t) => rich ? truecolor(BATLOW.raw, t) : theme.fg("muted", t),
143
168
  staged: (t) => rich ? truecolor(BATLOW.staged, t) : theme.fg("text", t),
@@ -154,19 +179,27 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
154
179
  else bar += ink[cells[i]](GLYPH[cells[i]]);
155
180
  }
156
181
  const pct = Math.round(model.share * 100);
157
- const parts: string[] = [theme.fg("text", `${pct}%`)];
158
- if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
159
- else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
160
- else parts.push(theme.fg("warning", "COMMIT DUE"));
161
- // A staged count with nothing priced behind it reads "0 to free", which is a
162
- // contradiction on the face of the row; state the count alone until the marks price.
163
- if (model.stagedMarks > 0) {
164
- parts.push(ink.staged(model.stagedTokens > 0
165
- ? `${model.stagedMarks} staged, ${formatTokens(model.stagedTokens)} to free`
166
- : `${model.stagedMarks} staged`));
182
+ const parts: string[] = [];
183
+ // A READING FROM BEFORE THE COMMIT SAYS SO. The count only moves when the provider
184
+ // answers, so right after a commit the bar showed the pre-commit percentage in the
185
+ // same type as a live one and read as a commit that did nothing. No estimate is
186
+ // drawn in its place: the freed bytes are transcript mass and the arithmetic would
187
+ // go negative against the window.
188
+ if (model.staleAfterCommit) {
189
+ parts.push(theme.fg("muted", `${pct}% before the commit`));
190
+ } else {
191
+ parts.push(theme.fg("text", `${pct}%`));
192
+ if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
193
+ else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
194
+ else parts.push(theme.fg("warning", "COMMIT DUE"));
167
195
  }
196
+ // A COUNT FOR WHAT IS STAGED, TOKENS FOR WHAT FOLDING BOUGHT. Both figures are priced
197
+ // by the image law now (measurement.ts pricedBytes): "5.8M to free" beside a 1M window
198
+ // was 83 screenshots' base64 counted as text (Shane 2026-09-03). The unit is written
199
+ // out because pi's k/M format read as megabytes.
200
+ if (model.stagedMarks > 0) parts.push(ink.staged(`${model.stagedMarks} staged`));
168
201
  if (model.folds > 0) {
169
- parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hold ${formatTokens(model.hiddenTokens)}`));
202
+ parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hide ${formatTokens(model.hiddenTokens)} tokens`));
170
203
  }
171
204
  // NO BRAND IN FRONT OF THE BAR (Shane 2026-09-02): the bar identifies itself, and the
172
205
  // two prose states above keep the brand because nothing else on them says whose they are.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
4
4
  "description": "Lossless context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",