pi-fold 4.0.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.
@@ -20,6 +20,7 @@ import { dirname, join } from "node:path";
20
20
  import { Container, Input, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
21
21
  import {
22
22
  DEFAULT_CONTEXT_WINDOW,
23
+ DEFAULT_NOTICE_LEAD_SHARE,
23
24
  DEFAULT_THRESHOLDS,
24
25
  DEFAULT_TOOL_FOLD_THRESHOLD,
25
26
  resolveThresholds,
@@ -27,6 +28,7 @@ import {
27
28
  ThresholdPolicyError,
28
29
  type ActiveContextThresholds,
29
30
  } from "./lib/policy.ts";
31
+ import { applyLiveSettings, liveSettingsReachable } from "./lib/live-settings.ts";
30
32
 
31
33
  export const DEFAULT_FOLD_SETTINGS_PATH = join(
32
34
  homedir(),
@@ -43,7 +45,8 @@ export interface FoldSettingsFile {
43
45
  // nothing but themselves, so absent simply means the package default, which is what
44
46
  // lets an older file gain them without a migration.
45
47
  toolFoldThreshold?: number;
46
- postFoldNotice?: boolean;
48
+ preCommitNotice?: boolean;
49
+ noticeLeadShare?: number;
47
50
  }
48
51
 
49
52
  // The four settings, named once. This was a hand-written union until the migration
@@ -58,7 +61,7 @@ const THRESHOLD_FIELDS = [
58
61
 
59
62
  // The settings that are NOT thresholds. Each is a top-level scalar with its own
60
63
  // validation and no relationship to any other value on the screen.
61
- const SCALAR_FIELDS = ["toolFoldThreshold", "postFoldNotice"] as const;
64
+ const SCALAR_FIELDS = ["toolFoldThreshold", "preCommitNotice", "noticeLeadShare"] as const;
62
65
 
63
66
  export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
64
67
 
@@ -75,12 +78,22 @@ export function applyFoldSettingsEdit(
75
78
  id: FoldSettingId,
76
79
  rawValue: string,
77
80
  ): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
78
- if (id === "postFoldNotice") {
81
+ if (id === "preCommitNotice") {
79
82
  const raw = rawValue.trim().toLowerCase();
80
83
  if (raw !== "true" && raw !== "false") {
81
- return { ok: false, error: "postFoldNotice is on or off" };
84
+ return { ok: false, error: "preCommitNotice is on or off" };
82
85
  }
83
- return { ok: true, draft: { ...draft, postFoldNotice: raw === "true" } };
86
+ return { ok: true, draft: { ...draft, preCommitNotice: raw === "true" } };
87
+ }
88
+ if (id === "noticeLeadShare") {
89
+ const value = Number(rawValue.trim());
90
+ // The runtime's own range, restated here so the screen refuses before the file does:
91
+ // a share strictly inside 0 and 1. Zero would put the notice on the same pass as the
92
+ // commit it warns about, which is not a warning.
93
+ if (!Number.isFinite(value) || value <= 0 || value >= 1) {
94
+ return { ok: false, error: "noticeLeadShare is a share above 0 and below 1: how far below the commit point the notice speaks" };
95
+ }
96
+ return { ok: true, draft: { ...draft, noticeLeadShare: value } };
84
97
  }
85
98
  if (id === "toolFoldThreshold") {
86
99
  const value = Number(rawValue.trim());
@@ -112,6 +125,14 @@ export function applyFoldSettingsEdit(
112
125
  // user surface on 2026-08-21 and stayed a registration option for the harness.
113
126
  const RETIRED_FILE_KEYS: readonly string[] = ["providerInputBudget"];
114
127
 
128
+ // Keys this surface RENAMED, carried across with their VALUE (2026-08-30). postFoldNotice
129
+ // was on the screen from 2026-08-27 and became preCommitNotice when the carrier stopped
130
+ // soliciting briefs and started stating status. Dropping it the way a retired key is
131
+ // dropped would silently revert a deployment that had deliberately turned the carrier ON,
132
+ // and refusing it would revert every other value in the file over a key this package
133
+ // itself wrote. Both readings mean the same thing on the same carrier, so the value moves.
134
+ const RENAMED_FILE_KEYS: Readonly<Record<string, FoldSettingId>> = { postFoldNotice: "preCommitNotice" };
135
+
115
136
  // Threshold fields this package's own surface used to write. freshTail was the fifth
116
137
  // setting until 2026-08-23, when fresh-tail protection was deleted outright: nothing
117
138
  // decided on it, and stale-first ordering already leaves recent material last. A stored
@@ -178,6 +199,15 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
178
199
  // wrote providerInputBudget until 2026-08-21, so refusing it would revert a file
179
200
  // this package created. A key that was never on the surface is still a refusal.
180
201
  let dropped = false;
202
+ for (const [from, to] of Object.entries(RENAMED_FILE_KEYS)) {
203
+ if (!Object.prototype.hasOwnProperty.call(parsed, from)) continue;
204
+ // The new name wins if a hand-edited file somehow carries both, since that is the
205
+ // one the screen and the runtime read; either way the old key leaves and the file is
206
+ // written back whole under the current surface.
207
+ if (parsed[to] === undefined) parsed[to] = parsed[from];
208
+ delete parsed[from];
209
+ dropped = true;
210
+ }
181
211
  for (const key of Object.keys(parsed)) {
182
212
  if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
183
213
  if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
@@ -194,11 +224,11 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
194
224
  if (!applied.ok) return refused(`fold settings ${field} is invalid: ${applied.error}`);
195
225
  // String("0.5") round-trips, but a JSON string would too, and the file's own type
196
226
  // has to be right or the value means something different on the next read.
197
- if (field === "postFoldNotice" && typeof value !== "boolean") {
198
- return refused("fold settings postFoldNotice must be true or false");
227
+ if (field === "preCommitNotice" && typeof value !== "boolean") {
228
+ return refused("fold settings preCommitNotice must be true or false");
199
229
  }
200
- if (field === "toolFoldThreshold" && typeof value !== "number") {
201
- return refused("fold settings toolFoldThreshold must be a number");
230
+ if ((field === "toolFoldThreshold" || field === "noticeLeadShare") && typeof value !== "number") {
231
+ return refused(`fold settings ${field} must be a number`);
202
232
  }
203
233
  Object.assign(scalars, applied.draft);
204
234
  }
@@ -239,7 +269,8 @@ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile):
239
269
  // Written only when SET. An absent scalar means the package default, and writing the
240
270
  // default out would pin today's value into a file that should follow the package.
241
271
  if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
242
- if (settings.postFoldNotice !== undefined) clean.postFoldNotice = settings.postFoldNotice;
272
+ if (settings.preCommitNotice !== undefined) clean.preCommitNotice = settings.preCommitNotice;
273
+ if (settings.noticeLeadShare !== undefined) clean.noticeLeadShare = settings.noticeLeadShare;
243
274
  mkdirSync(dirname(path), { recursive: true });
244
275
  const temporary = `${path}.tmp`;
245
276
  writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
@@ -264,16 +295,25 @@ const EDITOR_ROWS: readonly EditorRow[] = [
264
295
  { id: "consolidateAfter", label: "Folds per group", description: "Once this many folds are visible, pi-fold groups them under one (consolidateAfter)" },
265
296
  { id: "minFoldChars", label: "Smallest fold", description: "Text this short is not worth folding on its own; it joins the fold beside it instead (minFoldChars)" },
266
297
  { id: "toolFoldThreshold", label: "Clip old tool results", description: "The oldest share of the window shows tool results shortened, still recoverable in full. Off keeps them whole (toolFoldThreshold)" },
267
- { id: "postFoldNotice", label: "Ask the model for briefs", description: "Invite the model to rewrite a fold's summary. Off is what the measured runs used (postFoldNotice)" },
298
+ { id: "preCommitNotice", label: "Tell the model before folding", description: "Show the model the window's status once as a fold approaches: what is staged, what is pinned, what it can change. Off is what the measured runs used (preCommitNotice)" },
299
+ { id: "noticeLeadShare", label: "How early to tell it", description: "How far below the folding point that status appears. Only used when the notice is on (noticeLeadShare)" },
268
300
  ];
269
301
 
270
- // The cycle lattices. Shares step in cents so no float drift reaches a threshold;
271
- // each row's list is FILTERED against the current draft before display, which is
272
- // what makes an invalid combination unselectable rather than an error message.
273
- const SHARE_STEPS: Record<"maxTarget" | "minTarget", { min: number; max: number; step: number }> = {
274
- maxTarget: { min: 0.40, max: 0.95, step: 0.05 },
275
- minTarget: { min: 0.05, max: 0.60, step: 0.05 },
276
- };
302
+ // The cycle lattice for both shares. Shares step in cents so no float drift reaches a
303
+ // threshold; each row's list is FILTERED against the current draft before display, which
304
+ // is what makes an invalid combination unselectable rather than an error message.
305
+ //
306
+ // ONE LATTICE, AND ITS ONLY GUARDS ARE THE POLICY'S OWN (Shane, 2026-08-29). The rows
307
+ // used to carry ranges of their own, 0.40 to 0.95 for the trigger and 0.05 to 0.60 for
308
+ // the aim, and nothing in the runtime asks for either bound: resolveThresholds wants a
309
+ // share strictly between 0 and 1 with the aim below the trigger, and that is all. Found
310
+ // in use on a 1,000,000-token window, where 0.40 is a first fold at about 393,000 tokens:
311
+ // later than the person wanted to start, reachable past only through Enter's exact-value
312
+ // editor, and one arrow keypress after that snapped the value back up to 0.40, because
313
+ // the lattice did not contain where they were standing.
314
+ // The lattice now spans the legal range at the same step, so stepping is the ordinary
315
+ // way to reach a low band and typing is only for values finer than the step.
316
+ const SHARE_LATTICE = { min: 0.05, max: 0.95, step: 0.05 };
277
317
 
278
318
  const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
279
319
 
@@ -286,8 +326,8 @@ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32
286
326
  // off: without a 0 here the only way to decline in-view clipping would be the file.
287
327
  const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
288
328
 
289
- function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
290
- const { min, max, step } = SHARE_STEPS[id];
329
+ function shareCandidates(): number[] {
330
+ const { min, max, step } = SHARE_LATTICE;
291
331
  const values: number[] = [];
292
332
  for (let cents = Math.round(min * 100); cents <= Math.round(max * 100); cents += Math.round(step * 100)) {
293
333
  values.push(cents / 100);
@@ -299,12 +339,20 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
299
339
  // only to it: this list is stepped, never shown.
300
340
  function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
301
341
  if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
342
+ // THE LEAD STEPS THE ONE SHARE LATTICE, unfiltered. It answers to nothing but its own
343
+ // range: a lead wider than the trigger is legal and means "speak from the first
344
+ // measurement", which the runtime floors rather than refuses, so there is no
345
+ // cross-field invariant here for a filter to enforce.
346
+ if (id === "noticeLeadShare") return shareCandidates();
302
347
  if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
303
348
  if (id === "minFoldChars") return MIN_FOLD_CHOICES;
304
349
  const { minTarget, maxTarget } = thresholds;
350
+ // The cross-field invariant IS the filter, so a row can be stepped to any legal share
351
+ // and never to an illegal one; the trigger stops one increment above the aim rather
352
+ // than at some number chosen here.
305
353
  return id === "maxTarget"
306
- ? shareCandidates("maxTarget").filter((v) => v > minTarget)
307
- : shareCandidates("minTarget").filter((v) => v < maxTarget);
354
+ ? shareCandidates().filter((v) => v > minTarget)
355
+ : shareCandidates().filter((v) => v < maxTarget);
308
356
  }
309
357
 
310
358
  function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
@@ -312,9 +360,9 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
312
360
  // file omits what was never set on purpose, so the screen has to supply the same
313
361
  // value the runtime would, or the row would show a setting nobody chose.
314
362
  if (!isThresholdField(id)) {
315
- return id === "toolFoldThreshold"
316
- ? String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD)
317
- : String(settings.postFoldNotice ?? false);
363
+ if (id === "toolFoldThreshold") return String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD);
364
+ if (id === "noticeLeadShare") return String(settings.noticeLeadShare ?? DEFAULT_NOTICE_LEAD_SHARE);
365
+ return String(settings.preCommitNotice ?? false);
318
366
  }
319
367
  const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
320
368
  return String(thresholds[id]);
@@ -322,7 +370,13 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
322
370
 
323
371
  function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
324
372
  const raw = rowRawValue(settings, id);
325
- if (id === "postFoldNotice") return raw === "true" ? "on" : "off";
373
+ if (id === "preCommitNotice") return raw === "true" ? "on" : "off";
374
+ if (id === "noticeLeadShare") {
375
+ // STATED AS THE DISTANCE IT IS, in the same dialect as the two band rows: a share of
376
+ // the budget, with the tokens it works out to, because "10%" alone reads as a point
377
+ // on the bar rather than as a gap before one.
378
+ return `${Math.round(Number(raw) * 100)}% earlier · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tokens`;
379
+ }
326
380
  if (id === "toolFoldThreshold") {
327
381
  // The share is of the window rather than of a budget, so it is stated as the
328
382
  // slice it names instead of the token count the other two shares translate to.
@@ -422,12 +476,19 @@ export class FoldSettingsEditor extends Container {
422
476
  // place a refused file is ever named, so it says so before showing the
423
477
  // defaults that took its place.
424
478
  notice: string | null = null,
479
+ // THE RUNNING SESSION, when there is one to reach. Given, every saved edit is
480
+ // pushed into the runtime registered on the same host and takes effect at once;
481
+ // absent, the file is still written and the next start reads it. The screen SAYS
482
+ // which of the two it is rather than promising the one it cannot deliver.
483
+ private readonly live: ((settings: FoldSettingsFile) => void) | null = null,
425
484
  ) {
426
485
  super();
427
486
  const theme = this.themeLike;
428
487
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
429
488
  this.addChild(new Text(theme.bold(theme.fg("accent", "pi-fold settings")), 0, 0));
430
- this.addChild(new Text(theme.fg("muted", "Percentages are of the usable window. Every change saves as you make it."), 0, 0));
489
+ this.addChild(new Text(theme.fg("muted", live
490
+ ? "Percentages are of the usable window. Every change saves and takes effect at once."
491
+ : "Percentages are of the usable window. Every change saves now and applies when pi next starts."), 0, 0));
431
492
  if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
432
493
  this.addChild(new Spacer(1));
433
494
  // SettingsList takes its own theme shape; adapt it off the live theme.
@@ -441,7 +502,7 @@ export class FoldSettingsEditor extends Container {
441
502
  // than cycling a value, and nothing is cancelled because every change is saved
442
503
  // the moment it is made. The hint function is ours and is handed the string, so
443
504
  // the correction happens here rather than being contradicted a line below.
444
- hint: (_text: string) => theme.fg("dim", "←→ adjust · Enter types an exact value · Esc closes"),
505
+ hint: () => theme.fg("dim", "←→ adjust · Enter types an exact value · Esc closes"),
445
506
  };
446
507
  this.settingsList = new SettingsList(
447
508
  EDITOR_ROWS.map((row) => ({
@@ -474,7 +535,7 @@ export class FoldSettingsEditor extends Container {
474
535
  private step(id: FoldSettingId, direction: number): void {
475
536
  // A SWITCH HAS NO LATTICE. Either direction moves to the other value, which is
476
537
  // what a person expects of a two-state row sitting among stepped ones.
477
- if (id === "postFoldNotice") {
538
+ if (id === "preCommitNotice") {
478
539
  this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
479
540
  return;
480
541
  }
@@ -493,6 +554,12 @@ export class FoldSettingsEditor extends Container {
493
554
  if (!result.ok) return result;
494
555
  this.draft = result.draft;
495
556
  saveFoldSettingsFile(this.settingsPath, this.draft);
557
+ // THE FILE FIRST, THE SESSION SECOND. The draft has already been through
558
+ // resolveThresholds, which is the same validation the runtime applies, so a refusal
559
+ // here would be a defect rather than a person's mistake; it is caught anyway,
560
+ // because a settings screen may not take down the session it configures, and the
561
+ // durable file is already correct for the next start either way.
562
+ if (this.live) { try { this.live(this.draft); } catch { } }
496
563
  for (const row of EDITOR_ROWS) {
497
564
  const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
498
565
  if (item) item.currentValue = rowDisplayValue(this.draft, row.id, this.budgetTokens);
@@ -541,9 +608,16 @@ export function registerFoldSettings(
541
608
  // experiment harness, which declares a budget so runs stay comparable across
542
609
  // descriptor changes; no person has to know that to read this screen.
543
610
  const budgetTokens = servingBudgetTokens(descriptorWindow);
611
+ // THE CHANNEL TO THE RUNNING RUNTIME, read HERE rather than at registration: this
612
+ // module and registerPiFold are registered independently and in either order, and
613
+ // by the time a person opens this screen both have run. A host with no runtime on
614
+ // it gets a null, which the screen states plainly.
615
+ const live = liveSettingsReachable(pi)
616
+ ? (settings: FoldSettingsFile) => { applyLiveSettings(pi, settings, ctx); }
617
+ : null;
544
618
  await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
545
619
  new FoldSettingsEditor(
546
- stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal,
620
+ stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal, live,
547
621
  ));
548
622
  },
549
623
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.0.0",
4
- "description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
3
+ "version": "4.1.0",
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",
7
7
  "pi",
@@ -18,10 +18,10 @@
18
18
  "private": false,
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "git+https://github.com/shaneconner/pi-fold.git"
21
+ "url": "git+https://github.com/shaneconner/fold.git"
22
22
  },
23
- "bugs": "https://github.com/shaneconner/pi-fold/issues",
24
- "homepage": "https://github.com/shaneconner/pi-fold#readme",
23
+ "bugs": "https://github.com/shaneconner/fold/issues",
24
+ "homepage": "https://github.com/shaneconner/fold#readme",
25
25
  "author": "Shane Conner",
26
26
  "license": "MIT",
27
27
  "files": [