pi-fold 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +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
-
12
10
  type ToolHandler = (
13
11
  toolCallId: string,
14
12
  params: Record<string, unknown>,
@@ -45,16 +43,13 @@ export function buildActiveContextTool(input: {
45
43
  const recoveryGuidance = input.allowedActions.includes("expand")
46
44
  ? " 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
45
  : "";
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
46
  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
47
  return {
53
48
  name: input.name,
54
49
  label: input.label,
55
50
  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.`,
51
+ ? `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.`
52
+ : `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
53
  parameters: {
59
54
  type: "object",
60
55
  additionalProperties: false,
@@ -89,31 +84,6 @@ export function buildActiveContextTool(input: {
89
84
  },
90
85
  limit: { type: "integer", minimum: 1, maximum: 100 },
91
86
  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
87
  },
118
88
  },
119
89
  execute: input.handler,
@@ -31,6 +31,49 @@ import type {
31
31
  MappedMessage,
32
32
  } from "./policy.ts";
33
33
 
34
+ /**
35
+ * ONE MESSAGE, AS THE TEXT A PERSON WOULD READ (2026-08-30).
36
+ *
37
+ * WHAT THIS REPLACED. `/fold-editor` built its entry previews inline, and the reading was
38
+ * `content.find((part) => part.type === "text")` sliced to 300 characters. So a message
39
+ * whose first block is not text previewed as the empty string, a tool result carrying its
40
+ * body in a later block previewed as its first block alone, and everything past 300
41
+ * characters was unreachable from the editor at any depth: the detail row sliced to 480,
42
+ * a bound the 300-character preview could never reach, so the deeper number was dead.
43
+ * Shane, 2026-08-30: "the amount of detail we can expand out to is a bit limited".
44
+ *
45
+ * EVERY BLOCK IS ACCOUNTED FOR, and the ones that are not text are NAMED rather than
46
+ * dropped. A window a person is auditing may not show them an empty row where an image or
47
+ * a tool call stands; naming it is how the row stays honest about what it could not
48
+ * render. The result is UNBOUNDED here on purpose. Bounding belongs to the caller, which
49
+ * knows whether it is drawing a one-line row or serving a paged read, and a cut applied
50
+ * here would be a cut nobody could state.
51
+ */
52
+ export function entryText(message: unknown): string {
53
+ const content = ownValue(message, "content");
54
+ if (typeof content === "string") return content;
55
+ const parts = denseOwnArrayValues(content);
56
+ if (!parts) return "";
57
+ const out: string[] = [];
58
+ for (const part of parts) {
59
+ if (typeof part === "string") { out.push(part); continue; }
60
+ const type = ownValue(part, "type");
61
+ const text = ownValue(part, "text");
62
+ if (typeof text === "string" && text) { out.push(text); continue; }
63
+ if (type === "toolCall") {
64
+ const name = ownValue(part, "name");
65
+ // The ARGUMENTS are the content of a tool call, and they are what a person auditing
66
+ // a window needs to see: the call is why the result below it exists.
67
+ const args = ownValue(part, "arguments");
68
+ out.push(`[calls ${typeof name === "string" ? name : "a tool"}` +
69
+ `${args === undefined ? "" : ` ${stableStringify(args)}`}]`);
70
+ continue;
71
+ }
72
+ out.push(`[${typeof type === "string" && type ? type : "content"}]`);
73
+ }
74
+ return out.join("\n");
75
+ }
76
+
34
77
  export function terminalAssistant(message: unknown): boolean {
35
78
  if (messageRole(message) !== "assistant") return false;
36
79
  const stop = ownValue(message, "stopReason");
@@ -20,12 +20,15 @@ import { dirname, join } from "node:path";
20
20
  import { Container, Input, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
21
21
  import {
22
22
  DEFAULT_CONTEXT_WINDOW,
23
+ DEFAULT_NOTICE_LEAD_SHARE,
23
24
  DEFAULT_THRESHOLDS,
25
+ DEFAULT_TOOL_FOLD_THRESHOLD,
24
26
  resolveThresholds,
25
27
  servingBudgetTokens,
26
28
  ThresholdPolicyError,
27
29
  type ActiveContextThresholds,
28
30
  } from "./lib/policy.ts";
31
+ import { applyLiveSettings, liveSettingsReachable } from "./lib/live-settings.ts";
29
32
 
30
33
  export const DEFAULT_FOLD_SETTINGS_PATH = join(
31
34
  homedir(),
@@ -36,6 +39,14 @@ export const DEFAULT_FOLD_SETTINGS_PATH = join(
36
39
 
37
40
  export interface FoldSettingsFile {
38
41
  thresholds?: ActiveContextThresholds;
42
+ // TWO INDEPENDENT SCALARS, NOT A SECOND WHOLE-OBJECT (2026-08-28). The thresholds
43
+ // object is stored whole because its fields carry cross-field invariants and a partial
44
+ // one would silently mean whatever the defaults are at read time. These two answer to
45
+ // nothing but themselves, so absent simply means the package default, which is what
46
+ // lets an older file gain them without a migration.
47
+ toolFoldThreshold?: number;
48
+ preCommitNotice?: boolean;
49
+ noticeLeadShare?: number;
39
50
  }
40
51
 
41
52
  // The four settings, named once. This was a hand-written union until the migration
@@ -48,7 +59,15 @@ const THRESHOLD_FIELDS = [
48
59
  "minFoldChars",
49
60
  ] as const;
50
61
 
51
- export type FoldSettingId = typeof THRESHOLD_FIELDS[number];
62
+ // The settings that are NOT thresholds. Each is a top-level scalar with its own
63
+ // validation and no relationship to any other value on the screen.
64
+ const SCALAR_FIELDS = ["toolFoldThreshold", "preCommitNotice", "noticeLeadShare"] as const;
65
+
66
+ export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
67
+
68
+ function isThresholdField(id: FoldSettingId): id is typeof THRESHOLD_FIELDS[number] {
69
+ return (THRESHOLD_FIELDS as readonly string[]).includes(id);
70
+ }
52
71
 
53
72
  // One edit, applied against the WHOLE draft: the merged thresholds object is
54
73
  // re-validated through resolveThresholds so cross-field invariants
@@ -59,6 +78,33 @@ export function applyFoldSettingsEdit(
59
78
  id: FoldSettingId,
60
79
  rawValue: string,
61
80
  ): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
81
+ if (id === "preCommitNotice") {
82
+ const raw = rawValue.trim().toLowerCase();
83
+ if (raw !== "true" && raw !== "false") {
84
+ return { ok: false, error: "preCommitNotice is on or off" };
85
+ }
86
+ return { ok: true, draft: { ...draft, preCommitNotice: raw === "true" } };
87
+ }
88
+ if (id === "noticeLeadShare") {
89
+ const value = Number(rawValue.trim());
90
+ // The runtime's own range, restated here so the screen refuses before the file does:
91
+ // a share strictly inside 0 and 1. Zero would put the notice on the same pass as the
92
+ // commit it warns about, which is not a warning.
93
+ if (!Number.isFinite(value) || value <= 0 || value >= 1) {
94
+ return { ok: false, error: "noticeLeadShare is a share above 0 and below 1: how far below the commit point the notice speaks" };
95
+ }
96
+ return { ok: true, draft: { ...draft, noticeLeadShare: value } };
97
+ }
98
+ if (id === "toolFoldThreshold") {
99
+ const value = Number(rawValue.trim());
100
+ // The runtime's own range, restated here so the screen refuses before the file
101
+ // does: [0, 1) with 0 meaning off. Absence would ALSO mean the default rather
102
+ // than off, which is why 0 has to be expressible.
103
+ if (!Number.isFinite(value) || value < 0 || value >= 1) {
104
+ return { ok: false, error: "toolFoldThreshold is a share from 0 up to but not including 1; 0 turns it off" };
105
+ }
106
+ return { ok: true, draft: { ...draft, toolFoldThreshold: value } };
107
+ }
62
108
  try {
63
109
  const value = Number(rawValue.trim());
64
110
  if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
@@ -79,6 +125,14 @@ export function applyFoldSettingsEdit(
79
125
  // user surface on 2026-08-21 and stayed a registration option for the harness.
80
126
  const RETIRED_FILE_KEYS: readonly string[] = ["providerInputBudget"];
81
127
 
128
+ // Keys this surface RENAMED, carried across with their VALUE (2026-08-30). postFoldNotice
129
+ // was on the screen from 2026-08-27 and became preCommitNotice when the carrier stopped
130
+ // soliciting briefs and started stating status. Dropping it the way a retired key is
131
+ // dropped would silently revert a deployment that had deliberately turned the carrier ON,
132
+ // and refusing it would revert every other value in the file over a key this package
133
+ // itself wrote. Both readings mean the same thing on the same carrier, so the value moves.
134
+ const RENAMED_FILE_KEYS: Readonly<Record<string, FoldSettingId>> = { postFoldNotice: "preCommitNotice" };
135
+
82
136
  // Threshold fields this package's own surface used to write. freshTail was the fifth
83
137
  // setting until 2026-08-23, when fresh-tail protection was deleted outright: nothing
84
138
  // decided on it, and stale-first ordering already leaves recent material last. A stored
@@ -145,14 +199,42 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
145
199
  // wrote providerInputBudget until 2026-08-21, so refusing it would revert a file
146
200
  // this package created. A key that was never on the surface is still a refusal.
147
201
  let dropped = false;
202
+ for (const [from, to] of Object.entries(RENAMED_FILE_KEYS)) {
203
+ if (!Object.prototype.hasOwnProperty.call(parsed, from)) continue;
204
+ // The new name wins if a hand-edited file somehow carries both, since that is the
205
+ // one the screen and the runtime read; either way the old key leaves and the file is
206
+ // written back whole under the current surface.
207
+ if (parsed[to] === undefined) parsed[to] = parsed[from];
208
+ delete parsed[from];
209
+ dropped = true;
210
+ }
148
211
  for (const key of Object.keys(parsed)) {
149
- if (key === "thresholds") continue;
212
+ if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
150
213
  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`);
214
+ return refused(`fold settings file has no ${key} field: the surface is ` +
215
+ `thresholds, ${SCALAR_FIELDS.join(", ")}`);
216
+ }
217
+ // The two scalars are validated the same way the editor validates them, through the
218
+ // one path, so a hand-edited file cannot hold a value the screen would refuse.
219
+ const scalars: FoldSettingsFile = {};
220
+ for (const field of SCALAR_FIELDS) {
221
+ if (parsed[field] === undefined) continue;
222
+ const value = parsed[field];
223
+ const applied = applyFoldSettingsEdit({}, field, String(value));
224
+ if (!applied.ok) return refused(`fold settings ${field} is invalid: ${applied.error}`);
225
+ // String("0.5") round-trips, but a JSON string would too, and the file's own type
226
+ // has to be right or the value means something different on the next read.
227
+ if (field === "preCommitNotice" && typeof value !== "boolean") {
228
+ return refused("fold settings preCommitNotice must be true or false");
229
+ }
230
+ if ((field === "toolFoldThreshold" || field === "noticeLeadShare") && typeof value !== "number") {
231
+ return refused(`fold settings ${field} must be a number`);
232
+ }
233
+ Object.assign(scalars, applied.draft);
152
234
  }
153
235
  if (parsed.thresholds === undefined) {
154
- if (dropped) { try { saveFoldSettingsFile(path, {}); } catch { } }
155
- return { settings: {}, refusal: null, migrated: dropped };
236
+ if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
237
+ return { settings: scalars, refusal: null, migrated: dropped };
156
238
  }
157
239
  const stored = parsed.thresholds;
158
240
  if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
@@ -169,8 +251,9 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
169
251
  ? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
170
252
  : supplied);
171
253
  const migrated = missing.length > 0 || dropped;
172
- if (migrated) { try { saveFoldSettingsFile(path, { thresholds }); } catch { } }
173
- return { settings: { thresholds }, refusal: null, migrated };
254
+ const settings: FoldSettingsFile = { thresholds, ...scalars };
255
+ if (migrated) { try { saveFoldSettingsFile(path, settings); } catch { } }
256
+ return { settings, refusal: null, migrated };
174
257
  } catch (error: any) {
175
258
  return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
176
259
  }
@@ -183,6 +266,11 @@ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
183
266
  export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
184
267
  const clean: FoldSettingsFile = {};
185
268
  if (settings.thresholds !== undefined) clean.thresholds = settings.thresholds;
269
+ // Written only when SET. An absent scalar means the package default, and writing the
270
+ // default out would pin today's value into a file that should follow the package.
271
+ if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
272
+ if (settings.preCommitNotice !== undefined) clean.preCommitNotice = settings.preCommitNotice;
273
+ if (settings.noticeLeadShare !== undefined) clean.noticeLeadShare = settings.noticeLeadShare;
186
274
  mkdirSync(dirname(path), { recursive: true });
187
275
  const temporary = `${path}.tmp`;
188
276
  writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
@@ -195,20 +283,37 @@ interface EditorRow {
195
283
  description: string;
196
284
  }
197
285
 
286
+ // WRITTEN FOR THE READER, NOT THE IMPLEMENTER (2026-08-28). "Fires a fold epoch" and
287
+ // "parents owed per epoch = visible roots divided by this" are this codebase's words for
288
+ // its own internals, and a person setting a value should not have to learn them. Each
289
+ // description now says what the setting DOES to their session; the option name stays in
290
+ // parentheses as the bridge to the README table and to registerPiFold, which document
291
+ // these exact names.
198
292
  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)" },
293
+ { id: "maxTarget", label: "Start folding at", description: "How full the window gets before pi-fold folds. Nothing moves below this (maxTarget)" },
294
+ { 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)" },
295
+ { id: "consolidateAfter", label: "Folds per group", description: "Once this many folds are visible, pi-fold groups them under one (consolidateAfter)" },
296
+ { id: "minFoldChars", label: "Smallest fold", description: "Text this short is not worth folding on its own; it joins the fold beside it instead (minFoldChars)" },
297
+ { id: "toolFoldThreshold", label: "Clip old tool results", description: "The oldest share of the window shows tool results shortened, still recoverable in full. Off keeps them whole (toolFoldThreshold)" },
298
+ { id: "preCommitNotice", label: "Tell the model before folding", description: "Show the model the window's status once as a fold approaches: what is staged, what is pinned, what it can change. Off is what the measured runs used (preCommitNotice)" },
299
+ { id: "noticeLeadShare", label: "How early to tell it", description: "How far below the folding point that status appears. Only used when the notice is on (noticeLeadShare)" },
203
300
  ];
204
301
 
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
- };
302
+ // The cycle lattice for both shares. Shares step in cents so no float drift reaches a
303
+ // threshold; each row's list is FILTERED against the current draft before display, which
304
+ // is what makes an invalid combination unselectable rather than an error message.
305
+ //
306
+ // ONE LATTICE, AND ITS ONLY GUARDS ARE THE POLICY'S OWN (Shane, 2026-08-29). The rows
307
+ // used to carry ranges of their own, 0.40 to 0.95 for the trigger and 0.05 to 0.60 for
308
+ // the aim, and nothing in the runtime asks for either bound: resolveThresholds wants a
309
+ // share strictly between 0 and 1 with the aim below the trigger, and that is all. Found
310
+ // in use on a 1,000,000-token window, where 0.40 is a first fold at about 393,000 tokens:
311
+ // later than the person wanted to start, reachable past only through Enter's exact-value
312
+ // editor, and one arrow keypress after that snapped the value back up to 0.40, because
313
+ // the lattice did not contain where they were standing.
314
+ // The lattice now spans the legal range at the same step, so stepping is the ordinary
315
+ // way to reach a low band and typing is only for values finer than the step.
316
+ const SHARE_LATTICE = { min: 0.05, max: 0.95, step: 0.05 };
212
317
 
213
318
  const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
214
319
 
@@ -217,8 +322,12 @@ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
217
322
  // enforces, below which a placeholder can cost more than the source it replaces.
218
323
  const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
219
324
 
220
- function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
221
- const { min, max, step } = SHARE_STEPS[id];
325
+ // 0 IS ON THE LATTICE, and first, because absence means the package default rather than
326
+ // off: without a 0 here the only way to decline in-view clipping would be the file.
327
+ const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
328
+
329
+ function shareCandidates(): number[] {
330
+ const { min, max, step } = SHARE_LATTICE;
222
331
  const values: number[] = [];
223
332
  for (let cents = Math.round(min * 100); cents <= Math.round(max * 100); cents += Math.round(step * 100)) {
224
333
  values.push(cents / 100);
@@ -229,26 +338,56 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
229
338
  // The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
230
339
  // only to it: this list is stepped, never shown.
231
340
  function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
341
+ if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
342
+ // THE LEAD STEPS THE ONE SHARE LATTICE, unfiltered. It answers to nothing but its own
343
+ // range: a lead wider than the trigger is legal and means "speak from the first
344
+ // measurement", which the runtime floors rather than refuses, so there is no
345
+ // cross-field invariant here for a filter to enforce.
346
+ if (id === "noticeLeadShare") return shareCandidates();
232
347
  if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
233
348
  if (id === "minFoldChars") return MIN_FOLD_CHOICES;
234
349
  const { minTarget, maxTarget } = thresholds;
350
+ // The cross-field invariant IS the filter, so a row can be stepped to any legal share
351
+ // and never to an illegal one; the trigger stops one increment above the aim rather
352
+ // than at some number chosen here.
235
353
  return id === "maxTarget"
236
- ? shareCandidates("maxTarget").filter((v) => v > minTarget)
237
- : shareCandidates("minTarget").filter((v) => v < maxTarget);
354
+ ? shareCandidates().filter((v) => v > minTarget)
355
+ : shareCandidates().filter((v) => v < maxTarget);
238
356
  }
239
357
 
240
358
  function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
359
+ // A SCALAR THAT IS ABSENT READS AS THE PACKAGE DEFAULT, never as zero or false. The
360
+ // file omits what was never set on purpose, so the screen has to supply the same
361
+ // value the runtime would, or the row would show a setting nobody chose.
362
+ if (!isThresholdField(id)) {
363
+ if (id === "toolFoldThreshold") return String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD);
364
+ if (id === "noticeLeadShare") return String(settings.noticeLeadShare ?? DEFAULT_NOTICE_LEAD_SHARE);
365
+ return String(settings.preCommitNotice ?? false);
366
+ }
241
367
  const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
242
368
  return String(thresholds[id]);
243
369
  }
244
370
 
245
371
  function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
246
372
  const raw = rowRawValue(settings, id);
373
+ if (id === "preCommitNotice") return raw === "true" ? "on" : "off";
374
+ if (id === "noticeLeadShare") {
375
+ // STATED AS THE DISTANCE IT IS, in the same dialect as the two band rows: a share of
376
+ // the budget, with the tokens it works out to, because "10%" alone reads as a point
377
+ // on the bar rather than as a gap before one.
378
+ return `${Math.round(Number(raw) * 100)}% earlier · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tokens`;
379
+ }
380
+ if (id === "toolFoldThreshold") {
381
+ // The share is of the window rather than of a budget, so it is stated as the
382
+ // slice it names instead of the token count the other two shares translate to.
383
+ return Number(raw) === 0 ? "off" : `oldest ${Math.round(Number(raw) * 100)}%`;
384
+ }
247
385
  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`;
386
+ if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} characters`;
387
+ // A PERCENTAGE, like every other human surface. The screen used to read "0.80" while
388
+ // the status line, /fold-status and the editor header all said "80%", so the one place
389
+ // a person CHANGES the number spoke a different dialect from the three that report it.
390
+ return `${Math.round(Number(raw) * 100)}% · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tokens`;
252
391
  }
253
392
 
254
393
  // The frame Pi's own /settings screen draws around its list. Implemented locally
@@ -337,12 +476,19 @@ export class FoldSettingsEditor extends Container {
337
476
  // place a refused file is ever named, so it says so before showing the
338
477
  // defaults that took its place.
339
478
  notice: string | null = null,
479
+ // THE RUNNING SESSION, when there is one to reach. Given, every saved edit is
480
+ // pushed into the runtime registered on the same host and takes effect at once;
481
+ // absent, the file is still written and the next start reads it. The screen SAYS
482
+ // which of the two it is rather than promising the one it cannot deliver.
483
+ private readonly live: ((settings: FoldSettingsFile) => void) | null = null,
340
484
  ) {
341
485
  super();
342
486
  const theme = this.themeLike;
343
487
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
344
488
  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));
489
+ this.addChild(new Text(theme.fg("muted", live
490
+ ? "Percentages are of the usable window. Every change saves and takes effect at once."
491
+ : "Percentages are of the usable window. Every change saves now and applies when pi next starts."), 0, 0));
346
492
  if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
347
493
  this.addChild(new Spacer(1));
348
494
  // SettingsList takes its own theme shape; adapt it off the live theme.
@@ -351,7 +497,12 @@ export class FoldSettingsEditor extends Container {
351
497
  value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
352
498
  description: (text: string) => theme.fg("dim", text),
353
499
  cursor: theme.fg("accent", "→ "),
354
- hint: (text: string) => theme.fg("dim", text),
500
+ // SettingsList ships its own hint, "Enter/Space to change · Esc to cancel", and
501
+ // BOTH halves are wrong on this screen: Enter opens an exact-value editor rather
502
+ // than cycling a value, and nothing is cancelled because every change is saved
503
+ // the moment it is made. The hint function is ours and is handed the string, so
504
+ // the correction happens here rather than being contradicted a line below.
505
+ hint: () => theme.fg("dim", "←→ adjust · Enter types an exact value · Esc closes"),
355
506
  };
356
507
  this.settingsList = new SettingsList(
357
508
  EDITOR_ROWS.map((row) => ({
@@ -375,8 +526,6 @@ export class FoldSettingsEditor extends Container {
375
526
  () => this.done(true),
376
527
  );
377
528
  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
529
  this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
381
530
  }
382
531
 
@@ -384,6 +533,12 @@ export class FoldSettingsEditor extends Container {
384
533
  // filtered against the CURRENT draft, so stepping can never leave the policy
385
534
  // surface; an exact off-lattice value arrives only through Enter's editor.
386
535
  private step(id: FoldSettingId, direction: number): void {
536
+ // A SWITCH HAS NO LATTICE. Either direction moves to the other value, which is
537
+ // what a person expects of a two-state row sitting among stepped ones.
538
+ if (id === "preCommitNotice") {
539
+ this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
540
+ return;
541
+ }
387
542
  const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
388
543
  const candidates = allowedValues(id, thresholds);
389
544
  const current = Number(rowRawValue(this.draft, id));
@@ -399,6 +554,12 @@ export class FoldSettingsEditor extends Container {
399
554
  if (!result.ok) return result;
400
555
  this.draft = result.draft;
401
556
  saveFoldSettingsFile(this.settingsPath, this.draft);
557
+ // THE FILE FIRST, THE SESSION SECOND. The draft has already been through
558
+ // resolveThresholds, which is the same validation the runtime applies, so a refusal
559
+ // here would be a defect rather than a person's mistake; it is caught anyway,
560
+ // because a settings screen may not take down the session it configures, and the
561
+ // durable file is already correct for the next start either way.
562
+ if (this.live) { try { this.live(this.draft); } catch { } }
402
563
  for (const row of EDITOR_ROWS) {
403
564
  const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
404
565
  if (item) item.currentValue = rowDisplayValue(this.draft, row.id, this.budgetTokens);
@@ -434,7 +595,7 @@ export function registerFoldSettings(
434
595
  ): void {
435
596
  const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
436
597
  pi.registerCommand("fold-settings", {
437
- description: "Configure pi-fold: commit trigger, post-commit aim, consolidation divisor, minimum fold size",
598
+ description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, brief invitation",
438
599
  handler: async (_args: string, ctx: any) => {
439
600
  if (typeof ctx.ui?.custom !== "function") {
440
601
  throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
@@ -447,9 +608,16 @@ export function registerFoldSettings(
447
608
  // experiment harness, which declares a budget so runs stay comparable across
448
609
  // descriptor changes; no person has to know that to read this screen.
449
610
  const budgetTokens = servingBudgetTokens(descriptorWindow);
611
+ // THE CHANNEL TO THE RUNNING RUNTIME, read HERE rather than at registration: this
612
+ // module and registerPiFold are registered independently and in either order, and
613
+ // by the time a person opens this screen both have run. A host with no runtime on
614
+ // it gets a null, which the screen states plainly.
615
+ const live = liveSettingsReachable(pi)
616
+ ? (settings: FoldSettingsFile) => { applyLiveSettings(pi, settings, ctx); }
617
+ : null;
450
618
  await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
451
619
  new FoldSettingsEditor(
452
- stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal,
620
+ stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal, live,
453
621
  ));
454
622
  },
455
623
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "3.1.0",
4
- "description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
3
+ "version": "4.1.0",
4
+ "description": "Lossless context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -18,10 +18,10 @@
18
18
  "private": false,
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "git+https://github.com/shaneconner/pi-fold.git"
21
+ "url": "git+https://github.com/shaneconner/fold.git"
22
22
  },
23
- "bugs": "https://github.com/shaneconner/pi-fold/issues",
24
- "homepage": "https://github.com/shaneconner/pi-fold#readme",
23
+ "bugs": "https://github.com/shaneconner/fold/issues",
24
+ "homepage": "https://github.com/shaneconner/fold#readme",
25
25
  "author": "Shane Conner",
26
26
  "license": "MIT",
27
27
  "files": [