pi-fold 2.0.0 → 3.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.
@@ -5,8 +5,8 @@ export const DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX = "pi-fold-active-context"
5
5
  export const DEFAULT_ACTIVE_CONTEXT_TOOL_LABEL = "pi-fold Active Context";
6
6
  export const DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN = "pi-fold";
7
7
  export const DEFAULT_ACTIVE_CONTEXT_COMMAND_NAMES = Object.freeze({
8
- status: "pi-fold-context",
9
- fold: "fold-context",
8
+ status: "fold-status",
9
+ fold: "fold",
10
10
  });
11
11
 
12
12
  export function entryTypeNamespace(entryTypePrefix: string): string {
@@ -33,10 +33,64 @@ const DEFAULT_ENTRY_NAMESPACE = entryTypeNamespace(DEFAULT_ACTIVE_CONTEXT_ENTRY_
33
33
  export const ACTIVE_CONTEXT_STATE_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX}-state`;
34
34
  export const ACTIVE_CONTEXT_FOLD_RECORD_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX}-fold-record`;
35
35
  export const PROVIDER_CONTEXT_MEASUREMENT_ENTRY = `${DEFAULT_ENTRY_NAMESPACE}-provider-context-measurement`;
36
+ /**
37
+ * How many unbriefed pending folds stand before the runtime says so.
38
+ *
39
+ * A CONSTANT, not a threshold the deployment can turn (Shane's KISS rule): it is a
40
+ * batching size, and the only thing a knob here would buy is a deployment that never
41
+ * speaks. Three is the smallest batch that reads as a batch rather than as a running
42
+ * commentary on every cut.
43
+ */
44
+ export const UNBRIEFED_FOLDS_BEFORE_NOTICE = 3;
45
+
46
+ /**
47
+ * How many spans the frontier cuts on one projection pass.
48
+ *
49
+ * A CONSTANT, and a cost bound rather than a policy. Each cut rescans the window for the
50
+ * next stalest span, so an unbounded loop is quadratic in the number of spans a window
51
+ * holds: on a 300-turn fixture it took a gate from 2.5 seconds to 152. A frontier does not
52
+ * need to catch up in one pass, because a pass costs nothing to repeat and the material it
53
+ * has not reached yet is still raw and still in front of the agent, which is where the
54
+ * design wants it. Eight is comfortably ahead of any real arrival rate.
55
+ */
56
+ export const MAX_FRONTIER_CUTS_PER_PASS = 8;
57
+
36
58
  export const ACTIVE_CONTEXT_TOOL_ACTIONS = Object.freeze([
37
- "status", "peek", "fold", "expand", "refold", "protect", "unprotect",
38
- "rebrief", "reboundary", "unmark",
59
+ "status", "peek", "brief", "expand", "refold", "pin", "unpin",
60
+ "reboundary", "unmark",
39
61
  ] as const);
62
+ /**
63
+ * FOLD IS DELETED AS AN AGENT VERB (Shane, 2026-08-23), and `brief` is what replaced it.
64
+ *
65
+ * The agent used to choose spans from nothing. Measured against real compaction
66
+ * boundaries on 2026-08-23, that produced a projection byte-identical to the same arm
67
+ * with no agent at all: six marks, 9,450 characters of briefs, same visible count, same
68
+ * fold count, same aborts. It marks what the ladder would have taken anyway, because at
69
+ * the moment it is asked the only information available is staleness and the runtime
70
+ * already has that.
71
+ *
72
+ * So the runtime cuts at the frontier and the agent annotates what it cut, while the
73
+ * material is still in front of it. `brief` attaches or replaces the brief on a PENDING
74
+ * fold and refuses on a standing one, which is the rebrief deletion above read from the
75
+ * other side: a pending mark is not in the window, so writing its brief moves no bytes
76
+ * and costs no cache, and a standing fold's placeholder is in the window, so writing that
77
+ * one costs the whole prefix from there on. The name `fold` stays spent.
78
+ */
79
+ /**
80
+ * REBRIEF IS DELETED (Shane, 2026-08-21). Every rebrief rewrites the projection at its
81
+ * fold's placeholder, which breaks the prefix cache from that point on: sol-20260820
82
+ * rep 7's first rebrief hit a front-of-projection fold, identical_share collapsed to
83
+ * 0.0007, and nine ~185K-token requests ran cacheRead 0 for $8.36 in one burst. The
84
+ * end block read 10 of 10 in every pifold rep with or without rebriefs, because
85
+ * lossless peek already carries recall, so the action was paying a full cache break
86
+ * for something the runtime did not need. The name stays spent.
87
+ */
88
+ export const RETIRED_TOOL_ACTIONS: Readonly<Record<string, string>> = Object.freeze({
89
+ rebrief: "rebrief is deleted: rewriting a standing fold's brief rewrites the projection " +
90
+ "at that fold's placeholder and breaks the prefix cache from there on, for recall that " +
91
+ "peek already carries losslessly. Write the brief you want when you mark the span, or " +
92
+ "use reboundary to re-cut it.",
93
+ });
40
94
  export type ActiveContextToolAction = typeof ACTIVE_CONTEXT_TOOL_ACTIONS[number];
41
95
  export const USER_RESCUE_MAX_SOURCE_CHARS = 512_000;
42
96
  export const DEFAULT_CONTEXT_WINDOW = 272_000;
@@ -45,38 +99,86 @@ export const MAX_EXPAND_LEASES = 64;
45
99
 
46
100
  export const COMMIT_RECLAIM_FLOOR_SHARE = 0.02;
47
101
 
102
+ /**
103
+ * How far below the commit trigger the agent is told a commit is coming (Shane,
104
+ * 2026-08-22: "around 10% from the commit threshold"). A share of the serving budget
105
+ * rather than a token count, because a token count means a different fraction of every
106
+ * deployment's window. The advisory takes the wider of this and one worst recent inflow
107
+ * step, so a single large result cannot step over the whole band unasked.
108
+ */
48
109
  export const MAX_PINNED_SHARE = 0.25;
49
110
 
50
111
  export interface ActiveContextThresholds {
51
112
  maxTarget: number;
52
113
  minTarget: number;
53
- freshTail: number;
54
114
  consolidateAfter: number;
115
+ minFoldChars: number;
55
116
  }
56
117
 
118
+ /**
119
+ * The floor a fold has to clear to be worth making, in CHARACTERS (Shane, 2026-08-21).
120
+ *
121
+ * Characters rather than tokens because characters are provider-agnostic: a token count
122
+ * means a different amount of text on every wire, and this number exists so a person can
123
+ * reason about it. It replaces two constants nobody could tell apart, minToolChars at
124
+ * 2,000 and minChapterChars at 4,000, and it is not specific to tool results or to
125
+ * chapters: everything in the window costs tokens, so everything counts toward the floor.
126
+ *
127
+ * It is a floor, not a cut. The automatic span accumulates whole messages and stops at
128
+ * the first one that carries it OVER the floor, so a fold is always at least this big
129
+ * and never cuts a message in half to hit the number exactly. The same floor is what a
130
+ * gap between two folds has to clear to survive: anything smaller is absorbed by the
131
+ * fold beside it rather than left as a raw sliver, because a sliver costs its bytes and
132
+ * buys nothing.
133
+ *
134
+ * 8,000 was chosen against the sealed corpus. Rep 1 of sol-20260814-traps folded 89
135
+ * spans at a median of 57,808 source chars, but nine of them came in under 8,000 and
136
+ * TWO were net negative, 2,726 chars of source replaced by 5,341 of placeholder (196
137
+ * percent) and 6,384 replaced by 10,531 (165 percent). A fold that grows the window is
138
+ * not a fold. The floor sits above every observed inversion with room to spare.
139
+ */
140
+ export const MINIMUM_FOLD_CHARS_FLOOR = 2_000;
141
+
57
142
  export const DEFAULT_THRESHOLDS: Readonly<ActiveContextThresholds> = Object.freeze({
58
143
  maxTarget: 0.80,
59
- // 0.20, down from 0.35 (Shane 2026-08-14). The cut depth is what sets the epoch
60
- // cadence: every commit rewrites the prefix from the earliest folded byte, so the
61
- // cost of an epoch is nearly flat in how deep it cuts, while the relief it buys is
62
- // the whole distance to the trigger. sol-20260814-traps rep 1 ran six commits, each
63
- // freeing to 0.35 and refilling in two to three stages; at 0.20 the same run shape
64
- // owes roughly a quarter fewer epochs, which is a quarter fewer full-prefix
65
- // rewrites, the dominant self-inflicted cache break. The floor is a target the
66
- // class law outranks, not a guarantee: a session pinned to the 0.25 ceiling
67
- // beside the 0.02 fresh tail legally holds 0.27 and lands above this number,
68
- // and the epoch receipt reports targetBudgetShare beside actualFreedBudgetShare
69
- // so the shortfall is a readable fact per epoch rather than a silent miss. The
70
- // validation laws below bind exactly as before.
71
- minTarget: 0.20,
72
- freshTail: 0.02,
144
+ // 0.40, up from 0.20 (Shane 2026-08-23: "we change the thresholds to what hits that
145
+ // 100k/250k or so"). This is the only threshold that moved, because the corpus says
146
+ // the top of the band was already where he wants it and only the floor was wrong.
147
+ //
148
+ // Read against the 19 mature sealed pifold runs that share a 251,520-token budget,
149
+ // where the trigger sat at 0.80 of it, 201,216 tokens. Peak occupancy per run came in
150
+ // at a median 237,399, a 0.944 share, p90 247,551, max 257,117: a session runs roughly
151
+ // 36,000 tokens PAST its own trigger before the commit it fired takes effect, because
152
+ // the trigger fires at a measurement and inflow continues while the epoch is computed.
153
+ // So 0.80 already delivers a 250k top, and raising it toward 250k directly would put
154
+ // the median peak over the budget and into the fence.
155
+ //
156
+ // The floor was the miss. Across 206 commit landings the session came to rest at a
157
+ // median 79,034 tokens, and 153 of the 206 landed below 100,000. Landings sit ABOVE
158
+ // the aim rather than at it, since the class law and the available material both
159
+ // outrank it, so moving the aim to 0.40 sets a floor near where it lands rather than
160
+ // far under it: 102,246 tokens against a default 272,000-token descriptor, 100,608
161
+ // against the harness budget.
162
+ //
163
+ // 0.40 also makes the aim reachable for the first time. MAX_PINNED_SHARE is 0.25, so a
164
+ // fully pinned session legally holds more than a 0.20 aim asks for and the target was
165
+ // a number the class law outranked by construction. The epoch receipt still reports
166
+ // targetBudgetShare beside actualFreedBudgetShare, so any remaining shortfall stays a
167
+ // readable fact per epoch rather than a silent miss.
168
+ //
169
+ // The cost is paid in cadence and it is the 2026-08-14 argument running backwards: a
170
+ // shallower cut buys less runway for nearly the same commit, so the refill distance
171
+ // goes from the observed 0.314 -> 0.80 to 0.40 -> 0.80 and the same session shape owes
172
+ // about a fifth more epochs, which is a fifth more full-prefix rewrites. Shane bought
173
+ // that deliberately: 100k of raw context held across the whole cycle is what he is
174
+ // paying for. The validation laws below bind exactly as before.
175
+ minTarget: 0.40,
73
176
  consolidateAfter: 10,
177
+ minFoldChars: 8_000,
74
178
  });
75
179
 
76
180
  export const MINIMUM_SUPPORTED_BUDGET_TOKENS = 10_000;
77
181
 
78
- export const MINIMUM_FRESH_TAIL_TOKENS = 500;
79
-
80
182
  export class ThresholdPolicyError extends Error {
81
183
  readonly invariant: string;
82
184
  constructor(invariant: string, message: string) {
@@ -99,7 +201,7 @@ export function resolveThresholds(value: unknown): ActiveContextThresholds {
99
201
  throw new ThresholdPolicyError("shape", "thresholds must be an object with all four fields");
100
202
  }
101
203
  const supplied = value as Record<string, unknown>;
102
- const fields = ["maxTarget", "minTarget", "freshTail", "consolidateAfter"] as const;
204
+ const fields = ["maxTarget", "minTarget", "consolidateAfter", "minFoldChars"] as const;
103
205
  for (const key of Object.keys(supplied)) {
104
206
  if (!(fields as readonly string[]).includes(key)) {
105
207
  throw new ThresholdPolicyError("shape", `thresholds has no ${key} field`);
@@ -112,46 +214,31 @@ export function resolveThresholds(value: unknown): ActiveContextThresholds {
112
214
  }
113
215
  const maxTarget = thresholdProportion(supplied.maxTarget, "maxTarget");
114
216
  const minTarget = thresholdProportion(supplied.minTarget, "minTarget");
115
- const freshTail = thresholdProportion(supplied.freshTail, "freshTail");
116
217
  const consolidateAfter = supplied.consolidateAfter;
117
218
  if (typeof consolidateAfter !== "number" || !Number.isInteger(consolidateAfter) || consolidateAfter < 1) {
118
219
  throw new ThresholdPolicyError("consolidateAfter", "thresholds.consolidateAfter must be a positive integer count");
119
220
  }
221
+ const minFoldChars = supplied.minFoldChars;
222
+ if (typeof minFoldChars !== "number" || !Number.isInteger(minFoldChars) ||
223
+ minFoldChars < MINIMUM_FOLD_CHARS_FLOOR) {
224
+ throw new ThresholdPolicyError("minFoldChars",
225
+ "thresholds.minFoldChars must be an integer character count of at least " +
226
+ `${MINIMUM_FOLD_CHARS_FLOOR}; below that a fold can cost more placeholder than the ` +
227
+ "source it replaces");
228
+ }
120
229
  if (!(minTarget < maxTarget)) {
121
230
  throw new ThresholdPolicyError("minTarget<maxTarget",
122
231
  "thresholds.minTarget must sit below thresholds.maxTarget, or a commit has no depth to reach");
123
232
  }
124
- if (!(freshTail < maxTarget)) {
125
- throw new ThresholdPolicyError("freshTail<maxTarget",
126
- "thresholds.freshTail must sit below thresholds.maxTarget, or the trigger fires on protected mass alone");
127
- }
128
- if (!(maxTarget - minTarget >= freshTail)) {
129
- throw new ThresholdPolicyError("gap>=freshTail",
130
- "the hysteresis gap (maxTarget - minTarget) must be at least thresholds.freshTail, " +
131
- "or refilling the protected tail alone re-fires the trigger");
132
- }
133
- const pinned = Math.ceil(MAX_PINNED_SHARE * MINIMUM_SUPPORTED_BUDGET_TOKENS);
134
- const fresh = Math.ceil(freshTail * MINIMUM_SUPPORTED_BUDGET_TOKENS);
135
- if (!(pinned + fresh < Math.floor(maxTarget * MINIMUM_SUPPORTED_BUDGET_TOKENS))) {
136
- throw new ThresholdPolicyError("pinnedPlusFreshTail<maxTarget",
137
- `the pin ceiling (${MAX_PINNED_SHARE}) plus thresholds.freshTail must stay below ` +
138
- `thresholds.maxTarget at the ${MINIMUM_SUPPORTED_BUDGET_TOKENS}-token minimum supported budget`);
139
- }
140
- return { maxTarget, minTarget, freshTail, consolidateAfter };
233
+ return { maxTarget, minTarget, consolidateAfter, minFoldChars };
141
234
  }
142
235
 
143
- export function assertThresholdsServable(thresholds: ActiveContextThresholds, budgetTokens: number): void {
236
+ export function assertThresholdsServable(_thresholds: ActiveContextThresholds, budgetTokens: number): void {
144
237
  if (!Number.isFinite(budgetTokens) || budgetTokens < MINIMUM_SUPPORTED_BUDGET_TOKENS) {
145
238
  throw new ThresholdPolicyError("minimumBudget",
146
239
  `a ${Math.floor(budgetTokens)}-token serving budget is below the ` +
147
240
  `${MINIMUM_SUPPORTED_BUDGET_TOKENS}-token minimum this package supports`);
148
241
  }
149
- const freshTokens = Math.floor(thresholds.freshTail * budgetTokens);
150
- if (freshTokens < MINIMUM_FRESH_TAIL_TOKENS) {
151
- throw new ThresholdPolicyError("freshTailTokens",
152
- `thresholds.freshTail is ${freshTokens} tokens of a ${Math.floor(budgetTokens)}-token serving budget, ` +
153
- `below the ${MINIMUM_FRESH_TAIL_TOKENS}-token minimum one foldable unit needs`);
154
- }
155
242
  }
156
243
 
157
244
  export function zoneBytes(share: number, budgetTokens: number): number {
@@ -172,54 +259,51 @@ export const PEEK_MIN_SLICE_BYTES = 1_024;
172
259
 
173
260
  export const STATUS_DIET_INDEX_ROWS = 5;
174
261
 
175
- export interface ActiveContextGuidance {
176
- actionResponses: boolean;
177
- }
178
-
179
- export const DEFAULT_GUIDANCE: ActiveContextGuidance = Object.freeze({
180
- actionResponses: true,
181
- });
182
-
183
- const GUIDANCE_KEYS: readonly (keyof ActiveContextGuidance)[] = Object.freeze([
184
- "actionResponses",
185
- ]);
186
-
187
- export function resolveGuidance(value: unknown): ActiveContextGuidance {
188
- if (value === undefined) return DEFAULT_GUIDANCE;
189
- if (!value || typeof value !== "object" || Array.isArray(value)) {
190
- throw new Error("guidance must be an object of { actionResponses } booleans");
191
- }
192
- for (const key of Object.keys(value)) {
193
- if (!GUIDANCE_KEYS.includes(key as keyof ActiveContextGuidance)) {
194
- throw new Error(`guidance has no ${key} setting: the only keys are ` +
195
- `${GUIDANCE_KEYS.join(", ")}, and they are booleans`);
196
- }
197
- }
198
- const resolved = { ...DEFAULT_GUIDANCE } as ActiveContextGuidance;
199
- for (const key of GUIDANCE_KEYS) {
200
- if (!Object.hasOwn(value, key)) continue;
201
- const setting = (value as Record<string, unknown>)[key];
202
- if (typeof setting !== "boolean") throw new Error(`guidance.${key} must be a boolean`);
203
- resolved[key] = setting;
204
- }
205
- return Object.freeze(resolved);
206
- }
207
-
208
262
  export const MAX_CONTEXT_RECEIPTS = 3;
209
263
  export const CONTEXT_RECEIPT_BLOCK_BYTES = 900;
210
264
 
211
- export const CONTEXT_MARK_RESPONSE_BYTES = 900;
265
+ /**
266
+ * The post-fold notice's bound, and its own rather than the receipt block's.
267
+ *
268
+ * It is larger because it carries a variable-length list the receipt does not: three
269
+ * fixed sentences of instruction plus one entry per unbriefed fold, and the pending set
270
+ * grows past MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than the
271
+ * agent answers. 1,200 seats the instruction and roughly eight folds; past that the list
272
+ * gives way and states how many it could not name.
273
+ */
274
+ export const FOLD_NOTICE_BYTES = 1_200;
212
275
  export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
213
276
  export const MAX_UNMARKED_CANDIDATES = 3;
214
277
 
215
278
  export const MAX_FOLD_SPAN_CHARS = 16_000;
216
- export const MAX_WEDGE_ABSORB_TOKENS = 256;
217
279
  export const PEEK_DEFAULT_MAX_BYTES = 16_000;
218
280
  export const PEEK_HEAD_SHARE = 0.6;
219
281
 
282
+ /**
283
+ * A PEEK IS EPHEMERAL UNLESS THE AGENT SAYS OTHERWISE (Shane, 2026-08-22).
284
+ *
285
+ * The default follows the measured choice rather than the cautious one. On sealed run
286
+ * rep 7, six of seven peeks asked for ephemeral at first organic exposure, and the one
287
+ * durable read was the stage-64 probe, exactly where the bytes were wanted standing.
288
+ * The economics agree: a peek is a pure append and costs the cache nothing, while the
289
+ * withdrawal is a tail operation at the withdrawn result's own index, so the cheap read
290
+ * should be the one you get without asking.
291
+ *
292
+ * Only an explicit false is durable. A missing value, and anything the validator has
293
+ * already rejected, reads as ephemeral, so a host or model that never learned the
294
+ * argument still gets the cheap behaviour and can still recover the bytes by peeking
295
+ * again, which is lossless.
296
+ */
297
+ export function peekIsEphemeral(params: Record<string, unknown> | undefined | null): boolean {
298
+ return params?.ephemeral !== false;
299
+ }
300
+
220
301
  export const PEEK_READ_ONLY_CONTEXT_ACTIONS: ReadonlySet<string> = new Set(["status", "peek"]);
221
302
 
222
- export type MarkOrigin = "agent" | "ladder";
303
+ /** "user" is a mark laid down by the human through /fold-editor; it stages and
304
+ * commits through the same validated path as an agent mark and counts toward the
305
+ * commit's coverage the same way. */
306
+ export type MarkOrigin = "agent" | "ladder" | "user";
223
307
 
224
308
  export interface PendingFoldMark {
225
309
  mark: "fold";
@@ -245,12 +329,11 @@ export const ACTIVE_CONTEXT_POLICY = Object.freeze({
245
329
  refoldRatio: 0.85,
246
330
  prepareRatio: 0.90,
247
331
  responseReserve: 16_384,
248
- minToolChars: 2_000,
249
- minChapterChars: 4_000,
250
- maxChapterChars: 128_000,
251
- maxChapterTurns: 4,
332
+ // The most exact source bytes ONE read may put back in the window, whichever verb
333
+ // asks: an expand collapses its biggest children until the render fits, and a peek
334
+ // clamps its `bytes` to it. Was two numbers until 2026-08-22, when the peek ceiling
335
+ // was `maxChapterChars` 128,000 and that constant also claimed to cap a chapter.
252
336
  maxSourceChars: 200_000,
253
- maxFoldSourceRefs: 256,
254
337
  maxBriefChars: 2_000,
255
338
  orientationMessages: 2,
256
339
  maxOrientationChars: 12_000,
@@ -414,7 +497,6 @@ export interface ActiveContextSnapshot {
414
497
  thresholds: ActiveContextThresholds;
415
498
  budgetTokens: number;
416
499
  protectedIndices: Set<number>;
417
- toolProtectedIndices: Set<number>;
418
500
  policy: typeof ACTIVE_CONTEXT_POLICY;
419
501
  toolName: string;
420
502
  brandNoun: string;