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.
- package/README.md +26 -28
- package/extensions/active-context.ts +645 -412
- package/extensions/index.js +11 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +208 -74
- package/extensions/lib/editor-ui.ts +595 -66
- 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/measurement.ts +47 -0
- package/extensions/lib/persistence.ts +42 -39
- package/extensions/lib/policy.ts +72 -51
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/tool-surface.ts +2 -32
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +200 -32
- package/package.json +5 -5
|
@@ -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}${
|
|
57
|
-
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${
|
|
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");
|
package/extensions/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
155
|
-
return { settings:
|
|
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
|
-
|
|
173
|
-
|
|
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: "
|
|
200
|
-
{ id: "minTarget", label: "
|
|
201
|
-
{ id: "consolidateAfter", label: "
|
|
202
|
-
{ id: "minFoldChars", label: "
|
|
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
|
|
206
|
-
// each row's list is FILTERED against the current draft before display, which
|
|
207
|
-
// what makes an invalid combination unselectable rather than an error message.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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(
|
|
237
|
-
: shareCandidates(
|
|
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")}
|
|
249
|
-
//
|
|
250
|
-
// the
|
|
251
|
-
|
|
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",
|
|
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
|
|
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
|
|
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": "
|
|
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": [
|