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 +86 -0
- package/core/budget.mjs +277 -0
- package/core/engine.mjs +168 -8
- package/core/harness.mjs +162 -0
- package/core/project-settings.mjs +122 -0
- package/core/session.mjs +27 -3
- package/package.json +1 -1
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
|
});
|
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
|
}
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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