@womp/spark-agent 6.2.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,75 @@ 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
+
41
+ ## 6.3.0 — 2026-10-09
42
+
43
+ ### Added
44
+
45
+ - Goals start from the conditions in an ordinary message. `SparkAgentService.proposeGoal`,
46
+ `POST /conversations/:id/goal/proposal` and `SparkChatTransport.proposeGoal` return a title and
47
+ the message's success conditions, each marked `measurable` or left to the user's review. Hosts
48
+ supply `SparkAgentServiceOptions.proposeGoal`; `createSparkGoalProposer` is the shared one.
49
+ - The goal `set` command accepts `title` and `conditions`. The service attaches them to the
50
+ initiating message as a `data-goal` part, the evaluator judges each measurable condition after
51
+ every try, and `SparkGoal.conditions` records `met` and `reason`. A goal with conditions completes
52
+ only when no measurable condition is unmet. `SparkChatTransport.sendMessages` forwards
53
+ `body.goal`, so a confirmed goal is sent with its message.
54
+ - `SparkGoal.tries` keeps each check a goal with conditions has had: the try's number, the run that
55
+ made it, the evaluator's reason and every condition's result, so a host can show a goal's history.
56
+ - The goal `update_budget` command accepts `maxTurns` as well as `creditBudget`, either or both, so
57
+ a goal that used its tries can be given more and resumed.
58
+ - `GOAL_CONFIRMATION_DEFAULTS` (5 tries, 2,000 credits) and `MAX_GOAL_LENGTH` from
59
+ `@womp/spark-agent/ui`, for the confirmation a host shows and the objective it sends.
60
+ - The standalone UI reads each request's conditions, asks the user to confirm them with a tries and
61
+ a credit cap or to try once, and shows the goal's progress. It estimates credits from model cost
62
+ at a development rate; it does not bill.
63
+
64
+ ### Changed
65
+
66
+ - A goal's initiating user message can carry a `data-goal` part. Hosts that render or mirror user
67
+ message parts should show it as the confirmed goal or skip it. A host that gives the model its
68
+ own copy of the message, as Crow does from the Swan thread, must keep the part on that copy:
69
+ it is how the worker reads the conditions.
70
+
71
+ ### Removed
72
+
73
+ - `parseSparkGoalCommand`. A goal starts from the confirmation, not from a typed `/goal`; hosts
74
+ send the `set`, `pause`, `resume` and `clear` commands from their own controls.
75
+
7
76
  ## 6.2.0 — 2026-10-09
8
77
 
9
78
  ### 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
 
@@ -138,6 +138,7 @@ Closing or reloading the page only detaches the SSE subscriber. It does not canc
138
138
  POST /agent/conversations/:conversationId/messages
139
139
  PATCH /agent/conversations/:conversationId/settings
140
140
  POST /agent/conversations/:conversationId/goal
141
+ POST /agent/conversations/:conversationId/goal/proposal
141
142
  GET /agent/runs/:runId/stream?after=0
142
143
  POST /agent/runs/:runId/cancel
143
144
  ```
@@ -146,6 +147,15 @@ Conversation settings contain `modelId` and `reasoningEffort`; save them with
146
147
  `transport.saveConversationSettings(id, settings)`. The goal endpoint accepts `set`, `pause`,
147
148
  `resume`, `clear`, and `update_budget` commands.
148
149
 
150
+ A goal starts from an ordinary message. `transport.proposeGoal(id, message, settings)` returns a
151
+ title and the success conditions read from the message, each marked `measurable` or left to the
152
+ user's review; it stores and starts nothing. After the user confirms them with a tries cap
153
+ (`maxTurns`) and a credit cap, send the message with `body.goal` set to a `set` command carrying
154
+ `title`, `conditions`, `maxTurns` and `creditBudget`. The service attaches the confirmed conditions
155
+ to that message as a `data-goal` part, evaluates each measurable condition after every try, and
156
+ records the result on `goal.conditions`. Send the message without `body.goal` to try once. Hosts
157
+ enable proposals with `SparkAgentServiceOptions.proposeGoal`, such as `createSparkGoalProposer`.
158
+
149
159
  The SSE response contains ordered persistence envelopes around standard AI SDK chunks:
150
160
 
151
161
  ```ts
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,
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ChatRequestOptions, ChatTransport, UIMessageChunk } from "ai";
2
2
  import type { SparkRunSettings } from "./server/types.js";
3
- import type { SparkGoal, SparkGoalCommand } from "./goals.js";
4
- export type { SparkGoal, SparkGoalCommand } from "./goals.js";
3
+ import type { SparkGoal, SparkGoalCommand, SparkGoalProposal } from "./goals.js";
4
+ export type { SparkGoal, SparkGoalCommand, SparkGoalCondition, SparkGoalProposal, SparkGoalTry } from "./goals.js";
5
5
  import type { SparkConversation, SparkConversationSummary, SparkRun } from "./server/types.js";
6
6
  import type { SparkUIMessage } from "./ui.js";
7
7
  export type { SparkConversation, SparkConversationSummary, SparkRun, SparkRunSettings, } from "./server/types.js";
@@ -44,6 +44,8 @@ export declare class SparkChatTransport implements ChatTransport<SparkUIMessage>
44
44
  goal?: SparkGoal;
45
45
  run?: SparkRun;
46
46
  }>;
47
+ /** The success conditions read from a message, to confirm before sending it as a goal. */
48
+ proposeGoal(id: string, message: SparkUIMessage, settings: SparkRunSettings, signal?: AbortSignal): Promise<SparkGoalProposal>;
47
49
  getGoal(id: string): Promise<SparkGoal | undefined>;
48
50
  getRun(id: string): Promise<SparkRun>;
49
51
  saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
package/dist/client.js CHANGED
@@ -51,7 +51,8 @@ export class SparkChatTransport {
51
51
  : options.messages.find(({ id }) => id === options.messageId) ?? options.messages.at(-1);
52
52
  if (message === undefined)
53
53
  throw new Error("Spark Agent requires a message to send.");
54
- const settings = this.settingsFrom(options.body);
54
+ const requestBody = options.body;
55
+ const settings = this.settingsFrom(requestBody);
55
56
  const response = await this.request(`/conversations/${encodeURIComponent(options.chatId)}/messages`, {
56
57
  method: "POST",
57
58
  body: JSON.stringify({
@@ -59,6 +60,8 @@ export class SparkChatTransport {
59
60
  message,
60
61
  messageId: options.messageId,
61
62
  settings,
63
+ // A confirmed goal: the message starts it, and the run is its first try.
64
+ ...(requestBody?.goal === undefined ? {} : { goal: requestBody.goal }),
62
65
  }),
63
66
  signal: options.abortSignal,
64
67
  }, options.headers);
@@ -126,6 +129,15 @@ export class SparkChatTransport {
126
129
  throw await responseError(response);
127
130
  return response.json();
128
131
  }
132
+ /** The success conditions read from a message, to confirm before sending it as a goal. */
133
+ async proposeGoal(id, message, settings, signal) {
134
+ const response = await this.request(`/conversations/${encodeURIComponent(id)}/goal/proposal`, {
135
+ method: "POST", body: JSON.stringify({ message, settings }), signal,
136
+ });
137
+ if (!response.ok)
138
+ throw await responseError(response);
139
+ return (await response.json()).proposal;
140
+ }
129
141
  async getGoal(id) {
130
142
  const response = await this.request(`/conversations/${encodeURIComponent(id)}/goal`, { method: "GET" });
131
143
  if (!response.ok)
@@ -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;