pi-fold 0.1.0 → 1.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.
@@ -0,0 +1,437 @@
1
+ import { objectRefKey } from "../json.ts";
2
+ import {
3
+ bytes,
4
+ messageRole,
5
+ } from "./canonical.ts";
6
+ import { boundReceiptText } from "./measurement.ts";
7
+ import {
8
+ contextBrand,
9
+ CONTEXT_MARK_RESPONSE_BYTES,
10
+ CONTEXT_RECEIPT_BLOCK_BYTES,
11
+ CURATION_GATE_MAX_ROUNDS,
12
+ CURATION_OCCUPANCY_SHARE,
13
+ CURATION_REMINDER_SHARES,
14
+ CURATION_STALE_TOOL_SHARE,
15
+ DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
16
+ MAX_CONTEXT_RECEIPTS,
17
+ } from "./policy.ts";
18
+ import type {
19
+ ActiveContextSnapshot,
20
+ ActiveContextState,
21
+ } from "./policy.ts";
22
+
23
+ /**
24
+ * Guided curation.
25
+ *
26
+ * The ladder's commit triggers are correct and silent. Silent is the problem: the
27
+ * agent learns that its evidence became placeholders by reading a placeholder, which
28
+ * is the one moment it can no longer do anything about it. So a commit is ANNOUNCED
29
+ * before it happens, early enough that reacting is still cheap, and the announcement
30
+ * carries what is about to happen, what it costs, and the actions that change it.
31
+ *
32
+ * Everything here is deterministic: transcript ordinals, measured tokens and byte
33
+ * counts only. No wall clock and no randomness.
34
+ */
35
+
36
+ export interface StaleToolMass {
37
+ /** Serialized bytes of tool results outside the fresh tail and outside every fold. */
38
+ bytes: number;
39
+ /** How many tool results that mass is spread over. */
40
+ results: number;
41
+ }
42
+
43
+ /**
44
+ * Tool-result mass a commit could actually reach: outside the fresh tail, outside any
45
+ * protection, and not already owned by a fold. Occupancy alone would announce commits
46
+ * that have nothing to fold; this is the second signal that says the announcement is
47
+ * about to be worth something.
48
+ */
49
+ export function staleToolMass(
50
+ snapshot: ActiveContextSnapshot,
51
+ state: ActiveContextState,
52
+ ): StaleToolMass {
53
+ const owned = new Set(state.folds.flatMap((fold) =>
54
+ fold.parts.flatMap((part) => part.kind === "raw" ? [objectRefKey(part.ref)] : [])));
55
+ let total = 0;
56
+ let results = 0;
57
+ for (const item of snapshot.mapped) {
58
+ if (!item.ref || messageRole(item.message) !== "toolResult") continue;
59
+ if (snapshot.toolProtectedIndices.has(item.index)) continue;
60
+ if (owned.has(objectRefKey(item.ref))) continue;
61
+ total += bytes(item.message);
62
+ results += 1;
63
+ }
64
+ return { bytes: total, results };
65
+ }
66
+
67
+ export interface CurationSignals {
68
+ /** Measured provider tokens as a share of the truthful serving budget; null unmeasured. */
69
+ occupancy: number | null;
70
+ occupancyTokens: number | null;
71
+ /** THE serving budget: one resolved value, the same one the fence and estimator use. */
72
+ budgetTokens: number;
73
+ window: number;
74
+ /** Stale tool mass as a share of the serving budget, in the session's own tokens. */
75
+ staleToolShare: number;
76
+ staleToolTokens: number;
77
+ staleToolResults: number;
78
+ /** Marks a commit could apply right now. */
79
+ eligibleFolds: number;
80
+ }
81
+
82
+ /**
83
+ * Both signals are measured against ONE resolved serving budget, handed in by the
84
+ * caller that owns the capacity accounting. Nothing here re-derives a window from a
85
+ * descriptor: rep 15's trigger and gate ran the whole run against a 272,000-token
86
+ * per-request descriptor while the fence used the truthful 383,616, because the budget
87
+ * was computed twice from two different places.
88
+ */
89
+ export function curationSignals(input: {
90
+ snapshot: ActiveContextSnapshot;
91
+ state: ActiveContextState;
92
+ usedTokens: number | null;
93
+ budgetTokens: number;
94
+ /** The resolved serving WINDOW the budget was taken from, reported for audit. */
95
+ window: number;
96
+ /** The session's own measured serialized chars per token; never a fixed constant. */
97
+ charsPerToken: number;
98
+ eligibleFolds: number;
99
+ }): CurationSignals {
100
+ const mass = staleToolMass(input.snapshot, input.state);
101
+ const charsPerToken = Number.isFinite(input.charsPerToken) && input.charsPerToken > 0
102
+ ? input.charsPerToken
103
+ : 4;
104
+ const staleToolTokens = Math.ceil(mass.bytes / charsPerToken);
105
+ return {
106
+ occupancy: input.usedTokens === null || input.budgetTokens <= 0
107
+ ? null
108
+ : input.usedTokens / input.budgetTokens,
109
+ occupancyTokens: input.usedTokens,
110
+ budgetTokens: input.budgetTokens,
111
+ window: input.window,
112
+ staleToolShare: input.budgetTokens > 0 ? staleToolTokens / input.budgetTokens : 0,
113
+ staleToolTokens,
114
+ staleToolResults: mass.results,
115
+ eligibleFolds: input.eligibleFolds,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * BOTH signals, never either. Occupancy alone announces a commit that has nothing to
121
+ * fold; stale mass alone announces one in a window with room to spare.
122
+ */
123
+ export function curationTriggerFires(signals: CurationSignals): boolean {
124
+ return signals.occupancy !== null &&
125
+ Number.isFinite(signals.occupancy) &&
126
+ signals.occupancy >= CURATION_OCCUPANCY_SHARE &&
127
+ signals.staleToolShare >= CURATION_STALE_TOOL_SHARE;
128
+ }
129
+
130
+ /**
131
+ * The sparse reminders.
132
+ *
133
+ * Two per window cycle, one line each, tail-appended, and informatory. Below the
134
+ * curation threshold NOTHING folds automatically, so the only useful thing to say
135
+ * before the fold event is: mark as you go, and the folds will be neat when it fires.
136
+ * `fired` is the count already spent this cycle; a commit resets it to zero.
137
+ */
138
+ export function dueReminderIndex(
139
+ occupancy: number | null,
140
+ fired: number,
141
+ shares: readonly number[] = CURATION_REMINDER_SHARES,
142
+ ): number | null {
143
+ if (occupancy === null || !Number.isFinite(occupancy)) return null;
144
+ for (let index = Math.max(0, Math.trunc(fired)); index < shares.length; index += 1) {
145
+ if (occupancy >= shares[index]) return index;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ export function curationReminderText(input: {
151
+ signals: CurationSignals;
152
+ toolName: string;
153
+ brandNoun?: string;
154
+ }): string {
155
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
156
+ const occupancy = input.signals.occupancy === null
157
+ ? "unmeasured"
158
+ : `${Math.round(input.signals.occupancy * 100)}%`;
159
+ return `[${brand} curation] Occupancy ${occupancy} of the ${input.signals.budgetTokens}-token serving ` +
160
+ `budget; nothing folds until ${Math.round(CURATION_OCCUPANCY_SHARE * 100)}%. Mark SEVERAL finished ` +
161
+ `chapters in one call with ${input.toolName} {"action":"fold","marks":[{"ids":["<start>","<end>"],` +
162
+ "\"brief\":\"<factual brief>\"}]}: one call answers with everything held plus what is still " +
163
+ "unmarked, so the folds are neat when the fold event triggers.";
164
+ }
165
+
166
+ /**
167
+ * The awareness block a mark call answers with.
168
+ *
169
+ * The projection is byte-frozen between fold events, so this tool result is the ONLY
170
+ * cache-free place left to tell the agent where it stands. It carries three things and
171
+ * stops: what is now held, what is still on the table as an aggregate with a bounded
172
+ * head of the largest names, and the one percentage worth steering by. It is hard
173
+ * bounded on the receipt block's principle: awareness that becomes bloat is bloat.
174
+ */
175
+ export function markAwarenessText(input: {
176
+ held: ReadonlyArray<{ id: string; kind: string; tokens: number }>;
177
+ remainder: { spans: number; tokens: number; share: number; candidates: ReadonlyArray<{ id: string; tokens: number }> };
178
+ toolName: string;
179
+ brandNoun?: string;
180
+ }): string {
181
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
182
+ const { remainder } = input;
183
+ const held = input.held.length
184
+ ? input.held.map((span) => `${span.id} (${span.kind}, about ${span.tokens} tokens)`).join("; ")
185
+ : "none";
186
+ const candidates = remainder.candidates.length
187
+ ? remainder.candidates.map((item) => `${item.id} (about ${item.tokens} tokens)`).join("; ")
188
+ : "none";
189
+ return boundReceiptText([
190
+ `[${brand} marks] Held until they age out or the next fold event: ${held}.`,
191
+ `Unmarked remainder: ${remainder.spans} span(s), about ${remainder.tokens} tokens of stale mass, ` +
192
+ `${Math.round(remainder.share * 100)}% of the non-fresh window.`,
193
+ `Largest unmarked by reclaim value: ${candidates}.`,
194
+ `Mark several spans in one ${input.toolName} call: one call, and this whole picture comes back with it.`,
195
+ ].join("\n"), CONTEXT_MARK_RESPONSE_BYTES, `[${brand} marks] Held; the remainder is unavailable this pass.`);
196
+ }
197
+
198
+ export type CurationProceedReason = "go" | "non-context-response" | "round-cap";
199
+
200
+ export interface CurationGate {
201
+ openedOrdinal: number;
202
+ /** Context passes consumed while open. Monotonic, and capped, so a stall is impossible. */
203
+ roundsUsed: number;
204
+ /** Context-tool calls counted at the last evaluated pass. */
205
+ contextCallsAtLastRound: number;
206
+ marksAtOpen: number;
207
+ signals: CurationSignals;
208
+ }
209
+
210
+ export interface CurationGateVerdict {
211
+ gate: CurationGate | null;
212
+ event: "opened" | "held" | "proceeded";
213
+ proceed: boolean;
214
+ proceededBy: CurationProceedReason | null;
215
+ roundsUsed: number;
216
+ }
217
+
218
+ /**
219
+ * One evaluation of the gate, on one context pass.
220
+ *
221
+ * The termination argument, stated so it can be checked rather than trusted: every
222
+ * evaluation either proceeds or increments `roundsUsed`, and `roundsUsed` proceeds
223
+ * unconditionally once it reaches the cap. A pass whose response was not a
224
+ * context-management call proceeds immediately, so continuing the task IS the default
225
+ * path through the gate and no reply is ever required to make progress.
226
+ */
227
+ export function advanceCurationGate(input: {
228
+ gate: CurationGate | null;
229
+ ordinal: number;
230
+ signals: CurationSignals;
231
+ /** Context-management tool calls observed in this session so far. */
232
+ contextCalls: number;
233
+ pendingMarks: number;
234
+ maxRounds?: number;
235
+ }): CurationGateVerdict {
236
+ const maxRounds = input.maxRounds ?? CURATION_GATE_MAX_ROUNDS;
237
+ if (!input.gate) {
238
+ return {
239
+ gate: {
240
+ openedOrdinal: input.ordinal,
241
+ roundsUsed: 0,
242
+ contextCallsAtLastRound: input.contextCalls,
243
+ marksAtOpen: input.pendingMarks,
244
+ signals: input.signals,
245
+ },
246
+ event: "opened",
247
+ proceed: false,
248
+ proceededBy: null,
249
+ roundsUsed: 0,
250
+ };
251
+ }
252
+ const roundsUsed = input.gate.roundsUsed + 1;
253
+ const engaged = input.contextCalls > input.gate.contextCallsAtLastRound;
254
+ if (engaged && roundsUsed < maxRounds) {
255
+ return {
256
+ gate: { ...input.gate, roundsUsed, contextCallsAtLastRound: input.contextCalls },
257
+ event: "held",
258
+ proceed: false,
259
+ proceededBy: null,
260
+ roundsUsed,
261
+ };
262
+ }
263
+ return {
264
+ gate: null,
265
+ event: "proceeded",
266
+ proceed: true,
267
+ proceededBy: engaged ? "round-cap" : "non-context-response",
268
+ roundsUsed,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The last-call notice.
274
+ *
275
+ * Shape rule, load-bearing: operational status plus available actions, with continuing
276
+ * work as the stated default. A message shaped like a question inviting a chat-style
277
+ * reply can make a single-turn agent emit a final answer and end its own run, so this
278
+ * never asks anything and never implies a reply is expected.
279
+ */
280
+ export function curationNoticeText(input: {
281
+ signals: CurationSignals;
282
+ roundsUsed: number;
283
+ maxRounds?: number;
284
+ toolName: string;
285
+ brandNoun?: string;
286
+ }): string {
287
+ const maxRounds = input.maxRounds ?? CURATION_GATE_MAX_ROUNDS;
288
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
289
+ const { signals } = input;
290
+ const occupancy = signals.occupancy === null ? "unmeasured" : `${Math.round(signals.occupancy * 100)}%`;
291
+ const remaining = Math.max(0, maxRounds - input.roundsUsed);
292
+ return boundReceiptText([
293
+ `[${brand} curation] Occupancy ${occupancy} of the ${signals.budgetTokens}-token serving budget; ` +
294
+ `${Math.round(signals.staleToolShare * 100)}% of the window is stale tool output outside the fresh tail ` +
295
+ `(${signals.staleToolResults} result(s), about ${signals.staleToolTokens} tokens). ` +
296
+ "A commit epoch will fold that mass into briefed placeholders; " +
297
+ "the exact source stays expandable and nothing is lost.",
298
+ "Marking well is what makes this cheap: your briefs are what makes these spans findable later, and " +
299
+ "spans batched into one commit rewrite the prefix once instead of once per fold, so the cache survives " +
300
+ "and the next fold event arrives later.",
301
+ `Available now: ${input.toolName} ` +
302
+ "{\"action\":\"fold\",\"marks\":[{\"ids\":[\"<start>\",\"<end>\"],\"brief\":\"<factual brief>\"}]} " +
303
+ "marks SEVERAL spans in one call, and answers with everything held plus what is still unmarked; " +
304
+ "{\"action\":\"rebrief\",\"id\":\"<fold-id>\",\"brief\":\"<factual brief>\"} corrects an existing " +
305
+ "brief; {\"action\":\"reboundary\",\"id\":\"<fold-id>\"} returns a mis-cut fold to raw so you can " +
306
+ "re-fold it.",
307
+ "Continuing the task is the default: this commit proceeds on the next pass unless the next thing you do " +
308
+ `is a ${input.toolName} call, and it proceeds regardless after ${remaining} more curation round(s).`,
309
+ ].join("\n"), 2_048, `[${brand} curation] A commit epoch is due; details are unavailable this pass.`);
310
+ }
311
+
312
+ /** One automatic context action, reported back as status rather than as advice. */
313
+ export interface ContextReceipt {
314
+ kind: string;
315
+ ordinal: number;
316
+ trigger: string | null;
317
+ foldsCommitted: number;
318
+ foldsCreated: number;
319
+ freedTokens: number;
320
+ /** Measured occupancy either side of the action, in tokens. The impact, concretely. */
321
+ occupancyBefore: number | null;
322
+ occupancyAfter: number | null;
323
+ /** What the action actually folded, split the way an agent thinks about it. */
324
+ spansFolded: number;
325
+ toolResultsFolded: number;
326
+ /** Bite-sized splitting, when an oversized span became sequential folds. */
327
+ splitFolds: number;
328
+ splitFromChars: number;
329
+ /** Short stale spans a commit absorbed into their later neighbour fold. */
330
+ absorbedWedges: number;
331
+ /** Overflow recovery, when this action rebuilt a request the provider would reject. */
332
+ recovered: boolean;
333
+ note: string | null;
334
+ }
335
+
336
+ export function contextReceipt(input: Partial<ContextReceipt> & { kind: string; ordinal: number }): ContextReceipt {
337
+ return {
338
+ kind: input.kind,
339
+ ordinal: input.ordinal,
340
+ trigger: input.trigger ?? null,
341
+ foldsCommitted: input.foldsCommitted ?? 0,
342
+ foldsCreated: input.foldsCreated ?? 0,
343
+ freedTokens: input.freedTokens ?? 0,
344
+ occupancyBefore: input.occupancyBefore ?? null,
345
+ occupancyAfter: input.occupancyAfter ?? null,
346
+ spansFolded: input.spansFolded ?? 0,
347
+ toolResultsFolded: input.toolResultsFolded ?? 0,
348
+ splitFolds: input.splitFolds ?? 0,
349
+ splitFromChars: input.splitFromChars ?? 0,
350
+ absorbedWedges: input.absorbedWedges ?? 0,
351
+ recovered: input.recovered ?? false,
352
+ note: input.note ?? null,
353
+ };
354
+ }
355
+
356
+ /** Newest last, oldest evicted: a receipt block that can never grow into its own problem. */
357
+ export function withReceipt(
358
+ receipts: readonly ContextReceipt[],
359
+ receipt: ContextReceipt,
360
+ maximum = MAX_CONTEXT_RECEIPTS,
361
+ ): ContextReceipt[] {
362
+ const next = [...receipts, receipt];
363
+ return next.length > maximum ? next.slice(next.length - maximum) : next;
364
+ }
365
+
366
+ export function receiptLine(receipt: ContextReceipt): string {
367
+ const occupancy = receipt.occupancyBefore !== null && receipt.occupancyAfter !== null
368
+ ? `Occupancy ${receipt.occupancyBefore}→${receipt.occupancyAfter} tokens.`
369
+ : "";
370
+ const folded = receipt.spansFolded || receipt.toolResultsFolded
371
+ ? `${receipt.spansFolded} span(s) folded, ${receipt.toolResultsFolded} tool result(s) folded.`
372
+ : "";
373
+ const parts = [
374
+ `${receipt.kind}${receipt.trigger ? ` (${receipt.trigger})` : ""} at ordinal ${receipt.ordinal}:`,
375
+ receipt.foldsCommitted ? `${receipt.foldsCommitted} mark(s) committed,` : "",
376
+ receipt.foldsCreated ? `${receipt.foldsCreated} fold(s) created,` : "",
377
+ `about ${receipt.freedTokens} tokens freed.`,
378
+ occupancy,
379
+ folded,
380
+ receipt.splitFolds
381
+ ? `A ${receipt.splitFromChars}-char span was split into ${receipt.splitFolds} bite-sized folds.`
382
+ : "",
383
+ receipt.absorbedWedges
384
+ ? `${receipt.absorbedWedges} short stale span(s) wedged between folds were absorbed into their ` +
385
+ "later neighbour rather than left as crumbs."
386
+ : "",
387
+ receipt.recovered
388
+ ? "This ran as overflow recovery: the request would not have been transmissible, and it was rebuilt rather than dropped."
389
+ : "",
390
+ receipt.note ?? "",
391
+ ];
392
+ return parts.filter(Boolean).join(" ");
393
+ }
394
+
395
+ /**
396
+ * The receipt block. Informatory, never exhortative: it reports what the runtime did,
397
+ * what it cost, and the verbs that correct it. Hard-bounded, because a report about
398
+ * bloat that becomes bloat has argued against itself.
399
+ */
400
+ export function receiptBlockText(input: {
401
+ receipts: readonly ContextReceipt[];
402
+ toolName: string;
403
+ brandNoun?: string;
404
+ }): string | null {
405
+ if (!input.receipts.length) return null;
406
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
407
+ const header =
408
+ `[${brand} actions] Recent automatic context actions, newest last. Exact source remains expandable.`;
409
+ const verbs = `Correct any of it: ${input.toolName} ` +
410
+ "{\"action\":\"expand\",\"id\":\"<fold-id>\"} restores a span in place; " +
411
+ "{\"action\":\"rebrief\",\"id\":\"<fold-id>\",\"brief\":\"<factual brief>\"} replaces a brief that " +
412
+ "does not describe what you needed; {\"action\":\"reboundary\",\"id\":\"<fold-id>\"} returns a " +
413
+ "mis-cut fold to raw so you can fold the span you meant.";
414
+ // The verbs are the only part of this block an agent can act on, and they sit last, so
415
+ // a plain tail truncation drops them precisely when the report runs long -- which is
416
+ // when a commit did the most and correcting it matters most. Reserve them, and spend
417
+ // what remains on receipt lines newest-first: an older action that no longer fits is
418
+ // the right thing to lose, and its structured record is still in the ring.
419
+ const fixed = Buffer.byteLength(header, "utf8") + Buffer.byteLength(verbs, "utf8") + 1;
420
+ if (fixed > CONTEXT_RECEIPT_BLOCK_BYTES) {
421
+ return `[${brand} actions] Recent automatic context actions are unavailable this pass.`;
422
+ }
423
+ let remaining = CONTEXT_RECEIPT_BLOCK_BYTES - fixed;
424
+ const kept: string[] = [];
425
+ for (let index = input.receipts.length - 1; index >= 0; index -= 1) {
426
+ const line = receiptLine(input.receipts[index]);
427
+ if (Buffer.byteLength(line, "utf8") + 1 > remaining) {
428
+ // Not even the newest line fits whole: carry as much of it as the reservation
429
+ // leaves rather than reporting an action with no detail at all.
430
+ if (!kept.length && remaining > 1) kept.push(boundReceiptText(line, remaining - 1, ""));
431
+ break;
432
+ }
433
+ remaining -= Buffer.byteLength(line, "utf8") + 1;
434
+ kept.unshift(line);
435
+ }
436
+ return [header, ...kept, verbs].join("\n");
437
+ }