pi-fold 1.0.2 → 1.0.3

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.
@@ -1,14 +1,22 @@
1
1
  const loadPiHost = () => import("@earendil-works/pi-coding-agent");
2
2
 
3
3
  function normalizeSummarizer(summarizer) {
4
- if (summarizer === "session" || summarizer === "deterministic") return summarizer;
4
+ // "deterministic" was a way to select the FAILURE PATH on purpose. Every summarizer
5
+ // failure already falls back to the deterministic brief, so the value bought nothing a
6
+ // failure does not, while reading as a third generator a deployment had to choose
7
+ // between. The generator stays; the value that named it does not.
8
+ if (summarizer === "deterministic") {
9
+ throw new Error('summarizer has no "deterministic" value: the deterministic brief is the ' +
10
+ "automatic fallback whenever a summarizer fails, not a mode to select");
11
+ }
12
+ if (summarizer === "session") return summarizer;
5
13
  if (!summarizer || typeof summarizer !== "object" || Array.isArray(summarizer) ||
6
14
  typeof summarizer.provider !== "string" || !summarizer.provider ||
7
15
  typeof summarizer.model !== "string" || !summarizer.model ||
8
16
  (summarizer.effort !== undefined &&
9
17
  (typeof summarizer.effort !== "string" || !summarizer.effort))) {
10
18
  throw new Error(
11
- 'summarizer must be "session", "deterministic", or an object with nonempty provider and model strings',
19
+ 'summarizer must be "session" or an object with nonempty provider and model strings',
12
20
  );
13
21
  }
14
22
  return {
@@ -18,9 +26,158 @@ function normalizeSummarizer(summarizer) {
18
26
  };
19
27
  }
20
28
 
29
+ /**
30
+ * The runtime hands the generator two BOUNDED orientation slices beside the span: up to
31
+ * two messages and 12,000 characters either side, hashed into the fold identity. They are
32
+ * labelled distinctly here rather than pasted in, because a model that cannot tell the
33
+ * span from its surroundings briefs the surroundings. An absent slice arrives as the
34
+ * empty-orientation literal, and a label with "[]" under it reads as content.
35
+ */
36
+ const EMPTY_ORIENTATION = "[]";
37
+
38
+ function orientationBlock(label, text) {
39
+ return typeof text === "string" && text && text !== EMPTY_ORIENTATION
40
+ ? `${label}:\n${text}`
41
+ : `${label}: none.`;
42
+ }
43
+
44
+ /**
45
+ * A brief does two jobs at once, so the request says both: it summarizes the span, and it
46
+ * tells the agent what expanding or peeking that fold would return, which is the whole
47
+ * basis on which the agent decides to dig back in. Naming the concrete contents is what
48
+ * makes the second job possible: an abstract description of a span is not a statement of
49
+ * what is recoverable from it.
50
+ */
51
+ /**
52
+ * A GROUP is briefed under one extra rule: every child gets a share.
53
+ *
54
+ * A parent is what the ladder builds when folds pile up, and its brief is the only index
55
+ * of what the group holds. A brief that covers the first two children richly and drops the
56
+ * other eight closes them: the agent has no way to know the eighth is in there, so it never
57
+ * expands to find out. Coverage beats depth at this rung, and the children are numbered in
58
+ * the payload so "every one of them" is a countable instruction rather than a wish.
59
+ */
60
+ function groupCoverageRule(children) {
61
+ return `The span is a GROUP of ${children} folded children, numbered 1 to ${children} ` +
62
+ "below, each with its own brief and then its contents; folds nested inside a child " +
63
+ "stay folded and appear as placeholders, and a child marked collapsed shows only its " +
64
+ "brief. Your brief is the only index of this group, so give EVERY one of the " +
65
+ `${children} children a share of it: name what each one holds in the order they ` +
66
+ "appear. A child you leave out is a child the agent cannot know to look for. Prefer " +
67
+ "one concrete clause per child over a full account of the first few.";
68
+ }
69
+
70
+ /** The marker a batched answer separates its briefs with. On its own line, so a brief that
71
+ * happens to mention one cannot split itself. */
72
+ const BATCH_MARKER = (ordinal) => `=== BRIEF ${ordinal} ===`;
73
+ const BATCH_MARKER_PATTERN = /^===[ \t]*BRIEF[ \t]+(\d+)[ \t]*===[ \t]*$/;
74
+
75
+ /**
76
+ * Many spans, one call.
77
+ *
78
+ * A commit folds a dozen spans at once and the lane used to send a dozen separate requests,
79
+ * two at a time, so most folds never reached a generator at all: rep 2 of sol-20260811
80
+ * committed 87 folds and applied 14 model briefs. Batching is not only cheaper per brief,
81
+ * it is what makes coverage reachable. It also buys something a single-span call cannot
82
+ * have: the spans of one commit are consecutive, so each one is the orientation for the
83
+ * next, and a brief can say this continues the work two spans back because the model can
84
+ * see that it does. Orientation is therefore given ONCE around the whole run rather than
85
+ * per span, which is both smaller and truer.
86
+ */
87
+ function batchBriefRequestPrompt(request) {
88
+ const spans = request.spans;
89
+ const bodies = spans.map((span, at) => [
90
+ `SPAN ${at + 1} OF ${spans.length}`,
91
+ ...(Number.isInteger(span.children) && span.children > 1
92
+ ? [groupCoverageRule(span.children)]
93
+ : []),
94
+ span.sourceText,
95
+ ].join("\n"));
96
+ return [
97
+ `Write ${spans.length} factual briefs, one for each of the ${spans.length} numbered ` +
98
+ "SPANS below. Each brief covers its own span and nothing else, and each is at most " +
99
+ `${request.maxBriefChars} characters. A brief does two jobs at once: it summarizes what ` +
100
+ "its span contains, and it tells an agent what it would get back by expanding or " +
101
+ "peeking that fold later, so the agent can decide whether to dig back in. Once a span " +
102
+ "is folded its brief is the only visible trace, so name the concrete things inside it: " +
103
+ "files, identifiers, decisions, results, errors. Do not describe a span abstractly. " +
104
+ "The spans are consecutive, so where one continues the work of an earlier one you may " +
105
+ "say so by number; do not let that replace naming what is actually in the span. The " +
106
+ "BEFORE and AFTER sections are orientation only: they say where the whole run of spans " +
107
+ "sits in the larger conversation, and their content belongs to no brief. Use no " +
108
+ "preamble and no Markdown headers.\n\n" +
109
+ `Answer with exactly ${spans.length} sections and nothing else. Begin each section ` +
110
+ `with its marker alone on a line: ${BATCH_MARKER(1)} then the first brief, ` +
111
+ `${BATCH_MARKER(2)} then the second, and so on through ` +
112
+ `${BATCH_MARKER(spans.length)}.`,
113
+ orientationBlock("BEFORE THE SPANS (orientation only, do not brief)", request.beforeText),
114
+ bodies.join("\n\n"),
115
+ orientationBlock("AFTER THE SPANS (orientation only, do not brief)", request.afterText),
116
+ ...(typeof request.cure === "string" && request.cure
117
+ ? [`YOUR PREVIOUS ANSWER DID NOT MEET THE CRITERIA. ${request.cure}\n` +
118
+ `Write all ${spans.length} briefs again, each under its own marker.`]
119
+ : []),
120
+ ].join("\n\n");
121
+ }
122
+
123
+ /**
124
+ * Split a batched answer back into one brief per span, by marker.
125
+ *
126
+ * Ordinals rather than fold ids: an id is 24 hex characters the model has no reason to
127
+ * reproduce faithfully, and a single transposed character would attach a brief to the wrong
128
+ * span silently. A position it can count. Anything before the first marker is preamble the
129
+ * request asked for none of, and it is dropped rather than prepended to brief one.
130
+ */
131
+ function splitBatchedBriefs(text, expected) {
132
+ const briefs = new Array(expected).fill("");
133
+ let at = null;
134
+ let buffer = [];
135
+ const flush = () => {
136
+ if (at !== null && at >= 0 && at < expected) briefs[at] = buffer.join("\n").trim();
137
+ buffer = [];
138
+ };
139
+ for (const line of String(text).split(/\r?\n/)) {
140
+ const marked = BATCH_MARKER_PATTERN.exec(line.trim());
141
+ if (marked) {
142
+ flush();
143
+ at = Number(marked[1]) - 1;
144
+ continue;
145
+ }
146
+ if (at !== null) buffer.push(line);
147
+ }
148
+ flush();
149
+ return briefs;
150
+ }
151
+
152
+ function briefRequestPrompt(request) {
153
+ if (Array.isArray(request.spans)) return batchBriefRequestPrompt(request);
154
+ const children = Number.isInteger(request.children) && request.children > 1
155
+ ? request.children
156
+ : 0;
157
+ return [
158
+ `Write a factual brief of at most ${request.maxBriefChars} characters covering the ` +
159
+ "SPAN TO BRIEF below, and nothing else. The brief does two jobs at once: it " +
160
+ "summarizes what the span contains, and it tells an agent what it would get back by " +
161
+ "expanding or peeking this fold later, so the agent can decide whether to dig back " +
162
+ "in. Once the span is folded the brief is its only visible trace, so name the " +
163
+ "concrete things inside it: files, identifiers, decisions, results, errors. Do not " +
164
+ "describe the span abstractly. The BEFORE and AFTER sections are orientation only: " +
165
+ "they say where the span sits in the larger conversation, and their content is not " +
166
+ "part of what you are briefing. Use no preamble and no Markdown headers." +
167
+ (children ? `\n\n${groupCoverageRule(children)}` : ""),
168
+ orientationBlock("BEFORE THE SPAN (orientation only, do not brief)", request.beforeText),
169
+ `SPAN TO BRIEF:\n${request.sourceText}`,
170
+ orientationBlock("AFTER THE SPAN (orientation only, do not brief)", request.afterText),
171
+ // A second ask, and the only reason there is one: the first answer missed a criterion
172
+ // this request stated, and the caller would otherwise have to cut the brief itself.
173
+ ...(typeof request.cure === "string" && request.cure
174
+ ? [`YOUR PREVIOUS ANSWER DID NOT MEET THE CRITERIA. ${request.cure}\nWrite the brief again.`]
175
+ : []),
176
+ ].join("\n\n");
177
+ }
178
+
21
179
  export function createSummarizeContextSpan(summarizer = "session", loadHostModule = loadPiHost) {
22
180
  const configured = normalizeSummarizer(summarizer);
23
- if (configured === "deterministic") return undefined;
24
181
  if (typeof loadHostModule !== "function") {
25
182
  throw new Error("Summarizer host-module loader must be a function");
26
183
  }
@@ -38,8 +195,14 @@ export function createSummarizeContextSpan(summarizer = "session", loadHostModul
38
195
  };
39
196
 
40
197
  return async (request, ctx) => {
41
- if (typeof request?.sourceText !== "string" ||
42
- !Number.isInteger(request?.maxBriefChars) || request.maxBriefChars < 1) {
198
+ const batch = Array.isArray(request?.spans) ? request.spans : null;
199
+ if (batch && (!batch.length || !batch.every((span) => typeof span?.sourceText === "string"))) {
200
+ throw new Error("Summarizer batch requires a nonempty spans array, each with sourceText");
201
+ }
202
+ if (!batch && typeof request?.sourceText !== "string") {
203
+ throw new Error("Summarizer request requires sourceText and a positive maxBriefChars integer");
204
+ }
205
+ if (!Number.isInteger(request?.maxBriefChars) || request.maxBriefChars < 1) {
43
206
  throw new Error("Summarizer request requires sourceText and a positive maxBriefChars integer");
44
207
  }
45
208
 
@@ -63,9 +226,19 @@ export function createSummarizeContextSpan(summarizer = "session", loadHostModul
63
226
  : typeof ctx?.thinkingLevel === "string" && ctx.thinkingLevel
64
227
  ? ctx.thinkingLevel
65
228
  : "none";
66
- const prompt = `Write a factual brief of at most ${request.maxBriefChars} characters. ` +
67
- `Use no preamble and no Markdown headers.\n\n${request.sourceText}`;
68
- const completionOptions = { maxTokens: 512, signal: request.signal };
229
+ const prompt = briefRequestPrompt(request);
230
+ // NO TOKEN CEILING, deliberately (Shane 2026-08-11). A maxTokens value does not tell a
231
+ // model to be brief, it cuts the model off mid-answer, and on a reasoning model the
232
+ // reasoning is drawn from the same budget: the harder the span is to read, the less
233
+ // remains for the brief, until nothing remains and the response carries no text at all.
234
+ // The 2026-08-11 rep ran with 512 and paid for it: 22 percent of calls failed with
235
+ // "Summarizer returned no text", every one of them after two minutes of thinking, and
236
+ // brief length correlated NEGATIVELY with call duration at r = -0.575. The group
237
+ // payloads suffered worst, because the most interesting input provokes the most
238
+ // reasoning. Length is bounded by three things that can explain themselves to the
239
+ // model instead: the limit stated in the request, the cure that hands back an
240
+ // over-long brief with the complaint, and the caller's timeout for a runaway.
241
+ const completionOptions = { signal: request.signal };
69
242
  if (model.reasoning && effort !== "none" && effort !== "off") {
70
243
  completionOptions.reasoning = effort;
71
244
  }
@@ -84,12 +257,35 @@ export function createSummarizeContextSpan(summarizer = "session", loadHostModul
84
257
  typeof model.id !== "string" || !model.id) {
85
258
  throw new Error("Resolved summarizer model lacks provider/id attribution");
86
259
  }
87
- return {
88
- brief,
260
+ // The generator bills outside the session, so this is the ONLY place its spend is
261
+ // visible. Passed through when the provider reports it and omitted when it does not,
262
+ // never defaulted to zero: an absent number and a free call are different facts.
263
+ const usage = response?.usage && typeof response.usage === "object"
264
+ ? {
265
+ ...(Number.isFinite(response.usage.input) ? { input: response.usage.input } : {}),
266
+ ...(Number.isFinite(response.usage.output) ? { output: response.usage.output } : {}),
267
+ ...(Number.isFinite(response.usage.cacheRead) ? { cacheRead: response.usage.cacheRead } : {}),
268
+ ...(Number.isFinite(response.usage.totalTokens) ? { totalTokens: response.usage.totalTokens } : {}),
269
+ ...(Number.isFinite(response.usage?.cost?.total) ? { costTotal: response.usage.cost.total } : {}),
270
+ }
271
+ : null;
272
+ const attribution = {
89
273
  provider: model.provider,
90
274
  model: model.id,
91
275
  effort,
92
276
  toolCalls: 0,
277
+ ...(usage && Object.keys(usage).length ? { usage } : {}),
93
278
  };
279
+ // A batch answers with one brief per span, split by marker and returned positionally.
280
+ // A span whose marker never appeared comes back empty rather than shifted into a
281
+ // neighbour's place: the caller holds each brief to the same contract either way, so an
282
+ // empty one is a complaint about that span alone and the rest still land.
283
+ if (batch) {
284
+ return {
285
+ briefs: splitBatchedBriefs(brief, batch.length),
286
+ ...attribution,
287
+ };
288
+ }
289
+ return { brief, ...attribution };
94
290
  };
95
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,309 +0,0 @@
1
- import { sha256Value } from "../json.ts";
2
- import { clone } from "./canonical.ts";
3
- import {
4
- boundReceiptText,
5
- hardFenceRatio,
6
- } from "./measurement.ts";
7
- import { ADVISORY_BUDGETS, validAdvisoryState } from "./persistence.ts";
8
- import type {
9
- ActiveContextSnapshot,
10
- ActiveContextState,
11
- AdvisoryMilestone,
12
- GuidanceProfile,
13
- SurfacingSuggestion,
14
- } from "./policy.ts";
15
- import {
16
- contextBrand,
17
- DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
18
- DEFAULT_GUIDANCE_PROFILE,
19
- GUIDANCE_PROFILES,
20
- } from "./policy.ts";
21
-
22
- export { ADVISORY_BUDGETS };
23
-
24
- export interface AdvisorySchedule {
25
- key: string;
26
- rungs: Array<{ milestone: AdvisoryMilestone; threshold: number; budget: number }>;
27
- }
28
-
29
- export function advisoryState(state: ActiveContextState): NonNullable<ActiveContextState["advisory"]> {
30
- if (state.advisory === undefined) return { highWater: 0, delivered: {} };
31
- if (!validAdvisoryState(state.advisory)) {
32
- throw new Error("Corrupt in-memory advisory state (no silent fallback)");
33
- }
34
- return clone(state.advisory);
35
- }
36
-
37
- export function clearArmedAdvisory(state: ActiveContextState): ActiveContextState {
38
- const current = advisoryState(state);
39
- if (!current.armed) return state;
40
- const advisory = { ...current };
41
- delete advisory.armed;
42
- return { ...state, advisory };
43
- }
44
-
45
- export function normalizeGuidanceProfile(guidance: unknown = DEFAULT_GUIDANCE_PROFILE): GuidanceProfile {
46
- if (!GUIDANCE_PROFILES.includes(guidance as GuidanceProfile)) {
47
- throw new Error('guidance must be "pressure", "curation", or "minimal"');
48
- }
49
- return guidance as GuidanceProfile;
50
- }
51
-
52
- function guidanceRungs(
53
- snapshot: Pick<ActiveContextSnapshot, "policy" | "contextWindow">,
54
- guidance: GuidanceProfile,
55
- ): AdvisorySchedule["rungs"] {
56
- const urgent = {
57
- milestone: "urgent" as const,
58
- threshold: hardFenceRatio(snapshot) - 0.03,
59
- budget: ADVISORY_BUDGETS.urgent,
60
- };
61
- if (guidance === "minimal") return [urgent];
62
- return [
63
- ...(guidance === "curation"
64
- ? [{ milestone: "orientation" as const, threshold: 0.25, budget: ADVISORY_BUDGETS.orientation }]
65
- : []),
66
- { milestone: "notice" as const, threshold: 0.50, budget: ADVISORY_BUDGETS.notice },
67
- { milestone: "tools" as const, threshold: snapshot.policy.toolFoldRatio - 0.04,
68
- budget: ADVISORY_BUDGETS.tools },
69
- { milestone: "chapters" as const, threshold: snapshot.policy.prepareRatio - 0.05,
70
- budget: ADVISORY_BUDGETS.chapters },
71
- urgent,
72
- ];
73
- }
74
-
75
- export function advisorySchedule(
76
- snapshot: Pick<ActiveContextSnapshot, "policy" | "contextWindow">,
77
- guidance: GuidanceProfile = DEFAULT_GUIDANCE_PROFILE,
78
- ): AdvisorySchedule {
79
- const rungs = guidanceRungs(snapshot, guidance);
80
- for (let index = rungs.length - 2; index >= 0; index -= 1) {
81
- rungs[index].threshold = Math.min(rungs[index].threshold, rungs[index + 1].threshold - 0.02);
82
- }
83
- for (const rung of rungs) rung.threshold = Math.max(0, Math.min(1, rung.threshold));
84
- return {
85
- key: sha256Value(rungs.map(({ milestone, threshold }) => ({ milestone, threshold }))),
86
- rungs,
87
- };
88
- }
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
-
112
- export function updateAdvisoryMilestone(
113
- currentState: ActiveContextState,
114
- ratio: number,
115
- schedule: AdvisorySchedule,
116
- scheduleChanged: boolean,
117
- scheduleKey: string,
118
- /** Spend the milestone budget on DELIVERY rather than on arming. */
119
- countOnDelivery = false,
120
- ): { state: ActiveContextState; milestone: AdvisoryMilestone | null } {
121
- const current = advisoryState(currentState);
122
- if (scheduleChanged) {
123
- return {
124
- state: { ...currentState, advisory: { ...current, highWater: Math.min(1, ratio) } },
125
- milestone: null,
126
- };
127
- }
128
- let highWater = current.highWater;
129
- for (let index = schedule.rungs.length - 1; index >= 0; index -= 1) {
130
- const rung = schedule.rungs[index];
131
- if ((current.delivered[rung.milestone] ?? 0) > 0 && ratio < 0.85 * rung.threshold) {
132
- highWater = Math.min(highWater, index > 0 ? schedule.rungs[index - 1].threshold : 0);
133
- }
134
- }
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
- }
153
- const delivered = { ...current.delivered };
154
- if (selected && !countOnDelivery) {
155
- delivered[selected.milestone] = (delivered[selected.milestone] ?? 0) + 1;
156
- }
157
- const armed = selected
158
- ? { milestone: selected.milestone, threshold: selected.threshold, scheduleKey }
159
- : current.armed;
160
- return {
161
- state: {
162
- ...currentState,
163
- advisory: {
164
- highWater: Math.min(1, Math.max(highWater, ratio)),
165
- delivered,
166
- ...(armed ? { armed } : {}),
167
- },
168
- },
169
- milestone: selected?.milestone ?? null,
170
- };
171
- }
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 useful thing to say is what it will do and what reacting buys.
178
- */
179
- function curationText(milestone: AdvisoryMilestone, percent: number, toolName: string): string {
180
- if (milestone === "orientation") {
181
- return "Collapsed folds are a browsable index of the work behind you, and their briefs sit in the cached " +
182
- `prefix of the window, so paging that index costs almost nothing. Page it with ${toolName} ` +
183
- `{"action":"status"} and expand what the current task needs with ${toolName} ` +
184
- '{"action":"expand","id":"<fold-id>"}.';
185
- }
186
- if (milestone === "notice") {
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.";
192
- }
193
- if (milestone === "tools") {
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>"}.';
199
- }
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>"]}.`;
204
- }
205
-
206
- export function milestoneText(
207
- milestone: AdvisoryMilestone,
208
- sessionId: string,
209
- threshold: number,
210
- toolName: string,
211
- brandNoun = DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
212
- guidance: GuidanceProfile = DEFAULT_GUIDANCE_PROFILE,
213
- ): string {
214
- const percent = Math.round(threshold * 100);
215
- const prefix = `[${contextBrand(brandNoun)} milestone ${milestone}; session ${sessionId.slice(0, 16)}]`;
216
- if (guidance === "curation" && milestone !== "urgent") {
217
- return `${prefix} ${curationText(milestone, percent, toolName)}`;
218
- }
219
- if (milestone === "notice") {
220
- return `${prefix} Context pressure has crossed ${percent}%. Automatic folding is available. ` +
221
- `Inspect candidates exactly with ${toolName} {"action":"status"}.`;
222
- }
223
- if (milestone === "tools") {
224
- return `${prefix} The read-only tool-fold rung begins at ${percent}%. ` +
225
- "Eligible completed tool batches can be folded now; current endpoint ids are in the live advisory.";
226
- }
227
- if (milestone === "chapters") {
228
- return `${prefix} The chapter preparation rung begins at ${percent}%. ` +
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.";
232
- }
233
- return `${prefix} The hard context fence is near. The next automatic action is a committed chapter fold ` +
234
- "or the provider request is aborted before transmission.";
235
- }
236
-
237
- /**
238
- * The surfacing carrier. It rides the same ephemeral tail channel as the live
239
- * advisory: appended after the stable prefix, never durable, never a mutation of
240
- * the projection, and cleared whenever the next context event is built.
241
- */
242
- export function surfacingText(input: {
243
- suggestions: readonly SurfacingSuggestion[];
244
- brandNoun?: string;
245
- }): string | null {
246
- if (!input.suggestions.length) return null;
247
- const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
248
- const lines = input.suggestions.map((suggestion, index) => [
249
- `${index + 1}. ${suggestion.id}${suggestion.depth ? ` (depth ${suggestion.depth})` : ""}: ${suggestion.text}`,
250
- ...(suggestion.alternateRoute ? [` peek (ephemeral read): ${suggestion.alternateRoute}`] : []),
251
- ` expand (durable restore): ${suggestion.route}`,
252
- ].join("\n"));
253
- return boundReceiptText(
254
- [
255
- `[${brand} suggestions] ${input.suggestions.length} collapsed span(s) look relevant to the current ` +
256
- "task. Peek reads one back ephemerally at any depth; expand restores it in place, outermost first. " +
257
- "Ignoring them is a valid answer.",
258
- ...lines,
259
- ].join("\n"),
260
- 2_048,
261
- `[${brand} suggestions] Relevant collapsed spans are unavailable this pass.`,
262
- );
263
- }
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
-
278
- export function liveAdvisoryText(input: {
279
- milestone: AdvisoryMilestone;
280
- ratio: number;
281
- toolEndpoints: string[];
282
- chapterEndpoints: string[];
283
- remediationCount: number;
284
- brandNoun?: string;
285
- curation?: AdvisoryCuration | null;
286
- }): string {
287
- const tools = input.toolEndpoints.length
288
- ? input.toolEndpoints.slice(0, 3).join(", ")
289
- : "none";
290
- const chapter = input.chapterEndpoints.length
291
- ? `${input.chapterEndpoints[0]}..${input.chapterEndpoints.at(-1)}`
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
- : "";
300
- return boundReceiptText(
301
- `[${contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN)} advisory] ` +
302
- `pressure ${Math.round(input.ratio * 100)}%; milestone ${input.milestone}; ${curation}` +
303
- `eligible read-only batch endpoints: ${tools}; eligibleChapter endpoints: ${chapter}; ` +
304
- `session milestone count: ${input.remediationCount}.`,
305
- 2_048,
306
- `[${contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN)} advisory] ` +
307
- "Live pressure details are unavailable.",
308
- );
309
- }