pi-fold 3.1.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.
@@ -91,9 +91,7 @@ export const RETIRED_TOOL_ACTIONS: Readonly<Record<string, string>> = Object.fre
91
91
  "peek already carries losslessly. Write the brief you want when you mark the span, or " +
92
92
  "use reboundary to re-cut it.",
93
93
  });
94
- export type ActiveContextToolAction =
95
- | typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number]
96
- | typeof WORKING_MEMORY_TOOL_ACTIONS[number];
94
+ export type ActiveContextToolAction = typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number];
97
95
  export const USER_RESCUE_MAX_SOURCE_CHARS = 512_000;
98
96
  export const DEFAULT_CONTEXT_WINDOW = 272_000;
99
97
  export const EXPAND_LEASE_GENERATIONS = 8;
@@ -263,6 +261,22 @@ export function servingBudgetTokens(window: number): number {
263
261
 
264
262
  export const MAX_PENDING_MARKS = 256;
265
263
  export const ESTIMATED_BYTES_PER_TOKEN = 4;
264
+
265
+ /**
266
+ * What one image in the window is worth, in tokens, for estimation only.
267
+ *
268
+ * A provider prices an image from its DIMENSIONS, not its byte size: Anthropic bills
269
+ * about width x height / 750, which puts a 2000x563 screenshot near 1,500 tokens, and a
270
+ * large image caps close to this number once the long edge is scaled to the 1568px limit.
271
+ * The exact figure differs per provider and cannot be known from the base64 alone.
272
+ *
273
+ * It does not need to be exact. The value it replaces was the image's base64 length
274
+ * divided by a prose chars-per-token ratio, which read one screenshot as 110,000 tokens
275
+ * against a true cost near 1,500. Being within an order of magnitude ends the failure;
276
+ * the provider anchor corrects the rest as soon as a real measurement lands. A constant
277
+ * rather than an option, because nobody can tune this better than the anchor can.
278
+ */
279
+ export const IMAGE_ESTIMATED_TOKENS = 1_600;
266
280
  export const ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES = 240;
267
281
 
268
282
  export const PEEK_MIN_SLICE_BYTES = 1_024;
@@ -310,34 +324,6 @@ export function peekIsEphemeral(params: Record<string, unknown> | undefined | nu
310
324
 
311
325
  export const PEEK_READ_ONLY_CONTEXT_ACTIONS: ReadonlySet<string> = new Set(["status", "peek", "recall"]);
312
326
 
313
- /**
314
- * THE WORKING MEMORY (Shane, 2026-08-26). A session-scoped ordered dictionary the agent
315
- * maintains beside the fold index: keys list in a table of contents the projection
316
- * carries, bodies live in state and are read on demand with `recall`. It exists because
317
- * the campaign measured a structural gap, not a tuning one: a maintained digest (running
318
- * tallies, current totals) is a different operation from a lossless index, and blending
319
- * digest prose into the index's briefs was measured making BOTH worse (sol-20260826-full2
320
- * rep 4: nine confident wrong end-block answers against the deterministic arm's zero).
321
- * The digest gets its own channel and the index stays mechanical.
322
- *
323
- * Three constants, not knobs. The key cap keeps the table of contents a table of
324
- * contents; the body cap is deliberately larger than a brief because a digest is
325
- * maintained prose, not an index row; the entry cap bounds the whole store at a size
326
- * whose table of contents stays one glance.
327
- */
328
- export const MEMORY_KEY_CHARS = 64;
329
- export const MEMORY_BODY_CHARS = 4_000;
330
- export const MEMORY_KEYS_MAX = 24;
331
-
332
- export const WORKING_MEMORY_TOOL_ACTIONS = Object.freeze(["remember", "recall"] as const);
333
-
334
- export interface WorkingMemoryEntry {
335
- key: string;
336
- body: string;
337
- /** Write counter at last update; the table of contents lists freshest first. */
338
- ordinal: number;
339
- }
340
-
341
327
  /** "user" is a mark laid down by the human through /fold-editor; it stages and
342
328
  * commits through the same validated path as an agent mark and counts toward the
343
329
  * commit's coverage the same way. */
@@ -457,8 +443,6 @@ export interface ActiveContextState {
457
443
  armed?: { milestone: AdvisoryMilestone; threshold: number; scheduleKey: string };
458
444
  };
459
445
  rider?: { epoch: number; text: string };
460
- /** The working memory's entries, in write order; absent when empty. */
461
- memory?: WorkingMemoryEntry[];
462
446
  }
463
447
 
464
448
  export interface FoldRecordRef {
@@ -488,7 +472,6 @@ export interface ActiveContextCheckpointV2 {
488
472
  pendingMarks?: PendingMark[];
489
473
  briefs?: Record<string, BriefOverride>;
490
474
  clips?: ToolClip[];
491
- memory?: WorkingMemoryEntry[];
492
475
  advisory?: NonNullable<ActiveContextState["advisory"]>;
493
476
  rider?: NonNullable<ActiveContextState["rider"]>;
494
477
  lastCall?: NonNullable<ActiveContextState["lastCall"]>;
@@ -530,11 +513,6 @@ export interface ActiveContextDeltaV2 {
530
513
  clips?: ToolClip[];
531
514
  clipBase?: number;
532
515
  addClips?: ToolClip[];
533
- // `memory` on a delta is the WHOLE array. It is bounded by MEMORY_KEYS_MAX entries and
534
- // edited in place by key, so a change form would have to restate the key order the way
535
- // the marks do while saving at most a handful of bounded bodies: the whole array is the
536
- // cheaper wire. Absent means empty, by the same discriminator rule as the marks.
537
- memory?: WorkingMemoryEntry[];
538
516
  advisory?: NonNullable<ActiveContextState["advisory"]>;
539
517
  rider?: NonNullable<ActiveContextState["rider"]>;
540
518
  lastCall?: NonNullable<ActiveContextState["lastCall"]>;
@@ -7,7 +7,6 @@
7
7
  * nothing: the next model call that reads a result, a natural boundary in the work.
8
8
  */
9
9
 
10
- import { MEMORY_BODY_CHARS, MEMORY_KEY_CHARS, MEMORY_KEYS_MAX } from "./policy.ts";
11
10
 
12
11
  type ToolHandler = (
13
12
  toolCallId: string,
@@ -45,16 +44,13 @@ export function buildActiveContextTool(input: {
45
44
  const recoveryGuidance = input.allowedActions.includes("expand")
46
45
  ? " A fold brief is an index entry, not the source: when a question or task depends on material that is folded, peek or expand that fold and answer from the exact bytes, never from memory of them. Recalling folded detail without reopening it is how specifics get misremembered."
47
46
  : "";
48
- const memoryGuidance = input.allowedActions.includes("remember")
49
- ? ` WORKING MEMORY: a session-scoped dictionary you maintain beside the fold index. remember with key and body writes or updates an entry (${MEMORY_KEY_CHARS}-character keys, ${MEMORY_BODY_CHARS}-character bodies, ${MEMORY_KEYS_MAX} entries); an empty body removes it; recall with keys reads bodies back, or everything when keys is omitted. The bodies live outside your window: only a one-glance table of contents rides your context, refreshed at each commit, so writing an entry costs your window almost nothing. Keep entries current as the work moves: when a fact an entry records changes, or new information lands that belongs in one, update it then rather than at the end.`
50
- : "";
51
47
  const standingGuidance = " Folding is automatic, lossless and recoverable: the runtime folds stale spans on its own when the window needs room, nothing is ever discarded, and every folded span keeps its exact source. You are never asked to make room and no fold is ever announced in advance. Work normally and let it happen.";
52
48
  return {
53
49
  name: input.name,
54
50
  label: input.label,
55
51
  description: input.fullSurface
56
- ? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
57
- : `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
52
+ ? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
53
+ : `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
58
54
  parameters: {
59
55
  type: "object",
60
56
  additionalProperties: false,
@@ -89,31 +85,6 @@ export function buildActiveContextTool(input: {
89
85
  },
90
86
  limit: { type: "integer", minimum: 1, maximum: 100 },
91
87
  detail: { type: "string", enum: [...input.statusDetails] },
92
- ...(input.allowedActions.includes("remember")
93
- ? {
94
- key: {
95
- type: "string",
96
- minLength: 1,
97
- maxLength: MEMORY_KEY_CHARS,
98
- description: "remember only: the working-memory entry to write, update or " +
99
- "(with an empty body) remove.",
100
- },
101
- body: {
102
- type: "string",
103
- maxLength: MEMORY_BODY_CHARS,
104
- description: "remember only: the entry's full text, replacing what the key " +
105
- "held. Empty removes the entry.",
106
- },
107
- keys: {
108
- type: "array",
109
- minItems: 1,
110
- maxItems: MEMORY_KEYS_MAX,
111
- items: { type: "string", minLength: 1 },
112
- description: "recall only: the working-memory entries to read. Omit to read " +
113
- "every entry.",
114
- },
115
- }
116
- : {}),
117
88
  },
118
89
  },
119
90
  execute: input.handler,
@@ -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,7 +56,15 @@ 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;
62
+
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);
67
+ }
52
68
 
53
69
  // One edit, applied against the WHOLE draft: the merged thresholds object is
54
70
  // re-validated through resolveThresholds so cross-field invariants
@@ -59,6 +75,23 @@ export function applyFoldSettingsEdit(
59
75
  id: FoldSettingId,
60
76
  rawValue: string,
61
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
+ }
62
95
  try {
63
96
  const value = Number(rawValue.trim());
64
97
  if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
@@ -146,13 +179,32 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
146
179
  // this package created. A key that was never on the surface is still a refusal.
147
180
  let dropped = false;
148
181
  for (const key of Object.keys(parsed)) {
149
- if (key === "thresholds") continue;
182
+ if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
150
183
  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`);
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);
152
204
  }
153
205
  if (parsed.thresholds === undefined) {
154
- if (dropped) { try { saveFoldSettingsFile(path, {}); } catch { } }
155
- return { settings: {}, refusal: null, migrated: dropped };
206
+ if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
207
+ return { settings: scalars, refusal: null, migrated: dropped };
156
208
  }
157
209
  const stored = parsed.thresholds;
158
210
  if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
@@ -169,8 +221,9 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
169
221
  ? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
170
222
  : supplied);
171
223
  const migrated = missing.length > 0 || dropped;
172
- if (migrated) { try { saveFoldSettingsFile(path, { thresholds }); } catch { } }
173
- 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 };
174
227
  } catch (error: any) {
175
228
  return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
176
229
  }
@@ -183,6 +236,10 @@ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
183
236
  export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
184
237
  const clean: FoldSettingsFile = {};
185
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;
186
243
  mkdirSync(dirname(path), { recursive: true });
187
244
  const temporary = `${path}.tmp`;
188
245
  writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
@@ -195,11 +252,19 @@ interface EditorRow {
195
252
  description: string;
196
253
  }
197
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.
198
261
  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)" },
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)" },
203
268
  ];
204
269
 
205
270
  // The cycle lattices. Shares step in cents so no float drift reaches a threshold;
@@ -217,6 +282,10 @@ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
217
282
  // enforces, below which a placeholder can cost more than the source it replaces.
218
283
  const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
219
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
+
220
289
  function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
221
290
  const { min, max, step } = SHARE_STEPS[id];
222
291
  const values: number[] = [];
@@ -229,6 +298,7 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
229
298
  // The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
230
299
  // only to it: this list is stepped, never shown.
231
300
  function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
301
+ if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
232
302
  if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
233
303
  if (id === "minFoldChars") return MIN_FOLD_CHOICES;
234
304
  const { minTarget, maxTarget } = thresholds;
@@ -238,17 +308,32 @@ function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds):
238
308
  }
239
309
 
240
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
+ }
241
319
  const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
242
320
  return String(thresholds[id]);
243
321
  }
244
322
 
245
323
  function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
246
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
+ }
247
331
  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`;
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`;
252
337
  }
253
338
 
254
339
  // The frame Pi's own /settings screen draws around its list. Implemented locally
@@ -342,7 +427,7 @@ export class FoldSettingsEditor extends Container {
342
427
  const theme = this.themeLike;
343
428
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
344
429
  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));
430
+ this.addChild(new Text(theme.fg("muted", "Percentages are of the usable window. Every change saves as you make it."), 0, 0));
346
431
  if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
347
432
  this.addChild(new Spacer(1));
348
433
  // SettingsList takes its own theme shape; adapt it off the live theme.
@@ -351,7 +436,12 @@ export class FoldSettingsEditor extends Container {
351
436
  value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
352
437
  description: (text: string) => theme.fg("dim", text),
353
438
  cursor: theme.fg("accent", "→ "),
354
- 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"),
355
445
  };
356
446
  this.settingsList = new SettingsList(
357
447
  EDITOR_ROWS.map((row) => ({
@@ -375,8 +465,6 @@ export class FoldSettingsEditor extends Container {
375
465
  () => this.done(true),
376
466
  );
377
467
  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
468
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
381
469
  }
382
470
 
@@ -384,6 +472,12 @@ export class FoldSettingsEditor extends Container {
384
472
  // filtered against the CURRENT draft, so stepping can never leave the policy
385
473
  // surface; an exact off-lattice value arrives only through Enter's editor.
386
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
+ }
387
481
  const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
388
482
  const candidates = allowedValues(id, thresholds);
389
483
  const current = Number(rowRawValue(this.draft, id));
@@ -434,7 +528,7 @@ export function registerFoldSettings(
434
528
  ): void {
435
529
  const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
436
530
  pi.registerCommand("fold-settings", {
437
- 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",
438
532
  handler: async (_args: string, ctx: any) => {
439
533
  if (typeof ctx.ui?.custom !== "function") {
440
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.1.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",