pi-fold 1.0.4 → 3.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.
@@ -0,0 +1,481 @@
1
+ // /fold-settings: user-facing configuration for the deployment options that are
2
+ // otherwise code. The storage stays exactly what registerPiFold accepts (the
3
+ // thresholds object set whole); this module only
4
+ // changes the experience: one validation path, an in-TUI editor over a JSON file,
5
+ // and every state that reaches disk already passed resolveThresholds.
6
+ //
7
+ // The editor is built from pi-tui's own SettingsList and Input so it looks and
8
+ // behaves like Pi's native /settings screen, and ALL key handling goes through
9
+ // matchesKey: raw byte matching froze the editor on terminals in application
10
+ // cursor mode, where arrows arrive as SS3 (\x1bOA) rather than CSI (\x1b[A).
11
+ //
12
+ // The steppable rows CYCLE through allowed values the way /settings cycles its
13
+ // discrete options, with each row's list filtered against the CURRENT invariants,
14
+ // so a combination resolveThresholds would refuse is never even selectable. The
15
+ // input budget is genuinely free-form and keeps an Input submenu.
16
+
17
+ import { homedir } from "node:os";
18
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { Container, Input, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
21
+ import {
22
+ DEFAULT_CONTEXT_WINDOW,
23
+ DEFAULT_THRESHOLDS,
24
+ resolveThresholds,
25
+ servingBudgetTokens,
26
+ ThresholdPolicyError,
27
+ type ActiveContextThresholds,
28
+ } from "./lib/policy.ts";
29
+
30
+ export const DEFAULT_FOLD_SETTINGS_PATH = join(
31
+ homedir(),
32
+ ".config",
33
+ "pi-fold",
34
+ "settings.json",
35
+ );
36
+
37
+ export interface FoldSettingsFile {
38
+ thresholds?: ActiveContextThresholds;
39
+ }
40
+
41
+ // The four settings, named once. This was a hand-written union until the migration
42
+ // below needed the same names at runtime; deriving the type from the array is what
43
+ // keeps the two readings from drifting apart.
44
+ const THRESHOLD_FIELDS = [
45
+ "maxTarget",
46
+ "minTarget",
47
+ "consolidateAfter",
48
+ "minFoldChars",
49
+ ] as const;
50
+
51
+ export type FoldSettingId = typeof THRESHOLD_FIELDS[number];
52
+
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;
62
+ }
63
+
64
+ // One edit, applied against the WHOLE draft: the merged thresholds object is
65
+ // re-validated through resolveThresholds so cross-field invariants
66
+ // (minTarget < maxTarget) hold at every saved state, never
67
+ // only in the file's final form. Returns the next whole draft or a named error.
68
+ export function applyFoldSettingsEdit(
69
+ draft: FoldSettingsFile,
70
+ id: FoldSettingId,
71
+ rawValue: string,
72
+ ): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
73
+ 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 } };
84
+ }
85
+ const value = readProportion(rawValue.trim(), id);
86
+ const thresholds = resolveThresholds({
87
+ ...(draft.thresholds ?? DEFAULT_THRESHOLDS),
88
+ [id]: value,
89
+ });
90
+ return { ok: true, draft: { ...draft, thresholds } };
91
+ } catch (error) {
92
+ if (error instanceof ThresholdPolicyError) return { ok: false, error: error.message };
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ // Keys this package's own settings screen used to write. providerInputBudget left the
98
+ // user surface on 2026-08-21 and stayed a registration option for the harness.
99
+ const RETIRED_FILE_KEYS: readonly string[] = ["providerInputBudget"];
100
+
101
+ // Threshold fields this package's own surface used to write. freshTail was the fifth
102
+ // setting until 2026-08-23, when fresh-tail protection was deleted outright: nothing
103
+ // decided on it, and stale-first ordering already leaves recent material last. A stored
104
+ // file carrying it is a file /fold-settings itself wrote, so the field is dropped in
105
+ // migration, keeping every value the person actually tuned, rather than refused whole,
106
+ // which reverted maxTarget and minTarget to defaults over a key the person never chose.
107
+ const RETIRED_THRESHOLD_KEYS: readonly string[] = ["freshTail"];
108
+
109
+ export interface FoldSettingsLoad {
110
+ settings: FoldSettingsFile;
111
+ // Why the stored file was not used. Null when it was, including after a migration.
112
+ refusal: string | null;
113
+ // Fields the file predated were filled from the defaults and written back whole.
114
+ migrated: boolean;
115
+ }
116
+
117
+ /**
118
+ * A STORED FILE MAY NOT STOP THE AGENT (2026-08-22).
119
+ *
120
+ * The deployment calls this at module scope and hands the result to registerPiFold, so
121
+ * anything thrown here is a pi that does not start: "Failed to load extension ... :
122
+ * thresholds must declare minFoldChars". That is what shipping minFoldChars did to a
123
+ * settings file written the day before, and a user's own config is data rather than
124
+ * code: it may be refused, but it may not brick the session it configures.
125
+ *
126
+ * Two outcomes, and the split is deliberate.
127
+ *
128
+ * A file that is merely OLDER than the surface is MIGRATED. Fields it does not carry
129
+ * are filled from the defaults, the whole object is validated the ordinary way, and the
130
+ * result is written back so the file on disk is whole again, pinned at the value it was
131
+ * migrated with. That keeps the values the person actually tuned and keeps the
132
+ * whole-or-not-at-all law honest: a partial object never survives to be re-read against
133
+ * a later set of defaults. A failed write-back is not fatal either; the migration stands
134
+ * in memory and repeats harmlessly on the next boot.
135
+ *
136
+ * Anything else INVALID is refused by name and the package defaults are used, with the
137
+ * reason kept for /fold-settings to state. The limit is stated rather than hidden: a
138
+ * refused file reverts to defaults, and the settings screen is the only place that says
139
+ * so, so a person who never opens it sees defaults without being told why.
140
+ */
141
+ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH): FoldSettingsLoad {
142
+ let raw: string;
143
+ try {
144
+ raw = readFileSync(path, "utf8");
145
+ } catch (error: any) {
146
+ if (error?.code === "ENOENT") return { settings: {}, refusal: null, migrated: false };
147
+ return { settings: {}, refusal: `fold settings file at ${path} could not be read: ${error?.message ?? error}`, migrated: false };
148
+ }
149
+ const refused = (reason: string): FoldSettingsLoad => ({
150
+ settings: {},
151
+ refusal: `${reason}. Package defaults are in force; /fold-settings writes a valid file.`,
152
+ migrated: false,
153
+ });
154
+ let parsed: Record<string, unknown>;
155
+ try {
156
+ parsed = JSON.parse(raw) as Record<string, unknown>;
157
+ } catch (error: any) {
158
+ return refused(`fold settings file at ${path} is not valid JSON: ${error?.message ?? error}`);
159
+ }
160
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
161
+ return refused(`fold settings file at ${path} must be a JSON object`);
162
+ }
163
+ // A key the surface RETIRED is dropped rather than refused: /fold-settings itself
164
+ // wrote providerInputBudget until 2026-08-21, so refusing it would revert a file
165
+ // this package created. A key that was never on the surface is still a refusal.
166
+ let dropped = false;
167
+ for (const key of Object.keys(parsed)) {
168
+ if (key === "thresholds") continue;
169
+ 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`);
171
+ }
172
+ if (parsed.thresholds === undefined) {
173
+ if (dropped) { try { saveFoldSettingsFile(path, {}); } catch { } }
174
+ return { settings: {}, refusal: null, migrated: dropped };
175
+ }
176
+ const stored = parsed.thresholds;
177
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
178
+ return refused("fold settings thresholds must be an object");
179
+ }
180
+ const supplied = { ...(stored as Record<string, unknown>) };
181
+ for (const key of RETIRED_THRESHOLD_KEYS) {
182
+ if (Object.prototype.hasOwnProperty.call(supplied, key)) { delete supplied[key]; dropped = true; }
183
+ }
184
+ const missing = THRESHOLD_FIELDS.filter((field) =>
185
+ !Object.prototype.hasOwnProperty.call(supplied, field));
186
+ try {
187
+ const thresholds = resolveThresholds(missing.length
188
+ ? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
189
+ : supplied);
190
+ const migrated = missing.length > 0 || dropped;
191
+ if (migrated) { try { saveFoldSettingsFile(path, { thresholds }); } catch { } }
192
+ return { settings: { thresholds }, refusal: null, migrated };
193
+ } catch (error: any) {
194
+ return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
195
+ }
196
+ }
197
+
198
+ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH): FoldSettingsFile {
199
+ return readFoldSettingsFile(path).settings;
200
+ }
201
+
202
+ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
203
+ const clean: FoldSettingsFile = {};
204
+ if (settings.thresholds !== undefined) clean.thresholds = settings.thresholds;
205
+ mkdirSync(dirname(path), { recursive: true });
206
+ const temporary = `${path}.tmp`;
207
+ writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
208
+ renameSync(temporary, path);
209
+ }
210
+
211
+ interface EditorRow {
212
+ id: FoldSettingId;
213
+ label: string;
214
+ description: string;
215
+ }
216
+
217
+ 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)" },
222
+ ];
223
+
224
+ // The cycle lattices. Shares step in cents so no float drift reaches a threshold;
225
+ // each row's list is FILTERED against the current draft before display, which is
226
+ // what makes an invalid combination unselectable rather than an error message.
227
+ const SHARE_STEPS: Record<"maxTarget" | "minTarget", { min: number; max: number; step: number }> = {
228
+ maxTarget: { min: 0.40, max: 0.95, step: 0.05 },
229
+ minTarget: { min: 0.05, max: 0.60, step: 0.05 },
230
+ };
231
+
232
+ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
233
+
234
+ // Characters, not tokens: a token means a different amount of text on every wire, and
235
+ // this is a number a person picks. The list starts at the hard floor the validator
236
+ // enforces, below which a placeholder can cost more than the source it replaces.
237
+ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
238
+
239
+ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
240
+ const { min, max, step } = SHARE_STEPS[id];
241
+ const values: number[] = [];
242
+ for (let cents = Math.round(min * 100); cents <= Math.round(max * 100); cents += Math.round(step * 100)) {
243
+ values.push(cents / 100);
244
+ }
245
+ return values;
246
+ }
247
+
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`);
251
+ const { minTarget, maxTarget } = thresholds;
252
+ const candidates = id === "maxTarget"
253
+ ? shareCandidates("maxTarget").filter((v) => v > minTarget)
254
+ : 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
+ }
259
+
260
+ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
261
+ const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
262
+ return String(thresholds[id]);
263
+ }
264
+
265
+ function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
266
+ const raw = rowRawValue(settings, id);
267
+ 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`;
272
+ }
273
+
274
+ // The frame Pi's own /settings screen draws around its list. Implemented locally
275
+ // rather than imported because jiti keeps a separate module cache: the border's
276
+ // default color closure would bind to a different theme instance than the one the
277
+ // editor receives, so the color always arrives explicitly.
278
+ class SettingsBorder {
279
+ constructor(private readonly color: (text: string) => string) {}
280
+ invalidate(): void {}
281
+ render(width: number): string[] {
282
+ return [this.color("─".repeat(Math.max(1, width)))];
283
+ }
284
+ }
285
+
286
+ // The free-form editor behind the budget row's submenu: an Input prefilled with
287
+ // the raw value; Enter applies through applyFoldSettingsEdit and only a valid
288
+ // result calls done, so an invalid state can never reach the list, the file, or
289
+ // registration. Composition mirrors the native SelectSubmenu.
290
+ class FoldValueEditor extends Container {
291
+ private readonly input = new Input();
292
+ private readonly errorText = new Text("", 0, 0);
293
+
294
+ constructor(
295
+ private readonly themeLike: any,
296
+ private readonly rowLabel: string,
297
+ private readonly rowDescription: string,
298
+ initialValue: string,
299
+ private readonly apply: (raw: string) => { ok: true; display: string } | { ok: false; error: string },
300
+ private readonly done: (displayValue?: string) => void,
301
+ ) {
302
+ super();
303
+ const theme = this.themeLike;
304
+ this.addChild(new Text(theme.bold(theme.fg("accent", rowLabel)), 0, 0));
305
+ this.addChild(new Text(theme.fg("muted", rowDescription), 0, 0));
306
+ this.addChild(new Spacer(1));
307
+ this.input.setValue(initialValue);
308
+ // setValue parks the cursor at 0; a prefilled editor must start at the end,
309
+ // or typing inserts at the front and backspace deletes nothing.
310
+ (this.input as any).cursor = initialValue.length;
311
+ this.input.onSubmit = () => this.submit();
312
+ this.input.onEscape = () => this.done();
313
+ this.addChild(this.input);
314
+ this.addChild(new Spacer(1));
315
+ this.addChild(this.errorText);
316
+ this.addChild(new Text(theme.fg("dim", " Enter to apply · Esc to go back"), 0, 0));
317
+ }
318
+
319
+ private submit(): void {
320
+ const result = this.apply(this.input.getValue());
321
+ if (!result.ok) {
322
+ this.errorText.setText(this.themeLike.fg("error", ` ${result.error}`));
323
+ return;
324
+ }
325
+ this.done(result.display);
326
+ }
327
+
328
+ /** True until the first key: typing then REPLACES the prefill rather than
329
+ * appending to it ("0.35" over "0.4" was landing as "0.40.35" and refusing).
330
+ * Editing keys (backspace, arrows) keep the prefill for adjustment. */
331
+ private pristine = true;
332
+
333
+ handleInput(data: string): void {
334
+ if (this.pristine && data.length === 1 && data >= " " && data !== "\x7f") {
335
+ this.input.setValue("");
336
+ (this.input as any).cursor = 0;
337
+ }
338
+ this.pristine = false;
339
+ this.input.handleInput(data);
340
+ }
341
+ }
342
+
343
+ // The /fold-settings screen itself: a SettingsList over the four rows, styled off
344
+ // the live theme, with every applied change persisted immediately the way Pi's own
345
+ // /settings persists. Left/right STEPS the selected row through its allowed
346
+ // increments (clamped at the range ends); Enter opens an exact-value editor.
347
+ export class FoldSettingsEditor extends Container {
348
+ private readonly settingsList: SettingsList;
349
+
350
+ constructor(
351
+ private draft: FoldSettingsFile,
352
+ private readonly budgetTokens: number,
353
+ private readonly settingsPath: string,
354
+ private readonly themeLike: any,
355
+ private readonly done: (saved: boolean) => void,
356
+ // Why the stored file was not used, when it was not. This screen is the only
357
+ // place a refused file is ever named, so it says so before showing the
358
+ // defaults that took its place.
359
+ notice: string | null = null,
360
+ ) {
361
+ super();
362
+ const theme = this.themeLike;
363
+ this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
364
+ 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));
366
+ if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
367
+ this.addChild(new Spacer(1));
368
+ // SettingsList takes its own theme shape; adapt it off the live theme.
369
+ const listTheme = {
370
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
371
+ value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
372
+ description: (text: string) => theme.fg("dim", text),
373
+ cursor: theme.fg("accent", "→ "),
374
+ hint: (text: string) => theme.fg("dim", text),
375
+ };
376
+ this.settingsList = new SettingsList(
377
+ EDITOR_ROWS.map((row) => ({
378
+ id: row.id,
379
+ label: row.label,
380
+ description: row.description,
381
+ currentValue: rowDisplayValue(draft, row.id, budgetTokens),
382
+ submenu: (_current: string, submenuDone: (displayValue?: string) => void) =>
383
+ new FoldValueEditor(
384
+ themeLike,
385
+ row.label,
386
+ row.description,
387
+ rowRawValue(draft, row.id),
388
+ (raw) => this.applyEdit(row.id, raw),
389
+ submenuDone,
390
+ ),
391
+ })),
392
+ EDITOR_ROWS.length + 2,
393
+ listTheme,
394
+ () => {},
395
+ () => this.done(true),
396
+ );
397
+ 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
+ this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
401
+ }
402
+
403
+ // One step along the row's allowed lattice, clamped at its ends. The lattice is
404
+ // filtered against the CURRENT draft, so stepping can never leave the policy
405
+ // surface; an exact off-lattice value arrives only through Enter's editor.
406
+ private step(id: FoldSettingId, direction: number): void {
407
+ const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
408
+ const candidates = allowedValues(id, thresholds).map((entry) =>
409
+ Number.parseFloat(entry.replace(/,/g, "")));
410
+ const current = Number(rowRawValue(this.draft, id));
411
+ const target = direction > 0
412
+ ? candidates.find((candidate) => candidate > current + 1e-9)
413
+ : [...candidates].reverse().find((candidate) => candidate < current - 1e-9);
414
+ if (target === undefined) return;
415
+ this.applyAndSave(id, String(target));
416
+ }
417
+
418
+ private applyAndSave(id: FoldSettingId, raw: string): { ok: true; display: string } | { ok: false; error: string } {
419
+ const result = applyFoldSettingsEdit(this.draft, id, raw);
420
+ if (!result.ok) return result;
421
+ this.draft = result.draft;
422
+ saveFoldSettingsFile(this.settingsPath, this.draft);
423
+ for (const row of EDITOR_ROWS) {
424
+ const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
425
+ if (item) item.currentValue = rowDisplayValue(this.draft, row.id, this.budgetTokens);
426
+ }
427
+ return { ok: true, display: rowDisplayValue(this.draft, id, this.budgetTokens) };
428
+ }
429
+
430
+ private applyEdit(id: FoldSettingId, raw: string): { ok: true; display: string } | { ok: false; error: string } {
431
+ return this.applyAndSave(id, raw);
432
+ }
433
+
434
+ handleInput(data: string): void {
435
+ // An open submenu owns everything until it closes.
436
+ if (this.settingsList.submenuComponent) {
437
+ this.settingsList.handleInput(data);
438
+ return;
439
+ }
440
+ if (matchesKey(data, Key.left)) {
441
+ this.step(EDITOR_ROWS[this.settingsList.selectedIndex].id, -1);
442
+ return;
443
+ }
444
+ if (matchesKey(data, Key.right)) {
445
+ this.step(EDITOR_ROWS[this.settingsList.selectedIndex].id, +1);
446
+ return;
447
+ }
448
+ if (matchesKey(data, Key.escape)) {
449
+ this.done(true);
450
+ return;
451
+ }
452
+ this.settingsList.handleInput(data);
453
+ }
454
+ }
455
+
456
+ export function registerFoldSettings(
457
+ pi: any,
458
+ options: { settingsPath?: string } = {},
459
+ ): void {
460
+ const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
461
+ pi.registerCommand("fold-settings", {
462
+ description: "Configure pi-fold: commit trigger, post-commit aim, consolidation divisor, minimum fold size",
463
+ handler: async (_args: string, ctx: any) => {
464
+ if (typeof ctx.ui?.custom !== "function") {
465
+ throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
466
+ }
467
+ const stored = readFoldSettingsFile(settingsPath);
468
+ const descriptorWindow = ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
469
+ // SHARES APPLY TO THE MODEL WINDOW (Shane, 2026-08-21): providerInputBudget is
470
+ // off the user surface, so what a share means is derived from the descriptor's
471
+ // own window and nothing else. The registration option still exists for the
472
+ // experiment harness, which declares a budget so runs stay comparable across
473
+ // descriptor changes; no person has to know that to read this screen.
474
+ const budgetTokens = servingBudgetTokens(descriptorWindow);
475
+ await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
476
+ new FoldSettingsEditor(
477
+ stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal,
478
+ ));
479
+ },
480
+ });
481
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "1.0.4",
3
+ "version": "3.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",
@@ -52,5 +52,8 @@
52
52
  "extensions": [
53
53
  "./extensions"
54
54
  ]
55
+ },
56
+ "dependencies": {
57
+ "@earendil-works/pi-tui": "^0.84.2"
55
58
  }
56
59
  }