@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.
@@ -2,7 +2,7 @@ import { sparkErrorDiagnostics } from "../error-diagnostics.js";
2
2
  import { validateUIMessages } from "ai";
3
3
  import { foldAllChunks } from "./message-fold.js";
4
4
  import { sparkDataPartSchemas, sparkMessageMetadataSchema, } from "../ui.js";
5
- import { sparkGoalCommandSchema } from "../goals.js";
5
+ import { sparkGoalCommandSchema, sparkGoalProposalSchema } from "../goals.js";
6
6
  import { goalVerdictSchema } from "./goal-evaluator.js";
7
7
  import { wompifyContextSchema, WompifyCreditCapReachedError } from "../wompify.js";
8
8
  const TERMINAL_STATUSES = new Set([
@@ -52,13 +52,14 @@ function titleFor(message) {
52
52
  return "New build";
53
53
  return text.length > 44 ? `${text.slice(0, 44)}…` : text;
54
54
  }
55
- function normalizeUserMessage(message) {
55
+ /** `confirmedGoal` admits the goal part, which only a `set` command attaches. */
56
+ function normalizeUserMessage(message, confirmedGoal = false) {
56
57
  if (message.role !== "user") {
57
58
  throw new SparkServiceError(400, "A new chat message must have the user role.");
58
59
  }
59
60
  for (const part of message.parts) {
60
61
  // The data schemas are checked with the rest of the message (validateUIMessages).
61
- if (part.type === "text" || part.type === "data-selection")
62
+ if (part.type === "text" || part.type === "data-selection" || (confirmedGoal && part.type === "data-goal"))
62
63
  continue;
63
64
  if (part.type === "file" &&
64
65
  part.mediaType.startsWith("image/") &&
@@ -84,10 +85,21 @@ const GOAL_WORK_RULES = "Keep working toward this objective and verify it with c
84
85
  * turn reuses the provider's cached prefix.
85
86
  */
86
87
  function goalContinuationText(goal) {
88
+ const open = goal.conditions?.filter((condition) => condition.measurable && condition.met !== true) ?? [];
87
89
  return `Continue working toward the goal: ${goal.objective}\n${GOAL_WORK_RULES}\nProgress check: ${goal.lastReason}`
90
+ + (open.length ? `\nConditions not yet verified:${open.map(({ text, reason }) => `\n- ${text}${reason ? ` (${reason})` : ""}`).join("")}` : "")
88
91
  + (wompifyContextSchema.safeParse(goal.settings.wompify).success
89
92
  ? "\nKeep refining the existing reconstruction at the same detail level. Compare it with the reference and improve the remaining shape and color discrepancies." : "");
90
93
  }
94
+ /** The goal's conditions with what this evaluation found for each measurable one. */
95
+ function judgedConditions(goal, evaluation) {
96
+ if (!goal.conditions || !evaluation?.conditions)
97
+ return goal.conditions;
98
+ return goal.conditions.map((condition, index) => {
99
+ const result = evaluation.conditions.find((entry) => entry.index === index + 1);
100
+ return result && condition.measurable ? { ...condition, met: result.met, reason: result.reason } : condition;
101
+ });
102
+ }
91
103
  function goalStartText(goal, action) {
92
104
  return `${action === "set" ? "Goal" : "Resume the goal"}: ${goal.objective}\n${GOAL_WORK_RULES}`;
93
105
  }
@@ -95,6 +107,7 @@ export class SparkAgentService {
95
107
  store;
96
108
  executeRun;
97
109
  evaluateGoal;
110
+ proposer;
98
111
  readGoalCredits;
99
112
  continuationSettings;
100
113
  closing = false;
@@ -113,6 +126,7 @@ export class SparkAgentService {
113
126
  this.store = options.store;
114
127
  this.executeRun = options.executeRun;
115
128
  this.evaluateGoal = options.evaluateGoal;
129
+ this.proposer = options.proposeGoal;
116
130
  this.readGoalCredits = options.readGoalCredits;
117
131
  this.continuationSettings = options.continuationSettings;
118
132
  this.endOrphanedRuns = options.endOrphanedRuns ?? false;
@@ -153,6 +167,21 @@ export class SparkAgentService {
153
167
  }
154
168
  });
155
169
  }
170
+ /**
171
+ * The conditions a goal started from `message` would verify. Nothing is stored
172
+ * or started: the user confirms them, then sends the message with a `set` command.
173
+ */
174
+ async proposeGoal(ownerId, conversationId, message, settings, signal) {
175
+ if (!this.proposer)
176
+ throw new SparkServiceError(501, "This host has not configured goal proposals.");
177
+ const conversation = await this.getConversation(ownerId, conversationId);
178
+ const [input] = await validateUIMessages({ messages: [normalizeUserMessage(message)], metadataSchema: sparkMessageMetadataSchema, dataSchemas: sparkDataPartSchemas });
179
+ const timeout = AbortSignal.timeout(30_000);
180
+ return sparkGoalProposalSchema.parse(await this.proposer({
181
+ ownerId, message: input, messages: conversation.messages, settings,
182
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
183
+ }));
184
+ }
156
185
  /** User controls are separate from model execution and work while streaming. */
157
186
  async goalCommand(ownerId, conversationId, command, settings, message) {
158
187
  command = sparkGoalCommandSchema.parse(command);
@@ -171,10 +200,18 @@ export class SparkAgentService {
171
200
  throw new SparkServiceError(409, "This goal is already complete.");
172
201
  if (wompifyContextSchema.safeParse(current.settings.wompify).success)
173
202
  throw new SparkServiceError(409, "Wompify caps are set by the detail level.");
174
- const goal = await this.refreshGoalCredits(ownerId, { ...current, creditBudget: command.creditBudget });
175
- if (command.creditBudget < goal.creditsUsed)
203
+ if (command.creditBudget === undefined && command.maxTurns === undefined)
204
+ throw new SparkServiceError(400, "Give a credit cap or a tries cap to change.");
205
+ const goal = await this.refreshGoalCredits(ownerId, {
206
+ ...current,
207
+ ...(command.creditBudget === undefined ? {} : { creditBudget: command.creditBudget }),
208
+ ...(command.maxTurns === undefined ? {} : { maxTurns: command.maxTurns }),
209
+ });
210
+ if (command.creditBudget !== undefined && command.creditBudget < goal.creditsUsed)
176
211
  throw new SparkServiceError(409, "The cap cannot be lower than credits already used and pending. Refresh and try again.");
177
- if (goal.creditsUsed >= goal.creditBudget) {
212
+ if (command.maxTurns !== undefined && command.maxTurns < goal.turns)
213
+ throw new SparkServiceError(409, "The tries cap cannot be lower than the tries already used. Refresh and try again.");
214
+ if (goal.creditBudget !== undefined && goal.creditsUsed >= goal.creditBudget) {
178
215
  goal.status = "budget_limited";
179
216
  goal.lastReason = "The goal reached its credit budget.";
180
217
  }
@@ -224,19 +261,24 @@ export class SparkAgentService {
224
261
  throw new SparkServiceError(400, "Run settings are required.");
225
262
  const goal = command.action === "set" ? {
226
263
  id: crypto.randomUUID(), objective: command.objective, status: "active",
264
+ ...(command.title ? { title: command.title } : {}),
265
+ ...(command.conditions ? { conditions: command.conditions } : {}),
227
266
  settings: runSettings,
228
267
  createdAt: now(), updatedAt: now(), turns: 0,
229
268
  ...(command.maxTurns ? { maxTurns: command.maxTurns } : {}),
230
269
  ...(command.creditBudget ? { creditBudget: command.creditBudget, creditsUsed: 0 } : {}),
231
270
  } : { ...current, settings: runSettings, status: "active", updatedAt: now(),
232
271
  idleTurns: 0, };
233
- const inputMessage = normalizeUserMessage(message ?? {
272
+ // Only the confirmed command says what the goal's conditions are, not the message a client built.
273
+ const inputMessage = normalizeUserMessage(message ? { ...message, parts: message.parts.filter((part) => part.type !== "data-goal") } : {
234
274
  id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: goalStartText(goal, command.action) }],
235
275
  });
276
+ if (command.action === "set" && command.conditions)
277
+ inputMessage.parts.push({ type: "data-goal", data: { title: command.title ?? titleFor(inputMessage), conditions: command.conditions } });
236
278
  await validateUIMessages({ messages: [inputMessage], metadataSchema: sparkMessageMetadataSchema, dataSchemas: sparkDataPartSchemas });
237
279
  await this.store.saveConversationGoal(conversationId, goal);
238
280
  try {
239
- const run = await this.submitInTurn({ ownerId, conversationId, trigger: "submit-message", message: inputMessage, settings: goal.settings }, command.action === "resume");
281
+ const run = await this.submitInTurn({ ownerId, conversationId, trigger: "submit-message", message: inputMessage, settings: goal.settings }, command.action === "resume", true);
240
282
  return { goal: (await this.getConversation(ownerId, conversationId)).goal, run };
241
283
  }
242
284
  catch (error) {
@@ -300,7 +342,7 @@ export class SparkAgentService {
300
342
  async submitMessage(input) {
301
343
  return this.inConversationTurn(input.conversationId, () => this.submitInTurn(input));
302
344
  }
303
- async submitInTurn(input, goalContinuation = false) {
345
+ async submitInTurn(input, goalContinuation = false, confirmedGoal = false) {
304
346
  if (this.closing)
305
347
  throw new SparkServiceError(503, "The service is shutting down.");
306
348
  const conversation = await this.getConversation(input.ownerId, input.conversationId);
@@ -328,7 +370,7 @@ export class SparkAgentService {
328
370
  inputMessage = previous;
329
371
  }
330
372
  else {
331
- inputMessage = normalizeUserMessage(input.message);
373
+ inputMessage = normalizeUserMessage(input.message, confirmedGoal);
332
374
  if (goalContinuation)
333
375
  inputMessage.metadata = { ...inputMessage.metadata, goalContinuation: true };
334
376
  if (conversation.messages.some(({ id }) => id === inputMessage.id)) {
@@ -636,16 +678,26 @@ export class SparkAgentService {
636
678
  await this.store.saveConversationGoal(conversation.id, current);
637
679
  return;
638
680
  }
681
+ const conditions = judgedConditions(current, evaluation);
639
682
  const next = {
640
683
  ...current,
684
+ ...(conditions ? { conditions } : {}),
685
+ ...(conditions && evaluation ? {
686
+ tries: [...(current.tries ?? []), {
687
+ turn: current.turns, runId: run.id, reason: evaluation.reason,
688
+ conditions: conditions.map(({ met, reason }) => ({ ...(met === undefined ? {} : { met }), ...(reason === undefined ? {} : { reason }) })),
689
+ }],
690
+ } : {}),
641
691
  idleTurns: usedTools ? 0 : (current.idleTurns ?? 0) + 1,
642
692
  updatedAt: now(), lastReason: evaluation?.reason,
643
693
  };
694
+ // A verdict of met cannot stand over a condition the same evaluation found unmet.
695
+ const met = evaluation?.verdict === "met" && !conditions?.some((condition) => condition.measurable && condition.met === false);
644
696
  if (signal.aborted || this.closing) {
645
697
  next.status = "paused";
646
698
  next.lastReason = "The goal was interrupted. Resume to continue.";
647
699
  }
648
- else if (evaluation?.verdict === "met" && !wompify)
700
+ else if (met && !wompify)
649
701
  next.status = "completed";
650
702
  else if (evaluation?.verdict === "blocked")
651
703
  next.status = "blocked";
@@ -1,5 +1,7 @@
1
1
  import { type Express } from "express";
2
2
  import { KakapoAPI } from "@womp/kakapo-sdk";
3
+ import type { SparkGoal } from "../goals.js";
4
+ import type { SparkAgentStore } from "./types.js";
3
5
  export type StandaloneSparkAgentOptions = {
4
6
  openAiApiKey?: string;
5
7
  anthropicApiKey?: string;
@@ -17,6 +19,12 @@ export type StandaloneSparkAgentOptions = {
17
19
  export type StandaloneSparkAgentApp = Express & {
18
20
  dispose(): Promise<void>;
19
21
  };
22
+ /**
23
+ * A development fixture, not billing: the standalone server charges nothing, so
24
+ * a goal's credit cap is checked against the model cost its finished tries
25
+ * reported. Evaluations and the try in progress are not counted.
26
+ */
27
+ export declare function estimateGoalCredits(store: Pick<SparkAgentStore, "getRun" | "getConversation">, goal: SparkGoal): Promise<number>;
20
28
  export declare function checkStandaloneServices(api: Pick<KakapoAPI, "listFonts" | "validateOpenScad">): Promise<{
21
29
  fonts: import("@womp/kakapo-sdk").FontFamily[];
22
30
  fontsAvailable: boolean;
@@ -18,11 +18,34 @@ import { hasSceneResourcePressure, } from "../scene-health.js";
18
18
  import { QuickJSJavaScriptExecutor } from "../quickjs-executor.js";
19
19
  import { sparkModelOptions } from "../model-options.js";
20
20
  import { createSparkRunExecutor } from "./executor.js";
21
+ import { createSparkGoalEvaluator } from "./goal-evaluator.js";
22
+ import { createSparkGoalProposer } from "./goal-proposer.js";
21
23
  import { createSparkAgentRouter } from "./express.js";
22
24
  import { SparkAgentService } from "./service.js";
23
25
  import { SqliteSparkStore } from "./sqlite-store.js";
24
26
  import { clearSceneContents } from "./scene-actions.js";
25
27
  import { standaloneSceneAccess } from "./scene-access.js";
28
+ /** Production's list rate: 1,000 credits per USD of model cost, with its 1.25 markup. */
29
+ const DEVELOPMENT_CREDITS_PER_USD = 1_250;
30
+ /**
31
+ * A development fixture, not billing: the standalone server charges nothing, so
32
+ * a goal's credit cap is checked against the model cost its finished tries
33
+ * reported. Evaluations and the try in progress are not counted.
34
+ */
35
+ export async function estimateGoalCredits(store, goal) {
36
+ const run = goal.runId === undefined ? undefined : await store.getRun(goal.runId);
37
+ const conversation = run && await store.getConversation(run.conversationId);
38
+ let usd = 0;
39
+ for (const message of conversation?.messages ?? []) {
40
+ const runId = message.role === "assistant" ? message.metadata?.runId : undefined;
41
+ if (runId === undefined || (await store.getRun(runId))?.goalId !== goal.id)
42
+ continue;
43
+ for (const part of message.parts)
44
+ if (part.type === "data-step-metrics")
45
+ usd += part.data.cost?.totalUsd ?? 0;
46
+ }
47
+ return Math.ceil(usd * DEVELOPMENT_CREDITS_PER_USD);
48
+ }
26
49
  export async function checkStandaloneServices(api) {
27
50
  const [fonts, openScad] = await Promise.allSettled([
28
51
  api.listFonts(),
@@ -172,49 +195,56 @@ export async function createStandaloneSparkAgentApp(options) {
172
195
  throw error;
173
196
  }
174
197
  },
175
- resolveModel: (settings) => {
176
- const selected = getSparkModel(settings.modelId);
177
- if (SPARK_MODELS.some(({ id }) => id === settings.modelId) === false) {
178
- throw new Error(`Unsupported Spark model: ${settings.modelId}`);
179
- }
180
- if (selected.provider === "anthropic") {
181
- if (options.anthropicApiKey?.trim() === "" ||
182
- options.anthropicApiKey === undefined) {
183
- throw new Error("ANTHROPIC_API_KEY is required for this model.");
184
- }
185
- return {
186
- model: createAnthropic({ apiKey: options.anthropicApiKey })(selected.id),
187
- modelId: selected.id,
188
- providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
189
- maxOutputTokens: 48_000,
190
- };
191
- }
192
- if (selected.provider === "google") {
193
- if (!options.geminiApiKey?.trim()) {
194
- throw new Error("GEMINI_API_KEY is required for this model.");
195
- }
196
- return {
197
- model: createGoogleGenerativeAI({ apiKey: options.geminiApiKey })(selected.id),
198
- modelId: selected.id,
199
- providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
200
- maxOutputTokens: 48_000,
201
- };
198
+ resolveModel,
199
+ });
200
+ function resolveModel(settings) {
201
+ const selected = getSparkModel(settings.modelId);
202
+ if (SPARK_MODELS.some(({ id }) => id === settings.modelId) === false) {
203
+ throw new Error(`Unsupported Spark model: ${settings.modelId}`);
204
+ }
205
+ if (selected.provider === "anthropic") {
206
+ if (options.anthropicApiKey?.trim() === "" ||
207
+ options.anthropicApiKey === undefined) {
208
+ throw new Error("ANTHROPIC_API_KEY is required for this model.");
202
209
  }
203
- if (options.openAiApiKey?.trim() === "" ||
204
- options.openAiApiKey === undefined) {
205
- throw new Error("OPENAI_API_KEY is required for this model.");
210
+ return {
211
+ model: createAnthropic({ apiKey: options.anthropicApiKey })(selected.id),
212
+ modelId: selected.id,
213
+ providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
214
+ maxOutputTokens: 48_000,
215
+ };
216
+ }
217
+ if (selected.provider === "google") {
218
+ if (!options.geminiApiKey?.trim()) {
219
+ throw new Error("GEMINI_API_KEY is required for this model.");
206
220
  }
207
221
  return {
208
- model: createOpenAI({ apiKey: options.openAiApiKey }).responses(selected.id),
222
+ model: createGoogleGenerativeAI({ apiKey: options.geminiApiKey })(selected.id),
209
223
  modelId: selected.id,
210
224
  providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
211
225
  maxOutputTokens: 48_000,
212
226
  };
213
- },
214
- });
227
+ }
228
+ if (options.openAiApiKey?.trim() === "" ||
229
+ options.openAiApiKey === undefined) {
230
+ throw new Error("OPENAI_API_KEY is required for this model.");
231
+ }
232
+ return {
233
+ model: createOpenAI({ apiKey: options.openAiApiKey }).responses(selected.id),
234
+ modelId: selected.id,
235
+ providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
236
+ maxOutputTokens: 48_000,
237
+ };
238
+ }
215
239
  let clearingScene = false;
216
240
  let activeRuns = 0;
217
- const service = new SparkAgentService({ store, executeRun: async (context) => {
241
+ const service = new SparkAgentService({
242
+ store,
243
+ evaluateGoal: createSparkGoalEvaluator({ resolveModel, resolveImage: (ownerId, artifactId) => artifactStore.get(ownerId, artifactId) }),
244
+ // Reading a request's conditions precedes every send, so it runs at the lowest effort.
245
+ proposeGoal: createSparkGoalProposer({ resolveModel: (settings) => resolveModel({ ...settings, reasoningEffort: "low" }) }),
246
+ readGoalCredits: (_ownerId, goal) => estimateGoalCredits(store, goal),
247
+ executeRun: async (context) => {
218
248
  if (clearingScene)
219
249
  throw new Error("The scene is being cleared. Try again when it finishes.");
220
250
  activeRuns++;
@@ -226,7 +256,8 @@ export async function createStandaloneSparkAgentApp(options) {
226
256
  activeRuns--;
227
257
  throw error;
228
258
  }
229
- } });
259
+ },
260
+ });
230
261
  try {
231
262
  for (const run of await store.listInterruptedRuns())
232
263
  await service.recoverInterruptedRun(run.id);
package/dist/ui.d.ts CHANGED
@@ -3,7 +3,8 @@ export { sparkErrorDiagnostics, sparkErrorDiagnosticsSchema, type SparkErrorDiag
3
3
  import type { InferUIMessageChunk, UIMessage } from "ai";
4
4
  import type { SceneScreenshotOptions } from "./capabilities/scene.js";
5
5
  import { z } from "zod";
6
- export type { SparkGoal, SparkGoalCommand } from "./goals.js";
6
+ export type { SparkGoal, SparkGoalCommand, SparkGoalCondition, SparkGoalProposal, SparkGoalTry } from "./goals.js";
7
+ import { type SparkGoalProposal } from "./goals.js";
7
8
  import type { ExecutionReceipt, SparkSkill } from "./capabilities.js";
8
9
  export { DEFAULT_MODEL_ID, getSparkModel, REASONING_EFFORT_LABELS, resolveReasoningEffort, SPARK_MODELS, } from "./models.js";
9
10
  export type { ModelProvider, ReasoningEffort, SparkModel } from "./models.js";
@@ -90,11 +91,31 @@ export type SparkDataParts = {
90
91
  receipt: ExecutionReceipt;
91
92
  };
92
93
  selection: SparkSelectionData;
94
+ compaction: SparkCompaction;
95
+ goal: SparkGoalProposal;
93
96
  };
94
97
  /** Editor node IDs selected when a user message was sent; never rendered as chat text. */
95
98
  export type SparkSelectionData = {
96
99
  nodeIds: string[];
97
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
+ };
98
119
  export type ExecuteJavaScriptInput = {
99
120
  purpose: string;
100
121
  code: string;
@@ -333,6 +354,26 @@ export declare const sparkDataPartSchemas: {
333
354
  readonly selection: z.ZodObject<{
334
355
  nodeIds: z.ZodArray<z.ZodString>;
335
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>;
366
+ /**
367
+ * The conditions the user confirmed for the goal this message starts. The model
368
+ * reads it as text (model-history.ts).
369
+ */
370
+ readonly goal: z.ZodObject<{
371
+ title: z.ZodString;
372
+ conditions: z.ZodArray<z.ZodObject<{
373
+ text: z.ZodString;
374
+ measurable: z.ZodBoolean;
375
+ }, z.core.$strip>>;
376
+ }, z.core.$strip>;
336
377
  };
337
378
  export { parseWompifyCommand, WOMPIFY_PRESETS, wompifyPrompt, wompifyContextSchema, type WompifyRequest, type WompifyContext } from "./wompify.js";
338
- export { parseSparkGoalCommand, DEFAULT_GOAL_CREDITS } from "./goals.js";
379
+ export { DEFAULT_GOAL_CREDITS, GOAL_CONFIRMATION_DEFAULTS, MAX_GOAL_LENGTH, sparkGoalProposalSchema } from "./goals.js";
package/dist/ui.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { sparkErrorDiagnosticsSchema } from "./error-diagnostics.js";
2
2
  export { sparkErrorDiagnostics, sparkErrorDiagnosticsSchema, } from "./error-diagnostics.js";
3
3
  import { z } from "zod";
4
+ import { sparkGoalProposalSchema } from "./goals.js";
4
5
  export { DEFAULT_MODEL_ID, getSparkModel, REASONING_EFFORT_LABELS, resolveReasoningEffort, SPARK_MODELS, } from "./models.js";
5
6
  export const SPARK_RUN_STATUSES = [
6
7
  "queued",
@@ -85,6 +86,20 @@ export const sparkDataPartSchemas = {
85
86
  */
86
87
  // Editor ids are the engine's decimal integers; 8192 covers a select-all at the engine's budgets.
87
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
+ }),
98
+ /**
99
+ * The conditions the user confirmed for the goal this message starts. The model
100
+ * reads it as text (model-history.ts).
101
+ */
102
+ goal: sparkGoalProposalSchema,
88
103
  };
89
104
  export { parseWompifyCommand, WOMPIFY_PRESETS, wompifyPrompt, wompifyContextSchema } from "./wompify.js";
90
- export { parseSparkGoalCommand, DEFAULT_GOAL_CREDITS } from "./goals.js";
105
+ export { DEFAULT_GOAL_CREDITS, GOAL_CONFIRMATION_DEFAULTS, MAX_GOAL_LENGTH, sparkGoalProposalSchema } from "./goals.js";
@@ -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.2.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",