@womp/spark-agent 6.5.0 → 8.0.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +7 -4
  3. package/dist/agent.d.ts +3 -6
  4. package/dist/agent.js +41 -23
  5. package/dist/capabilities/geometry.d.ts +17 -5
  6. package/dist/capabilities/geometry.js +11 -12
  7. package/dist/compaction.d.ts +41 -8
  8. package/dist/compaction.js +241 -77
  9. package/dist/context-window.d.ts +23 -0
  10. package/dist/context-window.js +141 -0
  11. package/dist/generated/geometry-declarations.d.ts +1 -1
  12. package/dist/generated/geometry-declarations.js +1 -1
  13. package/dist/geometry/backendRegistry.d.ts +1 -0
  14. package/dist/geometry/common.d.ts +5 -2
  15. package/dist/geometry/common.js +18 -3
  16. package/dist/geometry/compiler.js +2 -2
  17. package/dist/geometry/diagnostics.d.ts +2 -7
  18. package/dist/geometry/diagnostics.js +3 -51
  19. package/dist/geometry/graph.d.ts +1 -1
  20. package/dist/geometry/graph.js +1 -0
  21. package/dist/geometry/index.d.ts +0 -1
  22. package/dist/geometry/index.js +0 -3
  23. package/dist/geometry/lift.js +105 -113
  24. package/dist/geometry/lower.js +29 -49
  25. package/dist/geometry/schemas/flowir-dialect.json +117 -117
  26. package/dist/geometry/writeParamData.js +4 -4
  27. package/dist/geometry.d.ts +2 -5
  28. package/dist/geometry.js +7 -11
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +2 -1
  31. package/dist/intent-bench.js +1 -1
  32. package/dist/sdk-geometry-host.d.ts +1 -1
  33. package/dist/sdk-geometry-host.js +253 -251
  34. package/dist/ui.d.ts +7 -0
  35. package/dist/ui.js +1 -0
  36. package/dist/womp-scene-api.d.ts +9 -1
  37. package/dist/womp-scene-api.js +41 -32
  38. package/package.json +2 -2
  39. package/dist/context-budget.d.ts +0 -65
  40. package/dist/context-budget.js +0 -364
@@ -1,25 +1,44 @@
1
1
  // Replace a long conversation's earlier messages with a summary.
2
2
  //
3
- // WHEN. Once per turn, before its first step: when the conversation reaches
4
- // COMPACTION_THRESHOLD_TOKENS, everything before the most recent
5
- // COMPACTION_KEEP_RECENT_TOKENS of whole turns is summarized by the run's own
6
- // model. Every later request sends the summary in their place. Below the
7
- // threshold nothing happens, and a summary that fails leaves the turn as it was:
8
- // ./context-budget.ts still keeps every request inside the model's window.
3
+ // This is the only thing that takes messages out of what the model reads. Nothing
4
+ // trims a request to fit the window: a request still over it after compaction is
5
+ // sent as it is, and fails with the provider's own error.
6
+ //
7
+ // WHEN. Before every step of a turn, once the conversation reaches
8
+ // COMPACTION_THRESHOLD_TOKENS (or the model's window less its output, if that is
9
+ // lower):
10
+ //
11
+ // - Before the first step (`compactSparkHistory`), everything before the most
12
+ // recent COMPACTION_KEEP_RECENT_TOKENS of whole turns is summarized.
13
+ // - Before a later step (`createStepCompaction`), the turn has grown by its own
14
+ // work: everything before the current user message, and the turn's steps
15
+ // before its most recent COMPACTION_KEEP_RECENT_TOKENS, is summarized. The
16
+ // user's message itself is kept as it is. A compaction that would save less
17
+ // than a quarter of the threshold is not made, so a turn whose own message is
18
+ // over the threshold does not summarize again on every step.
19
+ //
20
+ // A summary that fails leaves the step as it was.
9
21
  //
10
22
  // WHERE IT IS KEPT. The summary is a `data-compaction` part on the assistant
11
23
  // message of the turn that wrote it (SparkCompaction, ./ui.ts), so every host
12
24
  // stores and reloads it with the message, and the chat still shows every
13
25
  // message it replaces. `applyCompaction`, called by `convertSparkMessages`,
14
- // sends the latest one in place of the messages before `firstKeptMessageId`.
15
- // A later compaction is written from the conversation as the model then sees
16
- // it -- the earlier summary first -- so the summary is updated, not restarted.
26
+ // sends the latest one in place of the messages before `firstKeptMessageId` --
27
+ // and, for one written during its turn, in place of that message's steps before
28
+ // `firstKeptStep`. A later compaction is written from the conversation as the
29
+ // model then sees it -- the earlier summary first -- so the summary is updated,
30
+ // not restarted.
31
+ //
32
+ // WHAT IS SUMMARIZED. Only the messages the summary replaces. A kept message
33
+ // it also told of would come after it again, and the model would take that work
34
+ // for done twice.
17
35
  //
18
- // THE CACHE. The summary request is the previous request's prefix -- the same
19
- // system prompt, tool definitions and messages -- plus one instruction, so the
20
- // provider's prompt cache serves almost all of it. Tool definitions are sent
36
+ // THE CACHE. The summary request is an earlier request -- the same system
37
+ // prompt, tool definitions and messages, up to the kept ones -- plus one
38
+ // instruction, so the provider's prompt cache serves almost all of it. Tool definitions are sent
21
39
  // without `execute`: a call the model makes anyway is never run, and fails the
22
- // summary instead.
40
+ // summary instead. The messages a step is sent after a compaction are the ones a
41
+ // later turn rebuilds from storage, so the next turn's prefix is cached too.
23
42
  //
24
43
  // SKILLS. A skill read in the summarized messages would otherwise be lost with
25
44
  // them. The compaction names them, and its summary message carries their
@@ -30,15 +49,17 @@
30
49
  // COMPACTION_USER_IMAGES of them too, as the same file parts; the summary
31
50
  // describes the rest. They are still on the stored messages: nothing records them.
32
51
  import { generateText } from "ai";
33
- import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateRequestTokens, fitMessagesToContext, } from "./context-budget.js";
52
+ import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateMessagesTokens, estimateRequestTokens } from "./context-window.js";
34
53
  import { sparkDataPartSchemas } from "./ui.js";
35
54
  import { userImages } from "./user-images.js";
36
55
  /** Compact once the conversation reaches this many tokens. */
37
56
  export const COMPACTION_THRESHOLD_TOKENS = 256_000;
38
- /** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
57
+ /** Recent whole turns, or recent steps of the current turn, kept verbatim, up to this many tokens. */
39
58
  export const COMPACTION_KEEP_RECENT_TOKENS = 16_000;
40
59
  /** The user's newest images from the summarized messages that the summary message carries. */
41
60
  export const COMPACTION_USER_IMAGES = 4;
61
+ /** A compaction during a turn saves at least this share of the threshold, or is not made. */
62
+ export const COMPACTION_MIN_SAVED_RATIO = 0.25;
42
63
  export const COMPACTION_PROMPT = `Write a checkpoint summary of this conversation so far. It replaces the earlier messages in your context, so the work must be able to continue from it alone. Reply with the summary only, without calling tools. This request is not part of the conversation: leave it out of the summary.
43
64
  If the conversation begins with an earlier summary, carry its content forward and update it.
44
65
  Use these sections:
@@ -51,22 +72,23 @@ Use these sections:
51
72
  ## Errors and approaches to avoid
52
73
  Keep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.`;
53
74
  export const COMPACTION_NOTE = "[Earlier messages in this conversation were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
75
+ export const COMPACTION_STEPS_NOTE = "[Earlier messages in this conversation, and the earlier steps of your work on the message after this one, were replaced by the summary below. The scene itself is unchanged; inspect it for its exact state.]";
54
76
  /** The latest compaction in the conversation whose kept message is still in it. */
55
77
  export function latestCompaction(history) {
56
78
  for (let carrier = history.length - 1; carrier >= 0; carrier -= 1) {
57
79
  const message = history[carrier];
58
80
  if (message?.role !== "assistant")
59
81
  continue;
60
- for (let i = message.parts.length - 1; i >= 0; i -= 1) {
61
- const part = message.parts[i];
62
- if (part?.type !== "data-compaction")
82
+ for (let part = message.parts.length - 1; part >= 0; part -= 1) {
83
+ const value = message.parts[part];
84
+ if (value?.type !== "data-compaction")
63
85
  continue;
64
- const parsed = sparkDataPartSchemas.compaction.safeParse(part.data);
86
+ const parsed = sparkDataPartSchemas.compaction.safeParse(value.data);
65
87
  if (!parsed.success)
66
88
  continue;
67
89
  const firstKept = history.findIndex(({ id }) => id === parsed.data.firstKeptMessageId);
68
90
  if (firstKept >= 0)
69
- return { compaction: parsed.data, carrier, firstKept };
91
+ return { compaction: parsed.data, carrier, part, firstKept };
70
92
  }
71
93
  }
72
94
  return undefined;
@@ -74,21 +96,37 @@ export function latestCompaction(history) {
74
96
  /**
75
97
  * The conversation as the model reads it: the messages before the compaction's
76
98
  * kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
77
- * images the user attached to them. `compaction` defaults to the latest one in `history`; `skills` are the
78
- * host's, to send the current text of the skills it names. Without a
79
- * compaction, `history` is returned as it is.
99
+ * images the user attached to them, and -- for the latest compaction, when it was
100
+ * written during its turn -- the recording message's steps before
101
+ * `firstKeptStep` left out with them. `compaction` defaults to the latest one in
102
+ * `history`; `skills` are the host's, to send the current text of the skills it
103
+ * names. Without a compaction, `history` is returned as it is.
80
104
  */
81
105
  export function applyCompaction(history, options = {}) {
82
- const compaction = options.compaction ?? latestCompaction(history)?.compaction;
106
+ const latest = options.compaction === undefined ? latestCompaction(history) : undefined;
107
+ const compaction = options.compaction ?? latest?.compaction;
83
108
  if (compaction === undefined)
84
109
  return history;
85
110
  const firstKept = history.findIndex(({ id }) => id === compaction.firstKeptMessageId);
86
111
  if (firstKept < 0)
87
112
  return history;
113
+ const kept = history.slice(firstKept);
114
+ const carrier = latest === undefined ? -1 : latest.carrier - firstKept;
115
+ if (compaction.firstKeptStep !== undefined && carrier >= 0) {
116
+ kept[carrier] = fromStep(kept[carrier], compaction.firstKeptStep);
117
+ }
118
+ return [compactionMessage(history, firstKept, compaction, options.skills), ...kept];
119
+ }
120
+ /**
121
+ * The message the model reads in place of the summarized ones: the note, the
122
+ * skills, the user's newest images from before `firstKept`, then the summary.
123
+ */
124
+ function compactionMessage(history, firstKept, compaction, available) {
88
125
  // The skills go before the summary, in a part of their own: they change less
89
126
  // often, so the next compaction leaves them in the cached prefix.
90
- const skills = skillsText(compaction.skills, options.skills);
91
- const parts = [{ type: "text", text: COMPACTION_NOTE }];
127
+ const skills = skillsText(compaction.skills, available);
128
+ const note = compaction.firstKeptStep === undefined ? COMPACTION_NOTE : COMPACTION_STEPS_NOTE;
129
+ const parts = [{ type: "text", text: note }];
92
130
  if (skills !== undefined)
93
131
  parts.push({ type: "text", text: skills });
94
132
  const shown = new Set(userImages(history.slice(firstKept)).map(({ url }) => url));
@@ -99,7 +137,17 @@ export function applyCompaction(history, options = {}) {
99
137
  parts.push({ type: "text", text: "The newest images the user attached to the messages the summary replaces, in the order sent:" }, ...images);
100
138
  }
101
139
  parts.push({ type: "text", text: `<conversation-summary>\n${compaction.summary}\n</conversation-summary>` });
102
- return [{ id: `compaction-${compaction.firstKeptMessageId}`, role: "user", parts }, ...history.slice(firstKept)];
140
+ return { id: `compaction-${compaction.firstKeptMessageId}`, role: "user", parts };
141
+ }
142
+ /**
143
+ * `message` from its `step`th step on, counted from 0 by its `step-start` parts.
144
+ * A message with fewer steps than that is kept whole: sending a step twice, once
145
+ * summarized, is better than losing one.
146
+ */
147
+ function fromStep(message, step) {
148
+ let seen = -1;
149
+ const start = message.parts.findIndex((part) => part.type === "step-start" && ++seen === step);
150
+ return start < 0 ? message : { ...message, parts: message.parts.slice(start) };
103
151
  }
104
152
  function skillsText(names, available) {
105
153
  if (names.length === 0)
@@ -123,35 +171,27 @@ function skillsText(names, available) {
123
171
  export async function compactSparkHistory(options) {
124
172
  const { history } = options;
125
173
  const messages = await options.convert();
126
- const threshold = Math.min(options.limits?.thresholdTokens ?? COMPACTION_THRESHOLD_TOKENS, options.contextWindow === undefined ? Infinity : options.contextWindow - (options.maxOutputTokens ?? 0));
127
174
  const previous = latestCompaction(history);
128
- const size = conversationTokens(history, previous?.carrier ?? -1) ?? estimate(messages, options);
129
- if (size < threshold)
175
+ const size = conversationTokens(history, previous) ?? estimate(messages, options);
176
+ if (size < threshold(options))
130
177
  return { messages };
131
178
  const firstKept = cutIndex(history, previous?.firstKept ?? 0, options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS);
132
179
  if (firstKept === undefined)
133
180
  return { messages };
134
- // The request ends before the current user message: the previous request's
135
- // prefix, which the provider has cached.
136
- const current = lastIndex(messages, ({ role }) => role === "user");
137
- if (current <= 0)
181
+ // The summary covers what it replaces and nothing it keeps: a kept message it
182
+ // also told of would read as done twice. Those are the messages before the
183
+ // kept tail, which ends a request the provider has cached.
184
+ const kept = keptTail(messages, await options.convert({ ...placeholder(options), firstKeptMessageId: history[firstKept].id }));
185
+ const end = Math.min(messages.length - kept, lastIndex(messages, ({ role }) => role === "user"));
186
+ if (end <= 0)
138
187
  return { messages };
139
- options.onCompacting?.();
140
- let summary;
141
- try {
142
- summary = await summarize(messages.slice(0, current), options);
143
- }
144
- catch (error) {
145
- if (options.signal?.aborted)
146
- throw error;
147
- }
148
- options.signal?.throwIfAborted();
188
+ const summary = await summarize(messages.slice(0, end), options);
149
189
  if (summary === undefined)
150
190
  return { messages };
151
- const kept = readSkills(history.slice(firstKept));
191
+ const keptSkills = readSkills(history.slice(firstKept));
152
192
  const skills = [
153
193
  ...new Set([...(previous?.compaction.skills ?? []), ...readSkills(history.slice(previous?.firstKept ?? 0, firstKept))]),
154
- ].filter((name) => !kept.has(name));
194
+ ].filter((name) => !keptSkills.has(name));
155
195
  const compaction = {
156
196
  firstKeptMessageId: history[firstKept].id,
157
197
  summary,
@@ -165,29 +205,135 @@ export async function compactSparkHistory(options) {
165
205
  compaction.tokensAfter = estimate(compacted, options);
166
206
  return { messages: compacted, compaction };
167
207
  }
208
+ /**
209
+ * Compaction before each later step of a turn. Returns the step's messages,
210
+ * compacted when the conversation has reached the threshold; the AI SDK carries
211
+ * what a step was sent into the next, so a compaction holds for the rest of the
212
+ * turn.
213
+ */
214
+ export function createStepCompaction(options) {
215
+ const { history } = options;
216
+ const request = history[history.length - 1];
217
+ let previous = options.previous;
218
+ /** Steps of the turn already summarized. */
219
+ let summarizedSteps = 0;
220
+ return async (messages, last) => {
221
+ if (request?.role !== "user")
222
+ return messages;
223
+ const current = lastIndex(messages, ({ role }) => role === "user");
224
+ const lastStep = lastIndex(messages, ({ role }) => role === "assistant");
225
+ if (current < 0 || lastStep < current)
226
+ return messages;
227
+ // The provider's count for the step before, plus the results that came after it.
228
+ const size = last?.inputTokens !== undefined
229
+ ? last.inputTokens + (last.outputTokens ?? 0) + estimateMessagesTokens(messages.slice(lastStep + 1))
230
+ : estimate(messages, options);
231
+ const limit = threshold(options);
232
+ if (size < limit)
233
+ return messages;
234
+ // The turn's steps kept: the most recent that fit, whole, each an assistant
235
+ // message and the tool results after it.
236
+ const keepRecent = options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS;
237
+ let firstKept = messages.length;
238
+ for (let i = messages.length - 1; i > current; i -= 1) {
239
+ if (messages[i].role !== "assistant")
240
+ continue;
241
+ if (estimateMessagesTokens(messages.slice(i)) > keepRecent)
242
+ break;
243
+ firstKept = i;
244
+ }
245
+ const steps = summarizedSteps + messages.slice(current + 1, firstKept).filter(({ role }) => role === "assistant").length;
246
+ const compaction = (summary) => ({
247
+ firstKeptMessageId: request.id,
248
+ ...(steps > 0 ? { firstKeptStep: steps } : {}),
249
+ summary,
250
+ skills: summarizedSkills(messages, current, firstKept),
251
+ modelId: options.modelId,
252
+ tokensBefore: estimate(messages, options),
253
+ tokensAfter: 0,
254
+ createdAt: new Date().toISOString(),
255
+ });
256
+ const compacted = async (value) => [
257
+ ...(await options.convert([compactionMessage(history, history.length - 1, value, options.skills)])),
258
+ messages[current],
259
+ ...messages.slice(firstKept),
260
+ ];
261
+ // A summary costs a request; one that saves too little is not worth it.
262
+ const placeholder = await compacted(compaction("x".repeat(1_500 * 6)));
263
+ if (estimate(messages, options) - estimate(placeholder, options) < limit * COMPACTION_MIN_SAVED_RATIO)
264
+ return messages;
265
+ // Only the steps it replaces: a kept step it also told of would read as done
266
+ // twice. They end the request of the first kept step, which the provider has
267
+ // cached.
268
+ const summary = await summarize(messages.slice(0, firstKept), options);
269
+ if (summary === undefined)
270
+ return messages;
271
+ const next = compaction(summary);
272
+ const sent = await compacted(next);
273
+ next.tokensAfter = estimate(sent, options);
274
+ previous = next;
275
+ summarizedSteps = steps;
276
+ options.onCompaction(next);
277
+ return sent;
278
+ };
279
+ /** Skills read in what this compaction summarizes and not read again in what it keeps. */
280
+ function summarizedSkills(messages, current, firstKept) {
281
+ const from = previous === undefined ? 0 : Math.max(0, history.findIndex(({ id }) => id === previous.firstKeptMessageId));
282
+ const kept = modelSkills(messages.slice(firstKept));
283
+ return [
284
+ ...new Set([
285
+ ...(previous?.skills ?? []),
286
+ ...readSkills(history.slice(from, history.length - 1)),
287
+ ...modelSkills(messages.slice(current + 1, firstKept)),
288
+ ]),
289
+ ].filter((name) => !kept.has(name));
290
+ }
291
+ }
292
+ /** A compaction to measure with, before its summary is written. */
293
+ function placeholder(options) {
294
+ return { summary: "", skills: [], modelId: options.modelId, tokensBefore: 0, tokensAfter: 0, createdAt: new Date(0).toISOString() };
295
+ }
296
+ /** How many of `messages`' last messages `compacted` still sends as they are. */
297
+ function keptTail(messages, compacted) {
298
+ let kept = 0;
299
+ while (kept < messages.length &&
300
+ kept < compacted.length - 1 &&
301
+ JSON.stringify(messages[messages.length - 1 - kept]) === JSON.stringify(compacted[compacted.length - 1 - kept])) {
302
+ kept += 1;
303
+ }
304
+ return kept;
305
+ }
168
306
  async function summarize(messages, options) {
307
+ options.onCompacting?.();
169
308
  const definitions = Object.fromEntries(Object.entries(options.tools).map(([name, tool]) => {
170
309
  const { execute: _execute, onInputStart: _start, onInputDelta: _delta, onInputAvailable: _available, ...definition } = tool;
171
310
  return [name, definition];
172
311
  }));
173
- const fitted = fitMessagesToContext(messages, {
174
- contextWindow: options.contextWindow,
175
- maxOutputTokens: options.maxOutputTokens,
176
- system: options.system,
177
- tools: definitions,
178
- }).messages;
179
- const result = await generateText({
180
- model: options.model,
181
- system: options.system,
182
- tools: definitions,
183
- toolChoice: "auto",
184
- messages: [...fitted, { role: "user", content: [{ type: "text", text: COMPACTION_PROMPT }] }],
185
- providerOptions: options.providerOptions,
186
- maxOutputTokens: options.maxOutputTokens,
187
- abortSignal: options.signal,
188
- });
189
- const text = result.text.trim();
190
- return result.finishReason === "stop" && result.toolCalls.length === 0 && text.length > 0 ? text : undefined;
312
+ let summary;
313
+ try {
314
+ const result = await generateText({
315
+ model: options.model,
316
+ system: options.system,
317
+ tools: definitions,
318
+ toolChoice: "auto",
319
+ messages: [...messages, { role: "user", content: [{ type: "text", text: COMPACTION_PROMPT }] }],
320
+ providerOptions: options.providerOptions,
321
+ maxOutputTokens: options.maxOutputTokens,
322
+ abortSignal: options.signal,
323
+ });
324
+ const text = result.text.trim();
325
+ if (result.finishReason === "stop" && result.toolCalls.length === 0 && text.length > 0)
326
+ summary = text;
327
+ }
328
+ catch (error) {
329
+ if (options.signal?.aborted)
330
+ throw error;
331
+ }
332
+ options.signal?.throwIfAborted();
333
+ return summary;
334
+ }
335
+ function threshold(options) {
336
+ return Math.min(options.limits?.thresholdTokens ?? COMPACTION_THRESHOLD_TOKENS, options.contextWindow === undefined ? Infinity : options.contextWindow - (options.maxOutputTokens ?? 0));
191
337
  }
192
338
  function estimate(messages, options) {
193
339
  return estimateRequestTokens(messages, { system: options.system, tools: options.tools });
@@ -197,8 +343,8 @@ function estimate(messages, options) {
197
343
  * estimate of what came after it. Undefined when there is no count to go by:
198
344
  * the request is then estimated whole, which errs high.
199
345
  */
200
- function conversationTokens(history, carrier) {
201
- const step = lastStepTokens(history, carrier);
346
+ function conversationTokens(history, latest) {
347
+ const step = lastStepTokens(history, latest);
202
348
  if (step === undefined)
203
349
  return undefined;
204
350
  return step.tokens + sum(history.slice(step.index + 1).map(uiMessageTokens));
@@ -206,14 +352,16 @@ function conversationTokens(history, carrier) {
206
352
  /**
207
353
  * Input and output tokens of the conversation's last recorded step, when that
208
354
  * step was sent after the latest compaction -- otherwise it measured messages
209
- * the model no longer reads.
355
+ * the model no longer reads. The compaction's own message counts from the part
356
+ * that records it on.
210
357
  */
211
- function lastStepTokens(history, carrier) {
212
- for (let index = history.length - 1; index > carrier; index -= 1) {
358
+ function lastStepTokens(history, latest) {
359
+ for (let index = history.length - 1; index >= (latest?.carrier ?? 0); index -= 1) {
213
360
  const message = history[index];
214
361
  if (message?.role !== "assistant")
215
362
  continue;
216
- for (let i = message.parts.length - 1; i >= 0; i -= 1) {
363
+ const after = index === latest?.carrier ? latest.part : -1;
364
+ for (let i = message.parts.length - 1; i > after; i -= 1) {
217
365
  const part = message.parts[i];
218
366
  if (part?.type !== "data-step-metrics")
219
367
  continue;
@@ -246,7 +394,7 @@ function cutIndex(history, floor, keepRecent) {
246
394
  }
247
395
  return cut;
248
396
  }
249
- /** Skills read successfully in these messages, in the order first read. */
397
+ /** Skills read successfully in these stored messages, in the order first read. */
250
398
  function readSkills(messages) {
251
399
  const names = new Set();
252
400
  for (const message of messages) {
@@ -256,17 +404,33 @@ function readSkills(messages) {
256
404
  const isReadSkill = part.type === "tool-read_skill" || (part.type === "dynamic-tool" && part.toolName === "read_skill");
257
405
  if (!isReadSkill || part.state !== "output-available")
258
406
  continue;
259
- const skills = part.output?.skills;
260
- if (!Array.isArray(skills))
407
+ addSkills(names, part.output?.skills);
408
+ }
409
+ }
410
+ return names;
411
+ }
412
+ /** Skills read successfully in these model messages, in the order first read. */
413
+ function modelSkills(messages) {
414
+ const names = new Set();
415
+ for (const message of messages) {
416
+ if (message.role !== "tool")
417
+ continue;
418
+ for (const part of message.content) {
419
+ if (part.type !== "tool-result" || part.toolName !== "read_skill" || part.output.type !== "json")
261
420
  continue;
262
- for (const skill of skills) {
263
- if (skill?.ok === true && typeof skill.name === "string")
264
- names.add(skill.name);
265
- }
421
+ addSkills(names, part.output.value?.skills);
266
422
  }
267
423
  }
268
424
  return names;
269
425
  }
426
+ function addSkills(names, skills) {
427
+ if (!Array.isArray(skills))
428
+ return;
429
+ for (const skill of skills) {
430
+ if (skill?.ok === true && typeof skill.name === "string")
431
+ names.add(skill.name);
432
+ }
433
+ }
270
434
  /** A stored message's size, roughly: its JSON, with images at their model cost and data URLs left out. */
271
435
  function uiMessageTokens(message) {
272
436
  let images = 0;
@@ -0,0 +1,23 @@
1
+ import type { ModelMessage } from "ai";
2
+ /** Under prose's ~3.5-4, so the estimate errs high. */
3
+ export declare const CHARS_PER_TOKEN = 3;
4
+ /** About what one image at the providers' 1568 px ceiling costs. */
5
+ export declare const IMAGE_TOKENS = 1600;
6
+ export declare const MESSAGE_OVERHEAD_TOKENS = 4;
7
+ /** Per tool: its name, description and input schema around the counted text. */
8
+ export declare const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
9
+ /**
10
+ * The model's context window, or undefined when it is not known.
11
+ *
12
+ * Listed models first, then model families whose window is certain. Anything
13
+ * else -- a third-party model routed through OpenRouter -- is undefined:
14
+ * compaction then goes by its own threshold alone.
15
+ */
16
+ export declare function contextWindowFor(modelId: string): number | undefined;
17
+ /** The estimate for a list of messages. */
18
+ export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
19
+ /** The estimate for a whole request: its messages, system prompt and tool definitions. */
20
+ export declare function estimateRequestTokens(messages: ModelMessage[], options: {
21
+ system?: string;
22
+ tools?: Record<string, unknown>;
23
+ }): number;
@@ -0,0 +1,141 @@
1
+ // A model's context window, and how much of it a request uses.
2
+ //
3
+ // Spark never cuts a request down to fit: compaction (./compaction.ts) is the one
4
+ // thing that takes earlier messages out of what the model reads, and it decides
5
+ // when with these numbers. A request that is still over the window after it goes
6
+ // out as it is, and the provider's own error says so.
7
+ //
8
+ // ESTIMATES, NOT COUNTS. Counting needs the provider's tokenizer or a request.
9
+ // The estimate is deliberately high (CHARS_PER_TOKEN is under the ~3.5-4 of
10
+ // prose), so dense JSON that tokenizes worse than prose is not underestimated.
11
+ // Images count by number, never by their bytes. Where the provider has counted a
12
+ // step already, compaction uses its count instead.
13
+ import { SPARK_MODELS } from "./models.js";
14
+ /** Under prose's ~3.5-4, so the estimate errs high. */
15
+ export const CHARS_PER_TOKEN = 3;
16
+ /** About what one image at the providers' 1568 px ceiling costs. */
17
+ export const IMAGE_TOKENS = 1_600;
18
+ export const MESSAGE_OVERHEAD_TOKENS = 4;
19
+ /** Per tool: its name, description and input schema around the counted text. */
20
+ export const TOOL_DEFINITION_OVERHEAD_TOKENS = 300;
21
+ /**
22
+ * The model's context window, or undefined when it is not known.
23
+ *
24
+ * Listed models first, then model families whose window is certain. Anything
25
+ * else -- a third-party model routed through OpenRouter -- is undefined:
26
+ * compaction then goes by its own threshold alone.
27
+ */
28
+ export function contextWindowFor(modelId) {
29
+ const listed = SPARK_MODELS.find(({ id }) => id === modelId)?.contextWindow;
30
+ if (listed !== undefined)
31
+ return listed;
32
+ if (/^claude-haiku-/.test(modelId))
33
+ return 200_000;
34
+ if (/^claude-/.test(modelId))
35
+ return 1_000_000;
36
+ if (/^gpt-6-/.test(modelId))
37
+ return 1_050_000;
38
+ if (/^gemini-/.test(modelId))
39
+ return 1_048_576;
40
+ return undefined;
41
+ }
42
+ /** The estimate for a list of messages. */
43
+ export function estimateMessagesTokens(messages) {
44
+ return sum(messages.map(messageTokens));
45
+ }
46
+ /** The estimate for a whole request: its messages, system prompt and tool definitions. */
47
+ export function estimateRequestTokens(messages, options) {
48
+ return estimateMessagesTokens(messages) + textTokens(options.system ?? "") + toolsTokens(options.tools);
49
+ }
50
+ const estimates = new WeakMap();
51
+ function messageTokens(message) {
52
+ const known = estimates.get(message);
53
+ if (known !== undefined)
54
+ return known;
55
+ const content = message.content;
56
+ const size = MESSAGE_OVERHEAD_TOKENS +
57
+ (typeof content === "string"
58
+ ? textTokens(content)
59
+ : Array.isArray(content)
60
+ ? sum(content.map(partTokens))
61
+ : 0);
62
+ estimates.set(message, size);
63
+ return size;
64
+ }
65
+ function partTokens(part) {
66
+ if (isImage(part))
67
+ return IMAGE_TOKENS;
68
+ switch (part.type) {
69
+ case "text":
70
+ case "reasoning":
71
+ return textTokens(String(part.text ?? ""));
72
+ case "file":
73
+ return dataTokens(part.data);
74
+ case "tool-call":
75
+ return textTokens(String(part.toolName ?? "")) + textTokens(json(part.input));
76
+ case "tool-result":
77
+ return isToolResult(part) ? outputTokens(part.output) : 0;
78
+ default:
79
+ return textTokens(json(part));
80
+ }
81
+ }
82
+ function outputTokens(output) {
83
+ switch (output.type) {
84
+ case "text":
85
+ case "error-text":
86
+ return textTokens(String(output.value ?? ""));
87
+ case "content":
88
+ return Array.isArray(output.value)
89
+ ? sum(output.value.map((item) => isImage(item) ? IMAGE_TOKENS : item.type === "text" ? textTokens(String(item.text ?? "")) : dataTokens(item.data)))
90
+ : 0;
91
+ default:
92
+ return textTokens(json(output.value));
93
+ }
94
+ }
95
+ function textTokens(text) {
96
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
97
+ }
98
+ function dataTokens(data) {
99
+ if (typeof data === "string")
100
+ return textTokens(data);
101
+ if (data instanceof Uint8Array || data instanceof ArrayBuffer)
102
+ return Math.ceil(data.byteLength / CHARS_PER_TOKEN);
103
+ return 0;
104
+ }
105
+ function toolsTokens(tools) {
106
+ if (!tools)
107
+ return 0;
108
+ return sum(Object.entries(tools).map(([name, tool]) => {
109
+ const description = tool?.description;
110
+ return (TOOL_DEFINITION_OVERHEAD_TOKENS + textTokens(name) + textTokens(typeof description === "string" ? description : ""));
111
+ }));
112
+ }
113
+ /** JSON text, with binary left out: its size is not text the model reads. */
114
+ function json(value) {
115
+ try {
116
+ return (JSON.stringify(value, (_key, v) => (v instanceof Uint8Array || v instanceof ArrayBuffer ? "" : v)) ?? "");
117
+ }
118
+ catch {
119
+ return "";
120
+ }
121
+ }
122
+ function isImage(part) {
123
+ if (part.type === "image" || part.type === "image-data" || part.type === "image-url")
124
+ return true;
125
+ const mediaType = part.mediaType;
126
+ return ((part.type === "file" || part.type === "media" || part.type === "file-data" || part.type === "file-url") &&
127
+ typeof mediaType === "string" &&
128
+ mediaType.startsWith("image/"));
129
+ }
130
+ function isToolResult(part) {
131
+ return (part.type === "tool-result" &&
132
+ typeof part.output === "object" &&
133
+ part.output !== null &&
134
+ typeof part.output.type === "string");
135
+ }
136
+ function sum(values) {
137
+ let total = 0;
138
+ for (const value of values)
139
+ total += value;
140
+ return total;
141
+ }
@@ -1 +1 @@
1
- export declare const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
1
+ export declare const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"transform\": null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\": null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\"?: undefined | string;\n\"transform\"?: undefined | null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\"?: undefined | null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/generate-skill-declarations.mjs.
2
2
  // prettier-ignore
3
- export const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
3
+ export const WOMP_GEOMETRY_DECLARATIONS = "declare const womp: { geometry: {\n\"read\": ((objectId: number) => Promise<{\n\"kind\": \"container\" | \"graph\";\n\"objectId\": number;\n\"title\": string;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\": null | string;\n\"transform\": null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\": null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"instances\": Array<{\n\"instanceId\": number;\n\"objectIds\": Array<number>;\n\"params\": {\n[key: string]: number | {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n}>;\n\"diagnostics\": Array<string>;\n}>);\n\"write\": ((options: {\n\"objectId\": number;\n\"source\": string;\n}) => Promise<{\n\"status\": \"applied\" | \"rejected\";\n\"objectId\": number;\n\"graphId\"?: undefined | number;\n\"instanceId\"?: undefined | number;\n\"source\"?: undefined | string;\n\"transform\"?: undefined | null | {\n\"position\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"rotation\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"size\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"bounds\"?: undefined | null | {\n\"min\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n\"max\": {\n\"x\": number;\n\"y\": number;\n\"z\": number;\n};\n};\n\"affectedObjectIds\": Array<number>;\n\"diagnostics\": Array<string>;\n}>);\n} };\ndeclare function print(...values: unknown[]): void;\ndeclare function emitImage(ref: { id: string; mediaType: string; name?: string }, options?: { inspect?: boolean }): Promise<void>;\ndeclare function emitFile(ref: { id: string; mediaType: string; name?: string }): Promise<void>;\n";
@@ -31,6 +31,7 @@ export type GraphExt = {
31
31
  nodes: Record<string, {
32
32
  name?: string;
33
33
  position?: number[];
34
+ flowirSymbol?: string;
34
35
  [key: string]: unknown;
35
36
  }>;
36
37
  [key: string]: unknown;