wispy-cli 2.7.13 → 2.7.15

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * core/budget.mjs — API Spending Tracker for Wispy
3
+ *
4
+ * Tracks API costs per session and across sessions.
5
+ * Persisted to ~/.wispy/budget.json
6
+ *
7
+ * Usage:
8
+ * const budget = new BudgetManager({ maxBudgetUsd: 5.00 });
9
+ * budget.record(1000, 500, "gpt-4o");
10
+ * const check = budget.checkBudget();
11
+ * // { ok: true, remaining: 4.98, spent: 0.02 }
12
+ */
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+
18
+ // ── Model pricing (approximate, USD per 1M tokens) ────────────────────────────
19
+
20
+ export const MODEL_PRICING = {
21
+ // OpenAI
22
+ "gpt-4o": { input: 2.50, output: 10.00 },
23
+ "gpt-4o-mini": { input: 0.15, output: 0.60 },
24
+ "gpt-4-turbo": { input: 10.00, output: 30.00 },
25
+ "gpt-4": { input: 30.00, output: 60.00 },
26
+ "o1": { input: 15.00, output: 60.00 },
27
+ "o1-mini": { input: 3.00, output: 12.00 },
28
+ "o3": { input: 10.00, output: 40.00 },
29
+ "o3-mini": { input: 1.10, output: 4.40 },
30
+ "o4-mini": { input: 1.10, output: 4.40 },
31
+
32
+ // Anthropic
33
+ "claude-opus-4-20250514": { input: 15.00, output: 75.00 },
34
+ "claude-sonnet-4-20250514": { input: 3.00, output: 15.00 },
35
+ "claude-3-5-sonnet-20241022": { input: 3.00, output: 15.00 },
36
+ "claude-3-5-haiku-20241022": { input: 0.80, output: 4.00 },
37
+ "claude-3-opus-20240229": { input: 15.00, output: 75.00 },
38
+
39
+ // Google
40
+ "gemini-2.5-pro": { input: 1.25, output: 10.00 },
41
+ "gemini-2.5-flash": { input: 0.075, output: 0.30 },
42
+ "gemini-2.0-flash": { input: 0.075, output: 0.30 },
43
+ "gemini-1.5-pro": { input: 1.25, output: 5.00 },
44
+ "gemini-1.5-flash": { input: 0.075, output: 0.30 },
45
+
46
+ // DeepSeek
47
+ "deepseek-chat": { input: 0.14, output: 0.28 },
48
+ "deepseek-coder": { input: 0.14, output: 0.28 },
49
+ "deepseek-reasoner": { input: 0.55, output: 2.19 },
50
+
51
+ // Groq (free for now, but tracking usage)
52
+ "llama-3.3-70b-versatile": { input: 0, output: 0 },
53
+ "llama-3.1-8b-instant": { input: 0, output: 0 },
54
+ "mixtral-8x7b-32768": { input: 0, output: 0 },
55
+ "gemma2-9b-it": { input: 0, output: 0 },
56
+
57
+ // Mistral
58
+ "mistral-large-latest": { input: 2.00, output: 6.00 },
59
+ "mistral-small-latest": { input: 0.20, output: 0.60 },
60
+ "codestral-latest": { input: 1.00, output: 3.00 },
61
+
62
+ // xAI
63
+ "grok-3": { input: 3.00, output: 15.00 },
64
+ "grok-3-mini": { input: 0.30, output: 0.50 },
65
+ };
66
+
67
+ // ── BudgetManager ─────────────────────────────────────────────────────────────
68
+
69
+ export class BudgetManager {
70
+ /**
71
+ * @param {object} options
72
+ * @param {number|null} options.maxBudgetUsd - Max budget for this session (null = unlimited)
73
+ * @param {string} options.budgetPath - Path to persist budget.json
74
+ */
75
+ constructor(options = {}) {
76
+ this.maxBudgetUsd = options.maxBudgetUsd ?? null;
77
+ this.sessionSpend = 0; // USD spent this session
78
+ this.sessionInputTokens = 0;
79
+ this.sessionOutputTokens = 0;
80
+ this.sessionCalls = 0;
81
+ this.totalSpend = 0; // loaded from disk
82
+ this.totalInputTokens = 0;
83
+ this.totalOutputTokens = 0;
84
+ this.totalCalls = 0;
85
+ this._budgetPath = options.budgetPath ?? path.join(os.homedir(), ".wispy", "budget.json");
86
+ this._loaded = false;
87
+ }
88
+
89
+ /**
90
+ * Load persisted budget data from disk (lazy).
91
+ */
92
+ async _ensureLoaded() {
93
+ if (this._loaded) return;
94
+ this._loaded = true;
95
+ try {
96
+ const raw = await readFile(this._budgetPath, "utf8");
97
+ const data = JSON.parse(raw);
98
+ this.totalSpend = data.totalSpend ?? 0;
99
+ this.totalInputTokens = data.totalInputTokens ?? 0;
100
+ this.totalOutputTokens = data.totalOutputTokens ?? 0;
101
+ this.totalCalls = data.totalCalls ?? 0;
102
+ } catch {
103
+ // File doesn't exist yet — start fresh
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Persist total spend to disk.
109
+ */
110
+ async _persist() {
111
+ try {
112
+ await mkdir(path.dirname(this._budgetPath), { recursive: true });
113
+ await writeFile(
114
+ this._budgetPath,
115
+ JSON.stringify({
116
+ totalSpend: this.totalSpend,
117
+ totalInputTokens: this.totalInputTokens,
118
+ totalOutputTokens: this.totalOutputTokens,
119
+ totalCalls: this.totalCalls,
120
+ updatedAt: new Date().toISOString(),
121
+ }, null, 2) + "\n",
122
+ "utf8",
123
+ );
124
+ } catch { /* ignore write errors */ }
125
+ }
126
+
127
+ /**
128
+ * Get pricing for a model. Returns { input, output } in USD/1M tokens.
129
+ * Returns { input: 0, output: 0 } for unknown models (assume free/included).
130
+ * @param {string} model
131
+ */
132
+ getPricing(model) {
133
+ if (!model) return { input: 0, output: 0 };
134
+ // Exact match
135
+ if (MODEL_PRICING[model]) return MODEL_PRICING[model];
136
+ // Prefix match (e.g., "claude-sonnet-4" matches "claude-sonnet-4-20250514")
137
+ for (const [key, price] of Object.entries(MODEL_PRICING)) {
138
+ if (model.startsWith(key) || key.startsWith(model)) return price;
139
+ }
140
+ return { input: 0, output: 0 };
141
+ }
142
+
143
+ /**
144
+ * Estimate cost of an API call without recording it.
145
+ * @param {number} inputTokens
146
+ * @param {number} outputTokens
147
+ * @param {string} model
148
+ * @returns {number} Estimated cost in USD
149
+ */
150
+ estimateCost(inputTokens, outputTokens, model) {
151
+ const pricing = this.getPricing(model);
152
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
153
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
154
+ return inputCost + outputCost;
155
+ }
156
+
157
+ /**
158
+ * Record actual API usage after a call.
159
+ * @param {number} inputTokens
160
+ * @param {number} outputTokens
161
+ * @param {string} model
162
+ * @returns {number} Cost of this call in USD
163
+ */
164
+ async record(inputTokens, outputTokens, model) {
165
+ await this._ensureLoaded();
166
+
167
+ const cost = this.estimateCost(inputTokens || 0, outputTokens || 0, model);
168
+
169
+ this.sessionSpend += cost;
170
+ this.sessionInputTokens += inputTokens || 0;
171
+ this.sessionOutputTokens += outputTokens || 0;
172
+ this.sessionCalls++;
173
+
174
+ this.totalSpend += cost;
175
+ this.totalInputTokens += inputTokens || 0;
176
+ this.totalOutputTokens += outputTokens || 0;
177
+ this.totalCalls++;
178
+
179
+ // Persist async (don't await — non-blocking)
180
+ this._persist().catch(() => {});
181
+
182
+ return cost;
183
+ }
184
+
185
+ /**
186
+ * Check whether the current session is within budget.
187
+ * @returns {{ ok: boolean, remaining: number|null, spent: number }}
188
+ */
189
+ checkBudget() {
190
+ if (this.maxBudgetUsd === null) {
191
+ return { ok: true, remaining: null, spent: this.sessionSpend };
192
+ }
193
+ const remaining = this.maxBudgetUsd - this.sessionSpend;
194
+ return {
195
+ ok: remaining >= 0,
196
+ remaining,
197
+ spent: this.sessionSpend,
198
+ limit: this.maxBudgetUsd,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Get a spending report.
204
+ * @returns {object}
205
+ */
206
+ async getReport() {
207
+ await this._ensureLoaded();
208
+ return {
209
+ session: {
210
+ spend: this.sessionSpend,
211
+ inputTokens: this.sessionInputTokens,
212
+ outputTokens: this.sessionOutputTokens,
213
+ calls: this.sessionCalls,
214
+ limit: this.maxBudgetUsd,
215
+ remaining: this.maxBudgetUsd !== null ? this.maxBudgetUsd - this.sessionSpend : null,
216
+ },
217
+ total: {
218
+ spend: this.totalSpend,
219
+ inputTokens: this.totalInputTokens,
220
+ outputTokens: this.totalOutputTokens,
221
+ calls: this.totalCalls,
222
+ },
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Format a spending report for CLI display.
228
+ */
229
+ async formatReport() {
230
+ const report = await this.getReport();
231
+ const fmt = (n) => `$${n.toFixed(4)}`;
232
+ const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
233
+
234
+ const lines = ["\n 💰 Wispy Spending Report\n"];
235
+
236
+ lines.push(" This session:");
237
+ lines.push(` Spent: ${fmt(report.session.spend)}`);
238
+ if (report.session.limit !== null) {
239
+ const pct = ((report.session.spend / report.session.limit) * 100).toFixed(1);
240
+ const bar = this._progressBar(report.session.spend, report.session.limit, 20);
241
+ lines.push(` Budget: ${fmt(report.session.spend)} / ${fmt(report.session.limit)} [${bar}] ${pct}%`);
242
+ lines.push(` Remaining: ${fmt(Math.max(0, report.session.remaining))}`);
243
+ }
244
+ lines.push(` Tokens: ${fmtTokens(report.session.inputTokens)} in + ${fmtTokens(report.session.outputTokens)} out`);
245
+ lines.push(` API calls: ${report.session.calls}`);
246
+
247
+ lines.push("\n All time:");
248
+ lines.push(` Spent: ${fmt(report.total.spend)}`);
249
+ lines.push(` Tokens: ${fmtTokens(report.total.inputTokens)} in + ${fmtTokens(report.total.outputTokens)} out`);
250
+ lines.push(` API calls: ${report.total.calls}`);
251
+ lines.push("");
252
+
253
+ return lines.join("\n");
254
+ }
255
+
256
+ _progressBar(value, max, width = 20) {
257
+ if (max <= 0) return " ".repeat(width);
258
+ const filled = Math.min(width, Math.round((value / max) * width));
259
+ const color = filled > width * 0.8 ? "\x1b[31m" : filled > width * 0.5 ? "\x1b[33m" : "\x1b[32m";
260
+ return `${color}${"█".repeat(filled)}\x1b[0m${"░".repeat(width - filled)}`;
261
+ }
262
+
263
+ /**
264
+ * Format a budget exceeded warning.
265
+ */
266
+ formatExceededWarning() {
267
+ const check = this.checkBudget();
268
+ return [
269
+ "",
270
+ " \x1b[31m⛔ Budget limit reached!\x1b[0m",
271
+ ` Spent: $${this.sessionSpend.toFixed(4)} / $${this.maxBudgetUsd.toFixed(4)} limit`,
272
+ ` Session ended to prevent overspending.`,
273
+ ` Use --max-budget-usd to set a higher limit, or run without it for unlimited.`,
274
+ "",
275
+ ].join("\n");
276
+ }
277
+ }
package/core/engine.mjs CHANGED
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { readFile, writeFile, mkdir, appendFile, stat as fsStat } from "node:fs/promises";
16
16
 
17
17
  import { WISPY_DIR, CONVERSATIONS_DIR, MEMORY_DIR, MCP_CONFIG_PATH, detectProvider, PROVIDERS } from "./config.mjs";
18
+ import { findProjectSettings, mergeSettings } from "./project-settings.mjs";
18
19
  import { NullEmitter } from "../lib/jsonl-emitter.mjs";
19
20
 
20
21
  /**
@@ -44,6 +45,33 @@ import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mj
44
45
  import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
45
46
  import { BrowserBridge } from "./browser.mjs";
46
47
  import { LoopDetector } from "./loop-detector.mjs";
48
+ import { AgentManager } from "./agents.mjs";
49
+ import { BudgetManager } from "./budget.mjs";
50
+
51
+ // ── Effort levels ──────────────────────────────────────────────────────────────
52
+
53
+ export const EFFORT_LEVELS = {
54
+ low: {
55
+ maxRounds: 5,
56
+ maxTokens: 2000,
57
+ systemSuffix: "Be extremely brief. One-shot answers preferred. Minimize tool use.",
58
+ },
59
+ medium: {
60
+ maxRounds: 15,
61
+ maxTokens: 4000,
62
+ systemSuffix: "", // default behavior
63
+ },
64
+ high: {
65
+ maxRounds: 30,
66
+ maxTokens: 8000,
67
+ systemSuffix: "Be thorough. Explore multiple approaches. Verify your work.",
68
+ },
69
+ max: {
70
+ maxRounds: 50,
71
+ maxTokens: 16000,
72
+ systemSuffix: "Be exhaustive. Leave no stone unturned. Multiple verification passes. Consider edge cases.",
73
+ },
74
+ };
47
75
 
48
76
  const MAX_TOOL_ROUNDS = 10;
49
77
  const MAX_CONTEXT_CHARS = 40_000;
@@ -74,6 +102,15 @@ export class WispyEngine {
74
102
  ?? "default";
75
103
  // Personality: from config, or null (use default Wispy personality)
76
104
  this._personality = config.personality ?? null;
105
+ // Agent manager
106
+ this.agentManager = new AgentManager(config);
107
+ // Effort level: low | medium | high | max
108
+ this._effort = config.effort ?? process.env.WISPY_EFFORT ?? "medium";
109
+ // Budget manager
110
+ this.budget = new BudgetManager({
111
+ maxBudgetUsd: config.maxBudgetUsd ?? null,
112
+ budgetPath: config.budgetPath,
113
+ });
77
114
  }
78
115
 
79
116
  get activeWorkstream() { return this._activeWorkstream; }
@@ -168,13 +205,25 @@ export class WispyEngine {
168
205
  if (sessionId) {
169
206
  session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
170
207
  if (!session) {
171
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
208
+ session = this.sessions.create({
209
+ workstream: opts.workstream ?? this._activeWorkstream,
210
+ name: opts.sessionName ?? null,
211
+ });
212
+ } else if (opts.sessionName && !session.name) {
213
+ // Set name on existing session if not already named
214
+ session.name = opts.sessionName;
172
215
  }
173
216
  } else {
174
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
217
+ session = this.sessions.create({
218
+ workstream: opts.workstream ?? this._activeWorkstream,
219
+ name: opts.sessionName ?? null,
220
+ });
175
221
  }
176
222
  } catch (err) {
177
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
223
+ session = this.sessions.create({
224
+ workstream: opts.workstream ?? this._activeWorkstream,
225
+ name: opts.sessionName ?? null,
226
+ });
178
227
  }
179
228
 
180
229
  // JSONL emitter (no-op by default)
@@ -192,10 +241,44 @@ export class WispyEngine {
192
241
  // Resolve personality for this call
193
242
  const personality = opts.personality ?? this._personality ?? null;
194
243
 
244
+ // Resolve effort level for this call
245
+ const effort = opts.effort ?? this._effort ?? "medium";
246
+ const effortConfig = EFFORT_LEVELS[effort] ?? EFFORT_LEVELS.medium;
247
+
248
+ // Resolve agent for this call
249
+ const agentName = opts.agent ?? null;
250
+ const agentDef = agentName ? this.agentManager.get(agentName) : null;
251
+ if (agentName && !agentDef) {
252
+ return {
253
+ role: "assistant",
254
+ content: `⚠️ Unknown agent: "${agentName}". Run \`wispy agents\` to list available agents.`,
255
+ sessionId: session.id,
256
+ error: "UNKNOWN_AGENT",
257
+ };
258
+ }
259
+
260
+ // Check budget before processing
261
+ const budgetCheck = this.budget.checkBudget();
262
+ if (!budgetCheck.ok) {
263
+ return {
264
+ role: "assistant",
265
+ content: this.budget.formatExceededWarning(),
266
+ sessionId: session.id,
267
+ error: "BUDGET_EXCEEDED",
268
+ };
269
+ }
270
+
195
271
  // Build messages array for the provider
272
+ // opts.systemPrompt = full override, opts.appendSystemPrompt = append to default
196
273
  let systemPrompt;
197
274
  try {
198
- systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage, { personality });
275
+ systemPrompt = await this._buildSystemPrompt(userMessage, {
276
+ personality,
277
+ agentDef,
278
+ effortConfig,
279
+ systemPrompt: opts.systemPrompt ?? null,
280
+ appendSystemPrompt: opts.appendSystemPrompt ?? null,
281
+ });
199
282
  } catch {
200
283
  systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
201
284
  }
@@ -214,8 +297,15 @@ export class WispyEngine {
214
297
  }
215
298
  }
216
299
 
217
- // Add user message
218
- messages.push({ role: "user", content: userMessage });
300
+ // Add user message (with optional image attachments)
301
+ const userMsg = { role: "user", content: userMessage };
302
+ if (opts.images && opts.images.length > 0) {
303
+ const imageData = await this._loadImages(opts.images);
304
+ if (imageData.length > 0) {
305
+ userMsg.images = imageData;
306
+ }
307
+ }
308
+ messages.push(userMsg);
219
309
  this.sessions.addMessage(session.id, { role: "user", content: userMessage });
220
310
 
221
311
  // Audit: log incoming message
@@ -229,7 +319,7 @@ export class WispyEngine {
229
319
  const _startMs = Date.now();
230
320
  let responseText;
231
321
  try {
232
- responseText = await this._agentLoop(messages, session, { ...opts, emitter });
322
+ responseText = await this._agentLoop(messages, session, { ...opts, emitter, effortConfig, agentDef });
233
323
  } catch (err) {
234
324
  responseText = this._friendlyError(err);
235
325
  emitter.error(err);
@@ -338,7 +428,11 @@ export class WispyEngine {
338
428
  const loopDetector = new LoopDetector();
339
429
  let loopWarned = false;
340
430
 
341
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
431
+ // Effort level config
432
+ const effortConfig = opts.effortConfig ?? EFFORT_LEVELS.medium;
433
+ const maxRounds = effortConfig.maxRounds ?? MAX_TOOL_ROUNDS;
434
+
435
+ for (let round = 0; round < maxRounds; round++) {
342
436
  // ── Loop detection check before LLM call ─────────────────────────────
343
437
  if (loopDetector.size >= 2) {
344
438
  const loopCheck = loopDetector.check();
@@ -366,11 +460,25 @@ export class WispyEngine {
366
460
  }
367
461
  }
368
462
 
463
+ // Use agent model if specified
464
+ const agentDef = opts.agentDef ?? null;
465
+ const resolvedModel = agentDef?.model ?? opts.model;
466
+
369
467
  const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
370
468
  onChunk: opts.onChunk,
371
- model: opts.model,
469
+ model: resolvedModel,
470
+ maxTokens: effortConfig.maxTokens,
372
471
  });
373
472
 
473
+ // Record spend if usage info available
474
+ if (result?.usage) {
475
+ this.budget.record(
476
+ result.usage.inputTokens ?? result.usage.prompt_tokens ?? 0,
477
+ result.usage.outputTokens ?? result.usage.completion_tokens ?? 0,
478
+ resolvedModel ?? this.model,
479
+ ).catch(() => {});
480
+ }
481
+
374
482
  if (result.type === "text") {
375
483
  return result.text;
376
484
  }
@@ -419,7 +527,8 @@ export class WispyEngine {
419
527
  }
420
528
  }
421
529
 
422
- return "(tool call limit reached)";
530
+ const effortName = Object.entries(EFFORT_LEVELS).find(([, v]) => v === effortConfig)?.[0] ?? "medium";
531
+ return `(tool call limit reached — effort: ${effortName}, max rounds: ${maxRounds})`;
423
532
  }
424
533
 
425
534
  /**
@@ -865,6 +974,52 @@ export class WispyEngine {
865
974
 
866
975
  async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
867
976
  const personality = opts.personality ?? this._personality ?? null;
977
+ const agentDef = opts.agentDef ?? null;
978
+ const effortConfig = opts.effortConfig ?? null;
979
+
980
+ // Resolve system prompt override with precedence:
981
+ // opts.systemPrompt (call-level) > this._systemPrompt (engine config) > default
982
+ // Resolve append with precedence:
983
+ // opts.appendSystemPrompt (call-level) > this._appendSystemPrompt (engine config) > project settings
984
+ const systemPromptOverride = opts.systemPrompt ?? this._systemPrompt ?? null;
985
+ const appendSystemPrompt = opts.appendSystemPrompt ?? this._appendSystemPrompt ?? null;
986
+
987
+ // If a full system prompt override is provided (not from agent), use it as-is
988
+ // then apply appends
989
+ if (systemPromptOverride && !agentDef?.prompt) {
990
+ const parts = [systemPromptOverride];
991
+ if (appendSystemPrompt) {
992
+ parts.push("\n" + appendSystemPrompt);
993
+ }
994
+ if (personality && PERSONALITIES[personality]) {
995
+ parts.push(`\n## Personality Override (${personality})\n${PERSONALITIES[personality]}`);
996
+ }
997
+ if (effortConfig?.systemSuffix) {
998
+ parts.push("\n## Effort Level\n" + effortConfig.systemSuffix);
999
+ }
1000
+ return parts.join("");
1001
+ }
1002
+
1003
+ // If an agent with a custom prompt is active, use that as the system prompt base
1004
+ if (agentDef?.prompt) {
1005
+ const parts = [agentDef.prompt];
1006
+ // Append effort suffix if effort is not medium/default
1007
+ if (effortConfig?.systemSuffix) {
1008
+ parts.push("\n" + effortConfig.systemSuffix);
1009
+ }
1010
+ // Append custom system prompt addition if provided
1011
+ if (appendSystemPrompt) {
1012
+ parts.push("\n" + appendSystemPrompt);
1013
+ }
1014
+ // Still inject memories for context
1015
+ try {
1016
+ const memories = await this.memory.getContextForPrompt(lastUserMessage);
1017
+ if (memories) {
1018
+ parts.push("\n## Persistent Memory\n" + memories);
1019
+ }
1020
+ } catch { /* ignore */ }
1021
+ return parts.join("\n");
1022
+ }
868
1023
 
869
1024
  const parts = [
870
1025
  "You are Wispy 🌿 — a small ghost that lives in terminals.",
@@ -872,6 +1027,13 @@ export class WispyEngine {
872
1027
  "",
873
1028
  ];
874
1029
 
1030
+ // Inject effort level modifier if not default (medium)
1031
+ if (effortConfig?.systemSuffix) {
1032
+ parts.push("## Effort Level");
1033
+ parts.push(effortConfig.systemSuffix);
1034
+ parts.push("");
1035
+ }
1036
+
875
1037
  // Inject personality override if set
876
1038
  if (personality && PERSONALITIES[personality]) {
877
1039
  parts.push(`## Personality Override (${personality})`);
@@ -936,6 +1098,11 @@ export class WispyEngine {
936
1098
  }
937
1099
  }
938
1100
 
1101
+ // Append custom system prompt addition (from opts, engine config, or project settings)
1102
+ if (appendSystemPrompt) {
1103
+ parts.push("## Additional Instructions", appendSystemPrompt, "");
1104
+ }
1105
+
939
1106
  return parts.join("\n");
940
1107
  }
941
1108
 
@@ -1525,6 +1692,45 @@ export class WispyEngine {
1525
1692
  }
1526
1693
  }
1527
1694
 
1695
+ // ── Image handling ────────────────────────────────────────────────────────────
1696
+
1697
+ /**
1698
+ * Load images from file paths and return base64-encoded data with MIME types.
1699
+ * @param {string[]} imagePaths - Array of file paths
1700
+ * @returns {Array<{data: string, mimeType: string, path: string}>}
1701
+ */
1702
+ async _loadImages(imagePaths) {
1703
+ const MIME_TYPES = {
1704
+ ".png": "image/png",
1705
+ ".jpg": "image/jpeg",
1706
+ ".jpeg": "image/jpeg",
1707
+ ".gif": "image/gif",
1708
+ ".webp": "image/webp",
1709
+ ".bmp": "image/bmp",
1710
+ ".svg": "image/svg+xml",
1711
+ };
1712
+
1713
+ const results = [];
1714
+ for (const imgPath of imagePaths) {
1715
+ try {
1716
+ const resolvedPath = path.resolve(imgPath);
1717
+ const ext = path.extname(resolvedPath).toLowerCase();
1718
+ const mimeType = MIME_TYPES[ext] ?? "image/jpeg";
1719
+ const buffer = await readFile(resolvedPath);
1720
+ results.push({
1721
+ data: buffer.toString("base64"),
1722
+ mimeType,
1723
+ path: resolvedPath,
1724
+ });
1725
+ } catch (err) {
1726
+ if (process.env.WISPY_DEBUG) {
1727
+ console.error(`[wispy] Failed to load image ${imgPath}: ${err.message}`);
1728
+ }
1729
+ }
1730
+ }
1731
+ return results;
1732
+ }
1733
+
1528
1734
  // ── Cleanup ──────────────────────────────────────────────────────────────────
1529
1735
 
1530
1736
  destroy() {