@cubicecho/agent-core 2.12.0 → 2.14.0

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.
@@ -37,6 +37,25 @@ const messageText = (message) => {
37
37
  return `${textOf(message.content)} ${calls}`.trim();
38
38
  };
39
39
  const isSummary = (message) => message.role === "system" && textOf(message.content).startsWith(SUMMARY_LEAD);
40
+ /** The summary as it sits in a transcript, which is the one shape `isSummary` recognises again. */
41
+ const summaryMessage = (summary) => ({
42
+ role: "system",
43
+ content: `${SUMMARY_LEAD}${summary.trim()}`,
44
+ });
45
+ /**
46
+ * The leading system messages a fold never touches, and the summary an earlier one left among
47
+ * them. What a caller who keeps its system prompt out of the array knows already, and passes.
48
+ */
49
+ const systemHead = (messages) => {
50
+ let from = 0;
51
+ let previous;
52
+ while (from < messages.length && messages[from].role === "system") {
53
+ if (isSummary(messages[from]))
54
+ previous = textOf(messages[from].content).slice(SUMMARY_LEAD.length);
55
+ from++;
56
+ }
57
+ return { from, previous };
58
+ };
40
59
  /**
41
60
  * The transcript with every tool result but the latest few replaced by a one-line stub.
42
61
  *
@@ -80,22 +99,24 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
80
99
  * than summarised as if it were conversation. No plan comes back when the window is not full
81
100
  * enough, or when the only legal cut folds too little to pay for the summary.
82
101
  *
102
+ * Both of those are recovered by reading the transcript, which is what a host whose array holds
103
+ * everything it sends has to do. A host that keeps its fold as a record beside an append-only
104
+ * transcript — its system prompt a separate argument, no summary message in the array at all —
105
+ * knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
106
+ *
83
107
  * @param messages The transcript, system prompts included if the caller keeps them in it.
84
- * @param options The window, what is in use, and the ratios. See `CompactionOptions`.
108
+ * @param options The window, what is in use, the ratios, and where the last fold ended. See
109
+ * `CompactionOptions`.
85
110
  */
86
- export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), }) {
111
+ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
87
112
  if (!(limit > 0))
88
113
  return undefined;
89
114
  const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
90
115
  if (cost < limit * compactAt)
91
116
  return undefined;
92
- let from = 0;
93
- let previous;
94
- while (from < messages.length && messages[from].role === "system") {
95
- if (isSummary(messages[from]))
96
- previous = textOf(messages[from].content).slice(SUMMARY_LEAD.length);
97
- from++;
98
- }
117
+ const head = systemHead(messages);
118
+ const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
119
+ const previous = givenPrevious ?? head.previous;
99
120
  const budget = limit * keepRatio;
100
121
  let kept = 0;
101
122
  let cut = messages.length;
@@ -139,31 +160,21 @@ export function summaryInput(plan) {
139
160
  */
140
161
  export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = 1024, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
141
162
  /**
142
- * The transcript with the plan's stretch replaced by one system message holding its summary.
163
+ * The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
143
164
  *
144
- * `beforeCompact` is told what is being folded while the summary is written, beside it rather
145
- * than ahead of it a memory server filing it is not a rescue worth making the run wait for, and
146
- * `notify` never rejects. A host that wants its hooks able to stop a compaction sets
147
- * `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
148
- * `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
149
- * compaction ignores a veto and runs the hooks beside the summary as before, because a run already
150
- * past its window has no better option — a veto there only trades the summary for a
151
- * `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
152
- * comment on when to run it.
165
+ * What `compactTranscript` does before it rewrites anything, which is all a host needs when the
166
+ * fold lives on the session row and the messages stay where they are. Nothing here is persisted or
167
+ * logged that is the host's, and so is deciding what to do with a fold that did not happen.
153
168
  *
154
- * @param messages The transcript the plan was made for. Not written to.
169
+ * @param messages The transcript the plan was made for. Read only, and only for the hooks.
155
170
  * @param plan What `planCompaction` returned for it.
156
171
  * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
157
172
  * when a hook vetoes.
158
- * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
159
- * indexes are the plan's. `honourVeto` waits for the hooks and lets one stop the compaction; off
160
- * by default, which adds no latency. `forced` says the window is already exceeded — the caller
161
- * caught a `ContextOverflow`, or is compacting to make a refused request fit — and overrides
162
- * `honourVeto`.
163
- * @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
164
- * new array.
173
+ * @param options Hooks to tell and whether the window is already past. See `CompactionRunOptions`.
174
+ * @returns `undefined` when nothing was folded a hook vetoed, or the summary came back empty —
175
+ * so the caller stores nothing and the transcript is still whole.
165
176
  */
166
- export async function compactTranscript(messages, plan, summarise, { hooks, forced = false, } = {}) {
177
+ export async function runCompaction(messages, plan, summarise, { hooks, forced = false } = {}) {
167
178
  const context = hooks && {
168
179
  ...hooks.context,
169
180
  compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
@@ -173,7 +184,7 @@ export async function compactTranscript(messages, plan, summarise, { hooks, forc
173
184
  if (hooks && context && hooks.honourVeto && !forced) {
174
185
  const { vetoed } = await consult(hooks.run, "beforeCompact", context, hooks.onNote);
175
186
  if (vetoed)
176
- return messages;
187
+ return undefined;
177
188
  summary = await summarise(summaryInput(plan));
178
189
  }
179
190
  else {
@@ -183,10 +194,85 @@ export async function compactTranscript(messages, plan, summarise, { hooks, forc
183
194
  ]);
184
195
  }
185
196
  if (!summary.trim())
197
+ return undefined;
198
+ return { summary: summary.trim(), through: plan.cut, at: new Date().toISOString() };
199
+ }
200
+ /**
201
+ * The transcript as the server should see it: the folded head replaced by its summary.
202
+ *
203
+ * The inverse of storing a `CompactionRecord`, and the shape `planCompaction` expects to meet
204
+ * again — the same `SUMMARY_LEAD`, in a `system` message at the same place — so the next fold
205
+ * continues these notes rather than summarising them a second time. Any earlier summary message in
206
+ * the kept head is dropped, since the record's already contains it.
207
+ *
208
+ * @param messages The stored transcript, whole. Not written to.
209
+ * @param record The fold, or `undefined` for a session that has not been compacted, which hands
210
+ * back `messages` itself.
211
+ * @param options `from` is the first message the fold was allowed to take — the plan's, for a host
212
+ * that keeps its system prompts in the array; everything before it is kept ahead of the summary.
213
+ * Absent, the leading `system` messages are found by scanning, and zero of them is the ordinary
214
+ * case for a host whose system prompt is a separate argument.
215
+ */
216
+ export function applyCompaction(messages, record, { from } = {}) {
217
+ if (!record?.summary.trim())
186
218
  return messages;
219
+ const head = Math.min(Math.max(from ?? systemHead(messages).from, 0), record.through);
187
220
  return [
188
- ...messages.slice(0, plan.from).filter((message) => !isSummary(message)),
189
- { role: "system", content: `${SUMMARY_LEAD}${summary.trim()}` },
190
- ...messages.slice(plan.cut),
221
+ ...messages.slice(0, head).filter((message) => !isSummary(message)),
222
+ summaryMessage(record.summary),
223
+ ...messages.slice(record.through),
191
224
  ];
192
225
  }
226
+ /**
227
+ * Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted
228
+ * everything after it.
229
+ *
230
+ * A transcript that stays append-only and a request that does not are two numberings of the same
231
+ * conversation, and anything that names a position — `withContext`'s index, a range handed to a
232
+ * hook — has to say which it is in. An index inside the folded stretch answers with the summary
233
+ * message that now stands for it.
234
+ *
235
+ * @param index The position in the stored transcript.
236
+ * @param record The fold in force, or `undefined` for a session that has none, which hands the
237
+ * index straight back.
238
+ * @param head How many messages the request keeps ahead of the summary — the leading system
239
+ * prompts, when the host keeps them in the array. Zero, the default, is the stored-fold case,
240
+ * where the summary is the request's first message.
241
+ */
242
+ export const requestIndex = (index, record, head = 0) => {
243
+ if (!record)
244
+ return index;
245
+ return index < record.through ? head : index - record.through + head + 1;
246
+ };
247
+ /**
248
+ * The transcript with the plan's stretch replaced by one system message holding its summary.
249
+ *
250
+ * `beforeCompact` is told what is being folded while the summary is written, beside it rather
251
+ * than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
252
+ * `notify` never rejects. A host that wants its hooks able to stop a compaction sets
253
+ * `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
254
+ * `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
255
+ * compaction ignores a veto and runs the hooks beside the summary as before, because a run already
256
+ * past its window has no better option — a veto there only trades the summary for a
257
+ * `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
258
+ * comment on when to run it.
259
+ *
260
+ * `runCompaction` and `applyCompaction` are its two halves, and it is nothing but the two in
261
+ * order, so a host that stores the fold instead of the array gets the same summary at the same
262
+ * cut rather than a second implementation that drifts from this one.
263
+ *
264
+ * @param messages The transcript the plan was made for. Not written to.
265
+ * @param plan What `planCompaction` returned for it.
266
+ * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
267
+ * when a hook vetoes.
268
+ * @param options Hooks to tell and whether the window is already past. See
269
+ * `CompactionRunOptions`.
270
+ * @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
271
+ * new array.
272
+ */
273
+ export async function compactTranscript(messages, plan, summarise, options = {}) {
274
+ const record = await runCompaction(messages, plan, summarise, options);
275
+ if (!record)
276
+ return messages;
277
+ return applyCompaction(messages, record, { from: plan.from });
278
+ }
package/dist/hooks.d.ts CHANGED
@@ -256,26 +256,44 @@ export declare function untrusted(text: string, { source }?: {
256
256
  * two identical "ok"s one memory. The same message sent twice, after its turn and again when it
257
257
  * is compacted, is one.
258
258
  *
259
+ * That position is the array's, which is the host's only while the two agree. Once a compaction
260
+ * has folded the head of a request into one summary message, the same message sits at a lower
261
+ * index there than it does in a transcript the host kept whole — so the same turn, sent after it
262
+ * and again when it is compacted, would arrive under two uuids and be remembered twice. `offset`
263
+ * is what puts the numbering back on the host's own indexes; passing the stored transcript rather
264
+ * than the request avoids the question entirely.
265
+ *
259
266
  * @param sessionId Prefixes every uuid, so two sessions never share one.
260
267
  * @param messages The transcript, in whatever shape the host stores it, so long as each message
261
268
  * has an OpenAI-style `role` and `content`.
262
269
  * @param from The first index, inclusive. Below zero reads from the start.
263
270
  * @param to The end, exclusive. Absent, or past the end, reads to the end.
271
+ * @param options `offset` is what the array's first message is numbered as in the uuids — the
272
+ * stored index of `messages[0]`, when `messages` is a request a fold has shifted. Zero by default,
273
+ * which numbers by position as before. `from` and `to` stay array indexes either way.
264
274
  */
265
275
  export declare function turnMessages(sessionId: string, messages: readonly {
266
276
  role: string;
267
277
  content?: unknown;
268
- }[], from: number, to?: number): HookMessage[];
278
+ }[], from: number, to?: number, { offset }?: {
279
+ offset?: number;
280
+ }): HookMessage[];
269
281
  /**
270
282
  * Which turn of a session begins at a point, from 0: the user messages ahead of it.
271
283
  *
284
+ * Counted over what it is given, which is the session only while nothing has been folded away — a
285
+ * compacted request has lost the questions the summary now stands for, and turn eleven counting
286
+ * itself as turn two is the kind of thing a hook writes into a memory. Count over the stored
287
+ * transcript, or add what the fold took as `offset`.
288
+ *
272
289
  * @param messages The transcript.
273
290
  * @param before Where the turn begins. Absent is the end, which is the index of a turn whose
274
291
  * question has not been appended yet.
292
+ * @param offset Turns already folded away and so not in `messages`. Zero by default.
275
293
  */
276
294
  export declare const turnIndex: (messages: readonly {
277
295
  role: string;
278
- }[], before?: number) => number;
296
+ }[], before?: number, offset?: number) => number;
279
297
  /**
280
298
  * Runs the hooks ahead of a request and builds what they add to it.
281
299
  *
package/dist/hooks.js CHANGED
@@ -204,13 +204,23 @@ const textOf = (content) => {
204
204
  * two identical "ok"s one memory. The same message sent twice, after its turn and again when it
205
205
  * is compacted, is one.
206
206
  *
207
+ * That position is the array's, which is the host's only while the two agree. Once a compaction
208
+ * has folded the head of a request into one summary message, the same message sits at a lower
209
+ * index there than it does in a transcript the host kept whole — so the same turn, sent after it
210
+ * and again when it is compacted, would arrive under two uuids and be remembered twice. `offset`
211
+ * is what puts the numbering back on the host's own indexes; passing the stored transcript rather
212
+ * than the request avoids the question entirely.
213
+ *
207
214
  * @param sessionId Prefixes every uuid, so two sessions never share one.
208
215
  * @param messages The transcript, in whatever shape the host stores it, so long as each message
209
216
  * has an OpenAI-style `role` and `content`.
210
217
  * @param from The first index, inclusive. Below zero reads from the start.
211
218
  * @param to The end, exclusive. Absent, or past the end, reads to the end.
219
+ * @param options `offset` is what the array's first message is numbered as in the uuids — the
220
+ * stored index of `messages[0]`, when `messages` is a request a fold has shifted. Zero by default,
221
+ * which numbers by position as before. `from` and `to` stay array indexes either way.
212
222
  */
213
- export function turnMessages(sessionId, messages, from, to) {
223
+ export function turnMessages(sessionId, messages, from, to, { offset = 0 } = {}) {
214
224
  const end = Math.min(to ?? messages.length, messages.length);
215
225
  const out = [];
216
226
  for (let at = Math.max(0, from); at < end; at++) {
@@ -221,18 +231,28 @@ export function turnMessages(sessionId, messages, from, to) {
221
231
  if (!text)
222
232
  continue;
223
233
  const digest = createHash("sha256").update(`${message.role}\0${text}`).digest("hex");
224
- out.push({ speaker: message.role, text, uuid: `${sessionId}:${at}:${digest.slice(0, 12)}` });
234
+ out.push({
235
+ speaker: message.role,
236
+ text,
237
+ uuid: `${sessionId}:${at + offset}:${digest.slice(0, 12)}`,
238
+ });
225
239
  }
226
240
  return out;
227
241
  }
228
242
  /**
229
243
  * Which turn of a session begins at a point, from 0: the user messages ahead of it.
230
244
  *
245
+ * Counted over what it is given, which is the session only while nothing has been folded away — a
246
+ * compacted request has lost the questions the summary now stands for, and turn eleven counting
247
+ * itself as turn two is the kind of thing a hook writes into a memory. Count over the stored
248
+ * transcript, or add what the fold took as `offset`.
249
+ *
231
250
  * @param messages The transcript.
232
251
  * @param before Where the turn begins. Absent is the end, which is the index of a turn whose
233
252
  * question has not been appended yet.
253
+ * @param offset Turns already folded away and so not in `messages`. Zero by default.
234
254
  */
235
- export const turnIndex = (messages, before = messages.length) => messages.slice(0, before).filter((message) => message.role === "user").length;
255
+ export const turnIndex = (messages, before = messages.length, offset = 0) => offset + messages.slice(0, before).filter((message) => message.role === "user").length;
236
256
  /** A runner that rejected, as the one outcome its event can still be noted by. */
237
257
  const rejected = (event, error) => ({
238
258
  serverId: "",
package/dist/index.d.ts CHANGED
@@ -11,17 +11,17 @@
11
11
  */
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
- export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
+ export { type Capabilities, capabilitiesFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
15
15
  export type { CatalogServer } from "./catalog.ts";
16
16
  export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
17
- export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
17
+ export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
18
18
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
19
19
  export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./continuation.ts";
20
20
  export { errorMessage } from "./errors.ts";
21
21
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
22
  export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
23
23
  export { resetAll } from "./reset.ts";
24
- export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
24
+ export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
25
25
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
26
26
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
27
27
  export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
@@ -30,4 +30,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
30
30
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
31
31
  export { estimateTokens } from "./tokens.ts";
32
32
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
33
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
33
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, type ToolOrder, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -11,15 +11,15 @@
11
11
  */
12
12
  export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
14
- export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
14
+ export { capabilitiesFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
15
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
16
- export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
16
+ export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
17
17
  export { continueTurn, isContinuable, } from "./continuation.js";
18
18
  export { errorMessage } from "./errors.js";
19
19
  export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
20
20
  export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
21
21
  export { resetAll } from "./reset.js";
22
- export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
22
+ export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
23
23
  export { runTurn } from "./run-turn.js";
24
24
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
25
25
  export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
@@ -28,4 +28,4 @@ export { streamTurn, } from "./stream.js";
28
28
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
29
29
  export { estimateTokens } from "./tokens.js";
30
30
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
31
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
31
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/retry.d.ts CHANGED
@@ -79,6 +79,66 @@ export declare function requestChars(body: OpenAI.ChatCompletionCreateParamsStre
79
79
  * @param options The divisor, `CHARS_PER_TOKEN` when none is given.
80
80
  */
81
81
  export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming, { charsPerToken }?: TokenEstimateOptions) => number;
82
+ /**
83
+ * What a request is made of, by the part of it a consumer can actually do something about.
84
+ *
85
+ * The question an operator asks is not how big the request is — the total already answers that —
86
+ * but what is filling the window, and the only useful answer names a lever: a system prompt to
87
+ * shorten, a tool list to load on demand instead of declaring whole, a transcript to compact,
88
+ * results to prune. So the cut follows the levers rather than the roles: `toolResults` is exactly
89
+ * what `pruneToolResults` can shrink, and `history` is everything `planCompaction` folds, the
90
+ * arguments of the calls in it included.
91
+ */
92
+ export interface ContextBreakdown {
93
+ /** Every system and developer message, wherever it sits in the transcript. */
94
+ system: number;
95
+ /** The declared tool schemas, which a chat template renders ahead of the system prompt. */
96
+ tools: number;
97
+ /** What was said: the user and assistant messages, and the calls the assistant asked for. */
98
+ history: number;
99
+ /** What the tools handed back — the `tool` messages, and nothing else. */
100
+ toolResults: number;
101
+ /** The four above, summed. */
102
+ total: number;
103
+ }
104
+ /**
105
+ * What each part of a request is worth in characters, by the same walk `requestTokens` divides.
106
+ *
107
+ * Exact and additive: the parts sum to `total`, which is `requestChars` plus `toolsChars`. The
108
+ * conversion to tokens is `contextTokens`' business, because that is where an estimate and a
109
+ * reported count have to be told apart.
110
+ *
111
+ * @param body The request as it will be sent, tools included.
112
+ */
113
+ export declare function contextChars(body: OpenAI.ChatCompletionCreateParamsStreaming): ContextBreakdown;
114
+ /** What `contextTokens` takes besides the request. */
115
+ export interface ContextBreakdownOptions extends TokenEstimateOptions {
116
+ /**
117
+ * The prompt count the endpoint reported for this request, if it has answered. Given one, the
118
+ * parts are shares of it and the breakdown sums to what was actually charged rather than to an
119
+ * estimate; left out, they are shares of `requestTokens`.
120
+ */
121
+ promptTokens?: number;
122
+ }
123
+ /**
124
+ * What each part of a request costs the window, in tokens, adding up to the whole.
125
+ *
126
+ * Shares rather than four independent estimates, because a readout whose parts do not add up to
127
+ * the total beside them is a readout nobody trusts. Nothing in the round trip reports anything
128
+ * finer than a prompt count — a completion says how many tokens it read and not a word about
129
+ * where they came from — so the proportions are an estimate whatever the total is.
130
+ *
131
+ * Without a reported count the total is `requestTokens`, and the tools are counted the way it and
132
+ * `TurnMetrics.toolSchemaTokens` count them rather than shared out, so the two agree by
133
+ * construction and an operator does not read one number for the tool block in the metrics and a
134
+ * different one here. With a reported count every part is a share of it, the tools included:
135
+ * that number is the server's, and the point of using it is that the parts sum to what was
136
+ * charged.
137
+ *
138
+ * @param body The request as it will be sent, tools included.
139
+ * @param options The divisor, and the reported prompt count when there is one.
140
+ */
141
+ export declare function contextTokens(body: OpenAI.ChatCompletionCreateParamsStreaming, { charsPerToken, promptTokens }?: ContextBreakdownOptions): ContextBreakdown;
82
142
  /**
83
143
  * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
84
144
  *
package/dist/retry.js CHANGED
@@ -183,6 +183,89 @@ export const requestTokens = (body, { charsPerToken } = {}) => {
183
183
  const per = divisor(charsPerToken);
184
184
  return Math.ceil(requestChars(body) / per) + Math.ceil(toolsChars(body.tools ?? []) / per);
185
185
  };
186
+ /** The parts, in the order a readout reads them. */
187
+ const PARTS = ["system", "tools", "history", "toolResults"];
188
+ /**
189
+ * What each part of a request is worth in characters, by the same walk `requestTokens` divides.
190
+ *
191
+ * Exact and additive: the parts sum to `total`, which is `requestChars` plus `toolsChars`. The
192
+ * conversion to tokens is `contextTokens`' business, because that is where an estimate and a
193
+ * reported count have to be told apart.
194
+ *
195
+ * @param body The request as it will be sent, tools included.
196
+ */
197
+ export function contextChars(body) {
198
+ const out = {
199
+ system: 0,
200
+ tools: toolsChars(body.tools ?? []),
201
+ history: 0,
202
+ toolResults: 0,
203
+ total: 0,
204
+ };
205
+ for (const message of body.messages) {
206
+ const chars = messageChars(message);
207
+ // Every system message and not just the leading one: a host that appends guidance, or a
208
+ // hook that injects a preface, has put more of the window there and wants to be told so.
209
+ if (message.role === "system" || message.role === "developer")
210
+ out.system += chars;
211
+ else if (message.role === "tool")
212
+ out.toolResults += chars;
213
+ else
214
+ out.history += chars;
215
+ }
216
+ out.total = out.system + out.tools + out.history + out.toolResults;
217
+ return out;
218
+ }
219
+ /**
220
+ * Shares `total` out over these parts by their character counts, the largest absorbing the
221
+ * rounding so they add up to it exactly rather than to within a few tokens of it.
222
+ */
223
+ function share(chars, over, total) {
224
+ const out = { system: 0, tools: 0, history: 0, toolResults: 0, total };
225
+ const measured = over.reduce((sum, part) => sum + chars[part], 0);
226
+ if (measured <= 0 || total <= 0)
227
+ return out;
228
+ const absorber = over.reduce((a, b) => (chars[b] > chars[a] ? b : a));
229
+ let assigned = 0;
230
+ for (const part of over) {
231
+ if (part === absorber)
232
+ continue;
233
+ out[part] = Math.round((chars[part] / measured) * total);
234
+ assigned += out[part];
235
+ }
236
+ out[absorber] = Math.max(0, total - assigned);
237
+ return out;
238
+ }
239
+ /**
240
+ * What each part of a request costs the window, in tokens, adding up to the whole.
241
+ *
242
+ * Shares rather than four independent estimates, because a readout whose parts do not add up to
243
+ * the total beside them is a readout nobody trusts. Nothing in the round trip reports anything
244
+ * finer than a prompt count — a completion says how many tokens it read and not a word about
245
+ * where they came from — so the proportions are an estimate whatever the total is.
246
+ *
247
+ * Without a reported count the total is `requestTokens`, and the tools are counted the way it and
248
+ * `TurnMetrics.toolSchemaTokens` count them rather than shared out, so the two agree by
249
+ * construction and an operator does not read one number for the tool block in the metrics and a
250
+ * different one here. With a reported count every part is a share of it, the tools included:
251
+ * that number is the server's, and the point of using it is that the parts sum to what was
252
+ * charged.
253
+ *
254
+ * @param body The request as it will be sent, tools included.
255
+ * @param options The divisor, and the reported prompt count when there is one.
256
+ */
257
+ export function contextTokens(body, { charsPerToken, promptTokens } = {}) {
258
+ const chars = contextChars(body);
259
+ if (promptTokens !== undefined && promptTokens > 0)
260
+ return share(chars, PARTS, promptTokens);
261
+ const per = divisor(charsPerToken);
262
+ const tools = Math.ceil(chars.tools / per);
263
+ const rest = Math.ceil((chars.total - chars.tools) / per);
264
+ const out = share(chars, ["system", "history", "toolResults"], rest);
265
+ out.tools = tools;
266
+ out.total = rest + tools;
267
+ return out;
268
+ }
186
269
  /**
187
270
  * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
188
271
  *
@@ -28,6 +28,13 @@ export interface EndpointSnapshot {
28
28
  strictSchemas: boolean;
29
29
  usageInStream: boolean;
30
30
  models: Record<string, ModelSnapshot>;
31
+ /**
32
+ * When this endpoint was first met, as epoch milliseconds, so `expireCapabilities` measures a
33
+ * latch from when it was learned rather than from the boot that imported it. Absent in a snapshot
34
+ * taken before it was written, which reads as met now — the behaviour of every release until this
35
+ * one, and the reason no version bump is owed.
36
+ */
37
+ since?: number;
31
38
  }
32
39
  /** Every latched refusal in the process, JSON-safe. See `exportCapabilities`. */
33
40
  export interface CapabilitySnapshot {
@@ -43,7 +50,8 @@ export interface CapabilitySnapshot {
43
50
  * Covers what `negotiate` latches on endpoints and models and the models `ask` found refusing the
44
51
  * no-thinking hints. Only what was actually refused is in it, so a snapshot of a process that met
45
52
  * no refusals has no endpoints. Endpoints are named by digest rather than URL and key, since the
46
- * blob is meant to be written somewhere and a key must not be written with it.
53
+ * blob is meant to be written somewhere and a key must not be written with it. Each carries the
54
+ * `since` it was learned at, so importing it does not make an old latch young again.
47
55
  */
48
56
  export declare function exportCapabilities(): CapabilitySnapshot;
49
57
  /**
@@ -53,7 +61,8 @@ export declare function exportCapabilities(): CapabilitySnapshot;
53
61
  * off whatever the snapshot says, and one the snapshot has off is turned off. A snapshot of another
54
62
  * version, or anything that is not one, is ignored — a stale shape costs the refused requests it
55
63
  * would have saved, which is what a restart cost before. How old is too old is the consumer's call,
56
- * made on `savedAt` before importing, since a server behind a URL can be upgraded between boots.
64
+ * made on `savedAt` before importing, or afterwards per endpoint with `expireCapabilities`, since a
65
+ * server behind a URL can be upgraded between boots.
57
66
  *
58
67
  * @param snapshot What `exportCapabilities` returned, as stored. Read defensively: a field of the
59
68
  * wrong type is skipped rather than trusted.
package/dist/snapshot.js CHANGED
@@ -32,13 +32,21 @@ const refusedAnything = (model) => !model.reasoningEffort ||
32
32
  * Covers what `negotiate` latches on endpoints and models and the models `ask` found refusing the
33
33
  * no-thinking hints. Only what was actually refused is in it, so a snapshot of a process that met
34
34
  * no refusals has no endpoints. Endpoints are named by digest rather than URL and key, since the
35
- * blob is meant to be written somewhere and a key must not be written with it.
35
+ * blob is meant to be written somewhere and a key must not be written with it. Each carries the
36
+ * `since` it was learned at, so importing it does not make an old latch young again.
36
37
  */
37
38
  export function exportCapabilities() {
38
39
  const endpoints = {};
39
40
  const entry = (id) => {
40
- endpoints[id] ??= { strictSchemas: true, usageInStream: true, models: {} };
41
- return endpoints[id];
41
+ const held = endpoints[id];
42
+ if (held)
43
+ return held;
44
+ const fresh = { strictSchemas: true, usageInStream: true, models: {} };
45
+ const since = knownCapabilities().get(id)?.since;
46
+ if (since !== undefined)
47
+ fresh.since = since;
48
+ endpoints[id] = fresh;
49
+ return fresh;
42
50
  };
43
51
  for (const [id, supports] of knownCapabilities()) {
44
52
  const models = {};
@@ -79,7 +87,8 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
79
87
  * off whatever the snapshot says, and one the snapshot has off is turned off. A snapshot of another
80
88
  * version, or anything that is not one, is ignored — a stale shape costs the refused requests it
81
89
  * would have saved, which is what a restart cost before. How old is too old is the consumer's call,
82
- * made on `savedAt` before importing, since a server behind a URL can be upgraded between boots.
90
+ * made on `savedAt` before importing, or afterwards per endpoint with `expireCapabilities`, since a
91
+ * server behind a URL can be upgraded between boots.
83
92
  *
84
93
  * @param snapshot What `exportCapabilities` returned, as stored. Read defensively: a field of the
85
94
  * wrong type is skipped rather than trusted.
@@ -98,6 +107,11 @@ export function importCapabilities(snapshot) {
98
107
  supports.strictSchemas = false;
99
108
  if (endpoint.usageInStream === false)
100
109
  supports.usageInStream = false;
110
+ // Older of the two, so a snapshot ages an entry and never rejuvenates one: importing must not
111
+ // be a way to keep a latch from ever reaching `expireCapabilities`.
112
+ if (typeof endpoint.since === "number" && endpoint.since < supports.since) {
113
+ supports.since = endpoint.since;
114
+ }
101
115
  if (!isRecord(endpoint.models))
102
116
  continue;
103
117
  for (const [name, model] of Object.entries(endpoint.models)) {