pi-fold 2.0.0 → 3.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.
@@ -3,12 +3,10 @@ import {
3
3
  evidenceRef,
4
4
  evidenceValue,
5
5
  isPlainRecord,
6
- objectRefKey,
7
6
  sha256Text,
8
7
  stableStringify,
9
8
  } from "../json.ts";
10
9
  import {
11
- bytes,
12
10
  clone,
13
11
  messageRole,
14
12
  ownValue,
@@ -24,7 +22,6 @@ import {
24
22
  DEFAULT_THRESHOLDS,
25
23
  PEEK_READ_ONLY_CONTEXT_ACTIONS,
26
24
  servingBudgetTokens,
27
- zoneBytes,
28
25
  } from "./policy.ts";
29
26
  import type {
30
27
  ActiveContextSnapshot,
@@ -40,6 +37,17 @@ export function terminalAssistant(message: unknown): boolean {
40
37
  return stop === "stop" || stop === "length";
41
38
  }
42
39
 
40
+ /**
41
+ * An assistant stop that ends a turn by dying rather than by finishing. It is never
42
+ * terminal on its own: while it is the last message the turn may still be retried in
43
+ * place, so only a FOLLOWING user message proves the turn is over.
44
+ */
45
+ export function deadAssistant(message: unknown): boolean {
46
+ if (messageRole(message) !== "assistant") return false;
47
+ const stop = ownValue(message, "stopReason");
48
+ return stop === "error" || stop === "aborted";
49
+ }
50
+
43
51
  export function completeTurns(messages: unknown[]): CompleteTurn[] {
44
52
  const starts: number[] = [];
45
53
  for (let index = 0; index < messages.length; index += 1) if (messageRole(messages[index]) === "user") starts.push(index);
@@ -50,27 +58,35 @@ export function completeTurns(messages: unknown[]): CompleteTurn[] {
50
58
  let end = limit;
51
59
  while (end > start && (messageRole(messages[end - 1]) === "custom" ||
52
60
  messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
53
- if (end > start && terminalAssistant(messages[end - 1])) turns.push({ start, end });
61
+ if (end <= start) continue;
62
+ // A DEAD TURN IS A CLOSED TURN (sol-20260826-full2, pifold rep 1). A turn whose last
63
+ // word is an errored or aborted assistant, with a user message already standing after
64
+ // it, is over: no response for its material is ever coming, so holding its spans for
65
+ // a consumption proof holds them forever. That hold is what deadlocked a live run:
66
+ // the projection-budget fence aborted a request that would not fit, the abort put an
67
+ // errored assistant at the end of the turn holding the 423KB that made it not fit,
68
+ // the errored turn was invisible to every fold lane, and the next thirty-four
69
+ // requests aborted identically with the reclaimer finding nothing it was allowed to
70
+ // touch. The live tail keeps the strict rule: while the errored assistant is the
71
+ // last message the turn may still be retried in place, so death alone closes
72
+ // nothing.
73
+ if (terminalAssistant(messages[end - 1]) ||
74
+ (starts[cursor + 1] !== undefined && deadAssistant(messages[end - 1]))) {
75
+ turns.push({ start, end });
76
+ }
54
77
  }
55
78
  return turns;
56
79
  }
57
80
 
58
- export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn | null {
59
- if (messageRole(messages[0]) !== "compactionSummary") return null;
60
- let firstUser = -1;
61
- for (let index = 0; index < messages.length; index += 1) {
62
- if (messageRole(messages[index]) === "user") { firstUser = index; break; }
63
- }
64
- if (firstUser <= 1 || messageRole(messages[1]) !== "assistant") return null;
65
- let end = firstUser;
66
- while (end > 1 && (messageRole(messages[end - 1]) === "custom" ||
67
- messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
68
- return end > 1 && terminalAssistant(messages[end - 1]) ? { start: 1, end } : null;
69
- }
70
-
71
81
  export const READ_ONLY_CONTEXT_ACTION_ARGUMENTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
72
82
  status: Object.freeze(["action", "detail", "offset", "limit"]),
73
- peek: Object.freeze(["action", "id"]),
83
+ // offset and bytes are the tool surface's own narrowing parameters: a paged or
84
+ // narrowed peek is as byte-inert as a bare one, and excluding them made the
85
+ // read built to SAVE window mass linger as raw mass past the reclaimer.
86
+ // ephemeral joins them for the same reason: it changes when the RESULT leaves
87
+ // the projection, never what the call moves, and a call the reclaimer cannot
88
+ // claim would linger as exactly the mass the parameter exists to shed.
89
+ peek: Object.freeze(["action", "id", "offset", "bytes", "ephemeral"]),
74
90
  });
75
91
 
76
92
  export function isAutoFoldableToolCall(
@@ -317,7 +333,52 @@ export function unsafeChapterIndices(messages: unknown[]): Set<number> {
317
333
  return unsafe;
318
334
  }
319
335
 
320
- export function freshBoundary(messages: unknown[], turns: CompleteTurn[], freshBytes: number): number {
336
+ /**
337
+ * The first index of an unfinished user turn, or the end of the transcript when the last
338
+ * turn completed. Folding into an unfinished turn leaves a user message whose tool calls are
339
+ * unanswered, which is malformed for every provider, so this is a validity boundary rather
340
+ * than a curation preference. The fresh-tail byte walk that used to sit here is deleted:
341
+ * nothing decided on it, and stale-first ordering already leaves recent material last.
342
+ */
343
+ /**
344
+ * THE STALE-SPAN MATURITY FLOOR (2026-08-24). The tool-batch lane proves consumption
345
+ * before it will index a result: two later generations in an open turn, one or the
346
+ * terminal assistant in a closed one (resultCallIndex). The chapter lane had no such
347
+ * proof, and on the pull-shaped workload the frontier cuts the moment a span crosses
348
+ * minFoldChars, which is always one turn before the same span's batch qualifies:
349
+ * sol-20260823-live rep 3 cut 219 chapters and ZERO tool folds over a transcript whose
350
+ * every span replays as a tool-result when aged, so every placeholder read "User: No
351
+ * user ask · Tools: 1×repo_stage · Assistant: No assistant text" and the agent
352
+ * re-peeked 76 folds 209 times to rebuild the index the brief should have carried. One
353
+ * floor, kind-agnostic, derived from the proof the batch lane already demands: a stale
354
+ * span may be cut only when it ends before the second-newest open generation, so by the
355
+ * time the chapter lane may touch material the tool lane has had its turn at it, and
356
+ * the tool-first preference in selectAutomaticSpan is reachable rather than starved.
357
+ * This is NOT gate 19/72's fresh-tail proportion returning: nothing here reads bytes or
358
+ * shares, and a closed session (terminal assistant) folds to its end exactly as before.
359
+ */
360
+ export function staleSpanMatureEnd(messages: unknown[]): number {
361
+ if (messages.length && terminalAssistant(messages[messages.length - 1])) return messages.length;
362
+ const generations: number[] = [];
363
+ for (let index = 0; index < messages.length; index += 1) {
364
+ const message = messages[index];
365
+ if (messageRole(message) === "assistant" && ownValue(message, "stopReason") === "toolUse") {
366
+ generations.push(index);
367
+ }
368
+ }
369
+ const generationFloor = generations.length < 2 ? 0 : generations[generations.length - 2];
370
+ // MATURITY REACHES THROUGH EVERY CLOSED TURN; the generation floor governs only the
371
+ // live one. A closed turn's material has had every chance it will ever get: a finished
372
+ // turn was consumed by its own terminal, and a dead one (completeTurns' rule) can never
373
+ // be consumed at all, which is exactly the state that wedged sol-20260826-full2. The
374
+ // generation count alone under-reads both, because it keeps demanding two responses
375
+ // AFTER material that no response will ever follow.
376
+ const turns = completeTurns(messages);
377
+ const closedEnd = turns.length ? turns[turns.length - 1].end : 0;
378
+ return Math.max(generationFloor, closedEnd);
379
+ }
380
+
381
+ export function freshBoundary(messages: unknown[], turns: CompleteTurn[]): number {
321
382
  if (!messages.length) return 0;
322
383
  let unfinishedUser = messages.length;
323
384
  for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -329,36 +390,7 @@ export function freshBoundary(messages: unknown[], turns: CompleteTurn[], freshB
329
390
  unfinishedUser = complete ? messages.length : index;
330
391
  break;
331
392
  }
332
- const leading = leadingCompactionContinuation(messages);
333
- let boundary = Math.min(messages.length, unfinishedUser);
334
- while (boundary > 0) {
335
- const suffix: unknown[] = [];
336
- for (let index = boundary; index < messages.length; index += 1) suffix.push(messages[index]);
337
- if (bytes(suffix) >= freshBytes) break;
338
- let previous: CompleteTurn | null = null;
339
- for (let turnIndex = 0; turnIndex < turns.length; turnIndex += 1) {
340
- if (turns[turnIndex].start < boundary) previous = turns[turnIndex];
341
- }
342
- if (!previous) { boundary = leading?.end ?? 0; break; }
343
- boundary = previous.start;
344
- }
345
- return boundary;
346
- }
347
-
348
- export function toolFreshIndices(
349
- messages: unknown[],
350
- _turns: CompleteTurn[],
351
- freshBytes: number,
352
- ): Set<number> {
353
- const protectedIndices = new Set<number>();
354
- let tailBytes = 0;
355
- let boundary = messages.length;
356
- while (boundary > 0 && tailBytes < freshBytes) {
357
- boundary -= 1;
358
- tailBytes += bytes(messages[boundary]);
359
- }
360
- for (let index = boundary; index < messages.length; index += 1) protectedIndices.add(index);
361
- return protectedIndices;
393
+ return Math.min(messages.length, unfinishedUser);
362
394
  }
363
395
 
364
396
  type EntryEvidence = { message: unknown; ref: BranchObject["ref"] };
@@ -486,17 +518,13 @@ export function mapActiveContext(input: {
486
518
  const budgetTokens = input.netBudget === true
487
519
  ? (reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW)
488
520
  : servingBudgetTokens(reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW);
489
- const freshBytes = zoneBytes(thresholds.freshTail, budgetTokens);
490
521
  const turns = completeTurns(input.eventMessages);
491
- const boundary = freshBoundary(input.eventMessages, turns, freshBytes);
522
+ const boundary = freshBoundary(input.eventMessages, turns);
492
523
  const protectedIndices = unsafeChapterIndices(input.eventMessages);
493
- const toolProtectedIndices = toolFreshIndices(input.eventMessages, turns, freshBytes);
494
- for (const index of toolProtectedIndices) protectedIndices.add(index);
495
524
  for (let mappedIndex = 0; mappedIndex < mapped.length; mappedIndex += 1) {
496
525
  const item = mapped[mappedIndex];
497
526
  if (item.ref) continue;
498
527
  protectedIndices.add(item.index);
499
- toolProtectedIndices.add(item.index);
500
528
  }
501
529
  const snapshot: ActiveContextSnapshot = {
502
530
  sessionId: input.sessionId,
@@ -508,7 +536,6 @@ export function mapActiveContext(input: {
508
536
  thresholds,
509
537
  budgetTokens,
510
538
  protectedIndices,
511
- toolProtectedIndices,
512
539
  policy,
513
540
  toolName: input.toolName ?? DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
514
541
  brandNoun: input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
@@ -520,22 +547,3 @@ export function mapActiveContext(input: {
520
547
  };
521
548
  return snapshot;
522
549
  }
523
-
524
- export function currentTurnBoundary(snapshot: Pick<ActiveContextSnapshot, "messages">): number {
525
- let boundary = -1;
526
- for (let index = 0; index < snapshot.messages.length; index += 1) {
527
- if (terminalAssistant(snapshot.messages[index])) boundary = index;
528
- }
529
- return boundary;
530
- }
531
-
532
- export function currentTurnRefKeys(snapshot: ActiveContextSnapshot): Set<string> {
533
- const boundary = currentTurnBoundary(snapshot);
534
- const keys = new Set<string>();
535
- for (const item of snapshot.mapped) {
536
- if (item.index <= boundary || !item.ref) continue;
537
- if (messageRole(item.message) !== "toolResult") continue;
538
- keys.add(objectRefKey(item.ref));
539
- }
540
- return keys;
541
- }
@@ -0,0 +1,456 @@
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
+ // One edit, applied against the WHOLE draft: the merged thresholds object is
54
+ // re-validated through resolveThresholds so cross-field invariants
55
+ // (minTarget < maxTarget) hold at every saved state, never
56
+ // only in the file's final form. Returns the next whole draft or a named error.
57
+ export function applyFoldSettingsEdit(
58
+ draft: FoldSettingsFile,
59
+ id: FoldSettingId,
60
+ rawValue: string,
61
+ ): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
62
+ try {
63
+ const value = Number(rawValue.trim());
64
+ if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
65
+ return { ok: false, error: `thresholds.${id} must be a whole number` };
66
+ }
67
+ const thresholds = resolveThresholds({
68
+ ...(draft.thresholds ?? DEFAULT_THRESHOLDS),
69
+ [id]: value,
70
+ });
71
+ return { ok: true, draft: { ...draft, thresholds } };
72
+ } catch (error) {
73
+ if (error instanceof ThresholdPolicyError) return { ok: false, error: error.message };
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ // Keys this package's own settings screen used to write. providerInputBudget left the
79
+ // user surface on 2026-08-21 and stayed a registration option for the harness.
80
+ const RETIRED_FILE_KEYS: readonly string[] = ["providerInputBudget"];
81
+
82
+ // Threshold fields this package's own surface used to write. freshTail was the fifth
83
+ // setting until 2026-08-23, when fresh-tail protection was deleted outright: nothing
84
+ // decided on it, and stale-first ordering already leaves recent material last. A stored
85
+ // file carrying it is a file /fold-settings itself wrote, so the field is dropped in
86
+ // migration, keeping every value the person actually tuned, rather than refused whole,
87
+ // which reverted maxTarget and minTarget to defaults over a key the person never chose.
88
+ const RETIRED_THRESHOLD_KEYS: readonly string[] = ["freshTail"];
89
+
90
+ export interface FoldSettingsLoad {
91
+ settings: FoldSettingsFile;
92
+ // Why the stored file was not used. Null when it was, including after a migration.
93
+ refusal: string | null;
94
+ // Fields the file predated were filled from the defaults and written back whole.
95
+ migrated: boolean;
96
+ }
97
+
98
+ /**
99
+ * A STORED FILE MAY NOT STOP THE AGENT (2026-08-22).
100
+ *
101
+ * The deployment calls this at module scope and hands the result to registerPiFold, so
102
+ * anything thrown here is a pi that does not start: "Failed to load extension ... :
103
+ * thresholds must declare minFoldChars". That is what shipping minFoldChars did to a
104
+ * settings file written the day before, and a user's own config is data rather than
105
+ * code: it may be refused, but it may not brick the session it configures.
106
+ *
107
+ * Two outcomes, and the split is deliberate.
108
+ *
109
+ * A file that is merely OLDER than the surface is MIGRATED. Fields it does not carry
110
+ * are filled from the defaults, the whole object is validated the ordinary way, and the
111
+ * result is written back so the file on disk is whole again, pinned at the value it was
112
+ * migrated with. That keeps the values the person actually tuned and keeps the
113
+ * whole-or-not-at-all law honest: a partial object never survives to be re-read against
114
+ * a later set of defaults. A failed write-back is not fatal either; the migration stands
115
+ * in memory and repeats harmlessly on the next boot.
116
+ *
117
+ * Anything else INVALID is refused by name and the package defaults are used, with the
118
+ * reason kept for /fold-settings to state. The limit is stated rather than hidden: a
119
+ * refused file reverts to defaults, and the settings screen is the only place that says
120
+ * so, so a person who never opens it sees defaults without being told why.
121
+ */
122
+ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH): FoldSettingsLoad {
123
+ let raw: string;
124
+ try {
125
+ raw = readFileSync(path, "utf8");
126
+ } catch (error: any) {
127
+ if (error?.code === "ENOENT") return { settings: {}, refusal: null, migrated: false };
128
+ return { settings: {}, refusal: `fold settings file at ${path} could not be read: ${error?.message ?? error}`, migrated: false };
129
+ }
130
+ const refused = (reason: string): FoldSettingsLoad => ({
131
+ settings: {},
132
+ refusal: `${reason}. Package defaults are in force; /fold-settings writes a valid file.`,
133
+ migrated: false,
134
+ });
135
+ let parsed: Record<string, unknown>;
136
+ try {
137
+ parsed = JSON.parse(raw) as Record<string, unknown>;
138
+ } catch (error: any) {
139
+ return refused(`fold settings file at ${path} is not valid JSON: ${error?.message ?? error}`);
140
+ }
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
142
+ return refused(`fold settings file at ${path} must be a JSON object`);
143
+ }
144
+ // A key the surface RETIRED is dropped rather than refused: /fold-settings itself
145
+ // wrote providerInputBudget until 2026-08-21, so refusing it would revert a file
146
+ // this package created. A key that was never on the surface is still a refusal.
147
+ let dropped = false;
148
+ for (const key of Object.keys(parsed)) {
149
+ if (key === "thresholds") continue;
150
+ if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
151
+ return refused(`fold settings file has no ${key} field: the surface is thresholds`);
152
+ }
153
+ if (parsed.thresholds === undefined) {
154
+ if (dropped) { try { saveFoldSettingsFile(path, {}); } catch { } }
155
+ return { settings: {}, refusal: null, migrated: dropped };
156
+ }
157
+ const stored = parsed.thresholds;
158
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
159
+ return refused("fold settings thresholds must be an object");
160
+ }
161
+ const supplied = { ...(stored as Record<string, unknown>) };
162
+ for (const key of RETIRED_THRESHOLD_KEYS) {
163
+ if (Object.prototype.hasOwnProperty.call(supplied, key)) { delete supplied[key]; dropped = true; }
164
+ }
165
+ const missing = THRESHOLD_FIELDS.filter((field) =>
166
+ !Object.prototype.hasOwnProperty.call(supplied, field));
167
+ try {
168
+ const thresholds = resolveThresholds(missing.length
169
+ ? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
170
+ : supplied);
171
+ const migrated = missing.length > 0 || dropped;
172
+ if (migrated) { try { saveFoldSettingsFile(path, { thresholds }); } catch { } }
173
+ return { settings: { thresholds }, refusal: null, migrated };
174
+ } catch (error: any) {
175
+ return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
176
+ }
177
+ }
178
+
179
+ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH): FoldSettingsFile {
180
+ return readFoldSettingsFile(path).settings;
181
+ }
182
+
183
+ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
184
+ const clean: FoldSettingsFile = {};
185
+ if (settings.thresholds !== undefined) clean.thresholds = settings.thresholds;
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ const temporary = `${path}.tmp`;
188
+ writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
189
+ renameSync(temporary, path);
190
+ }
191
+
192
+ interface EditorRow {
193
+ id: FoldSettingId;
194
+ label: string;
195
+ description: string;
196
+ }
197
+
198
+ const EDITOR_ROWS: readonly EditorRow[] = [
199
+ { id: "maxTarget", label: "Commit trigger", description: "Occupancy share of the budget that fires a fold epoch (maxTarget)" },
200
+ { id: "minTarget", label: "Post-commit aim", description: "Share the epoch cuts back down toward (minTarget)" },
201
+ { id: "consolidateAfter", label: "Consolidation divisor", description: "Parents owed per epoch = visible roots divided by this (consolidateAfter)" },
202
+ { 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)" },
203
+ ];
204
+
205
+ // The cycle lattices. Shares step in cents so no float drift reaches a threshold;
206
+ // each row's list is FILTERED against the current draft before display, which is
207
+ // what makes an invalid combination unselectable rather than an error message.
208
+ const SHARE_STEPS: Record<"maxTarget" | "minTarget", { min: number; max: number; step: number }> = {
209
+ maxTarget: { min: 0.40, max: 0.95, step: 0.05 },
210
+ minTarget: { min: 0.05, max: 0.60, step: 0.05 },
211
+ };
212
+
213
+ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
214
+
215
+ // Characters, not tokens: a token means a different amount of text on every wire, and
216
+ // this is a number a person picks. The list starts at the hard floor the validator
217
+ // enforces, below which a placeholder can cost more than the source it replaces.
218
+ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
219
+
220
+ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
221
+ const { min, max, step } = SHARE_STEPS[id];
222
+ const values: number[] = [];
223
+ for (let cents = Math.round(min * 100); cents <= Math.round(max * 100); cents += Math.round(step * 100)) {
224
+ values.push(cents / 100);
225
+ }
226
+ return values;
227
+ }
228
+
229
+ // The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
230
+ // only to it: this list is stepped, never shown.
231
+ function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
232
+ if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
233
+ if (id === "minFoldChars") return MIN_FOLD_CHOICES;
234
+ const { minTarget, maxTarget } = thresholds;
235
+ return id === "maxTarget"
236
+ ? shareCandidates("maxTarget").filter((v) => v > minTarget)
237
+ : shareCandidates("minTarget").filter((v) => v < maxTarget);
238
+ }
239
+
240
+ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
241
+ const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
242
+ return String(thresholds[id]);
243
+ }
244
+
245
+ function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
246
+ const raw = rowRawValue(settings, id);
247
+ if (id === "consolidateAfter") return raw;
248
+ if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} chars`;
249
+ // toFixed(2) matches the cycle entries exactly; String(0.8) renders "0.8" while
250
+ // the cycle list carries "0.80", and SettingsList cycles by indexOf(currentValue).
251
+ return `${Number(raw).toFixed(2)} · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tok`;
252
+ }
253
+
254
+ // The frame Pi's own /settings screen draws around its list. Implemented locally
255
+ // rather than imported because jiti keeps a separate module cache: the border's
256
+ // default color closure would bind to a different theme instance than the one the
257
+ // editor receives, so the color always arrives explicitly.
258
+ class SettingsBorder {
259
+ constructor(private readonly color: (text: string) => string) {}
260
+ invalidate(): void {}
261
+ render(width: number): string[] {
262
+ return [this.color("─".repeat(Math.max(1, width)))];
263
+ }
264
+ }
265
+
266
+ // The free-form editor behind the budget row's submenu: an Input prefilled with
267
+ // the raw value; Enter applies through applyFoldSettingsEdit and only a valid
268
+ // result calls done, so an invalid state can never reach the list, the file, or
269
+ // registration. Composition mirrors the native SelectSubmenu.
270
+ class FoldValueEditor extends Container {
271
+ private readonly input = new Input();
272
+ private readonly errorText = new Text("", 0, 0);
273
+
274
+ constructor(
275
+ private readonly themeLike: any,
276
+ private readonly rowLabel: string,
277
+ private readonly rowDescription: string,
278
+ initialValue: string,
279
+ private readonly apply: (raw: string) => { ok: true; display: string } | { ok: false; error: string },
280
+ private readonly done: (displayValue?: string) => void,
281
+ ) {
282
+ super();
283
+ const theme = this.themeLike;
284
+ this.addChild(new Text(theme.bold(theme.fg("accent", rowLabel)), 0, 0));
285
+ this.addChild(new Text(theme.fg("muted", rowDescription), 0, 0));
286
+ this.addChild(new Spacer(1));
287
+ this.input.setValue(initialValue);
288
+ // setValue parks the cursor at 0; a prefilled editor must start at the end,
289
+ // or typing inserts at the front and backspace deletes nothing.
290
+ (this.input as any).cursor = initialValue.length;
291
+ this.input.onSubmit = () => this.submit();
292
+ this.input.onEscape = () => this.done();
293
+ this.addChild(this.input);
294
+ this.addChild(new Spacer(1));
295
+ this.addChild(this.errorText);
296
+ this.addChild(new Text(theme.fg("dim", " Enter to apply · Esc to go back"), 0, 0));
297
+ }
298
+
299
+ private submit(): void {
300
+ const result = this.apply(this.input.getValue());
301
+ if (!result.ok) {
302
+ this.errorText.setText(this.themeLike.fg("error", ` ${result.error}`));
303
+ return;
304
+ }
305
+ this.done(result.display);
306
+ }
307
+
308
+ /** True until the first key: typing then REPLACES the prefill rather than
309
+ * appending to it ("0.35" over "0.4" was landing as "0.40.35" and refusing).
310
+ * Editing keys (backspace, arrows) keep the prefill for adjustment. */
311
+ private pristine = true;
312
+
313
+ handleInput(data: string): void {
314
+ if (this.pristine && data.length === 1 && data >= " " && data !== "\x7f") {
315
+ this.input.setValue("");
316
+ (this.input as any).cursor = 0;
317
+ }
318
+ this.pristine = false;
319
+ this.input.handleInput(data);
320
+ }
321
+ }
322
+
323
+ // The /fold-settings screen itself: a SettingsList over the four rows, styled off
324
+ // the live theme, with every applied change persisted immediately the way Pi's own
325
+ // /settings persists. Left/right STEPS the selected row through its allowed
326
+ // increments (clamped at the range ends); Enter opens an exact-value editor.
327
+ export class FoldSettingsEditor extends Container {
328
+ private readonly settingsList: SettingsList;
329
+
330
+ constructor(
331
+ private draft: FoldSettingsFile,
332
+ private readonly budgetTokens: number,
333
+ private readonly settingsPath: string,
334
+ private readonly themeLike: any,
335
+ private readonly done: (saved: boolean) => void,
336
+ // Why the stored file was not used, when it was not. This screen is the only
337
+ // place a refused file is ever named, so it says so before showing the
338
+ // defaults that took its place.
339
+ notice: string | null = null,
340
+ ) {
341
+ super();
342
+ const theme = this.themeLike;
343
+ this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
344
+ this.addChild(new Text(theme.bold(theme.fg("accent", "pi-fold settings")), 0, 0));
345
+ this.addChild(new Text(theme.fg("muted", "Shares are of the serving budget; edits save immediately. ←→ steps · Enter types an exact value."), 0, 0));
346
+ if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
347
+ this.addChild(new Spacer(1));
348
+ // SettingsList takes its own theme shape; adapt it off the live theme.
349
+ const listTheme = {
350
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
351
+ value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
352
+ description: (text: string) => theme.fg("dim", text),
353
+ cursor: theme.fg("accent", "→ "),
354
+ hint: (text: string) => theme.fg("dim", text),
355
+ };
356
+ this.settingsList = new SettingsList(
357
+ EDITOR_ROWS.map((row) => ({
358
+ id: row.id,
359
+ label: row.label,
360
+ description: row.description,
361
+ currentValue: rowDisplayValue(draft, row.id, budgetTokens),
362
+ submenu: (_current: string, submenuDone: (displayValue?: string) => void) =>
363
+ new FoldValueEditor(
364
+ themeLike,
365
+ row.label,
366
+ row.description,
367
+ rowRawValue(draft, row.id),
368
+ (raw) => this.applyAndSave(row.id, raw),
369
+ submenuDone,
370
+ ),
371
+ })),
372
+ EDITOR_ROWS.length + 2,
373
+ listTheme,
374
+ () => {},
375
+ () => this.done(true),
376
+ );
377
+ this.addChild(this.settingsList);
378
+ this.addChild(new Spacer(1));
379
+ this.addChild(new Text(theme.fg("dim", " ←→ to step · Enter for exact · Esc to close"), 0, 0));
380
+ this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
381
+ }
382
+
383
+ // One step along the row's allowed lattice, clamped at its ends. The lattice is
384
+ // filtered against the CURRENT draft, so stepping can never leave the policy
385
+ // surface; an exact off-lattice value arrives only through Enter's editor.
386
+ private step(id: FoldSettingId, direction: number): void {
387
+ const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
388
+ const candidates = allowedValues(id, thresholds);
389
+ const current = Number(rowRawValue(this.draft, id));
390
+ const target = direction > 0
391
+ ? candidates.find((candidate) => candidate > current + 1e-9)
392
+ : [...candidates].reverse().find((candidate) => candidate < current - 1e-9);
393
+ if (target === undefined) return;
394
+ this.applyAndSave(id, String(target));
395
+ }
396
+
397
+ private applyAndSave(id: FoldSettingId, raw: string): { ok: true; display: string } | { ok: false; error: string } {
398
+ const result = applyFoldSettingsEdit(this.draft, id, raw);
399
+ if (!result.ok) return result;
400
+ this.draft = result.draft;
401
+ saveFoldSettingsFile(this.settingsPath, this.draft);
402
+ for (const row of EDITOR_ROWS) {
403
+ const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
404
+ if (item) item.currentValue = rowDisplayValue(this.draft, row.id, this.budgetTokens);
405
+ }
406
+ return { ok: true, display: rowDisplayValue(this.draft, id, this.budgetTokens) };
407
+ }
408
+
409
+ handleInput(data: string): void {
410
+ // An open submenu owns everything until it closes.
411
+ if (this.settingsList.submenuComponent) {
412
+ this.settingsList.handleInput(data);
413
+ return;
414
+ }
415
+ if (matchesKey(data, Key.left)) {
416
+ this.step(EDITOR_ROWS[this.settingsList.selectedIndex].id, -1);
417
+ return;
418
+ }
419
+ if (matchesKey(data, Key.right)) {
420
+ this.step(EDITOR_ROWS[this.settingsList.selectedIndex].id, +1);
421
+ return;
422
+ }
423
+ if (matchesKey(data, Key.escape)) {
424
+ this.done(true);
425
+ return;
426
+ }
427
+ this.settingsList.handleInput(data);
428
+ }
429
+ }
430
+
431
+ export function registerFoldSettings(
432
+ pi: any,
433
+ options: { settingsPath?: string } = {},
434
+ ): void {
435
+ const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
436
+ pi.registerCommand("fold-settings", {
437
+ description: "Configure pi-fold: commit trigger, post-commit aim, consolidation divisor, minimum fold size",
438
+ handler: async (_args: string, ctx: any) => {
439
+ if (typeof ctx.ui?.custom !== "function") {
440
+ throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
441
+ }
442
+ const stored = readFoldSettingsFile(settingsPath);
443
+ const descriptorWindow = ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
444
+ // SHARES APPLY TO THE MODEL WINDOW (Shane, 2026-08-21): providerInputBudget is
445
+ // off the user surface, so what a share means is derived from the descriptor's
446
+ // own window and nothing else. The registration option still exists for the
447
+ // experiment harness, which declares a budget so runs stay comparable across
448
+ // descriptor changes; no person has to know that to read this screen.
449
+ const budgetTokens = servingBudgetTokens(descriptorWindow);
450
+ await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
451
+ new FoldSettingsEditor(
452
+ stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal,
453
+ ));
454
+ },
455
+ });
456
+ }