@womp/spark-agent 6.3.0 → 6.4.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ Notable changes to `@womp/spark-agent`, newest first. Versions follow
4
4
  [Semantic Versioning](https://semver.org/). Add an entry with each version bump; describe what
5
5
  changed for hosts and users, and how to migrate across breaking changes.
6
6
 
7
+ ## 6.4.0 — 2026-10-12
8
+
9
+ ### Added
10
+
11
+ - Conversation compaction. Once a conversation reaches 256k tokens (by the provider's count for its
12
+ last step, or an estimate), the run's own model summarizes everything before the most recent
13
+ 16k tokens of whole turns, before the turn's first step. Later requests send the summary, the
14
+ current text of the skills read in the summarized messages, and the newest four images the user
15
+ attached to them (`COMPACTION_USER_IMAGES`), in their place. The summary is
16
+ recorded as a `data-compaction` part (`SparkCompaction`) on the turn's assistant message, so
17
+ hosts store it with the message; the chat still has every message. A summary that fails leaves
18
+ the turn uncompacted, and context trimming still applies.
19
+ - `SparkAgentOptions.history`: the stored conversation, converted and compacted by the run. Pass it
20
+ instead of `messages` to get compaction; `createSparkRunExecutor` now does.
21
+ - `convertSparkMessages` takes `{ compaction, skills }` and applies the latest compaction in the
22
+ messages it is given.
23
+ - `SparkAgentOptions.compaction` and `SparkRunExecutorOptions.compaction` (`SparkCompactionLimits`)
24
+ lower the threshold and the recent tail kept, for a host that exercises compaction without a
25
+ production-sized conversation, such as local development. Absent, the defaults apply.
26
+
27
+ ### Changed
28
+
29
+ - Goal evaluation attaches the newest `GOAL_REFERENCE_IMAGES` images the user attached to the
30
+ conversation, however far back, as the reference, ahead of the inspection screenshots. It reads
31
+ them only from `data:` URLs, so a host that stores them elsewhere passes `messages` with those
32
+ images inline; others are listed as unavailable.
33
+ - When a conversation is over its context window, trimming omits the agent's older screenshots
34
+ before the older images the user attached.
35
+
36
+ ### Migration
37
+
38
+ - Hosts that validate stored messages with `sparkDataPartSchemas` must take this version before any
39
+ host writing compactions reads the same conversations, or the new part fails validation.
40
+
7
41
  ## 6.3.0 — 2026-10-09
8
42
 
9
43
  ### Added
package/README.md CHANGED
@@ -35,8 +35,8 @@ Kakapo, Express, or React at runtime.
35
35
 
36
36
  ## Running in a host application
37
37
 
38
- `runSparkAgent` accepts a configured model, model messages, an executor, a capability composition,
39
- and an artifact resolver. It exposes exactly `read_skill` and `execute_javascript` to the model.
38
+ `runSparkAgent` accepts a configured model, the stored conversation (`history`, compacted once it
39
+ is long) or model messages, an executor, a capability composition, and an artifact resolver. It exposes exactly `read_skill` and `execute_javascript` to the model.
40
40
  Skills carry instructions, declarations, and JavaScript examples. Kakapo skill declarations are
41
41
  generated from its typed API at build time and packaged as JavaScript strings.
42
42
 
package/dist/agent.d.ts CHANGED
@@ -3,7 +3,8 @@ import { type SparkPersistence } from "./agent-tools.js";
3
3
  import type { SparkArtifactResolver, SparkCapabilities } from "./capabilities.js";
4
4
  import type { JavaScriptExecutor } from "./executor.js";
5
5
  import { type ContextBudgetReport } from "./context-budget.js";
6
- import type { SparkUIMessageChunk } from "./ui.js";
6
+ import { type SparkCompactionLimits } from "./compaction.js";
7
+ import type { SparkUIMessage, SparkUIMessageChunk } from "./ui.js";
7
8
  export type SparkAgentResult = {
8
9
  text: string;
9
10
  steps: number;
@@ -12,7 +13,14 @@ export type SparkAgentResult = {
12
13
  finishReason: string;
13
14
  };
14
15
  export type SparkAgentOptions = {
15
- messages: ModelMessage[];
16
+ /** The conversation as model messages, sent as given. Pass `history` instead to have long conversations compacted. */
17
+ messages?: ModelMessage[];
18
+ /**
19
+ * The stored conversation. It is converted with `convertSparkMessages`, and
20
+ * compacted first once it is long enough (./compaction.ts); the new
21
+ * compaction is written to this run's message as a `data-compaction` part.
22
+ */
23
+ history?: SparkUIMessage[];
16
24
  model: LanguageModel;
17
25
  modelId: string;
18
26
  providerOptions?: Parameters<typeof streamText>[0]["providerOptions"];
@@ -30,6 +38,8 @@ export type SparkAgentOptions = {
30
38
  prepareMessages?(messages: ModelMessage[]): Promise<ModelMessage[]>;
31
39
  /** The model's context window. Absent: `contextWindowFor(modelId)`. */
32
40
  contextWindow?: number;
41
+ /** When `history` is compacted. Absent: the defaults in ./compaction.ts. */
42
+ compaction?: SparkCompactionLimits;
33
43
  /** Called when a step's messages had to be trimmed to fit, or still do not fit. */
34
44
  onContextBudget?(report: ContextBudgetReport): void;
35
45
  };
package/dist/agent.js CHANGED
@@ -5,6 +5,8 @@ import { createSparkTools } from "./agent-tools.js";
5
5
  import { sparkSystemPrompt } from "./prompt.js";
6
6
  import { calculateStepCost } from "./pricing.js";
7
7
  import { contextWindowFor, fitMessagesToContext } from "./context-budget.js";
8
+ import { compactSparkHistory } from "./compaction.js";
9
+ import { convertSparkMessages } from "./model-history.js";
8
10
  function errorMessage(error) {
9
11
  return sparkErrorDiagnostics(error).message;
10
12
  }
@@ -37,6 +39,9 @@ export function runSparkAgent(options) {
37
39
  if (Number.isInteger(maxSteps) === false || maxSteps < 1) {
38
40
  throw new Error("maxSteps must be a positive integer.");
39
41
  }
42
+ if ((options.messages === undefined) === (options.history === undefined)) {
43
+ throw new Error("Pass either messages or history.");
44
+ }
40
45
  const startedAt = Date.now();
41
46
  const controller = new AbortController();
42
47
  const onAbort = () => controller.abort(options.signal?.reason);
@@ -125,10 +130,36 @@ export function runSparkAgent(options) {
125
130
  write: (chunk) => writer.write(chunk),
126
131
  });
127
132
  const readSystem = () => sparkSystemPrompt(options.capabilities.skills, options.instructions);
133
+ let messages = options.messages ?? [];
134
+ if (options.history !== undefined) {
135
+ const history = options.history;
136
+ const compacted = await compactSparkHistory({
137
+ history,
138
+ convert: (compaction) => convertSparkMessages(history, tools, { compaction, skills: options.capabilities.skills }),
139
+ model: options.model,
140
+ modelId: options.modelId,
141
+ system: readSystem(),
142
+ tools,
143
+ providerOptions: options.providerOptions,
144
+ maxOutputTokens: options.maxOutputTokens,
145
+ contextWindow,
146
+ signal: controller.signal,
147
+ limits: options.compaction,
148
+ onCompacting: () => writer.write({
149
+ type: "data-run-status",
150
+ data: { runId: options.runId, status: "running", label: "Making room so we can keep talking" },
151
+ }),
152
+ });
153
+ messages = compacted.messages;
154
+ if (compacted.compaction !== undefined) {
155
+ writer.write({ type: "data-compaction", id: `compaction-${options.messageId}`, data: compacted.compaction });
156
+ }
157
+ controller.signal.throwIfAborted();
158
+ }
128
159
  const generation = streamText({
129
160
  model: options.model,
130
161
  system: readSystem(),
131
- messages: options.messages,
162
+ messages,
132
163
  tools,
133
164
  toolChoice: "auto",
134
165
  abortSignal: controller.signal,
@@ -0,0 +1,64 @@
1
+ import { generateText, type LanguageModel, type ModelMessage, type ToolSet } from "ai";
2
+ import type { SparkSkill } from "./capabilities.js";
3
+ import { type SparkCompaction, type SparkUIMessage } from "./ui.js";
4
+ /** Compact once the conversation reaches this many tokens. */
5
+ export declare const COMPACTION_THRESHOLD_TOKENS = 256000;
6
+ /** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
7
+ export declare const COMPACTION_KEEP_RECENT_TOKENS = 16000;
8
+ /** The user's newest images from the summarized messages that the summary message carries. */
9
+ export declare const COMPACTION_USER_IMAGES = 4;
10
+ /**
11
+ * When a conversation is compacted, for a host that exercises compaction
12
+ * without filling a production-sized conversation, such as local development.
13
+ */
14
+ export type SparkCompactionLimits = {
15
+ /** Default COMPACTION_THRESHOLD_TOKENS. */
16
+ thresholdTokens?: number;
17
+ /** Default COMPACTION_KEEP_RECENT_TOKENS. */
18
+ keepRecentTokens?: number;
19
+ };
20
+ export declare 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.\nIf the conversation begins with an earlier summary, carry its content forward and update it.\nUse these sections:\n## Goal\n## User preferences and constraints\n## Decisions and current scene state (objects with their names and IDs)\n## Done\n## Open and next steps\n## Artifacts and images (each artifact ID, and what each image the user attached shows)\n## Errors and approaches to avoid\nKeep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.";
21
+ export declare 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.]";
22
+ /** The latest compaction in the conversation whose kept message is still in it. */
23
+ export declare function latestCompaction(history: SparkUIMessage[]): {
24
+ compaction: SparkCompaction;
25
+ carrier: number;
26
+ firstKept: number;
27
+ } | undefined;
28
+ /**
29
+ * The conversation as the model reads it: the messages before the compaction's
30
+ * kept message replaced by its summary, with the newest COMPACTION_USER_IMAGES
31
+ * images the user attached to them. `compaction` defaults to the latest one in `history`; `skills` are the
32
+ * host's, to send the current text of the skills it names. Without a
33
+ * compaction, `history` is returned as it is.
34
+ */
35
+ export declare function applyCompaction(history: SparkUIMessage[], options?: {
36
+ compaction?: SparkCompaction;
37
+ skills?: readonly SparkSkill[];
38
+ }): SparkUIMessage[];
39
+ export type CompactSparkHistoryOptions = {
40
+ history: SparkUIMessage[];
41
+ /** `history` as model messages, with `compaction` applied when given. */
42
+ convert(compaction?: SparkCompaction): Promise<ModelMessage[]>;
43
+ model: LanguageModel;
44
+ modelId: string;
45
+ system: string;
46
+ /** The run's tools; the summary request sends their definitions without `execute`. */
47
+ tools: ToolSet;
48
+ providerOptions?: Parameters<typeof generateText>[0]["providerOptions"];
49
+ maxOutputTokens?: number;
50
+ contextWindow?: number;
51
+ signal?: AbortSignal;
52
+ /** Called just before the summary request, which can take a while. */
53
+ onCompacting?(): void;
54
+ limits?: SparkCompactionLimits;
55
+ };
56
+ /**
57
+ * The turn's model messages, compacted first when the conversation has reached
58
+ * the threshold. `compaction` is the new one, for the host to record; it is
59
+ * absent when nothing was compacted, including when the summary failed.
60
+ */
61
+ export declare function compactSparkHistory(options: CompactSparkHistoryOptions): Promise<{
62
+ messages: ModelMessage[];
63
+ compaction?: SparkCompaction;
64
+ }>;
@@ -0,0 +1,296 @@
1
+ // Replace a long conversation's earlier messages with a summary.
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.
9
+ //
10
+ // WHERE IT IS KEPT. The summary is a `data-compaction` part on the assistant
11
+ // message of the turn that wrote it (SparkCompaction, ./ui.ts), so every host
12
+ // stores and reloads it with the message, and the chat still shows every
13
+ // 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.
17
+ //
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
21
+ // without `execute`: a call the model makes anyway is never run, and fails the
22
+ // summary instead.
23
+ //
24
+ // SKILLS. A skill read in the summarized messages would otherwise be lost with
25
+ // them. The compaction names them, and its summary message carries their
26
+ // current text, ahead of the summary, so the model does not have to read them again.
27
+ //
28
+ // IMAGES. The images the user attached to the summarized messages are what the
29
+ // work is often built from, so the summary message carries the newest
30
+ // COMPACTION_USER_IMAGES of them too, as the same file parts; the summary
31
+ // describes the rest. They are still on the stored messages: nothing records them.
32
+ import { generateText } from "ai";
33
+ import { CHARS_PER_TOKEN, IMAGE_TOKENS, estimateRequestTokens, fitMessagesToContext, } from "./context-budget.js";
34
+ import { sparkDataPartSchemas } from "./ui.js";
35
+ import { userImages } from "./user-images.js";
36
+ /** Compact once the conversation reaches this many tokens. */
37
+ export const COMPACTION_THRESHOLD_TOKENS = 256_000;
38
+ /** Recent whole turns kept verbatim, up to this many tokens, besides the current turn. */
39
+ export const COMPACTION_KEEP_RECENT_TOKENS = 16_000;
40
+ /** The user's newest images from the summarized messages that the summary message carries. */
41
+ export const COMPACTION_USER_IMAGES = 4;
42
+ 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
+ If the conversation begins with an earlier summary, carry its content forward and update it.
44
+ Use these sections:
45
+ ## Goal
46
+ ## User preferences and constraints
47
+ ## Decisions and current scene state (objects with their names and IDs)
48
+ ## Done
49
+ ## Open and next steps
50
+ ## Artifacts and images (each artifact ID, and what each image the user attached shows)
51
+ ## Errors and approaches to avoid
52
+ Keep exact names, IDs, numbers and the user's own wording for requirements. Stay under 1,500 words.`;
53
+ 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.]";
54
+ /** The latest compaction in the conversation whose kept message is still in it. */
55
+ export function latestCompaction(history) {
56
+ for (let carrier = history.length - 1; carrier >= 0; carrier -= 1) {
57
+ const message = history[carrier];
58
+ if (message?.role !== "assistant")
59
+ 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")
63
+ continue;
64
+ const parsed = sparkDataPartSchemas.compaction.safeParse(part.data);
65
+ if (!parsed.success)
66
+ continue;
67
+ const firstKept = history.findIndex(({ id }) => id === parsed.data.firstKeptMessageId);
68
+ if (firstKept >= 0)
69
+ return { compaction: parsed.data, carrier, firstKept };
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ /**
75
+ * The conversation as the model reads it: the messages before the compaction's
76
+ * 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.
80
+ */
81
+ export function applyCompaction(history, options = {}) {
82
+ const compaction = options.compaction ?? latestCompaction(history)?.compaction;
83
+ if (compaction === undefined)
84
+ return history;
85
+ const firstKept = history.findIndex(({ id }) => id === compaction.firstKeptMessageId);
86
+ if (firstKept < 0)
87
+ return history;
88
+ // The skills go before the summary, in a part of their own: they change less
89
+ // 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 }];
92
+ if (skills !== undefined)
93
+ parts.push({ type: "text", text: skills });
94
+ const shown = new Set(userImages(history.slice(firstKept)).map(({ url }) => url));
95
+ const images = userImages(history.slice(0, firstKept))
96
+ .filter(({ url }) => !shown.has(url))
97
+ .slice(-COMPACTION_USER_IMAGES);
98
+ if (images.length > 0) {
99
+ parts.push({ type: "text", text: "The newest images the user attached to the messages the summary replaces, in the order sent:" }, ...images);
100
+ }
101
+ 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)];
103
+ }
104
+ function skillsText(names, available) {
105
+ if (names.length === 0)
106
+ return undefined;
107
+ if (available === undefined) {
108
+ return `Skills read earlier in this conversation (read them again before using their APIs): ${names.join(", ")}`;
109
+ }
110
+ const skills = names.flatMap((name) => available.filter((skill) => skill.name === name));
111
+ if (skills.length === 0)
112
+ return undefined;
113
+ return [
114
+ "Skills already read in this conversation, as read_skill returns them now; they need no reading again:",
115
+ ...skills.map((skill) => `<skill name="${skill.name}" version="${skill.version}">\n${skill.instructions}\n\n${skill.declarations}${skill.examples.length > 0 ? `\n\nExamples:\n${skill.examples.join("\n\n")}` : ""}\n</skill>`),
116
+ ].join("\n\n");
117
+ }
118
+ /**
119
+ * The turn's model messages, compacted first when the conversation has reached
120
+ * the threshold. `compaction` is the new one, for the host to record; it is
121
+ * absent when nothing was compacted, including when the summary failed.
122
+ */
123
+ export async function compactSparkHistory(options) {
124
+ const { history } = options;
125
+ 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
+ const previous = latestCompaction(history);
128
+ const size = conversationTokens(history, previous?.carrier ?? -1) ?? estimate(messages, options);
129
+ if (size < threshold)
130
+ return { messages };
131
+ const firstKept = cutIndex(history, previous?.firstKept ?? 0, options.limits?.keepRecentTokens ?? COMPACTION_KEEP_RECENT_TOKENS);
132
+ if (firstKept === undefined)
133
+ 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)
138
+ 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();
149
+ if (summary === undefined)
150
+ return { messages };
151
+ const kept = readSkills(history.slice(firstKept));
152
+ const skills = [
153
+ ...new Set([...(previous?.compaction.skills ?? []), ...readSkills(history.slice(previous?.firstKept ?? 0, firstKept))]),
154
+ ].filter((name) => !kept.has(name));
155
+ const compaction = {
156
+ firstKeptMessageId: history[firstKept].id,
157
+ summary,
158
+ skills,
159
+ modelId: options.modelId,
160
+ tokensBefore: estimate(messages, options),
161
+ tokensAfter: 0,
162
+ createdAt: new Date().toISOString(),
163
+ };
164
+ const compacted = await options.convert(compaction);
165
+ compaction.tokensAfter = estimate(compacted, options);
166
+ return { messages: compacted, compaction };
167
+ }
168
+ async function summarize(messages, options) {
169
+ const definitions = Object.fromEntries(Object.entries(options.tools).map(([name, tool]) => {
170
+ const { execute: _execute, onInputStart: _start, onInputDelta: _delta, onInputAvailable: _available, ...definition } = tool;
171
+ return [name, definition];
172
+ }));
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;
191
+ }
192
+ function estimate(messages, options) {
193
+ return estimateRequestTokens(messages, { system: options.system, tools: options.tools });
194
+ }
195
+ /**
196
+ * The conversation's size from the provider's count for its last step, plus an
197
+ * estimate of what came after it. Undefined when there is no count to go by:
198
+ * the request is then estimated whole, which errs high.
199
+ */
200
+ function conversationTokens(history, carrier) {
201
+ const step = lastStepTokens(history, carrier);
202
+ if (step === undefined)
203
+ return undefined;
204
+ return step.tokens + sum(history.slice(step.index + 1).map(uiMessageTokens));
205
+ }
206
+ /**
207
+ * Input and output tokens of the conversation's last recorded step, when that
208
+ * step was sent after the latest compaction -- otherwise it measured messages
209
+ * the model no longer reads.
210
+ */
211
+ function lastStepTokens(history, carrier) {
212
+ for (let index = history.length - 1; index > carrier; index -= 1) {
213
+ const message = history[index];
214
+ if (message?.role !== "assistant")
215
+ continue;
216
+ for (let i = message.parts.length - 1; i >= 0; i -= 1) {
217
+ const part = message.parts[i];
218
+ if (part?.type !== "data-step-metrics")
219
+ continue;
220
+ const usage = part.data.usage;
221
+ if (typeof usage?.inputTokens !== "number")
222
+ continue;
223
+ return { index, tokens: usage.inputTokens + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0) };
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+ /**
229
+ * The first message to keep: the earliest user message after `floor` such that
230
+ * it and everything up to the current user message fit `keepRecent` tokens.
231
+ * The current user message when no earlier turn fits. Undefined when nothing
232
+ * after `floor` would be summarized.
233
+ */
234
+ function cutIndex(history, floor, keepRecent) {
235
+ const current = lastIndex(history, ({ role }) => role === "user");
236
+ if (current <= floor)
237
+ return undefined;
238
+ let cut = current;
239
+ let tokens = 0;
240
+ for (let i = current - 1; i > floor; i -= 1) {
241
+ tokens += uiMessageTokens(history[i]);
242
+ if (tokens > keepRecent)
243
+ break;
244
+ if (history[i].role === "user")
245
+ cut = i;
246
+ }
247
+ return cut;
248
+ }
249
+ /** Skills read successfully in these messages, in the order first read. */
250
+ function readSkills(messages) {
251
+ const names = new Set();
252
+ for (const message of messages) {
253
+ if (message.role !== "assistant")
254
+ continue;
255
+ for (const part of message.parts) {
256
+ const isReadSkill = part.type === "tool-read_skill" || (part.type === "dynamic-tool" && part.toolName === "read_skill");
257
+ if (!isReadSkill || part.state !== "output-available")
258
+ continue;
259
+ const skills = part.output?.skills;
260
+ if (!Array.isArray(skills))
261
+ continue;
262
+ for (const skill of skills) {
263
+ if (skill?.ok === true && typeof skill.name === "string")
264
+ names.add(skill.name);
265
+ }
266
+ }
267
+ }
268
+ return names;
269
+ }
270
+ /** A stored message's size, roughly: its JSON, with images at their model cost and data URLs left out. */
271
+ function uiMessageTokens(message) {
272
+ let images = 0;
273
+ const json = JSON.stringify(message.parts, (key, value) => {
274
+ if (typeof value === "string" && key === "url" && value.startsWith("data:"))
275
+ return "";
276
+ if (typeof value === "object" && value !== null) {
277
+ const item = value;
278
+ if (item.type === "file" && typeof item.mediaType === "string" && item.mediaType.startsWith("image/"))
279
+ images += 1;
280
+ if (item.kind === "image" && item.inspect !== false)
281
+ images += 1;
282
+ }
283
+ return value;
284
+ });
285
+ return Math.ceil((json?.length ?? 0) / CHARS_PER_TOKEN) + images * IMAGE_TOKENS;
286
+ }
287
+ function sum(values) {
288
+ return values.reduce((total, value) => total + value, 0);
289
+ }
290
+ function lastIndex(items, test) {
291
+ for (let i = items.length - 1; i >= 0; i -= 1) {
292
+ if (test(items[i]))
293
+ return i;
294
+ }
295
+ return -1;
296
+ }
@@ -61,3 +61,5 @@ export type ContextBudgetResult = {
61
61
  export declare function fitMessagesToContext(messages: ModelMessage[], options: ContextBudgetOptions): ContextBudgetResult;
62
62
  /** The estimate for a list of messages, as `fitMessagesToContext` counts them. */
63
63
  export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
64
+ /** The estimate for a whole request: its messages, system prompt and tool definitions. */
65
+ export declare function estimateRequestTokens(messages: ModelMessage[], options: Pick<ContextBudgetOptions, "system" | "tools">): number;
@@ -14,7 +14,9 @@
14
14
  // WHAT, in this order, until the estimate is back under the target:
15
15
  //
16
16
  // 1. Images before the current turn, oldest first -- the most tokens for the
17
- // least text, and the ones a later step least needs to see again.
17
+ // least text, and the ones a later step least needs to see again. The
18
+ // agent's own screenshots go before the images the user attached, which
19
+ // are what the work is often built from.
18
20
  // 2. Large tool results before the current turn, oldest first. The call and a
19
21
  // result part stay, with a note in place of the output, so no provider is
20
22
  // handed a call without its result.
@@ -155,12 +157,12 @@ export function fitMessagesToContext(messages, options) {
155
157
  * replacement and roughly what that saves -- used only to stop partway through a
156
158
  * message, which is then measured again.
157
159
  */
158
- const rewriteEarlier = (rewrite) => {
160
+ const rewriteEarlier = (rewrite, only = () => true) => {
159
161
  for (let i = 0; i < protectedFrom && total > target; i += 1) {
160
162
  if (i === current)
161
163
  continue;
162
164
  const message = out[i];
163
- if (message === undefined || !Array.isArray(message.content))
165
+ if (message === undefined || !Array.isArray(message.content) || !only(message))
164
166
  continue;
165
167
  let projected = total;
166
168
  let changed = false;
@@ -183,8 +185,8 @@ export function fitMessagesToContext(messages, options) {
183
185
  out[i] = next;
184
186
  }
185
187
  };
186
- // 1. Earlier images.
187
- rewriteEarlier((part, projected) => {
188
+ // 1. Earlier images: the agent's own first, then those the user attached.
189
+ const omitImages = (part, projected) => {
188
190
  if (isImage(part)) {
189
191
  report.omittedImages += 1;
190
192
  return { part: omittedImage(), saved: IMAGE_TOKENS };
@@ -201,7 +203,9 @@ export function fitMessagesToContext(messages, options) {
201
203
  return omittedImage();
202
204
  });
203
205
  return saved === 0 ? { part, saved } : { part: { ...part, output: { ...part.output, value } }, saved };
204
- });
206
+ };
207
+ rewriteEarlier(omitImages, ({ role }) => role !== "user");
208
+ rewriteEarlier(omitImages, ({ role }) => role === "user");
205
209
  // 2. Earlier large tool results.
206
210
  rewriteEarlier((part) => {
207
211
  if (!isToolResult(part))
@@ -249,6 +253,10 @@ export function fitMessagesToContext(messages, options) {
249
253
  export function estimateMessagesTokens(messages) {
250
254
  return sum(messages.map(messageTokens));
251
255
  }
256
+ /** The estimate for a whole request: its messages, system prompt and tool definitions. */
257
+ export function estimateRequestTokens(messages, options) {
258
+ return estimateMessagesTokens(messages) + textTokens(options.system ?? "") + toolsTokens(options.tools);
259
+ }
252
260
  // ── estimation ──────────────────────────────────────────────────────────────
253
261
  const estimates = new WeakMap();
254
262
  function messageTokens(message) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { runSparkAgent, type SparkAgentOptions, type SparkAgentResult, type SparkAgentRun, } from "./agent.js";
2
2
  export { createSparkTools, type SparkPersistence, type SparkToolCall } from "./agent-tools.js";
3
3
  export { convertSparkMessages } from "./model-history.js";
4
+ export type { SparkCompactionLimits } from "./compaction.js";
4
5
  export * from "./context-budget.js";
5
6
  export * from "./capabilities.js";
6
7
  export { createSdkSceneEditor, createSdkSceneHost, type SdkSceneEditor, type SdkSceneEditorOptions, type SdkSceneEditIdentity, type SdkSceneServices, } from "./sdk-scene-host.js";
@@ -1,5 +1,15 @@
1
1
  import { type ModelMessage } from "ai";
2
2
  import type { createSparkTools } from "./agent-tools.js";
3
- import { type SparkUIMessage } from "./ui.js";
4
- /** UI artifact previews already represented by a tool result are not model output. */
5
- export declare function convertSparkMessages(messages: SparkUIMessage[], tools: ReturnType<typeof createSparkTools>): Promise<ModelMessage[]>;
3
+ import type { SparkSkill } from "./capabilities.js";
4
+ import { type SparkCompaction, type SparkUIMessage } from "./ui.js";
5
+ /**
6
+ * The stored conversation as model messages. UI artifact previews already
7
+ * represented by a tool result are not model output. The messages before the
8
+ * latest compaction's kept message -- or `options.compaction`'s -- are sent as
9
+ * its summary, with the current text of the skills it names from
10
+ * `options.skills` and the images the user attached to them (./compaction.ts).
11
+ */
12
+ export declare function convertSparkMessages(messages: SparkUIMessage[], tools: ReturnType<typeof createSparkTools>, options?: {
13
+ compaction?: SparkCompaction;
14
+ skills?: readonly SparkSkill[];
15
+ }): Promise<ModelMessage[]>;
@@ -1,4 +1,5 @@
1
1
  import { asSchema, convertToModelMessages } from "ai";
2
+ import { applyCompaction } from "./compaction.js";
2
3
  import { sparkDataPartSchemas } from "./ui.js";
3
4
  function record(value) {
4
5
  return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -32,9 +33,15 @@ function goalConditionsText({ conditions }) {
32
33
  + (reviewed ? `\nPreferences the user will review themselves:${reviewed}` : "")
33
34
  + "]";
34
35
  }
35
- /** UI artifact previews already represented by a tool result are not model output. */
36
- export async function convertSparkMessages(messages, tools) {
37
- const history = await Promise.all(messages.map(async (message) => {
36
+ /**
37
+ * The stored conversation as model messages. UI artifact previews already
38
+ * represented by a tool result are not model output. The messages before the
39
+ * latest compaction's kept message -- or `options.compaction`'s -- are sent as
40
+ * its summary, with the current text of the skills it names from
41
+ * `options.skills` and the images the user attached to them (./compaction.ts).
42
+ */
43
+ export async function convertSparkMessages(messages, tools, options = {}) {
44
+ const history = await Promise.all(applyCompaction(messages, options).map(async (message) => {
38
45
  if (message.role !== "assistant")
39
46
  return message;
40
47
  const previews = new Set();
@@ -8,6 +8,8 @@ export type ResolvedSparkModel = {
8
8
  maxOutputTokens?: number;
9
9
  };
10
10
  export type SparkRunExecutorOptions = {
11
+ /** When a run's conversation is compacted; see `SparkAgentOptions.compaction`. */
12
+ compaction?: SparkAgentOptions["compaction"];
11
13
  resolveModel(settings: SparkRunSettings): ResolvedSparkModel | Promise<ResolvedSparkModel>;
12
14
  createHost(context: SparkRunExecutionContext): Promise<Pick<SparkAgentOptions, "capabilities" | "executor" | "artifacts" | "persistence"> & {
13
15
  dispose(): void;
@@ -1,25 +1,17 @@
1
- import { convertSparkMessages } from "../model-history.js";
2
1
  import { runSparkAgent } from "../agent.js";
3
- import { createSparkTools } from "../agent-tools.js";
4
2
  export function createSparkRunExecutor(options) {
5
3
  return async (context) => {
6
4
  const resolved = await options.resolveModel(context.settings);
7
5
  const host = await options.createHost(context);
8
6
  try {
9
- const tools = createSparkTools({
10
- ...host,
11
- runId: context.runId,
12
- controller: new AbortController(),
13
- write: () => undefined,
14
- });
15
- const messages = await convertSparkMessages(context.uiMessages, tools);
16
7
  const run = runSparkAgent({
17
8
  ...host,
18
- messages,
9
+ history: context.uiMessages,
19
10
  model: resolved.model,
20
11
  modelId: resolved.modelId,
21
12
  providerOptions: resolved.providerOptions,
22
13
  maxOutputTokens: resolved.maxOutputTokens,
14
+ compaction: options.compaction,
23
15
  runId: context.runId,
24
16
  messageId: context.assistantMessageId,
25
17
  signal: context.signal,
@@ -2,6 +2,8 @@ import { z } from "zod";
2
2
  import type { SparkGoal } from "../goals.js";
3
3
  import type { SparkUIMessage } from "../ui.js";
4
4
  import type { SparkRunExecutorOptions } from "./executor.js";
5
+ /** How many of the user's newest images an evaluation attaches as the reference. */
6
+ export declare const GOAL_REFERENCE_IMAGES = 4;
5
7
  export declare const goalVerdictSchema: z.ZodObject<{
6
8
  conditions: z.ZodOptional<z.ZodArray<z.ZodObject<{
7
9
  index: z.ZodNumber;
@@ -19,6 +21,11 @@ export type SparkGoalEvaluation = z.infer<typeof goalVerdictSchema>;
19
21
  export type SparkGoalEvaluator = (input: {
20
22
  ownerId?: string;
21
23
  goal: SparkGoal;
24
+ /**
25
+ * The conversation. The images the user attached to it are the evaluation's
26
+ * reference, read only from `data:` URLs: a host that stores them elsewhere
27
+ * passes the messages with those images inline.
28
+ */
22
29
  messages: SparkUIMessage[];
23
30
  context?: Record<string, unknown>;
24
31
  signal: AbortSignal;
@@ -1,6 +1,14 @@
1
1
  import { generateText, Output } from "ai";
2
2
  import { z } from "zod";
3
+ import { userImages } from "../user-images.js";
3
4
  import { WOMPIFY_PRESETS, wompifyContextSchema } from "../wompify.js";
5
+ /** How many of the user's newest images an evaluation attaches as the reference. */
6
+ export const GOAL_REFERENCE_IMAGES = 4;
7
+ /** A `data:` URL's bytes; hosts hand the evaluator the user's images inline. */
8
+ function dataUrlBytes(url) {
9
+ const match = /^data:[^,]*;base64,(.*)$/s.exec(url);
10
+ return match ? new Uint8Array(Buffer.from(match[1], "base64")) : undefined;
11
+ }
4
12
  const verdictShape = {
5
13
  verdict: z.enum(["met", "continue", "blocked"]),
6
14
  reason: z.string().min(1).max(2_000),
@@ -36,6 +44,20 @@ export function createSparkGoalEvaluator(options) {
36
44
  const content = [];
37
45
  const unavailable = [];
38
46
  let bytes = 0;
47
+ // The images the user attached are the reference the reconstruction views
48
+ // are compared with, so they go first, ahead of the byte cap. A Wompify
49
+ // goal's reference is a scene object instead.
50
+ const references = wompify.success ? [] : userImages(messages).slice(-GOAL_REFERENCE_IMAGES);
51
+ for (const [index, image] of references.entries()) {
52
+ const name = image.filename ?? `reference ${index + 1}`;
53
+ const data = dataUrlBytes(image.url);
54
+ if (!data || bytes + data.byteLength > 8_000_000) {
55
+ unavailable.push(name);
56
+ continue;
57
+ }
58
+ bytes += data.byteLength;
59
+ content.push({ type: "text", text: `Reference image the user attached: ${name}` }, { type: "file", data, mediaType: image.mediaType });
60
+ }
39
61
  for (const id of selected) {
40
62
  signal.throwIfAborted();
41
63
  const image = ownerId ? await options.resolveImage?.(ownerId, id) : undefined;
@@ -1,6 +1,6 @@
1
1
  export { MemorySparkArtifactStore } from "./artifacts.js";
2
2
  export { WompifyCreditCapReachedError } from "../wompify.js";
3
- export { createSparkGoalEvaluator, type SparkGoalEvaluator, type SparkGoalEvaluation } from "./goal-evaluator.js";
3
+ export { createSparkGoalEvaluator, GOAL_REFERENCE_IMAGES, type SparkGoalEvaluator, type SparkGoalEvaluation } from "./goal-evaluator.js";
4
4
  export { createSparkGoalProposer, type SparkGoalProposer } from "./goal-proposer.js";
5
5
  export { sparkGoalCommandSchema, sparkGoalProposalSchema, type SparkGoal, type SparkGoalCommand, type SparkGoalCondition, type SparkGoalProposal, type SparkGoalTry } from "../goals.js";
6
6
  export type { SparkArtifactStore, StoredSparkArtifact } from "./artifacts.js";
@@ -1,6 +1,6 @@
1
1
  export { MemorySparkArtifactStore } from "./artifacts.js";
2
2
  export { WompifyCreditCapReachedError } from "../wompify.js";
3
- export { createSparkGoalEvaluator } from "./goal-evaluator.js";
3
+ export { createSparkGoalEvaluator, GOAL_REFERENCE_IMAGES } from "./goal-evaluator.js";
4
4
  export { createSparkGoalProposer } from "./goal-proposer.js";
5
5
  export { sparkGoalCommandSchema, sparkGoalProposalSchema } from "../goals.js";
6
6
  export { createSparkRunExecutor, } from "./executor.js";
package/dist/ui.d.ts CHANGED
@@ -91,12 +91,31 @@ export type SparkDataParts = {
91
91
  receipt: ExecutionReceipt;
92
92
  };
93
93
  selection: SparkSelectionData;
94
+ compaction: SparkCompaction;
94
95
  goal: SparkGoalProposal;
95
96
  };
96
97
  /** Editor node IDs selected when a user message was sent; never rendered as chat text. */
97
98
  export type SparkSelectionData = {
98
99
  nodeIds: string[];
99
100
  };
101
+ /**
102
+ * A summary of a long conversation's earlier messages, recorded on the assistant
103
+ * message of the turn that wrote it. The model is sent the summary in place of
104
+ * every message before `firstKeptMessageId`; the chat still shows all of them.
105
+ */
106
+ export type SparkCompaction = {
107
+ /** The user message the model reads verbatim from; everything before it is summarized. */
108
+ firstKeptMessageId: string;
109
+ summary: string;
110
+ /** Skills read in the summarized messages; their current text is sent after the summary. */
111
+ skills: string[];
112
+ /** The model that wrote the summary. */
113
+ modelId: string;
114
+ /** The conversation's size before and after, in tokens. */
115
+ tokensBefore: number;
116
+ tokensAfter: number;
117
+ createdAt: string;
118
+ };
100
119
  export type ExecuteJavaScriptInput = {
101
120
  purpose: string;
102
121
  code: string;
@@ -335,6 +354,15 @@ export declare const sparkDataPartSchemas: {
335
354
  readonly selection: z.ZodObject<{
336
355
  nodeIds: z.ZodArray<z.ZodString>;
337
356
  }, z.core.$strip>;
357
+ readonly compaction: z.ZodObject<{
358
+ firstKeptMessageId: z.ZodString;
359
+ summary: z.ZodString;
360
+ skills: z.ZodArray<z.ZodString>;
361
+ modelId: z.ZodString;
362
+ tokensBefore: z.ZodNumber;
363
+ tokensAfter: z.ZodNumber;
364
+ createdAt: z.ZodString;
365
+ }, z.core.$strip>;
338
366
  /**
339
367
  * The conditions the user confirmed for the goal this message starts. The model
340
368
  * reads it as text (model-history.ts).
package/dist/ui.js CHANGED
@@ -86,6 +86,15 @@ export const sparkDataPartSchemas = {
86
86
  */
87
87
  // Editor ids are the engine's decimal integers; 8192 covers a select-all at the engine's budgets.
88
88
  selection: z.object({ nodeIds: z.array(z.string().min(1).max(64)).min(1).max(8192) }),
89
+ compaction: z.object({
90
+ firstKeptMessageId: z.string().min(1),
91
+ summary: z.string().min(1),
92
+ skills: z.array(z.string().min(1)),
93
+ modelId: z.string(),
94
+ tokensBefore: z.number().int().nonnegative(),
95
+ tokensAfter: z.number().int().nonnegative(),
96
+ createdAt: z.string().datetime(),
97
+ }),
89
98
  /**
90
99
  * The conditions the user confirmed for the goal this message starts. The model
91
100
  * reads it as text (model-history.ts).
@@ -0,0 +1,10 @@
1
+ import type { SparkUIMessage } from "./ui.js";
2
+ export type SparkFilePart = Extract<SparkUIMessage["parts"][number], {
3
+ type: "file";
4
+ }>;
5
+ /**
6
+ * The images the user attached to `messages`, each once, in the order sent:
7
+ * what the work is often built from, so the model calls that do or judge it
8
+ * keep them longest.
9
+ */
10
+ export declare function userImages(messages: readonly SparkUIMessage[]): SparkFilePart[];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The images the user attached to `messages`, each once, in the order sent:
3
+ * what the work is often built from, so the model calls that do or judge it
4
+ * keep them longest.
5
+ */
6
+ export function userImages(messages) {
7
+ const seen = new Set();
8
+ const images = [];
9
+ for (const message of messages) {
10
+ if (message.role !== "user")
11
+ continue;
12
+ for (const part of message.parts) {
13
+ if (part.type !== "file" || !part.mediaType.startsWith("image/") || seen.has(part.url))
14
+ continue;
15
+ seen.add(part.url);
16
+ images.push(part);
17
+ }
18
+ }
19
+ return images;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@womp/spark-agent",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "Portable skills-based AI agent with local and durable chat transports for Womp",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",