pi-fold 1.0.1 → 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.
- package/README.md +35 -42
- package/extensions/active-context.ts +2375 -601
- package/extensions/evidence.js +15 -2
- package/extensions/index.js +69 -21
- package/extensions/json.ts +33 -1
- package/extensions/lib/curation.ts +141 -139
- package/extensions/lib/folding.ts +418 -74
- package/extensions/lib/instrumentation.ts +24 -4
- package/extensions/lib/measurement.ts +92 -16
- package/extensions/lib/persistence.ts +212 -42
- package/extensions/lib/policy.ts +511 -155
- package/extensions/lib/rollback.ts +216 -0
- package/extensions/lib/scheduling.ts +415 -107
- package/extensions/lib/selection.ts +528 -144
- package/extensions/lib/surfacing.ts +480 -225
- package/extensions/lib/tool-surface.ts +25 -5
- package/extensions/lib/transcript.ts +146 -62
- package/extensions/summarizer.js +206 -10
- package/package.json +1 -1
- package/extensions/lib/advisory.ts +0 -309
package/extensions/summarizer.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
const loadPiHost = () => import("@earendil-works/pi-coding-agent");
|
|
2
2
|
|
|
3
3
|
function normalizeSummarizer(summarizer) {
|
|
4
|
-
|
|
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"
|
|
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
|
-
|
|
42
|
-
|
|
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 =
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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,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 honest 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
|
-
}
|