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.
- package/bin/wispy.mjs +301 -0
- package/core/agents.mjs +133 -0
- package/core/budget.mjs +277 -0
- package/core/engine.mjs +216 -10
- package/core/harness.mjs +162 -0
- package/core/project-settings.mjs +122 -0
- package/core/session.mjs +27 -3
- package/lib/wispy-repl.mjs +141 -3
- package/package.json +1 -1
package/core/budget.mjs
ADDED
|
@@ -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({
|
|
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({
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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() {
|