pi-fold 3.0.0 → 4.0.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.
@@ -21,6 +21,7 @@ import { Container, Input, Key, matchesKey, SettingsList, Spacer, Text } from "@
21
21
  import {
22
22
  DEFAULT_CONTEXT_WINDOW,
23
23
  DEFAULT_THRESHOLDS,
24
+ DEFAULT_TOOL_FOLD_THRESHOLD,
24
25
  resolveThresholds,
25
26
  servingBudgetTokens,
26
27
  ThresholdPolicyError,
@@ -36,6 +37,13 @@ export const DEFAULT_FOLD_SETTINGS_PATH = join(
36
37
 
37
38
  export interface FoldSettingsFile {
38
39
  thresholds?: ActiveContextThresholds;
40
+ // TWO INDEPENDENT SCALARS, NOT A SECOND WHOLE-OBJECT (2026-08-28). The thresholds
41
+ // object is stored whole because its fields carry cross-field invariants and a partial
42
+ // one would silently mean whatever the defaults are at read time. These two answer to
43
+ // nothing but themselves, so absent simply means the package default, which is what
44
+ // lets an older file gain them without a migration.
45
+ toolFoldThreshold?: number;
46
+ postFoldNotice?: boolean;
39
47
  }
40
48
 
41
49
  // The four settings, named once. This was a hand-written union until the migration
@@ -48,17 +56,14 @@ const THRESHOLD_FIELDS = [
48
56
  "minFoldChars",
49
57
  ] as const;
50
58
 
51
- export type FoldSettingId = typeof THRESHOLD_FIELDS[number];
59
+ // The settings that are NOT thresholds. Each is a top-level scalar with its own
60
+ // validation and no relationship to any other value on the screen.
61
+ const SCALAR_FIELDS = ["toolFoldThreshold", "postFoldNotice"] as const;
52
62
 
53
- function readProportion(raw: string, field: string): number {
54
- const value = Number(raw);
55
- if (!Number.isFinite(value) || value <= 0 || value >= 1) {
56
- throw new ThresholdPolicyError(
57
- field,
58
- `thresholds.${field} must be a proportion above 0 and below 1`,
59
- );
60
- }
61
- return value;
63
+ export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
64
+
65
+ function isThresholdField(id: FoldSettingId): id is typeof THRESHOLD_FIELDS[number] {
66
+ return (THRESHOLD_FIELDS as readonly string[]).includes(id);
62
67
  }
63
68
 
64
69
  // One edit, applied against the WHOLE draft: the merged thresholds object is
@@ -70,19 +75,28 @@ export function applyFoldSettingsEdit(
70
75
  id: FoldSettingId,
71
76
  rawValue: string,
72
77
  ): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
78
+ if (id === "postFoldNotice") {
79
+ const raw = rawValue.trim().toLowerCase();
80
+ if (raw !== "true" && raw !== "false") {
81
+ return { ok: false, error: "postFoldNotice is on or off" };
82
+ }
83
+ return { ok: true, draft: { ...draft, postFoldNotice: raw === "true" } };
84
+ }
85
+ if (id === "toolFoldThreshold") {
86
+ const value = Number(rawValue.trim());
87
+ // The runtime's own range, restated here so the screen refuses before the file
88
+ // does: [0, 1) with 0 meaning off. Absence would ALSO mean the default rather
89
+ // than off, which is why 0 has to be expressible.
90
+ if (!Number.isFinite(value) || value < 0 || value >= 1) {
91
+ return { ok: false, error: "toolFoldThreshold is a share from 0 up to but not including 1; 0 turns it off" };
92
+ }
93
+ return { ok: true, draft: { ...draft, toolFoldThreshold: value } };
94
+ }
73
95
  try {
74
- if (id === "consolidateAfter" || id === "minFoldChars") {
75
- const value = Number(rawValue.trim());
76
- if (!Number.isSafeInteger(value)) {
77
- return { ok: false, error: `thresholds.${id} must be a whole number` };
78
- }
79
- const thresholds = resolveThresholds({
80
- ...(draft.thresholds ?? DEFAULT_THRESHOLDS),
81
- [id]: value,
82
- });
83
- return { ok: true, draft: { ...draft, thresholds } };
96
+ const value = Number(rawValue.trim());
97
+ if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
98
+ return { ok: false, error: `thresholds.${id} must be a whole number` };
84
99
  }
85
- const value = readProportion(rawValue.trim(), id);
86
100
  const thresholds = resolveThresholds({
87
101
  ...(draft.thresholds ?? DEFAULT_THRESHOLDS),
88
102
  [id]: value,
@@ -165,13 +179,32 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
165
179
  // this package created. A key that was never on the surface is still a refusal.
166
180
  let dropped = false;
167
181
  for (const key of Object.keys(parsed)) {
168
- if (key === "thresholds") continue;
182
+ if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
169
183
  if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
170
- return refused(`fold settings file has no ${key} field: the surface is thresholds`);
184
+ return refused(`fold settings file has no ${key} field: the surface is ` +
185
+ `thresholds, ${SCALAR_FIELDS.join(", ")}`);
186
+ }
187
+ // The two scalars are validated the same way the editor validates them, through the
188
+ // one path, so a hand-edited file cannot hold a value the screen would refuse.
189
+ const scalars: FoldSettingsFile = {};
190
+ for (const field of SCALAR_FIELDS) {
191
+ if (parsed[field] === undefined) continue;
192
+ const value = parsed[field];
193
+ const applied = applyFoldSettingsEdit({}, field, String(value));
194
+ if (!applied.ok) return refused(`fold settings ${field} is invalid: ${applied.error}`);
195
+ // String("0.5") round-trips, but a JSON string would too, and the file's own type
196
+ // 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");
199
+ }
200
+ if (field === "toolFoldThreshold" && typeof value !== "number") {
201
+ return refused("fold settings toolFoldThreshold must be a number");
202
+ }
203
+ Object.assign(scalars, applied.draft);
171
204
  }
172
205
  if (parsed.thresholds === undefined) {
173
- if (dropped) { try { saveFoldSettingsFile(path, {}); } catch { } }
174
- return { settings: {}, refusal: null, migrated: dropped };
206
+ if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
207
+ return { settings: scalars, refusal: null, migrated: dropped };
175
208
  }
176
209
  const stored = parsed.thresholds;
177
210
  if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
@@ -188,8 +221,9 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
188
221
  ? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
189
222
  : supplied);
190
223
  const migrated = missing.length > 0 || dropped;
191
- if (migrated) { try { saveFoldSettingsFile(path, { thresholds }); } catch { } }
192
- return { settings: { thresholds }, refusal: null, migrated };
224
+ const settings: FoldSettingsFile = { thresholds, ...scalars };
225
+ if (migrated) { try { saveFoldSettingsFile(path, settings); } catch { } }
226
+ return { settings, refusal: null, migrated };
193
227
  } catch (error: any) {
194
228
  return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
195
229
  }
@@ -202,6 +236,10 @@ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
202
236
  export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
203
237
  const clean: FoldSettingsFile = {};
204
238
  if (settings.thresholds !== undefined) clean.thresholds = settings.thresholds;
239
+ // Written only when SET. An absent scalar means the package default, and writing the
240
+ // default out would pin today's value into a file that should follow the package.
241
+ if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
242
+ if (settings.postFoldNotice !== undefined) clean.postFoldNotice = settings.postFoldNotice;
205
243
  mkdirSync(dirname(path), { recursive: true });
206
244
  const temporary = `${path}.tmp`;
207
245
  writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
@@ -214,11 +252,19 @@ interface EditorRow {
214
252
  description: string;
215
253
  }
216
254
 
255
+ // WRITTEN FOR THE READER, NOT THE IMPLEMENTER (2026-08-28). "Fires a fold epoch" and
256
+ // "parents owed per epoch = visible roots divided by this" are this codebase's words for
257
+ // its own internals, and a person setting a value should not have to learn them. Each
258
+ // description now says what the setting DOES to their session; the option name stays in
259
+ // parentheses as the bridge to the README table and to registerPiFold, which document
260
+ // these exact names.
217
261
  const EDITOR_ROWS: readonly EditorRow[] = [
218
- { id: "maxTarget", label: "Commit trigger", description: "Occupancy share of the budget that fires a fold epoch (maxTarget)" },
219
- { id: "minTarget", label: "Post-commit aim", description: "Share the epoch cuts back down toward (minTarget)" },
220
- { id: "consolidateAfter", label: "Consolidation divisor", description: "Parents owed per epoch = visible roots divided by this (consolidateAfter)" },
221
- { id: "minFoldChars", label: "Minimum fold size", description: "Characters a fold must reach to be worth making; a smaller gap between folds is absorbed by the fold beside it (minFoldChars)" },
262
+ { id: "maxTarget", label: "Start folding at", description: "How full the window gets before pi-fold folds. Nothing moves below this (maxTarget)" },
263
+ { id: "minTarget", label: "Fold down to", description: "How empty a fold leaves the window. The gap between the two sets how often folding runs (minTarget)" },
264
+ { id: "consolidateAfter", label: "Folds per group", description: "Once this many folds are visible, pi-fold groups them under one (consolidateAfter)" },
265
+ { 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
+ { 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)" },
222
268
  ];
223
269
 
224
270
  // The cycle lattices. Shares step in cents so no float drift reaches a threshold;
@@ -236,6 +282,10 @@ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
236
282
  // enforces, below which a placeholder can cost more than the source it replaces.
237
283
  const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
238
284
 
285
+ // 0 IS ON THE LATTICE, and first, because absence means the package default rather than
286
+ // off: without a 0 here the only way to decline in-view clipping would be the file.
287
+ const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
288
+
239
289
  function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
240
290
  const { min, max, step } = SHARE_STEPS[id];
241
291
  const values: number[] = [];
@@ -245,30 +295,45 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
245
295
  return values;
246
296
  }
247
297
 
248
- function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds, budgetTokens: number): string[] {
249
- if (id === "consolidateAfter") return CONSOLIDATE_CHOICES.map(String);
250
- if (id === "minFoldChars") return MIN_FOLD_CHOICES.map((value) => `${value.toLocaleString("en-US")} chars`);
298
+ // The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
299
+ // only to it: this list is stepped, never shown.
300
+ function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
301
+ if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
302
+ if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
303
+ if (id === "minFoldChars") return MIN_FOLD_CHOICES;
251
304
  const { minTarget, maxTarget } = thresholds;
252
- const candidates = id === "maxTarget"
305
+ return id === "maxTarget"
253
306
  ? shareCandidates("maxTarget").filter((v) => v > minTarget)
254
307
  : shareCandidates("minTarget").filter((v) => v < maxTarget);
255
- // Entries carry the SAME display format as currentValue: SettingsList cycles by
256
- // indexOf(currentValue), so a format mismatch wraps every first press to the head.
257
- return candidates.map((v) => `${v.toFixed(2)} · ${Math.round(v * budgetTokens).toLocaleString("en-US")} tok`);
258
308
  }
259
309
 
260
310
  function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
311
+ // A SCALAR THAT IS ABSENT READS AS THE PACKAGE DEFAULT, never as zero or false. The
312
+ // file omits what was never set on purpose, so the screen has to supply the same
313
+ // value the runtime would, or the row would show a setting nobody chose.
314
+ if (!isThresholdField(id)) {
315
+ return id === "toolFoldThreshold"
316
+ ? String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD)
317
+ : String(settings.postFoldNotice ?? false);
318
+ }
261
319
  const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
262
320
  return String(thresholds[id]);
263
321
  }
264
322
 
265
323
  function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
266
324
  const raw = rowRawValue(settings, id);
325
+ if (id === "postFoldNotice") return raw === "true" ? "on" : "off";
326
+ if (id === "toolFoldThreshold") {
327
+ // The share is of the window rather than of a budget, so it is stated as the
328
+ // slice it names instead of the token count the other two shares translate to.
329
+ return Number(raw) === 0 ? "off" : `oldest ${Math.round(Number(raw) * 100)}%`;
330
+ }
267
331
  if (id === "consolidateAfter") return raw;
268
- if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} chars`;
269
- // toFixed(2) matches the cycle entries exactly; String(0.8) renders "0.8" while
270
- // the cycle list carries "0.80", and SettingsList cycles by indexOf(currentValue).
271
- return `${Number(raw).toFixed(2)} · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tok`;
332
+ if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} characters`;
333
+ // A PERCENTAGE, like every other human surface. The screen used to read "0.80" while
334
+ // the status line, /fold-status and the editor header all said "80%", so the one place
335
+ // a person CHANGES the number spoke a different dialect from the three that report it.
336
+ return `${Math.round(Number(raw) * 100)}% · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tokens`;
272
337
  }
273
338
 
274
339
  // The frame Pi's own /settings screen draws around its list. Implemented locally
@@ -362,7 +427,7 @@ export class FoldSettingsEditor extends Container {
362
427
  const theme = this.themeLike;
363
428
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
364
429
  this.addChild(new Text(theme.bold(theme.fg("accent", "pi-fold settings")), 0, 0));
365
- this.addChild(new Text(theme.fg("muted", "Shares are of the serving budget; edits save immediately. ←→ steps · Enter types an exact value."), 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));
366
431
  if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
367
432
  this.addChild(new Spacer(1));
368
433
  // SettingsList takes its own theme shape; adapt it off the live theme.
@@ -371,7 +436,12 @@ export class FoldSettingsEditor extends Container {
371
436
  value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
372
437
  description: (text: string) => theme.fg("dim", text),
373
438
  cursor: theme.fg("accent", "→ "),
374
- hint: (text: string) => theme.fg("dim", text),
439
+ // SettingsList ships its own hint, "Enter/Space to change · Esc to cancel", and
440
+ // BOTH halves are wrong on this screen: Enter opens an exact-value editor rather
441
+ // than cycling a value, and nothing is cancelled because every change is saved
442
+ // the moment it is made. The hint function is ours and is handed the string, so
443
+ // 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"),
375
445
  };
376
446
  this.settingsList = new SettingsList(
377
447
  EDITOR_ROWS.map((row) => ({
@@ -385,7 +455,7 @@ export class FoldSettingsEditor extends Container {
385
455
  row.label,
386
456
  row.description,
387
457
  rowRawValue(draft, row.id),
388
- (raw) => this.applyEdit(row.id, raw),
458
+ (raw) => this.applyAndSave(row.id, raw),
389
459
  submenuDone,
390
460
  ),
391
461
  })),
@@ -395,8 +465,6 @@ export class FoldSettingsEditor extends Container {
395
465
  () => this.done(true),
396
466
  );
397
467
  this.addChild(this.settingsList);
398
- this.addChild(new Spacer(1));
399
- this.addChild(new Text(theme.fg("dim", " ←→ to step · Enter for exact · Esc to close"), 0, 0));
400
468
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
401
469
  }
402
470
 
@@ -404,9 +472,14 @@ export class FoldSettingsEditor extends Container {
404
472
  // filtered against the CURRENT draft, so stepping can never leave the policy
405
473
  // surface; an exact off-lattice value arrives only through Enter's editor.
406
474
  private step(id: FoldSettingId, direction: number): void {
475
+ // A SWITCH HAS NO LATTICE. Either direction moves to the other value, which is
476
+ // what a person expects of a two-state row sitting among stepped ones.
477
+ if (id === "postFoldNotice") {
478
+ this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
479
+ return;
480
+ }
407
481
  const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
408
- const candidates = allowedValues(id, thresholds).map((entry) =>
409
- Number.parseFloat(entry.replace(/,/g, "")));
482
+ const candidates = allowedValues(id, thresholds);
410
483
  const current = Number(rowRawValue(this.draft, id));
411
484
  const target = direction > 0
412
485
  ? candidates.find((candidate) => candidate > current + 1e-9)
@@ -427,10 +500,6 @@ export class FoldSettingsEditor extends Container {
427
500
  return { ok: true, display: rowDisplayValue(this.draft, id, this.budgetTokens) };
428
501
  }
429
502
 
430
- private applyEdit(id: FoldSettingId, raw: string): { ok: true; display: string } | { ok: false; error: string } {
431
- return this.applyAndSave(id, raw);
432
- }
433
-
434
503
  handleInput(data: string): void {
435
504
  // An open submenu owns everything until it closes.
436
505
  if (this.settingsList.submenuComponent) {
@@ -459,7 +528,7 @@ export function registerFoldSettings(
459
528
  ): void {
460
529
  const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
461
530
  pi.registerCommand("fold-settings", {
462
- description: "Configure pi-fold: commit trigger, post-commit aim, consolidation divisor, minimum fold size",
531
+ description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, brief invitation",
463
532
  handler: async (_args: string, ctx: any) => {
464
533
  if (typeof ctx.ui?.custom !== "function") {
465
534
  throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",