wispy-cli 2.7.14 → 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 CHANGED
@@ -60,6 +60,16 @@ const globalPersonality = extractFlag(["--personality"], true);
60
60
  const globalJsonMode = hasFlag("--json");
61
61
  if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
62
62
 
63
+ // New flags: system prompt, session name, effort, agent, budget, tool allow/deny
64
+ const globalSystemPrompt = extractFlag(["--system-prompt"], true);
65
+ const globalAppendSystemPrompt = extractFlag(["--append-system-prompt"], true);
66
+ const globalSessionName = extractFlag(["--name"], true);
67
+ const globalAgent = extractFlag(["--agent"], true);
68
+ const globalEffort = extractFlag(["--effort"], true);
69
+ const globalMaxBudget = extractFlag(["--max-budget-usd"], true);
70
+ const globalAllowedTools = extractFlag(["--allowedTools", "--allowed-tools"], true);
71
+ const globalDisallowedTools = extractFlag(["--disallowedTools", "--disallowed-tools"], true);
72
+
63
73
  // Parse image flags: -i <path> or --image <path> (multiple allowed)
64
74
  const imagePaths = [];
65
75
  {
@@ -78,6 +88,14 @@ const imagePaths = [];
78
88
  if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
79
89
  if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
80
90
  if (imagePaths.length > 0) process.env.WISPY_IMAGES = JSON.stringify(imagePaths);
91
+ if (globalSystemPrompt) process.env.WISPY_SYSTEM_PROMPT = globalSystemPrompt;
92
+ if (globalAppendSystemPrompt) process.env.WISPY_APPEND_SYSTEM_PROMPT = globalAppendSystemPrompt;
93
+ if (globalSessionName) process.env.WISPY_SESSION_NAME = globalSessionName;
94
+ if (globalAgent) process.env.WISPY_AGENT = globalAgent;
95
+ if (globalEffort) process.env.WISPY_EFFORT = globalEffort;
96
+ if (globalMaxBudget) process.env.WISPY_MAX_BUDGET_USD = globalMaxBudget;
97
+ if (globalAllowedTools) process.env.WISPY_ALLOWED_TOOLS = globalAllowedTools;
98
+ if (globalDisallowedTools) process.env.WISPY_DISALLOWED_TOOLS = globalDisallowedTools;
81
99
 
82
100
  // ── Flags ─────────────────────────────────────────────────────────────────────
83
101
 
@@ -134,22 +152,75 @@ Usage:
134
152
  wispy review --base <branch> Review changes against branch
135
153
  wispy review --commit <sha> Review a specific commit
136
154
  wispy review --json Output review as JSON
155
+ wispy agents [list] List available agents (built-in + custom)
156
+ wispy cost Show API spending report
137
157
 
138
158
  Options:
139
159
  -w, --workstream <name> Set active workstream
140
160
  -p, --profile <name> Use a named config profile
141
161
  -i, --image <path> Attach image (can use multiple times)
142
162
  --session <id> Resume a session
163
+ --name <name> Give this session a display name (e.g. --name "refactor-auth")
143
164
  --model <name> Override AI model
144
165
  --provider <name> Override AI provider
145
166
  --personality <name> Set personality (pragmatic|concise|explanatory|friendly|strict)
167
+ --agent <name> Use a named agent (reviewer|planner|explorer|custom)
168
+ --effort <level> Effort level: low|medium|high|max (default: medium)
169
+ --system-prompt <prompt> Replace the default system prompt entirely
170
+ --append-system-prompt <p> Append text to the default system prompt
171
+ --max-budget-usd <amount> Session budget cap in USD (e.g. 5.00)
172
+ --allowedTools <patterns> Only allow specified tools (e.g. "read_file Bash(git:*)")
173
+ --disallowedTools <patterns> Block specified tools (e.g. "write_file delete_file")
146
174
  --json Output JSONL events (for exec command, CI/pipeline use)
147
175
  --help, -h Show this help
148
176
  --version, -v Show version
177
+
178
+ Project settings:
179
+ .wispy/settings.json Per-project config (model, personality, tools, agents, etc.)
180
+ Wispy walks up from cwd to find this file.
149
181
  `);
150
182
  process.exit(0);
151
183
  }
152
184
 
185
+ // ── Agents ────────────────────────────────────────────────────────────────────
186
+
187
+ if (command === "agents" || command === "agent") {
188
+ try {
189
+ const { loadConfig } = await import(join(rootDir, "core/config.mjs"));
190
+ const { AgentManager } = await import(join(rootDir, "core/agents.mjs"));
191
+
192
+ const sub = args[1];
193
+ const config = await loadConfig();
194
+ const mgr = new AgentManager(config);
195
+
196
+ if (!sub || sub === "list") {
197
+ console.log(mgr.formatList());
198
+ } else {
199
+ console.error(`Unknown subcommand: ${sub}`);
200
+ console.log("Available: list");
201
+ process.exit(1);
202
+ }
203
+ } catch (err) {
204
+ console.error("Agents error:", err.message);
205
+ process.exit(1);
206
+ }
207
+ process.exit(0);
208
+ }
209
+
210
+ // ── Cost / Budget ──────────────────────────────────────────────────────────────
211
+
212
+ if (command === "cost" || command === "budget") {
213
+ try {
214
+ const { BudgetManager } = await import(join(rootDir, "core/budget.mjs"));
215
+ const budget = new BudgetManager();
216
+ console.log(await budget.formatReport());
217
+ } catch (err) {
218
+ console.error("Cost error:", err.message);
219
+ process.exit(1);
220
+ }
221
+ process.exit(0);
222
+ }
223
+
153
224
  // ── Setup (first-run wizard) ──────────────────────────────────────────────────
154
225
 
155
226
  if (command === "setup") {
@@ -680,9 +751,18 @@ if (command === "exec") {
680
751
  if (profileConfig.model) process.env.WISPY_MODEL = process.env.WISPY_MODEL || profileConfig.model;
681
752
 
682
753
  const personality = globalPersonality || profileConfig.personality || null;
754
+ const effort = globalEffort || profileConfig.effort || null;
755
+ const maxBudgetUsd = globalMaxBudget ? parseFloat(globalMaxBudget) : null;
756
+ const allowedTools = globalAllowedTools || null;
757
+ const disallowedTools = globalDisallowedTools || null;
758
+ const agent = globalAgent || null;
683
759
 
684
760
  const engine = new WispyEngine({
685
761
  personality,
762
+ effort,
763
+ maxBudgetUsd,
764
+ allowedTools,
765
+ disallowedTools,
686
766
  workstream: process.env.WISPY_WORKSTREAM ?? "default",
687
767
  });
688
768
 
@@ -698,11 +778,17 @@ if (command === "exec") {
698
778
  process.exit(1);
699
779
  }
700
780
 
781
+ // Apply tool allow/deny to harness
782
+ if (allowedTools) engine.harness.setAllowedTools(allowedTools);
783
+ if (disallowedTools) engine.harness.setDisallowedTools(disallowedTools);
784
+
701
785
  const emitter = createEmitter(globalJsonMode);
702
786
 
703
787
  const result = await engine.processMessage(null, message, {
704
788
  emitter,
705
789
  personality,
790
+ effort,
791
+ agent,
706
792
  skipSkillCapture: true,
707
793
  skipUserModel: true,
708
794
  });
@@ -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
  }
@@ -236,7 +319,7 @@ export class WispyEngine {
236
319
  const _startMs = Date.now();
237
320
  let responseText;
238
321
  try {
239
- responseText = await this._agentLoop(messages, session, { ...opts, emitter });
322
+ responseText = await this._agentLoop(messages, session, { ...opts, emitter, effortConfig, agentDef });
240
323
  } catch (err) {
241
324
  responseText = this._friendlyError(err);
242
325
  emitter.error(err);
@@ -345,7 +428,11 @@ export class WispyEngine {
345
428
  const loopDetector = new LoopDetector();
346
429
  let loopWarned = false;
347
430
 
348
- 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++) {
349
436
  // ── Loop detection check before LLM call ─────────────────────────────
350
437
  if (loopDetector.size >= 2) {
351
438
  const loopCheck = loopDetector.check();
@@ -373,11 +460,25 @@ export class WispyEngine {
373
460
  }
374
461
  }
375
462
 
463
+ // Use agent model if specified
464
+ const agentDef = opts.agentDef ?? null;
465
+ const resolvedModel = agentDef?.model ?? opts.model;
466
+
376
467
  const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
377
468
  onChunk: opts.onChunk,
378
- model: opts.model,
469
+ model: resolvedModel,
470
+ maxTokens: effortConfig.maxTokens,
379
471
  });
380
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
+
381
482
  if (result.type === "text") {
382
483
  return result.text;
383
484
  }
@@ -426,7 +527,8 @@ export class WispyEngine {
426
527
  }
427
528
  }
428
529
 
429
- 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})`;
430
532
  }
431
533
 
432
534
  /**
@@ -872,6 +974,52 @@ export class WispyEngine {
872
974
 
873
975
  async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
874
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
+ }
875
1023
 
876
1024
  const parts = [
877
1025
  "You are Wispy 🌿 — a small ghost that lives in terminals.",
@@ -879,6 +1027,13 @@ export class WispyEngine {
879
1027
  "",
880
1028
  ];
881
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
+
882
1037
  // Inject personality override if set
883
1038
  if (personality && PERSONALITIES[personality]) {
884
1039
  parts.push(`## Personality Override (${personality})`);
@@ -943,6 +1098,11 @@ export class WispyEngine {
943
1098
  }
944
1099
  }
945
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
+
946
1106
  return parts.join("\n");
947
1107
  }
948
1108
 
package/core/harness.mjs CHANGED
@@ -19,6 +19,99 @@ import os from "node:os";
19
19
 
20
20
  import { EVENT_TYPES } from "./audit.mjs";
21
21
 
22
+ // ── Tool allow/deny patterns ───────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Claude Code compatible tool name aliases.
26
+ * Maps user-facing alias to internal tool name.
27
+ */
28
+ export const TOOL_ALIASES = {
29
+ "Bash": "run_command",
30
+ "Edit": "file_edit",
31
+ "Write": "write_file",
32
+ "Read": "read_file",
33
+ "Grep": "file_search",
34
+ "LS": "list_directory",
35
+ "WebSearch": "web_search",
36
+ };
37
+
38
+ /**
39
+ * Parse a tool pattern string like:
40
+ * "Bash(git:*)" → { tool: "run_command", argPattern: "git *" }
41
+ * "Edit" → { tool: "file_edit", argPattern: "*" }
42
+ * "Read(*.ts)" → { tool: "read_file", argPattern: "*.ts" }
43
+ * "run_command" → { tool: "run_command", argPattern: "*" }
44
+ *
45
+ * @param {string} pattern
46
+ * @returns {{ tool: string, argPattern: string }}
47
+ */
48
+ export function parseToolPattern(pattern) {
49
+ const m = pattern.match(/^([^(]+)(?:\(([^)]*)\))?$/);
50
+ if (!m) return null;
51
+
52
+ const rawName = m[1].trim();
53
+ const argSpec = m[2] ?? "*";
54
+
55
+ // Resolve alias
56
+ const toolName = TOOL_ALIASES[rawName] ?? rawName;
57
+
58
+ // Normalize "git:*" style to "git *" for command matching
59
+ const argPattern = argSpec.replace(/:/g, " ").trim() || "*";
60
+
61
+ return { tool: toolName, argPattern };
62
+ }
63
+
64
+ /**
65
+ * Parse a space-separated list of tool patterns.
66
+ * @param {string} patternsStr - e.g. "Bash(git:*) read_file Edit"
67
+ * @returns {Array<{ tool: string, argPattern: string }>}
68
+ */
69
+ export function parseToolPatternList(patternsStr) {
70
+ if (!patternsStr) return [];
71
+ // Split on whitespace but respect parentheses
72
+ const patterns = [];
73
+ let current = "";
74
+ let depth = 0;
75
+ for (const ch of patternsStr) {
76
+ if (ch === "(") { depth++; current += ch; }
77
+ else if (ch === ")") { depth--; current += ch; }
78
+ else if ((ch === " " || ch === ",") && depth === 0) {
79
+ if (current.trim()) patterns.push(current.trim());
80
+ current = "";
81
+ } else {
82
+ current += ch;
83
+ }
84
+ }
85
+ if (current.trim()) patterns.push(current.trim());
86
+ return patterns.map(parseToolPattern).filter(Boolean);
87
+ }
88
+
89
+ /**
90
+ * Check whether a tool call matches a pattern.
91
+ * @param {string} toolName
92
+ * @param {object} args
93
+ * @param {{ tool: string, argPattern: string }} pattern
94
+ */
95
+ export function matchesPattern(toolName, args, pattern) {
96
+ // Tool name must match
97
+ if (pattern.tool !== toolName && pattern.tool !== "*") return false;
98
+ // If no arg pattern, it's a match
99
+ if (pattern.argPattern === "*") return true;
100
+ // Get the relevant arg string
101
+ const argStr = _getArgString(toolName, args);
102
+ return _globMatch(argStr, pattern.argPattern);
103
+ }
104
+
105
+ /**
106
+ * Simple glob matching (supports * wildcard).
107
+ */
108
+ function _globMatch(str, pattern) {
109
+ if (pattern === "*") return true;
110
+ // Convert glob to regex
111
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
112
+ return new RegExp(`^${escaped}`, "i").test(str);
113
+ }
114
+
22
115
  // ── Approval gate constants ────────────────────────────────────────────────────
23
116
 
24
117
  // Tools that require approval depending on security mode
@@ -491,6 +584,55 @@ export class Harness extends EventEmitter {
491
584
  file_edit: "diff",
492
585
  git: "preview",
493
586
  };
587
+
588
+ // Tool allow/deny patterns (from config)
589
+ this._allowedPatterns = config.allowedTools ? parseToolPatternList(config.allowedTools) : null; // null = allow all
590
+ this._disallowedPatterns = config.disallowedTools ? parseToolPatternList(config.disallowedTools) : [];
591
+ }
592
+
593
+ /**
594
+ * Set allowed tools (replaces current filter).
595
+ * @param {string|Array} patterns - Space-separated string or array of pattern strings
596
+ */
597
+ setAllowedTools(patterns) {
598
+ if (!patterns) { this._allowedPatterns = null; return; }
599
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
600
+ this._allowedPatterns = parseToolPatternList(str);
601
+ }
602
+
603
+ /**
604
+ * Set disallowed tools.
605
+ * @param {string|Array} patterns
606
+ */
607
+ setDisallowedTools(patterns) {
608
+ if (!patterns) { this._disallowedPatterns = []; return; }
609
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
610
+ this._disallowedPatterns = parseToolPatternList(str);
611
+ }
612
+
613
+ /**
614
+ * Check if a tool call is allowed by the current allow/deny filters.
615
+ * @param {string} toolName
616
+ * @param {object} args
617
+ * @returns {{ allowed: boolean, reason?: string }}
618
+ */
619
+ checkToolFilter(toolName, args) {
620
+ // Check disallowed patterns first (deny takes precedence)
621
+ for (const pattern of this._disallowedPatterns) {
622
+ if (matchesPattern(toolName, args, pattern)) {
623
+ return { allowed: false, reason: `Tool '${toolName}' is in the disallowed list.` };
624
+ }
625
+ }
626
+
627
+ // Check allowed patterns (if set)
628
+ if (this._allowedPatterns !== null && this._allowedPatterns.length > 0) {
629
+ const allowed = this._allowedPatterns.some(p => matchesPattern(toolName, args, p));
630
+ if (!allowed) {
631
+ return { allowed: false, reason: `Tool '${toolName}' is not in the allowed list.` };
632
+ }
633
+ }
634
+
635
+ return { allowed: true };
494
636
  }
495
637
 
496
638
  /**
@@ -510,6 +652,26 @@ export class Harness extends EventEmitter {
510
652
 
511
653
  const callStart = Date.now();
512
654
 
655
+ // ── 0. Tool allow/deny filter ────────────────────────────────────────────
656
+ const filterResult = this.checkToolFilter(toolName, args);
657
+ if (!filterResult.allowed) {
658
+ receipt.approved = false;
659
+ receipt.success = false;
660
+ receipt.error = filterResult.reason ?? `Tool '${toolName}' is not available.`;
661
+ receipt.duration = 0;
662
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "tool-filter" });
663
+ // Return a clear error that the LLM can understand
664
+ return new HarnessResult({
665
+ result: {
666
+ success: false,
667
+ error: filterResult.reason ?? `Tool '${toolName}' is not available in this session.`,
668
+ tool_not_available: true,
669
+ },
670
+ receipt,
671
+ denied: true,
672
+ });
673
+ }
674
+
513
675
  // ── 1. Permission check ──────────────────────────────────────────────────
514
676
  const permResult = await this.permissions.check(toolName, args, context);
515
677
  receipt.permissionLevel = permResult.level ?? "auto";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * core/project-settings.mjs — Per-project configuration for Wispy
3
+ *
4
+ * Supports .wispy/settings.json in the project root (or any parent dir).
5
+ * Settings precedence (highest to lowest):
6
+ * 1. CLI flags
7
+ * 2. Project settings (.wispy/settings.json)
8
+ * 3. User profile settings (wispy -p <name>)
9
+ * 4. User config (~/.wispy/config.json)
10
+ * 5. Defaults
11
+ *
12
+ * Example .wispy/settings.json:
13
+ * {
14
+ * "model": "gpt-4o",
15
+ * "personality": "pragmatic",
16
+ * "effort": "high",
17
+ * "appendSystemPrompt": "This is a TypeScript project using Next.js 15.",
18
+ * "features": { "browser_integration": true },
19
+ * "tools": {
20
+ * "allow": ["run_command", "read_file", "write_file", "file_edit"],
21
+ * "deny": ["delete_file"]
22
+ * },
23
+ * "agents": {
24
+ * "styler": { "description": "CSS specialist", "prompt": "You are a CSS/Tailwind expert." }
25
+ * }
26
+ * }
27
+ */
28
+
29
+ import path from "node:path";
30
+ import { readFile } from "node:fs/promises";
31
+
32
+ const SETTINGS_FILENAME = "settings.json";
33
+ const SETTINGS_DIR = ".wispy";
34
+
35
+ /**
36
+ * Walk up from startDir looking for .wispy/settings.json.
37
+ * Returns parsed settings object (with _projectRoot added) or null.
38
+ *
39
+ * @param {string} startDir - Directory to start searching from (default: cwd)
40
+ * @returns {Promise<object|null>}
41
+ */
42
+ export async function findProjectSettings(startDir = process.cwd()) {
43
+ let dir = path.resolve(startDir);
44
+ const root = path.parse(dir).root;
45
+
46
+ while (true) {
47
+ const settingsPath = path.join(dir, SETTINGS_DIR, SETTINGS_FILENAME);
48
+ try {
49
+ const raw = await readFile(settingsPath, "utf8");
50
+ const settings = JSON.parse(raw);
51
+ if (settings && typeof settings === "object") {
52
+ settings._projectRoot = dir;
53
+ settings._settingsPath = settingsPath;
54
+ return settings;
55
+ }
56
+ } catch { /* not found or parse error, keep walking */ }
57
+
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir || dir === root) break;
60
+ dir = parent;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Deep-merge settings with correct precedence.
67
+ * CLI flags > project settings > profile settings > base config > defaults.
68
+ *
69
+ * For object values (like "features", "tools", "agents"), performs shallow merge.
70
+ * For scalar values, higher-precedence wins.
71
+ *
72
+ * @param {object} base - Base user config (~/.wispy/config.json)
73
+ * @param {object|null} project - Project settings (.wispy/settings.json)
74
+ * @param {object|null} profile - Named profile from config.profiles
75
+ * @param {object} cli - CLI flag overrides
76
+ * @returns {object} Merged settings
77
+ */
78
+ export function mergeSettings(base = {}, project = null, profile = null, cli = {}) {
79
+ // Start with base config (strip profiles map to avoid leakage)
80
+ const { profiles: _profiles, ...baseClean } = base;
81
+ let merged = { ...baseClean };
82
+
83
+ // Apply profile on top (profile keys override base)
84
+ if (profile && typeof profile === "object") {
85
+ const { profiles: _pp, ...profileClean } = profile;
86
+ for (const [key, value] of Object.entries(profileClean)) {
87
+ if (value !== undefined) merged[key] = value;
88
+ }
89
+ }
90
+
91
+ // Apply project settings on top (project overrides profile + base)
92
+ if (project && typeof project === "object") {
93
+ for (const [key, value] of Object.entries(project)) {
94
+ if (key.startsWith("_")) continue; // skip internal metadata keys
95
+ if (value !== undefined && value !== null) {
96
+ // Deep merge objects (features, tools, agents)
97
+ if (
98
+ typeof value === "object" &&
99
+ !Array.isArray(value) &&
100
+ typeof merged[key] === "object" &&
101
+ !Array.isArray(merged[key]) &&
102
+ merged[key] !== null
103
+ ) {
104
+ merged[key] = { ...merged[key], ...value };
105
+ } else {
106
+ merged[key] = value;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // Apply CLI flags last (highest precedence)
113
+ if (cli && typeof cli === "object") {
114
+ for (const [key, value] of Object.entries(cli)) {
115
+ if (value !== undefined && value !== null) {
116
+ merged[key] = value;
117
+ }
118
+ }
119
+ }
120
+
121
+ return merged;
122
+ }
package/core/session.mjs CHANGED
@@ -23,7 +23,7 @@ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
23
23
  import { SESSIONS_DIR } from "./config.mjs";
24
24
 
25
25
  export class Session {
26
- constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null }) {
26
+ constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null, name = null, model = null, cwd = null }) {
27
27
  this.id = id;
28
28
  this.workstream = workstream;
29
29
  this.channel = channel;
@@ -31,14 +31,20 @@ export class Session {
31
31
  this.messages = messages;
32
32
  this.createdAt = createdAt ?? new Date().toISOString();
33
33
  this.updatedAt = new Date().toISOString();
34
+ this.name = name ?? null; // user-given display name
35
+ this.model = model ?? null; // model used in this session
36
+ this.cwd = cwd ?? process.cwd(); // working directory when session was created
34
37
  }
35
38
 
36
39
  toJSON() {
37
40
  return {
38
41
  id: this.id,
42
+ name: this.name,
39
43
  workstream: this.workstream,
40
44
  channel: this.channel,
41
45
  chatId: this.chatId,
46
+ model: this.model,
47
+ cwd: this.cwd,
42
48
  messages: this.messages,
43
49
  createdAt: this.createdAt,
44
50
  updatedAt: this.updatedAt,
@@ -55,9 +61,9 @@ export class SessionManager {
55
61
  /**
56
62
  * Create a new session
57
63
  */
58
- create({ workstream = "default", channel = null, chatId = null } = {}) {
64
+ create({ workstream = "default", channel = null, chatId = null, name = null, model = null, cwd = null } = {}) {
59
65
  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
60
- const session = new Session({ id, workstream, channel, chatId });
66
+ const session = new Session({ id, workstream, channel, chatId, name, model, cwd });
61
67
  this._sessions.set(id, session);
62
68
  if (channel && chatId) {
63
69
  this._keyMap.set(`${channel}:${chatId}`, id);
@@ -136,6 +142,7 @@ export class SessionManager {
136
142
 
137
143
  results.push({
138
144
  id: data.id,
145
+ name: data.name ?? null,
139
146
  workstream: data.workstream ?? "default",
140
147
  channel: data.channel ?? null,
141
148
  chatId: data.chatId ?? null,
@@ -184,6 +191,8 @@ export class SessionManager {
184
191
  workstream: opts.workstream ?? source.workstream,
185
192
  channel: opts.channel ?? null,
186
193
  chatId: opts.chatId ?? null,
194
+ name: opts.name ?? null,
195
+ model: opts.model ?? source.model ?? null,
187
196
  });
188
197
 
189
198
  // Copy message history (deep copy)
@@ -196,6 +205,21 @@ export class SessionManager {
196
205
  return forked;
197
206
  }
198
207
 
208
+ /**
209
+ * Set (or update) the display name of a session.
210
+ * Persists immediately to disk.
211
+ *
212
+ * @param {string} id - Session ID
213
+ * @param {string} name - Display name
214
+ */
215
+ async setName(id, name) {
216
+ const session = this._sessions.get(id);
217
+ if (!session) throw new Error(`Session not found: ${id}`);
218
+ session.name = name;
219
+ session.updatedAt = new Date().toISOString();
220
+ await this.save(id);
221
+ }
222
+
199
223
  /**
200
224
  * Add a message to a session.
201
225
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.14",
3
+ "version": "2.7.15",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",