pi-fold 0.1.1 → 1.0.1

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.
@@ -87,12 +87,36 @@ export function advisorySchedule(
87
87
  };
88
88
  }
89
89
 
90
+ /**
91
+ * Record that an armed advisory actually REACHED the agent.
92
+ *
93
+ * The budget used to be spent when a milestone armed. Any automatic fold in the same
94
+ * context pass then cleared the arm before the projection was built, so a working
95
+ * ladder drained every milestone budget without ever emitting a word. Measured
96
+ * 2026-08-06 on an identical pressure schedule: an idle-ladder session delivered 7
97
+ * advisories, and a tool-using session delivered 0 while reporting notice, tools and
98
+ * chapters each "delivered" once. All four instrumented runs were the second case.
99
+ */
100
+ export function recordAdvisoryDelivery(
101
+ state: ActiveContextState,
102
+ milestone: AdvisoryMilestone,
103
+ ): ActiveContextState {
104
+ const current = advisoryState(state);
105
+ const delivered = { ...current.delivered };
106
+ delivered[milestone] = (delivered[milestone] ?? 0) + 1;
107
+ const advisory = { ...current, delivered };
108
+ delete advisory.armed;
109
+ return { ...state, advisory };
110
+ }
111
+
90
112
  export function updateAdvisoryMilestone(
91
113
  currentState: ActiveContextState,
92
114
  ratio: number,
93
115
  schedule: AdvisorySchedule,
94
116
  scheduleChanged: boolean,
95
117
  scheduleKey: string,
118
+ /** Spend the milestone budget on DELIVERY rather than on arming. */
119
+ countOnDelivery = false,
96
120
  ): { state: ActiveContextState; milestone: AdvisoryMilestone | null } {
97
121
  const current = advisoryState(currentState);
98
122
  if (scheduleChanged) {
@@ -108,12 +132,28 @@ export function updateAdvisoryMilestone(
108
132
  highWater = Math.min(highWater, index > 0 ? schedule.rungs[index - 1].threshold : 0);
109
133
  }
110
134
  }
111
- const crossed = schedule.rungs.filter((rung) =>
112
- highWater < rung.threshold && ratio >= rung.threshold &&
113
- (current.delivered[rung.milestone] ?? 0) < rung.budget);
114
- const selected = crossed.at(-1) ?? null;
135
+ let selected: AdvisorySchedule["rungs"][number] | null;
136
+ if (countOnDelivery) {
137
+ // Only ever the highest rung the current pressure has reached, so a plateau does
138
+ // not replay a descending cascade of stale milestones. A rung that has never been
139
+ // DELIVERED stays armable even after the high-water mark has passed it: the old
140
+ // ratchet locked out every rung that armed once and was cleared before it spoke.
141
+ const reached = schedule.rungs.filter((rung) => ratio >= rung.threshold).at(-1) ?? null;
142
+ const deliveries = reached ? current.delivered[reached.milestone] ?? 0 : 0;
143
+ selected = reached && deliveries < reached.budget &&
144
+ (highWater < reached.threshold || deliveries === 0)
145
+ ? reached
146
+ : null;
147
+ } else {
148
+ const crossed = schedule.rungs.filter((rung) =>
149
+ highWater < rung.threshold && ratio >= rung.threshold &&
150
+ (current.delivered[rung.milestone] ?? 0) < rung.budget);
151
+ selected = crossed.at(-1) ?? null;
152
+ }
115
153
  const delivered = { ...current.delivered };
116
- if (selected) delivered[selected.milestone] = (delivered[selected.milestone] ?? 0) + 1;
154
+ if (selected && !countOnDelivery) {
155
+ delivered[selected.milestone] = (delivered[selected.milestone] ?? 0) + 1;
156
+ }
117
157
  const armed = selected
118
158
  ? { milestone: selected.milestone, threshold: selected.threshold, scheduleKey }
119
159
  : current.armed;
@@ -130,6 +170,12 @@ export function updateAdvisoryMilestone(
130
170
  };
131
171
  }
132
172
 
173
+ /**
174
+ * Wording rule for every dosage below: report what is HAPPENING and name the actions
175
+ * that change it. The earlier phrasing asked the agent to fold proactively, which is
176
+ * advice it can only act on by interrupting its own task; the runtime folds either way,
177
+ * so the honest thing to say is what it will do and what reacting buys.
178
+ */
133
179
  function curationText(milestone: AdvisoryMilestone, percent: number, toolName: string): string {
134
180
  if (milestone === "orientation") {
135
181
  return "Collapsed folds are a browsable index of the work behind you, and their briefs sit in the cached " +
@@ -138,20 +184,23 @@ function curationText(milestone: AdvisoryMilestone, percent: number, toolName: s
138
184
  '{"action":"expand","id":"<fold-id>"}.';
139
185
  }
140
186
  if (milestone === "notice") {
141
- return `Context is ${percent}% full; curate it against the task you are on now. Fold the spans that task ` +
142
- `no longer needs with ${toolName} ` +
143
- '{"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"}, and keep what must stay raw out ' +
144
- `of every fold with ${toolName} {"action":"protect","ids":["<entry-id>"]}.`;
187
+ return `Context is ${percent}% full, and the ladder is folding stale spans as it fills. Spans you fold ` +
188
+ `yourself carry your brief instead of a generated one: ${toolName} ` +
189
+ '{"action":"fold","marks":[{"ids":["<start>","<end>"],"brief":"<factual brief>"}]} batches several at ' +
190
+ `once, and ${toolName} {"action":"protect","ids":["<entry-id>"]} keeps what must stay raw out of every ` +
191
+ "fold. Continuing the task is the default; nothing here needs a reply.";
145
192
  }
146
193
  if (milestone === "tools") {
147
- return "Completed read-only tool batches are the cheapest thing to fold, and their endpoint ids are in the " +
148
- "live advisory. Fold the batches whose detail this task is finished with; each one expands back to the " +
149
- `exact entries later with ${toolName} {"action":"expand","id":"<fold-id>"}.`;
194
+ return "Completed read-only tool batches are what the ladder reclaims first, and their endpoint ids are in " +
195
+ "the live advisory. A batch it folds keeps a generated brief; one you fold keeps yours. Either way the " +
196
+ `exact entries come back with ${toolName} {"action":"expand","id":"<fold-id>"}, and a brief that does ` +
197
+ `not describe what you needed is corrected with ${toolName} ` +
198
+ '{"action":"rebrief","id":"<fold-id>","brief":"<factual brief>"}.';
150
199
  }
151
- return "Fold up: hand two or more adjacent folds of finished work to " +
152
- `${toolName} {"action":"fold","ids":["<fold-id>","<fold-id>"],"brief":"<factual brief>"} so they nest in ` +
153
- "one deeper fold, leaving the oldest material deepest and still exactly recoverable. A closed chapter " +
154
- `folds first, from the eligibleChapter endpoints in ${toolName} {"action":"status"}.`;
200
+ return "Adjacent folds of finished work are the next thing the ladder consolidates. Handing two or more to " +
201
+ `${toolName} {"action":"fold","ids":["<fold-id>","<fold-id>"],"brief":"<factual brief>"} nests them under ` +
202
+ "a brief you wrote, leaving the oldest material deepest and still exactly recoverable; a boundary that " +
203
+ `came out wrong is re-cut with ${toolName} {"action":"reboundary","ids":["<start>","<end>"]}.`;
155
204
  }
156
205
 
157
206
  export function milestoneText(
@@ -177,8 +226,9 @@ export function milestoneText(
177
226
  }
178
227
  if (milestone === "chapters") {
179
228
  return `${prefix} The chapter preparation rung begins at ${percent}%. ` +
180
- `Use eligibleChapter endpoints with ${toolName} ` +
181
- '{"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"}.';
229
+ `Closed chapters fold from the eligibleChapter endpoints in ${toolName} {"action":"status"}; ` +
230
+ `${toolName} {"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"} folds one with ` +
231
+ "your own brief instead of a generated one.";
182
232
  }
183
233
  return `${prefix} The hard context fence is near. The next automatic action is a committed chapter fold ` +
184
234
  "or the provider request is aborted before transmission.";
@@ -212,6 +262,19 @@ export function surfacingText(input: {
212
262
  );
213
263
  }
214
264
 
265
+ export interface AdvisoryCuration {
266
+ /** Room left before the truthful fence, in tokens. */
267
+ headroomTokens: number | null;
268
+ budgetTokens: number;
269
+ pendingMarks: number;
270
+ eligibleMarks: number;
271
+ /** What the pending marks would free, in TOKENS. A percentage is not actionable. */
272
+ freedTokens: number;
273
+ eligibleFreedTokens: number;
274
+ /** The share of what the request currently pays for that no pending decision reclaims. */
275
+ unmarkedShare: number;
276
+ }
277
+
215
278
  export function liveAdvisoryText(input: {
216
279
  milestone: AdvisoryMilestone;
217
280
  ratio: number;
@@ -219,6 +282,7 @@ export function liveAdvisoryText(input: {
219
282
  chapterEndpoints: string[];
220
283
  remediationCount: number;
221
284
  brandNoun?: string;
285
+ curation?: AdvisoryCuration | null;
222
286
  }): string {
223
287
  const tools = input.toolEndpoints.length
224
288
  ? input.toolEndpoints.slice(0, 3).join(", ")
@@ -226,9 +290,16 @@ export function liveAdvisoryText(input: {
226
290
  const chapter = input.chapterEndpoints.length
227
291
  ? `${input.chapterEndpoints[0]}..${input.chapterEndpoints.at(-1)}`
228
292
  : "none";
293
+ const curation = input.curation
294
+ ? `headroom ${input.curation.headroomTokens ?? "unmeasured"} of ${input.curation.budgetTokens} tokens; ` +
295
+ `unmarked share ${Math.round(input.curation.unmarkedShare * 100)}%; ` +
296
+ `${input.curation.pendingMarks} pending mark(s), ${input.curation.eligibleMarks} eligible now, ` +
297
+ `together freeing about ${input.curation.eligibleFreedTokens} tokens of the ` +
298
+ `${input.curation.freedTokens} marked; `
299
+ : "";
229
300
  return boundReceiptText(
230
301
  `[${contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN)} advisory] ` +
231
- `pressure ${Math.round(input.ratio * 100)}%; milestone ${input.milestone}; ` +
302
+ `pressure ${Math.round(input.ratio * 100)}%; milestone ${input.milestone}; ${curation}` +
232
303
  `eligible read-only batch endpoints: ${tools}; eligibleChapter endpoints: ${chapter}; ` +
233
304
  `session milestone count: ${input.remediationCount}.`,
234
305
  2_048,
@@ -23,6 +23,18 @@ export function boundedUtf8(text: string, maximumBytes: number): string {
23
23
  return buffer.subarray(0, maximumBytes).toString("utf8").replace(/\uFFFD$/, "");
24
24
  }
25
25
 
26
+ /**
27
+ * The suffix of `text` beginning at a byte offset. A multi-byte sequence cut by the
28
+ * offset decodes to one leading replacement character; dropping it keeps the slice an
29
+ * exact suffix of the source, which is what makes a paged peek losslessly rejoinable.
30
+ */
31
+ export function utf8Slice(text: string, offsetBytes: number): string {
32
+ if (offsetBytes <= 0) return text;
33
+ const buffer = Buffer.from(text, "utf8");
34
+ if (offsetBytes >= buffer.length) return "";
35
+ return buffer.subarray(offsetBytes).toString("utf8").replace(/^�/, "");
36
+ }
37
+
26
38
  export function ownValue(value: unknown, key: string): unknown {
27
39
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
28
40
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
@@ -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
+ }