pi-fold 1.0.2 → 1.0.4

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.
@@ -562,8 +562,21 @@ function mcpServerForTool(toolName, fallback) {
562
562
  : fallback;
563
563
  }
564
564
 
565
+ /**
566
+ * MCP tools are recognized by their name, and by nothing else.
567
+ *
568
+ * This was a host-supplied predicate whose default returned false for everything, so a
569
+ * conventionally named MCP tool was never classified unless a host wrote the predicate:
570
+ * a documented feature whose shipped state guaranteed it never ran. `mcp__server__tool`
571
+ * is the convention every MCP host follows, so it is the rule. A name outside the
572
+ * convention is deliberately not classified as MCP, and its oversized results fall to
573
+ * the ordinary tool-result path.
574
+ */
575
+ function conventionalMcpTool(toolName) {
576
+ return mcpServerForTool(toolName, null) !== null;
577
+ }
578
+
565
579
  export function registerEvidenceIngestion(pi, {
566
- isMcpTool = () => false,
567
580
  entryTypePrefix = DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
568
581
  } = {}) {
569
582
  const evidenceNamespace = safeSegment(
@@ -623,7 +636,7 @@ export function registerEvidenceIngestion(pi, {
623
636
  return projectEvidenceText(text, descriptor, "utf8-head", truncation ? { truncation } : undefined);
624
637
  }
625
638
 
626
- if (isMcpTool(toolName)) {
639
+ if (conventionalMcpTool(toolName)) {
627
640
  const envelope = eventEnvelope(event);
628
641
  const serialized = `${stableStringify(envelope)}\n`;
629
642
  if (utf8Bytes(serialized) <= TOOL_RESULT_PROJECTION_BYTES) return;
@@ -2,29 +2,77 @@ import { registerActiveContext } from "./active-context.ts";
2
2
  import { registerEvidenceIngestion } from "./evidence.js";
3
3
  import { createSummarizeContextSpan } from "./summarizer.js";
4
4
 
5
+ /**
6
+ * THE public surface: five options, and every name outside them is refused.
7
+ *
8
+ * Unknown keys used to spread through silently, which made every deletion a no-op rather
9
+ * than an error and handed a deployment the opposite of what it asked for. A caller that
10
+ * passes a name believes it bought behavior, so a name this package does not sell is a
11
+ * throw with the replacement named in it.
12
+ */
13
+ const PUBLIC_OPTIONS = Object.freeze([
14
+ "thresholds", "summarizer", "providerInputBudget", "blacklistAutoFoldTools", "guidance",
15
+ ]);
16
+
17
+ /**
18
+ * Both spellings of the auto-fold list were ALLOW-lists, and the list runs the other way
19
+ * now. Forwarding one silently would be the worst possible outcome of a rename: a set of
20
+ * tools a deployment named to make foldable would become the exact set it barred.
21
+ */
22
+ const INVERTED_AUTO_FOLD_LIST = "renamed blacklistAutoFoldTools, and the sense is INVERTED: " +
23
+ "every completed tool batch folds unmarked, and the list names the exceptions whose " +
24
+ "results must stay raw. An allow-list moved across verbatim would bar exactly the tools " +
25
+ "it meant to permit";
26
+
27
+ /**
28
+ * Options that left the surface, each answered by name. Deployment identity is hardwired:
29
+ * this is a branded package with one deployment, not a framework a host rebrands, and a
30
+ * consumer that moved `entryTypePrefix` stranded its own durable folds under a namespace
31
+ * nothing would read back.
32
+ */
33
+ const REFUSED_OPTIONS = Object.freeze({
34
+ toolName: "the deployment identity is hardwired to pi-fold: the tool is pi_fold_context",
35
+ toolLabel: "the deployment identity is hardwired to pi-fold",
36
+ brandNoun: "the deployment identity is hardwired to pi-fold",
37
+ entryTypePrefix: "the deployment identity is hardwired to pi-fold: durable state lives " +
38
+ "under pi-fold-active-context, and moving it would strand every fold already written",
39
+ commandPrefix: "the deployment identity is hardwired to pi-fold: the commands are " +
40
+ "/pi-fold-context and /fold-context",
41
+ commandNames: "the deployment identity is hardwired to pi-fold: the commands are " +
42
+ "/pi-fold-context and /fold-context",
43
+ isMcpTool: "MCP tools are recognized by the mcp__server__tool naming convention. The " +
44
+ "predicate defaulted to false, so conventionally named MCP tools were never classified " +
45
+ "unless a host supplied one, which is a documented feature that never ran",
46
+ readOnlyTools: INVERTED_AUTO_FOLD_LIST,
47
+ autoFoldableTools: INVERTED_AUTO_FOLD_LIST,
48
+ evidenceIngestion: "evidence ingestion is always on: the hook always registers, oversized " +
49
+ "tool results are pinned to read-only 0444 artifacts under the session directory, and " +
50
+ "the 512 MB session cap is a constant. Exact recovery is what the folds promise, and a " +
51
+ "switch that removed the anchors removed the promise",
52
+ providerTotalWindow: "renamed providerInputBudget, and it is ALREADY NET: pass the tokens " +
53
+ "the deployment may actually fill, not the total window the runtime then subtracts a " +
54
+ "guessed output reservation from",
55
+ summarizeContextSpan: "choose a brief generator with summarizer (\"session\" or " +
56
+ "{ provider, model, effort })",
57
+ });
58
+
5
59
  export function registerPiFold(pi, options = {}, loadHostModule) {
6
- const {
7
- evidenceIngestion = true,
8
- isMcpTool,
9
- summarizer = "session",
10
- summarizeContextSpan,
11
- ...activeContextOptions
12
- } = options;
13
- if (Object.hasOwn(options, "summarizer") && summarizeContextSpan !== undefined) {
14
- throw new Error("summarizer and summarizeContextSpan cannot be configured together");
15
- }
16
- const configuredSummarizer = summarizeContextSpan ??
17
- createSummarizeContextSpan(summarizer, loadHostModule);
18
- if (evidenceIngestion) {
19
- registerEvidenceIngestion(pi, {
20
- isMcpTool,
21
- entryTypePrefix: activeContextOptions.entryTypePrefix,
22
- });
60
+ for (const name of Object.keys(options)) {
61
+ if (Object.hasOwn(REFUSED_OPTIONS, name)) {
62
+ throw new Error(`${name} is no longer an option: ${REFUSED_OPTIONS[name]}`);
63
+ }
64
+ if (!PUBLIC_OPTIONS.includes(name)) {
65
+ throw new Error(`${name} is not a pi-fold option: the surface is ` +
66
+ `${PUBLIC_OPTIONS.join(", ")}`);
67
+ }
23
68
  }
24
- return registerActiveContext(pi, {
25
- ...activeContextOptions,
26
- ...(configuredSummarizer ? { summarizeContextSpan: configuredSummarizer } : {}),
27
- });
69
+ const { summarizer = "session", ...activeContextOptions } = options;
70
+ const summarizeContextSpan = createSummarizeContextSpan(summarizer, loadHostModule);
71
+ // Evidence first, then the runtime. Ingestion is unconditional: the artifacts are the
72
+ // exact-recovery anchors an oversized tool result folds against, so a deployment that
73
+ // turned them off kept the folds and lost what makes them lossless.
74
+ registerEvidenceIngestion(pi);
75
+ return registerActiveContext(pi, { ...activeContextOptions, summarizeContextSpan });
28
76
  }
29
77
 
30
78
  export default function piFold(pi, options) {
@@ -216,8 +216,40 @@ export function evidenceValue(message: unknown): unknown {
216
216
  };
217
217
  }
218
218
 
219
+ /**
220
+ * Evidence digests, kept against the message OBJECT.
221
+ *
222
+ * A message is canonicalized and hashed whole to produce its digest, and the messages
223
+ * this runs on are transcript entries: tool results in the tens of kilobytes. Nothing
224
+ * cached the result, so every reading that wanted to confirm a ref rehashed the entire
225
+ * message from scratch. `exactMapped` does exactly that on every lookup, and the
226
+ * selection, surfacing, ordering and accounting passes call it once per ref, per fold,
227
+ * several times a turn. Replayed against the sealed sol-20260812 rep-2 session at its
228
+ * 85 percent point, that single recomputation was 76 percent of all CPU, and it grows
229
+ * with the transcript because the transcript is what it rehashes.
230
+ *
231
+ * A WeakMap on the object is the same shape `MAPPED_BY_KEY` and `BRANCH_POSITIONS`
232
+ * already use one line over, and the same rule gate 113 settled for entry evidence: the
233
+ * session is append-only, so keep the derivation against the object and let a REPLACED
234
+ * object miss rather than serve a stale answer. Entries are never edited in place; they
235
+ * are appended, and a changed message is a different object, which misses and rehashes.
236
+ *
237
+ * What this no longer detects, stated plainly: a message mutated IN PLACE after it was
238
+ * first hashed now keeps its original digest. That case was already half invisible,
239
+ * because the ref a snapshot carries was itself computed from that same object at
240
+ * mapping time, so a mutation that beat the digest would have to be caught by this
241
+ * recomputation alone. No path in this package edits a persisted message, and the
242
+ * runtime clones before it stores.
243
+ */
244
+ const EVIDENCE_DIGESTS = new WeakMap<object, string>();
245
+
219
246
  export function evidenceSha256(message: unknown): string {
220
- return sha256Value(evidenceValue(message));
247
+ if (!message || typeof message !== "object") return sha256Value(evidenceValue(message));
248
+ const cached = EVIDENCE_DIGESTS.get(message);
249
+ if (cached !== undefined) return cached;
250
+ const digest = sha256Value(evidenceValue(message));
251
+ EVIDENCE_DIGESTS.set(message, digest);
252
+ return digest;
221
253
  }
222
254
 
223
255
  export function evidenceRef(sessionId: string, entryId: string, message: unknown): EvidenceRef {
@@ -1,5 +1,6 @@
1
1
  import { objectRefKey } from "../json.ts";
2
2
  import {
3
+ boundedUtf8,
3
4
  bytes,
4
5
  messageRole,
5
6
  } from "./canonical.ts";
@@ -8,12 +9,11 @@ import {
8
9
  contextBrand,
9
10
  CONTEXT_MARK_RESPONSE_BYTES,
10
11
  CONTEXT_RECEIPT_BLOCK_BYTES,
11
- CURATION_GATE_MAX_ROUNDS,
12
- CURATION_OCCUPANCY_SHARE,
13
- CURATION_REMINDER_SHARES,
14
- CURATION_STALE_TOOL_SHARE,
15
12
  DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
16
13
  MAX_CONTEXT_RECEIPTS,
14
+ MAX_LAST_CALL_TEXT_BYTES,
15
+ MAX_SURFACING_LINE_BYTES,
16
+ MAX_THRESHOLD_NOTICE_TEXT_BYTES,
17
17
  } from "./policy.ts";
18
18
  import type {
19
19
  ActiveContextSnapshot,
@@ -67,6 +67,8 @@ export function staleToolMass(
67
67
  export interface CurationSignals {
68
68
  /** Measured provider tokens as a share of the truthful serving budget; null unmeasured. */
69
69
  occupancy: number | null;
70
+ /** The trigger line this occupancy is read against: thresholds.maxTarget. */
71
+ maxTarget: number;
70
72
  occupancyTokens: number | null;
71
73
  /** THE serving budget: one resolved value, the same one the fence and estimator use. */
72
74
  budgetTokens: number;
@@ -103,6 +105,7 @@ export function curationSignals(input: {
103
105
  : 4;
104
106
  const staleToolTokens = Math.ceil(mass.bytes / charsPerToken);
105
107
  return {
108
+ maxTarget: input.snapshot.thresholds.maxTarget,
106
109
  occupancy: input.usedTokens === null || input.budgetTokens <= 0
107
110
  ? null
108
111
  : input.usedTokens / input.budgetTokens,
@@ -117,50 +120,20 @@ export function curationSignals(input: {
117
120
  }
118
121
 
119
122
  /**
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.
123
+ * ONE signal. Occupancy of the serving budget reaches maxTarget, and a commit is due.
124
+ *
125
+ * The second signal is gone with the announcement it guarded. It existed because the
126
+ * trigger ANNOUNCED a commit before running one, and announcing a commit that has
127
+ * nothing stale to fold is a false statement in the window; the announcement was
128
+ * deleted when its cache cost was measured, and the AND-condition outlived its reason.
129
+ * A commit with nothing eligible now simply applies nothing and says so, which the
130
+ * reclaim floor and the eligibility accounting already report. Stale mass is still
131
+ * measured and still reported; it is no longer a condition on the trigger.
122
132
  */
123
133
  export function curationTriggerFires(signals: CurationSignals): boolean {
124
134
  return signals.occupancy !== null &&
125
135
  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.";
136
+ signals.occupancy >= signals.maxTarget;
164
137
  }
165
138
 
166
139
  /**
@@ -195,118 +168,141 @@ export function markAwarenessText(input: {
195
168
  ].join("\n"), CONTEXT_MARK_RESPONSE_BYTES, `[${brand} marks] Held; the remainder is unavailable this pass.`);
196
169
  }
197
170
 
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;
171
+ /**
172
+ * The rider: the one post-commit carrier, composed at a fold commit and delivered
173
+ * INSIDE the rewrite that commit already paid for, beside the receipt. Its wording
174
+ * follows the curation rule (report what is happening, name the actions), its numbers
175
+ * are decision inputs only: pending agent marks with what they free, up to three
176
+ * completed-unit anchors, and the pinned share against its cap. Raw occupancy,
177
+ * distance-to-commit and unmarked-share percentages stay OUT: they are pressure
178
+ * readouts, not decisions, and the pre-commit last call is where they are both
179
+ * accurate and free.
180
+ */
181
+ export function contextRiderText(input: {
182
+ toolName: string;
183
+ brandNoun?: string;
184
+ pendingAgentMarks: number;
185
+ eligibleMarks: number;
186
+ freedTokens: number;
187
+ eligibleFreedTokens: number;
188
+ /** Up to three completed-unit entry ids the agent could mark next. */
189
+ anchors: string[];
190
+ pinnedShare: number;
191
+ maxPinnedShare: number;
192
+ /** The surfacing slate, already bounded and already logged; null on a silent pass. */
193
+ suggestion?: string | null;
194
+ }): string {
195
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
196
+ const anchors = input.anchors.length
197
+ ? input.anchors.slice(0, 3).join(", ")
198
+ : "none";
199
+ const pinnedPercent = Math.round(input.pinnedShare * 100);
200
+ const capPercent = Math.round(input.maxPinnedShare * 100);
201
+ // The slate carries its OWN bound and is joined after this text is bounded, so the
202
+ // carrier's overhead is its bound plus the line's, and neither can eat the other: a
203
+ // long rider must never truncate the one sentence a suggestion consists of.
204
+ return joinSurfacing(boundReceiptText(
205
+ [
206
+ `[${brand} notice] A fold commit just landed; the next one will batch every pending mark ` +
207
+ "into one rewrite, and marks are free until then.",
208
+ "Mark FINISHED units at their clean boundaries; batches beat singles: " +
209
+ `${input.toolName} {"action":"fold","marks":[{"ids":["<start>","<end>"],"brief":"<factual brief>"}]} ` +
210
+ "carries several decisions into that single rewrite, each keeping your brief instead of a " +
211
+ `generated one. ${input.pendingAgentMarks} of your mark(s) pending; ` +
212
+ `${input.eligibleMarks} mark(s) eligible now, freeing about ${input.eligibleFreedTokens} ` +
213
+ `of the ${input.freedTokens} marked token(s).`,
214
+ `Completed units ready to mark, largest first: ${anchors}.`,
215
+ `Pinning: ${input.toolName} {"action":"protect","ids":["<entry-id>"]} holds entries raw through ` +
216
+ `every fold, and {"action":"unprotect"} releases them. Pinned context is ${pinnedPercent}% of ` +
217
+ `the working window against a ${capPercent}% cap; at the cap, protect refuses until ` +
218
+ "something is released.",
219
+ ].join("\n"),
220
+ 2_048,
221
+ `[${brand} notice] Post-commit curation details are unavailable this pass.`,
222
+ ), input.suggestion);
208
223
  }
209
224
 
210
- export interface CurationGateVerdict {
211
- gate: CurationGate | null;
212
- event: "opened" | "held" | "proceeded";
213
- proceed: boolean;
214
- proceededBy: CurationProceedReason | null;
215
- roundsUsed: number;
225
+ /** One carrier, one optional slate line, appended last and bounded on its own. */
226
+ export function joinSurfacing(text: string, suggestion?: string | null): string {
227
+ if (!suggestion) return text;
228
+ return `${text}\n${boundedUtf8(suggestion, MAX_SURFACING_LINE_BYTES)}`;
216
229
  }
217
230
 
231
+ /** The ruled last-call sentence (Shane, 2026-08-09 13:23), verbatim. */
232
+ export const LAST_CALL_WORDING =
233
+ "Fold commit triggered, please add or edit marks, pin or unpin context based on foreseeable relevance.";
234
+
218
235
  /**
219
- * One evaluation of the gate, on one context pass.
236
+ * The pre-commit last-call.
220
237
  *
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.
238
+ * It rides the commit boundary: the band-top trigger has fired, the rewrite is one
239
+ * round away, so this is the single moment telemetry is both accurate and free. It
240
+ * carries the ruled wording, the three numbers the ruling names (occupancy against the
241
+ * commit line, unmarked foldable mass the ladder can reach, pending marks), the verbs,
242
+ * and the one-round law. Never a question: continuing the task is the stated default,
243
+ * and the commit proceeds regardless.
226
244
  */
227
- export function advanceCurationGate(input: {
228
- gate: CurationGate | null;
229
- ordinal: number;
245
+ export function lastCallText(input: {
230
246
  signals: CurationSignals;
231
- /** Context-management tool calls observed in this session so far. */
232
- contextCalls: number;
247
+ unmarked: { spans: number; tokens: number };
233
248
  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
- };
249
+ /** Peek copies this exposure marked for reclaim, so the pin that vetoes one is timely. */
250
+ peekReclaims?: number;
251
+ /** The surfacing slate, already bounded and already logged; null on a silent pass. */
252
+ suggestion?: string | null;
253
+ toolName: string;
254
+ brandNoun?: string;
255
+ }): string {
256
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
257
+ const { signals } = input;
258
+ const occupancy = signals.occupancy === null ? "unmeasured" : `${Math.round(signals.occupancy * 100)}%`;
259
+ const peekReclaims = input.peekReclaims ?? 0;
260
+ return joinSurfacing(boundReceiptText([
261
+ `[${brand} last call] ${LAST_CALL_WORDING}`,
262
+ `Occupancy is ${occupancy} of the ${signals.budgetTokens}-token serving budget, at or past the ` +
263
+ `${Math.round(signals.maxTarget * 100)}% commit line. Unmarked foldable mass: ` +
264
+ `${input.unmarked.spans} span(s), about ${input.unmarked.tokens} tokens. ` +
265
+ `Pending marks: ${input.pendingMarks}.`,
266
+ // Stated only when there is one to veto: a standing sentence about zero peek copies
267
+ // is bytes every exposure pays for and no round ever acts on.
268
+ peekReclaims > 0
269
+ ? `Peek copies reclaimed by this commit: ${peekReclaims}. Each duplicates a fold you can peek ` +
270
+ "again at any time, and its placeholder names that fold, so nothing becomes unreachable. " +
271
+ "Pin one to keep the copy raw."
272
+ : "",
273
+ `Marks: ${input.toolName} ` +
274
+ "{\"action\":\"fold\",\"marks\":[{\"ids\":[\"<start>\",\"<end>\"],\"brief\":\"<factual brief>\"}]} " +
275
+ "adds or widens several in one call. Pins: {\"action\":\"protect\",\"ids\":[\"<entry-id>\"]} holds " +
276
+ "entries raw through every fold, and {\"action\":\"unprotect\"} releases them.",
277
+ "This is one round: the commit proceeds on the pass after your next response with whatever marks " +
278
+ "exist. Continuing the task is the default; nothing here needs a reply.",
279
+ ].filter(Boolean).join("\n"), MAX_LAST_CALL_TEXT_BYTES,
280
+ `[${brand} last call] ${LAST_CALL_WORDING}`), input.suggestion);
270
281
  }
271
282
 
272
283
  /**
273
- * The last-call notice.
274
- *
275
- * Shape rule, chosen deliberately: 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.
284
+ * One occupancy waypoint, stated once. Informatory and quiet: below the commit line
285
+ * nothing folds automatically, so the only useful sentence is where the window stands
286
+ * and that marks and pins made now shape the eventual commit.
279
287
  */
280
- export function curationNoticeText(input: {
281
- signals: CurationSignals;
282
- roundsUsed: number;
283
- maxRounds?: number;
288
+ export function thresholdNoticeText(input: {
289
+ share: number;
290
+ occupancyTokens: number;
291
+ budgetTokens: number;
292
+ maxTarget: number;
284
293
  toolName: string;
285
294
  brandNoun?: string;
286
295
  }): string {
287
- const maxRounds = input.maxRounds ?? CURATION_GATE_MAX_ROUNDS;
288
296
  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.`);
297
+ return boundReceiptText(
298
+ `[${brand} notice] Context occupancy crossed ${Math.round(input.share * 100)}% of the ` +
299
+ `${input.budgetTokens}-token serving budget (${input.occupancyTokens} tokens). Nothing folds before ` +
300
+ `the fold commit at ${Math.round(input.maxTarget * 100)}%; marks and pins made now shape it: ` +
301
+ `${input.toolName} {"action":"fold","marks":[{"ids":["<start>","<end>"],"brief":"<factual brief>"}]} ` +
302
+ 'or {"action":"protect","ids":["<entry-id>"]}.',
303
+ MAX_THRESHOLD_NOTICE_TEXT_BYTES,
304
+ `[${brand} notice] Context occupancy crossed ${Math.round(input.share * 100)}% of the serving budget.`,
305
+ );
310
306
  }
311
307
 
312
308
  /** One automatic context action, reported back as status rather than as advice. */
@@ -330,6 +326,8 @@ export interface ContextReceipt {
330
326
  absorbedWedges: number;
331
327
  /** Overflow recovery, when this action rebuilt a request the provider would reject. */
332
328
  recovered: boolean;
329
+ /** Mass the agent's pins held out of this commit's reach, in bytes. */
330
+ protectedBytes: number;
333
331
  note: string | null;
334
332
  }
335
333
 
@@ -349,6 +347,7 @@ export function contextReceipt(input: Partial<ContextReceipt> & { kind: string;
349
347
  splitFromChars: input.splitFromChars ?? 0,
350
348
  absorbedWedges: input.absorbedWedges ?? 0,
351
349
  recovered: input.recovered ?? false,
350
+ protectedBytes: input.protectedBytes ?? 0,
352
351
  note: input.note ?? null,
353
352
  };
354
353
  }
@@ -387,6 +386,9 @@ export function receiptLine(receipt: ContextReceipt): string {
387
386
  receipt.recovered
388
387
  ? "This ran as overflow recovery: the request would not have been transmissible, and it was rebuilt rather than dropped."
389
388
  : "",
389
+ receipt.protectedBytes
390
+ ? `${receipt.protectedBytes} byte(s) stay pinned under protect; unprotect releases them when the work moves on.`
391
+ : "",
390
392
  receipt.note ?? "",
391
393
  ];
392
394
  return parts.filter(Boolean).join(" ");