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