pi-fold 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -21
- package/extensions/active-context.ts +138 -282
- package/extensions/index.js +5 -1
- package/extensions/lib/curation.ts +130 -35
- package/extensions/lib/editor-ui.ts +133 -25
- package/extensions/lib/measurement.ts +47 -0
- package/extensions/lib/persistence.ts +5 -36
- package/extensions/lib/policy.ts +17 -39
- package/extensions/lib/tool-surface.ts +2 -31
- package/extensions/settings.ts +114 -20
- package/package.json +1 -1
package/extensions/lib/policy.ts
CHANGED
|
@@ -91,9 +91,7 @@ export const RETIRED_TOOL_ACTIONS: Readonly<Record<string, string>> = Object.fre
|
|
|
91
91
|
"peek already carries losslessly. Write the brief you want when you mark the span, or " +
|
|
92
92
|
"use reboundary to re-cut it.",
|
|
93
93
|
});
|
|
94
|
-
export type ActiveContextToolAction =
|
|
95
|
-
| typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number]
|
|
96
|
-
| typeof WORKING_MEMORY_TOOL_ACTIONS[number];
|
|
94
|
+
export type ActiveContextToolAction = typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number];
|
|
97
95
|
export const USER_RESCUE_MAX_SOURCE_CHARS = 512_000;
|
|
98
96
|
export const DEFAULT_CONTEXT_WINDOW = 272_000;
|
|
99
97
|
export const EXPAND_LEASE_GENERATIONS = 8;
|
|
@@ -263,6 +261,22 @@ export function servingBudgetTokens(window: number): number {
|
|
|
263
261
|
|
|
264
262
|
export const MAX_PENDING_MARKS = 256;
|
|
265
263
|
export const ESTIMATED_BYTES_PER_TOKEN = 4;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* What one image in the window is worth, in tokens, for estimation only.
|
|
267
|
+
*
|
|
268
|
+
* A provider prices an image from its DIMENSIONS, not its byte size: Anthropic bills
|
|
269
|
+
* about width x height / 750, which puts a 2000x563 screenshot near 1,500 tokens, and a
|
|
270
|
+
* large image caps close to this number once the long edge is scaled to the 1568px limit.
|
|
271
|
+
* The exact figure differs per provider and cannot be known from the base64 alone.
|
|
272
|
+
*
|
|
273
|
+
* It does not need to be exact. The value it replaces was the image's base64 length
|
|
274
|
+
* divided by a prose chars-per-token ratio, which read one screenshot as 110,000 tokens
|
|
275
|
+
* against a true cost near 1,500. Being within an order of magnitude ends the failure;
|
|
276
|
+
* the provider anchor corrects the rest as soon as a real measurement lands. A constant
|
|
277
|
+
* rather than an option, because nobody can tune this better than the anchor can.
|
|
278
|
+
*/
|
|
279
|
+
export const IMAGE_ESTIMATED_TOKENS = 1_600;
|
|
266
280
|
export const ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES = 240;
|
|
267
281
|
|
|
268
282
|
export const PEEK_MIN_SLICE_BYTES = 1_024;
|
|
@@ -310,34 +324,6 @@ export function peekIsEphemeral(params: Record<string, unknown> | undefined | nu
|
|
|
310
324
|
|
|
311
325
|
export const PEEK_READ_ONLY_CONTEXT_ACTIONS: ReadonlySet<string> = new Set(["status", "peek", "recall"]);
|
|
312
326
|
|
|
313
|
-
/**
|
|
314
|
-
* THE WORKING MEMORY (Shane, 2026-08-26). A session-scoped ordered dictionary the agent
|
|
315
|
-
* maintains beside the fold index: keys list in a table of contents the projection
|
|
316
|
-
* carries, bodies live in state and are read on demand with `recall`. It exists because
|
|
317
|
-
* the campaign measured a structural gap, not a tuning one: a maintained digest (running
|
|
318
|
-
* tallies, current totals) is a different operation from a lossless index, and blending
|
|
319
|
-
* digest prose into the index's briefs was measured making BOTH worse (sol-20260826-full2
|
|
320
|
-
* rep 4: nine confident wrong end-block answers against the deterministic arm's zero).
|
|
321
|
-
* The digest gets its own channel and the index stays mechanical.
|
|
322
|
-
*
|
|
323
|
-
* Three constants, not knobs. The key cap keeps the table of contents a table of
|
|
324
|
-
* contents; the body cap is deliberately larger than a brief because a digest is
|
|
325
|
-
* maintained prose, not an index row; the entry cap bounds the whole store at a size
|
|
326
|
-
* whose table of contents stays one glance.
|
|
327
|
-
*/
|
|
328
|
-
export const MEMORY_KEY_CHARS = 64;
|
|
329
|
-
export const MEMORY_BODY_CHARS = 4_000;
|
|
330
|
-
export const MEMORY_KEYS_MAX = 24;
|
|
331
|
-
|
|
332
|
-
export const WORKING_MEMORY_TOOL_ACTIONS = Object.freeze(["remember", "recall"] as const);
|
|
333
|
-
|
|
334
|
-
export interface WorkingMemoryEntry {
|
|
335
|
-
key: string;
|
|
336
|
-
body: string;
|
|
337
|
-
/** Write counter at last update; the table of contents lists freshest first. */
|
|
338
|
-
ordinal: number;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
327
|
/** "user" is a mark laid down by the human through /fold-editor; it stages and
|
|
342
328
|
* commits through the same validated path as an agent mark and counts toward the
|
|
343
329
|
* commit's coverage the same way. */
|
|
@@ -457,8 +443,6 @@ export interface ActiveContextState {
|
|
|
457
443
|
armed?: { milestone: AdvisoryMilestone; threshold: number; scheduleKey: string };
|
|
458
444
|
};
|
|
459
445
|
rider?: { epoch: number; text: string };
|
|
460
|
-
/** The working memory's entries, in write order; absent when empty. */
|
|
461
|
-
memory?: WorkingMemoryEntry[];
|
|
462
446
|
}
|
|
463
447
|
|
|
464
448
|
export interface FoldRecordRef {
|
|
@@ -488,7 +472,6 @@ export interface ActiveContextCheckpointV2 {
|
|
|
488
472
|
pendingMarks?: PendingMark[];
|
|
489
473
|
briefs?: Record<string, BriefOverride>;
|
|
490
474
|
clips?: ToolClip[];
|
|
491
|
-
memory?: WorkingMemoryEntry[];
|
|
492
475
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
493
476
|
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
494
477
|
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
@@ -530,11 +513,6 @@ export interface ActiveContextDeltaV2 {
|
|
|
530
513
|
clips?: ToolClip[];
|
|
531
514
|
clipBase?: number;
|
|
532
515
|
addClips?: ToolClip[];
|
|
533
|
-
// `memory` on a delta is the WHOLE array. It is bounded by MEMORY_KEYS_MAX entries and
|
|
534
|
-
// edited in place by key, so a change form would have to restate the key order the way
|
|
535
|
-
// the marks do while saving at most a handful of bounded bodies: the whole array is the
|
|
536
|
-
// cheaper wire. Absent means empty, by the same discriminator rule as the marks.
|
|
537
|
-
memory?: WorkingMemoryEntry[];
|
|
538
516
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
539
517
|
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
540
518
|
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* nothing: the next model call that reads a result, a natural boundary in the work.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { MEMORY_BODY_CHARS, MEMORY_KEY_CHARS, MEMORY_KEYS_MAX } from "./policy.ts";
|
|
11
10
|
|
|
12
11
|
type ToolHandler = (
|
|
13
12
|
toolCallId: string,
|
|
@@ -45,16 +44,13 @@ export function buildActiveContextTool(input: {
|
|
|
45
44
|
const recoveryGuidance = input.allowedActions.includes("expand")
|
|
46
45
|
? " A fold brief is an index entry, not the source: when a question or task depends on material that is folded, peek or expand that fold and answer from the exact bytes, never from memory of them. Recalling folded detail without reopening it is how specifics get misremembered."
|
|
47
46
|
: "";
|
|
48
|
-
const memoryGuidance = input.allowedActions.includes("remember")
|
|
49
|
-
? ` WORKING MEMORY: a session-scoped dictionary you maintain beside the fold index. remember with key and body writes or updates an entry (${MEMORY_KEY_CHARS}-character keys, ${MEMORY_BODY_CHARS}-character bodies, ${MEMORY_KEYS_MAX} entries); an empty body removes it; recall with keys reads bodies back, or everything when keys is omitted. The bodies live outside your window: only a one-glance table of contents rides your context, refreshed at each commit, so writing an entry costs your window almost nothing. Keep entries current as the work moves: when a fact an entry records changes, or new information lands that belongs in one, update it then rather than at the end.`
|
|
50
|
-
: "";
|
|
51
47
|
const standingGuidance = " Folding is automatic, lossless and recoverable: the runtime folds stale spans on its own when the window needs room, nothing is ever discarded, and every folded span keeps its exact source. You are never asked to make room and no fold is ever announced in advance. Work normally and let it happen.";
|
|
52
48
|
return {
|
|
53
49
|
name: input.name,
|
|
54
50
|
label: input.label,
|
|
55
51
|
description: input.fullSurface
|
|
56
|
-
? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${
|
|
57
|
-
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${
|
|
52
|
+
? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
|
|
53
|
+
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
|
|
58
54
|
parameters: {
|
|
59
55
|
type: "object",
|
|
60
56
|
additionalProperties: false,
|
|
@@ -89,31 +85,6 @@ export function buildActiveContextTool(input: {
|
|
|
89
85
|
},
|
|
90
86
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
91
87
|
detail: { type: "string", enum: [...input.statusDetails] },
|
|
92
|
-
...(input.allowedActions.includes("remember")
|
|
93
|
-
? {
|
|
94
|
-
key: {
|
|
95
|
-
type: "string",
|
|
96
|
-
minLength: 1,
|
|
97
|
-
maxLength: MEMORY_KEY_CHARS,
|
|
98
|
-
description: "remember only: the working-memory entry to write, update or " +
|
|
99
|
-
"(with an empty body) remove.",
|
|
100
|
-
},
|
|
101
|
-
body: {
|
|
102
|
-
type: "string",
|
|
103
|
-
maxLength: MEMORY_BODY_CHARS,
|
|
104
|
-
description: "remember only: the entry's full text, replacing what the key " +
|
|
105
|
-
"held. Empty removes the entry.",
|
|
106
|
-
},
|
|
107
|
-
keys: {
|
|
108
|
-
type: "array",
|
|
109
|
-
minItems: 1,
|
|
110
|
-
maxItems: MEMORY_KEYS_MAX,
|
|
111
|
-
items: { type: "string", minLength: 1 },
|
|
112
|
-
description: "recall only: the working-memory entries to read. Omit to read " +
|
|
113
|
-
"every entry.",
|
|
114
|
-
},
|
|
115
|
-
}
|
|
116
|
-
: {}),
|
|
117
88
|
},
|
|
118
89
|
},
|
|
119
90
|
execute: input.handler,
|
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,7 +56,15 @@ 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;
|
|
62
|
+
|
|
63
|
+
export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
|
|
64
|
+
|
|
65
|
+
function isThresholdField(id: FoldSettingId): id is typeof THRESHOLD_FIELDS[number] {
|
|
66
|
+
return (THRESHOLD_FIELDS as readonly string[]).includes(id);
|
|
67
|
+
}
|
|
52
68
|
|
|
53
69
|
// One edit, applied against the WHOLE draft: the merged thresholds object is
|
|
54
70
|
// re-validated through resolveThresholds so cross-field invariants
|
|
@@ -59,6 +75,23 @@ export function applyFoldSettingsEdit(
|
|
|
59
75
|
id: FoldSettingId,
|
|
60
76
|
rawValue: string,
|
|
61
77
|
): { ok: true; draft: FoldSettingsFile } | { ok: false; error: string } {
|
|
78
|
+
if (id === "postFoldNotice") {
|
|
79
|
+
const raw = rawValue.trim().toLowerCase();
|
|
80
|
+
if (raw !== "true" && raw !== "false") {
|
|
81
|
+
return { ok: false, error: "postFoldNotice is on or off" };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, draft: { ...draft, postFoldNotice: raw === "true" } };
|
|
84
|
+
}
|
|
85
|
+
if (id === "toolFoldThreshold") {
|
|
86
|
+
const value = Number(rawValue.trim());
|
|
87
|
+
// The runtime's own range, restated here so the screen refuses before the file
|
|
88
|
+
// does: [0, 1) with 0 meaning off. Absence would ALSO mean the default rather
|
|
89
|
+
// than off, which is why 0 has to be expressible.
|
|
90
|
+
if (!Number.isFinite(value) || value < 0 || value >= 1) {
|
|
91
|
+
return { ok: false, error: "toolFoldThreshold is a share from 0 up to but not including 1; 0 turns it off" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, draft: { ...draft, toolFoldThreshold: value } };
|
|
94
|
+
}
|
|
62
95
|
try {
|
|
63
96
|
const value = Number(rawValue.trim());
|
|
64
97
|
if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
|
|
@@ -146,13 +179,32 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
146
179
|
// this package created. A key that was never on the surface is still a refusal.
|
|
147
180
|
let dropped = false;
|
|
148
181
|
for (const key of Object.keys(parsed)) {
|
|
149
|
-
if (key === "thresholds") continue;
|
|
182
|
+
if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
|
|
150
183
|
if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
|
|
151
|
-
return refused(`fold settings file has no ${key} field: the surface is
|
|
184
|
+
return refused(`fold settings file has no ${key} field: the surface is ` +
|
|
185
|
+
`thresholds, ${SCALAR_FIELDS.join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
// The two scalars are validated the same way the editor validates them, through the
|
|
188
|
+
// one path, so a hand-edited file cannot hold a value the screen would refuse.
|
|
189
|
+
const scalars: FoldSettingsFile = {};
|
|
190
|
+
for (const field of SCALAR_FIELDS) {
|
|
191
|
+
if (parsed[field] === undefined) continue;
|
|
192
|
+
const value = parsed[field];
|
|
193
|
+
const applied = applyFoldSettingsEdit({}, field, String(value));
|
|
194
|
+
if (!applied.ok) return refused(`fold settings ${field} is invalid: ${applied.error}`);
|
|
195
|
+
// String("0.5") round-trips, but a JSON string would too, and the file's own type
|
|
196
|
+
// has to be right or the value means something different on the next read.
|
|
197
|
+
if (field === "postFoldNotice" && typeof value !== "boolean") {
|
|
198
|
+
return refused("fold settings postFoldNotice must be true or false");
|
|
199
|
+
}
|
|
200
|
+
if (field === "toolFoldThreshold" && typeof value !== "number") {
|
|
201
|
+
return refused("fold settings toolFoldThreshold must be a number");
|
|
202
|
+
}
|
|
203
|
+
Object.assign(scalars, applied.draft);
|
|
152
204
|
}
|
|
153
205
|
if (parsed.thresholds === undefined) {
|
|
154
|
-
if (dropped) { try { saveFoldSettingsFile(path,
|
|
155
|
-
return { settings:
|
|
206
|
+
if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
|
|
207
|
+
return { settings: scalars, refusal: null, migrated: dropped };
|
|
156
208
|
}
|
|
157
209
|
const stored = parsed.thresholds;
|
|
158
210
|
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
|
|
@@ -169,8 +221,9 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
169
221
|
? { ...Object.fromEntries(missing.map((field) => [field, DEFAULT_THRESHOLDS[field]])), ...supplied }
|
|
170
222
|
: supplied);
|
|
171
223
|
const migrated = missing.length > 0 || dropped;
|
|
172
|
-
|
|
173
|
-
|
|
224
|
+
const settings: FoldSettingsFile = { thresholds, ...scalars };
|
|
225
|
+
if (migrated) { try { saveFoldSettingsFile(path, settings); } catch { } }
|
|
226
|
+
return { settings, refusal: null, migrated };
|
|
174
227
|
} catch (error: any) {
|
|
175
228
|
return refused(`fold settings file at ${path} is invalid: ${error?.message ?? error}`);
|
|
176
229
|
}
|
|
@@ -183,6 +236,10 @@ export function loadFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
183
236
|
export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile): void {
|
|
184
237
|
const clean: FoldSettingsFile = {};
|
|
185
238
|
if (settings.thresholds !== undefined) clean.thresholds = settings.thresholds;
|
|
239
|
+
// Written only when SET. An absent scalar means the package default, and writing the
|
|
240
|
+
// default out would pin today's value into a file that should follow the package.
|
|
241
|
+
if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
|
|
242
|
+
if (settings.postFoldNotice !== undefined) clean.postFoldNotice = settings.postFoldNotice;
|
|
186
243
|
mkdirSync(dirname(path), { recursive: true });
|
|
187
244
|
const temporary = `${path}.tmp`;
|
|
188
245
|
writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
|
|
@@ -195,11 +252,19 @@ interface EditorRow {
|
|
|
195
252
|
description: string;
|
|
196
253
|
}
|
|
197
254
|
|
|
255
|
+
// WRITTEN FOR THE READER, NOT THE IMPLEMENTER (2026-08-28). "Fires a fold epoch" and
|
|
256
|
+
// "parents owed per epoch = visible roots divided by this" are this codebase's words for
|
|
257
|
+
// its own internals, and a person setting a value should not have to learn them. Each
|
|
258
|
+
// description now says what the setting DOES to their session; the option name stays in
|
|
259
|
+
// parentheses as the bridge to the README table and to registerPiFold, which document
|
|
260
|
+
// these exact names.
|
|
198
261
|
const EDITOR_ROWS: readonly EditorRow[] = [
|
|
199
|
-
{ id: "maxTarget", label: "
|
|
200
|
-
{ id: "minTarget", label: "
|
|
201
|
-
{ id: "consolidateAfter", label: "
|
|
202
|
-
{ 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)" },
|
|
203
268
|
];
|
|
204
269
|
|
|
205
270
|
// The cycle lattices. Shares step in cents so no float drift reaches a threshold;
|
|
@@ -217,6 +282,10 @@ const CONSOLIDATE_CHOICES = [2, 3, 4, 5, 6, 8, 10, 12, 15, 20];
|
|
|
217
282
|
// enforces, below which a placeholder can cost more than the source it replaces.
|
|
218
283
|
const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32_000];
|
|
219
284
|
|
|
285
|
+
// 0 IS ON THE LATTICE, and first, because absence means the package default rather than
|
|
286
|
+
// off: without a 0 here the only way to decline in-view clipping would be the file.
|
|
287
|
+
const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
|
|
288
|
+
|
|
220
289
|
function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
|
|
221
290
|
const { min, max, step } = SHARE_STEPS[id];
|
|
222
291
|
const values: number[] = [];
|
|
@@ -229,6 +298,7 @@ function shareCandidates(id: "maxTarget" | "minTarget"): number[] {
|
|
|
229
298
|
// The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
|
|
230
299
|
// only to it: this list is stepped, never shown.
|
|
231
300
|
function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
|
|
301
|
+
if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
|
|
232
302
|
if (id === "consolidateAfter") return CONSOLIDATE_CHOICES;
|
|
233
303
|
if (id === "minFoldChars") return MIN_FOLD_CHOICES;
|
|
234
304
|
const { minTarget, maxTarget } = thresholds;
|
|
@@ -238,17 +308,32 @@ function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds):
|
|
|
238
308
|
}
|
|
239
309
|
|
|
240
310
|
function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
|
|
311
|
+
// A SCALAR THAT IS ABSENT READS AS THE PACKAGE DEFAULT, never as zero or false. The
|
|
312
|
+
// file omits what was never set on purpose, so the screen has to supply the same
|
|
313
|
+
// value the runtime would, or the row would show a setting nobody chose.
|
|
314
|
+
if (!isThresholdField(id)) {
|
|
315
|
+
return id === "toolFoldThreshold"
|
|
316
|
+
? String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD)
|
|
317
|
+
: String(settings.postFoldNotice ?? false);
|
|
318
|
+
}
|
|
241
319
|
const thresholds = settings.thresholds ?? DEFAULT_THRESHOLDS;
|
|
242
320
|
return String(thresholds[id]);
|
|
243
321
|
}
|
|
244
322
|
|
|
245
323
|
function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTokens: number): string {
|
|
246
324
|
const raw = rowRawValue(settings, id);
|
|
325
|
+
if (id === "postFoldNotice") return raw === "true" ? "on" : "off";
|
|
326
|
+
if (id === "toolFoldThreshold") {
|
|
327
|
+
// The share is of the window rather than of a budget, so it is stated as the
|
|
328
|
+
// slice it names instead of the token count the other two shares translate to.
|
|
329
|
+
return Number(raw) === 0 ? "off" : `oldest ${Math.round(Number(raw) * 100)}%`;
|
|
330
|
+
}
|
|
247
331
|
if (id === "consolidateAfter") return raw;
|
|
248
|
-
if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")}
|
|
249
|
-
//
|
|
250
|
-
// the
|
|
251
|
-
|
|
332
|
+
if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} characters`;
|
|
333
|
+
// A PERCENTAGE, like every other human surface. The screen used to read "0.80" while
|
|
334
|
+
// the status line, /fold-status and the editor header all said "80%", so the one place
|
|
335
|
+
// a person CHANGES the number spoke a different dialect from the three that report it.
|
|
336
|
+
return `${Math.round(Number(raw) * 100)}% · ${Math.round(Number(raw) * budgetTokens).toLocaleString("en-US")} tokens`;
|
|
252
337
|
}
|
|
253
338
|
|
|
254
339
|
// The frame Pi's own /settings screen draws around its list. Implemented locally
|
|
@@ -342,7 +427,7 @@ export class FoldSettingsEditor extends Container {
|
|
|
342
427
|
const theme = this.themeLike;
|
|
343
428
|
this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
|
|
344
429
|
this.addChild(new Text(theme.bold(theme.fg("accent", "pi-fold settings")), 0, 0));
|
|
345
|
-
this.addChild(new Text(theme.fg("muted", "
|
|
430
|
+
this.addChild(new Text(theme.fg("muted", "Percentages are of the usable window. Every change saves as you make it."), 0, 0));
|
|
346
431
|
if (notice) this.addChild(new Text(theme.fg("error", notice), 0, 0));
|
|
347
432
|
this.addChild(new Spacer(1));
|
|
348
433
|
// SettingsList takes its own theme shape; adapt it off the live theme.
|
|
@@ -351,7 +436,12 @@ export class FoldSettingsEditor extends Container {
|
|
|
351
436
|
value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
352
437
|
description: (text: string) => theme.fg("dim", text),
|
|
353
438
|
cursor: theme.fg("accent", "→ "),
|
|
354
|
-
hint
|
|
439
|
+
// SettingsList ships its own hint, "Enter/Space to change · Esc to cancel", and
|
|
440
|
+
// BOTH halves are wrong on this screen: Enter opens an exact-value editor rather
|
|
441
|
+
// than cycling a value, and nothing is cancelled because every change is saved
|
|
442
|
+
// the moment it is made. The hint function is ours and is handed the string, so
|
|
443
|
+
// the correction happens here rather than being contradicted a line below.
|
|
444
|
+
hint: (_text: string) => theme.fg("dim", "←→ adjust · Enter types an exact value · Esc closes"),
|
|
355
445
|
};
|
|
356
446
|
this.settingsList = new SettingsList(
|
|
357
447
|
EDITOR_ROWS.map((row) => ({
|
|
@@ -375,8 +465,6 @@ export class FoldSettingsEditor extends Container {
|
|
|
375
465
|
() => this.done(true),
|
|
376
466
|
);
|
|
377
467
|
this.addChild(this.settingsList);
|
|
378
|
-
this.addChild(new Spacer(1));
|
|
379
|
-
this.addChild(new Text(theme.fg("dim", " ←→ to step · Enter for exact · Esc to close"), 0, 0));
|
|
380
468
|
this.addChild(new SettingsBorder((text) => theme.fg("border", text)));
|
|
381
469
|
}
|
|
382
470
|
|
|
@@ -384,6 +472,12 @@ export class FoldSettingsEditor extends Container {
|
|
|
384
472
|
// filtered against the CURRENT draft, so stepping can never leave the policy
|
|
385
473
|
// surface; an exact off-lattice value arrives only through Enter's editor.
|
|
386
474
|
private step(id: FoldSettingId, direction: number): void {
|
|
475
|
+
// A SWITCH HAS NO LATTICE. Either direction moves to the other value, which is
|
|
476
|
+
// what a person expects of a two-state row sitting among stepped ones.
|
|
477
|
+
if (id === "postFoldNotice") {
|
|
478
|
+
this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
387
481
|
const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
|
|
388
482
|
const candidates = allowedValues(id, thresholds);
|
|
389
483
|
const current = Number(rowRawValue(this.draft, id));
|
|
@@ -434,7 +528,7 @@ export function registerFoldSettings(
|
|
|
434
528
|
): void {
|
|
435
529
|
const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
|
|
436
530
|
pi.registerCommand("fold-settings", {
|
|
437
|
-
description: "Configure pi-fold: commit
|
|
531
|
+
description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, brief invitation",
|
|
438
532
|
handler: async (_args: string, ctx: any) => {
|
|
439
533
|
if (typeof ctx.ui?.custom !== "function") {
|
|
440
534
|
throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
|
package/package.json
CHANGED