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.
- package/README.md +142 -53
- package/extensions/active-context.ts +2158 -255
- package/extensions/lib/advisory.ts +90 -19
- package/extensions/lib/canonical.ts +12 -0
- package/extensions/lib/curation.ts +437 -0
- package/extensions/lib/folding.ts +388 -21
- package/extensions/lib/instrumentation.ts +333 -0
- package/extensions/lib/measurement.ts +111 -0
- package/extensions/lib/persistence.ts +117 -2
- package/extensions/lib/policy.ts +314 -1
- package/extensions/lib/scheduling.ts +923 -0
- package/extensions/lib/selection.ts +437 -12
- package/extensions/lib/surfacing.ts +23 -6
- package/extensions/lib/tool-surface.ts +50 -4
- package/extensions/lib/transcript.ts +58 -4
- package/package.json +6 -3
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
142
|
-
`
|
|
143
|
-
'{"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"}
|
|
144
|
-
`
|
|
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
|
|
148
|
-
"live advisory.
|
|
149
|
-
`exact entries
|
|
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 "
|
|
152
|
-
`${toolName} {"action":"fold","ids":["<fold-id>","<fold-id>"],"brief":"<factual brief>"}
|
|
153
|
-
"
|
|
154
|
-
`
|
|
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
|
-
`
|
|
181
|
-
|
|
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
|
+
}
|