pi-fold 4.0.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.
- package/README.md +11 -10
- package/extensions/active-context.ts +510 -133
- package/extensions/index.js +7 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +78 -39
- package/extensions/lib/editor-ui.ts +468 -47
- package/extensions/lib/folding.ts +20 -2
- package/extensions/lib/instrumentation.ts +3 -1
- package/extensions/lib/live-settings.ts +73 -0
- package/extensions/lib/persistence.ts +37 -3
- package/extensions/lib/policy.ts +55 -12
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/tool-surface.ts +0 -1
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +104 -30
- package/package.json +5 -5
package/extensions/settings.ts
CHANGED
|
@@ -20,6 +20,7 @@ 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,
|
|
24
25
|
DEFAULT_TOOL_FOLD_THRESHOLD,
|
|
25
26
|
resolveThresholds,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
ThresholdPolicyError,
|
|
28
29
|
type ActiveContextThresholds,
|
|
29
30
|
} from "./lib/policy.ts";
|
|
31
|
+
import { applyLiveSettings, liveSettingsReachable } from "./lib/live-settings.ts";
|
|
30
32
|
|
|
31
33
|
export const DEFAULT_FOLD_SETTINGS_PATH = join(
|
|
32
34
|
homedir(),
|
|
@@ -43,7 +45,8 @@ export interface FoldSettingsFile {
|
|
|
43
45
|
// nothing but themselves, so absent simply means the package default, which is what
|
|
44
46
|
// lets an older file gain them without a migration.
|
|
45
47
|
toolFoldThreshold?: number;
|
|
46
|
-
|
|
48
|
+
preCommitNotice?: boolean;
|
|
49
|
+
noticeLeadShare?: number;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
// The four settings, named once. This was a hand-written union until the migration
|
|
@@ -58,7 +61,7 @@ const THRESHOLD_FIELDS = [
|
|
|
58
61
|
|
|
59
62
|
// The settings that are NOT thresholds. Each is a top-level scalar with its own
|
|
60
63
|
// validation and no relationship to any other value on the screen.
|
|
61
|
-
const SCALAR_FIELDS = ["toolFoldThreshold", "
|
|
64
|
+
const SCALAR_FIELDS = ["toolFoldThreshold", "preCommitNotice", "noticeLeadShare"] as const;
|
|
62
65
|
|
|
63
66
|
export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
|
|
64
67
|
|
|
@@ -75,12 +78,22 @@ export function applyFoldSettingsEdit(
|
|
|
75
78
|
id: FoldSettingId,
|
|
76
79
|
rawValue: string,
|
|
77
80
|
): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
|
|
78
|
-
if (id === "
|
|
81
|
+
if (id === "preCommitNotice") {
|
|
79
82
|
const raw = rawValue.trim().toLowerCase();
|
|
80
83
|
if (raw !== "true" && raw !== "false") {
|
|
81
|
-
return { ok: false, error: "
|
|
84
|
+
return { ok: false, error: "preCommitNotice is on or off" };
|
|
82
85
|
}
|
|
83
|
-
return { ok: true, draft: { ...draft,
|
|
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 } };
|
|
84
97
|
}
|
|
85
98
|
if (id === "toolFoldThreshold") {
|
|
86
99
|
const value = Number(rawValue.trim());
|
|
@@ -112,6 +125,14 @@ export function applyFoldSettingsEdit(
|
|
|
112
125
|
// user surface on 2026-08-21 and stayed a registration option for the harness.
|
|
113
126
|
const RETIRED_FILE_KEYS: readonly string[] = ["providerInputBudget"];
|
|
114
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
|
+
|
|
115
136
|
// Threshold fields this package's own surface used to write. freshTail was the fifth
|
|
116
137
|
// setting until 2026-08-23, when fresh-tail protection was deleted outright: nothing
|
|
117
138
|
// decided on it, and stale-first ordering already leaves recent material last. A stored
|
|
@@ -178,6 +199,15 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
178
199
|
// wrote providerInputBudget until 2026-08-21, so refusing it would revert a file
|
|
179
200
|
// this package created. A key that was never on the surface is still a refusal.
|
|
180
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
|
+
}
|
|
181
211
|
for (const key of Object.keys(parsed)) {
|
|
182
212
|
if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
|
|
183
213
|
if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
|
|
@@ -194,11 +224,11 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
194
224
|
if (!applied.ok) return refused(`fold settings ${field} is invalid: ${applied.error}`);
|
|
195
225
|
// String("0.5") round-trips, but a JSON string would too, and the file's own type
|
|
196
226
|
// has to be right or the value means something different on the next read.
|
|
197
|
-
if (field === "
|
|
198
|
-
return refused("fold settings
|
|
227
|
+
if (field === "preCommitNotice" && typeof value !== "boolean") {
|
|
228
|
+
return refused("fold settings preCommitNotice must be true or false");
|
|
199
229
|
}
|
|
200
|
-
if (field === "toolFoldThreshold" && typeof value !== "number") {
|
|
201
|
-
return refused(
|
|
230
|
+
if ((field === "toolFoldThreshold" || field === "noticeLeadShare") && typeof value !== "number") {
|
|
231
|
+
return refused(`fold settings ${field} must be a number`);
|
|
202
232
|
}
|
|
203
233
|
Object.assign(scalars, applied.draft);
|
|
204
234
|
}
|
|
@@ -239,7 +269,8 @@ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile):
|
|
|
239
269
|
// Written only when SET. An absent scalar means the package default, and writing the
|
|
240
270
|
// default out would pin today's value into a file that should follow the package.
|
|
241
271
|
if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
|
|
242
|
-
if (settings.
|
|
272
|
+
if (settings.preCommitNotice !== undefined) clean.preCommitNotice = settings.preCommitNotice;
|
|
273
|
+
if (settings.noticeLeadShare !== undefined) clean.noticeLeadShare = settings.noticeLeadShare;
|
|
243
274
|
mkdirSync(dirname(path), { recursive: true });
|
|
244
275
|
const temporary = `${path}.tmp`;
|
|
245
276
|
writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
|
|
@@ -264,16 +295,25 @@ const EDITOR_ROWS: readonly EditorRow[] = [
|
|
|
264
295
|
{ id: "consolidateAfter", label: "Folds per group", description: "Once this many folds are visible, pi-fold groups them under one (consolidateAfter)" },
|
|
265
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)" },
|
|
266
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)" },
|
|
267
|
-
{ id: "
|
|
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)" },
|
|
268
300
|
];
|
|
269
301
|
|
|
270
|
-
// The cycle
|
|
271
|
-
// each row's list is FILTERED against the current draft before display, which
|
|
272
|
-
// what makes an invalid combination unselectable rather than an error message.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 };
|
|
277
317
|
|
|
278
318
|
const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
|
|
279
319
|
|
|
@@ -286,8 +326,8 @@ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32
|
|
|
286
326
|
// off: without a 0 here the only way to decline in-view clipping would be the file.
|
|
287
327
|
const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
|
|
288
328
|
|
|
289
|
-
function shareCandidates(
|
|
290
|
-
const { min, max, step } =
|
|
329
|
+
function shareCandidates(): number[] {
|
|
330
|
+
const { min, max, step } = SHARE_LATTICE;
|
|
291
331
|
const values: number[] = [];
|
|
292
332
|
for (let cents = Math.round(min * 100); cents <= Math.round(max * 100); cents += Math.round(step * 100)) {
|
|
293
333
|
values.push(cents / 100);
|
|
@@ -299,12 +339,20 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
|
|
|
299
339
|
// only to it: this list is stepped, never shown.
|
|
300
340
|
function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
|
|
301
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();
|
|
302
347
|
if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
|
|
303
348
|
if (id === "minFoldChars") return MIN_FOLD_CHOICES;
|
|
304
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.
|
|
305
353
|
return id === "maxTarget"
|
|
306
|
-
? shareCandidates(
|
|
307
|
-
: shareCandidates(
|
|
354
|
+
? shareCandidates().filter((v) => v > minTarget)
|
|
355
|
+
: shareCandidates().filter((v) => v < maxTarget);
|
|
308
356
|
}
|
|
309
357
|
|
|
310
358
|
function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
|
|
@@ -312,9 +360,9 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
|
|
|
312
360
|
// file omits what was never set on purpose, so the screen has to supply the same
|
|
313
361
|
// value the runtime would, or the row would show a setting nobody chose.
|
|
314
362
|
if (!isThresholdField(id)) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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);
|
|
318
366
|
}
|
|
319
367
|
const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
|
|
320
368
|
return String(thresholds[id]);
|
|
@@ -322,7 +370,13 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
|
|
|
322
370
|
|
|
323
371
|
function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
|
|
324
372
|
const raw = rowRawValue(settings, id);
|
|
325
|
-
if (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
|
+
}
|
|
326
380
|
if (id === "toolFoldThreshold") {
|
|
327
381
|
// The share is of the window rather than of a budget, so it is stated as the
|
|
328
382
|
// slice it names instead of the token count the other two shares translate to.
|
|
@@ -422,12 +476,19 @@ export class FoldSettingsEditor extends Container {
|
|
|
422
476
|
// place a refused file is ever named, so it says so before showing the
|
|
423
477
|
// defaults that took its place.
|
|
424
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,
|
|
425
484
|
) {
|
|
426
485
|
super();
|
|
427
486
|
const theme = this.themeLike;
|
|
428
487
|
this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
|
|
429
488
|
this.addChild(new Text(theme.bold(theme.fg("accent", "pi-fold settings")), 0, 0));
|
|
430
|
-
this.addChild(new Text(theme.fg("muted",
|
|
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));
|
|
431
492
|
if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
|
|
432
493
|
this.addChild(new Spacer(1));
|
|
433
494
|
// SettingsList takes its own theme shape; adapt it off the live theme.
|
|
@@ -441,7 +502,7 @@ export class FoldSettingsEditor extends Container {
|
|
|
441
502
|
// than cycling a value, and nothing is cancelled because every change is saved
|
|
442
503
|
// the moment it is made. The hint function is ours and is handed the string, so
|
|
443
504
|
// the correction happens here rather than being contradicted a line below.
|
|
444
|
-
hint: (
|
|
505
|
+
hint: () => theme.fg("dim", "←→ adjust · Enter types an exact value · Esc closes"),
|
|
445
506
|
};
|
|
446
507
|
this.settingsList = new SettingsList(
|
|
447
508
|
EDITOR_ROWS.map((row) => ({
|
|
@@ -474,7 +535,7 @@ export class FoldSettingsEditor extends Container {
|
|
|
474
535
|
private step(id: FoldSettingId, direction: number): void {
|
|
475
536
|
// A SWITCH HAS NO LATTICE. Either direction moves to the other value, which is
|
|
476
537
|
// what a person expects of a two-state row sitting among stepped ones.
|
|
477
|
-
if (id === "
|
|
538
|
+
if (id === "preCommitNotice") {
|
|
478
539
|
this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
|
|
479
540
|
return;
|
|
480
541
|
}
|
|
@@ -493,6 +554,12 @@ export class FoldSettingsEditor extends Container {
|
|
|
493
554
|
if (!result.ok) return result;
|
|
494
555
|
this.draft = result.draft;
|
|
495
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 { } }
|
|
496
563
|
for (const row of EDITOR_ROWS) {
|
|
497
564
|
const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
|
|
498
565
|
if (item) item.currentValue = rowDisplayValue(this.draft, row.id, this.budgetTokens);
|
|
@@ -541,9 +608,16 @@ export function registerFoldSettings(
|
|
|
541
608
|
// experiment harness, which declares a budget so runs stay comparable across
|
|
542
609
|
// descriptor changes; no person has to know that to read this screen.
|
|
543
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;
|
|
544
618
|
await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
|
|
545
619
|
new FoldSettingsEditor(
|
|
546
|
-
stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal,
|
|
620
|
+
stored.settings, budgetTokens, settingsPath, theme, done, stored.refusal, live,
|
|
547
621
|
));
|
|
548
622
|
},
|
|
549
623
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fold",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Lossless
|
|
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/
|
|
21
|
+
"url": "git+https://github.com/shaneconner/fold.git"
|
|
22
22
|
},
|
|
23
|
-
"bugs": "https://github.com/shaneconner/
|
|
24
|
-
"homepage": "https://github.com/shaneconner/
|
|
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": [
|