wispy-cli 2.7.14 → 2.7.16
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 +112 -5
- package/core/budget.mjs +277 -0
- package/core/engine.mjs +174 -8
- package/core/harness.mjs +162 -0
- package/core/project-settings.mjs +122 -0
- package/core/session.mjs +27 -3
- package/lib/wispy-repl.mjs +47 -2
- 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,37 @@ 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;
|
|
759
|
+
|
|
760
|
+
// Load project settings and merge
|
|
761
|
+
const { findProjectSettings, mergeSettings } = await import(join(rootDir, "core/project-settings.mjs"));
|
|
762
|
+
const projectSettings = await findProjectSettings();
|
|
763
|
+
const mergedConfig = mergeSettings(
|
|
764
|
+
profileConfig,
|
|
765
|
+
projectSettings,
|
|
766
|
+
null, // profile already merged into profileConfig via loadConfigWithProfile
|
|
767
|
+
{
|
|
768
|
+
...(personality ? { personality } : {}),
|
|
769
|
+
...(effort ? { effort } : {}),
|
|
770
|
+
...(maxBudgetUsd ? { maxBudgetUsd } : {}),
|
|
771
|
+
...(globalSystemPrompt ? { systemPrompt: globalSystemPrompt } : {}),
|
|
772
|
+
...(globalAppendSystemPrompt ? { appendSystemPrompt: globalAppendSystemPrompt } : {}),
|
|
773
|
+
}
|
|
774
|
+
);
|
|
683
775
|
|
|
684
776
|
const engine = new WispyEngine({
|
|
685
|
-
|
|
777
|
+
...mergedConfig,
|
|
778
|
+
personality: mergedConfig.personality ?? personality,
|
|
779
|
+
effort: mergedConfig.effort ?? effort,
|
|
780
|
+
maxBudgetUsd: mergedConfig.maxBudgetUsd ?? maxBudgetUsd,
|
|
781
|
+
allowedTools: mergedConfig.allowedTools ?? allowedTools,
|
|
782
|
+
disallowedTools: mergedConfig.disallowedTools ?? disallowedTools,
|
|
783
|
+
systemPrompt: mergedConfig.systemPrompt ?? null,
|
|
784
|
+
appendSystemPrompt: mergedConfig.appendSystemPrompt ?? null,
|
|
686
785
|
workstream: process.env.WISPY_WORKSTREAM ?? "default",
|
|
687
786
|
});
|
|
688
787
|
|
|
@@ -698,11 +797,18 @@ if (command === "exec") {
|
|
|
698
797
|
process.exit(1);
|
|
699
798
|
}
|
|
700
799
|
|
|
800
|
+
// Apply tool allow/deny to harness
|
|
801
|
+
if (allowedTools) engine.harness.setAllowedTools(allowedTools);
|
|
802
|
+
if (disallowedTools) engine.harness.setDisallowedTools(disallowedTools);
|
|
803
|
+
|
|
701
804
|
const emitter = createEmitter(globalJsonMode);
|
|
702
805
|
|
|
703
806
|
const result = await engine.processMessage(null, message, {
|
|
704
807
|
emitter,
|
|
705
808
|
personality,
|
|
809
|
+
effort,
|
|
810
|
+
agent,
|
|
811
|
+
sessionName: globalSessionName ?? null,
|
|
706
812
|
skipSkillCapture: true,
|
|
707
813
|
skipUserModel: true,
|
|
708
814
|
});
|
|
@@ -890,7 +996,8 @@ if (command === "sessions") {
|
|
|
890
996
|
for (const s of sessions) {
|
|
891
997
|
const ts = new Date(s.updatedAt).toLocaleString();
|
|
892
998
|
const preview = s.firstMessage ? ` "${s.firstMessage.slice(0, 60)}${s.firstMessage.length > 60 ? "…" : ""}"` : "";
|
|
893
|
-
|
|
999
|
+
const nameLabel = s.name ? ` · 📎 ${s.name}` : "";
|
|
1000
|
+
console.log(` ${s.id}${nameLabel}`);
|
|
894
1001
|
console.log(` ${ts} · ${s.workstream} · ${s.messageCount} msgs${s.model ? ` · ${s.model}` : ""}`);
|
|
895
1002
|
if (preview) console.log(` ${preview}`);
|
|
896
1003
|
console.log("");
|
|
@@ -901,7 +1008,7 @@ if (command === "sessions") {
|
|
|
901
1008
|
const choices = [
|
|
902
1009
|
{ name: "(exit)", value: null },
|
|
903
1010
|
...sessions.map(s => ({
|
|
904
|
-
name: `${s.id} ${new Date(s.updatedAt).toLocaleString()} "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
1011
|
+
name: `${s.id}${s.name ? ` [${s.name}]` : ""} ${new Date(s.updatedAt).toLocaleString()} "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
905
1012
|
value: s.id,
|
|
906
1013
|
})),
|
|
907
1014
|
];
|
|
@@ -970,7 +1077,7 @@ if (command === "resume") {
|
|
|
970
1077
|
}
|
|
971
1078
|
|
|
972
1079
|
const choices = sessions.map(s => ({
|
|
973
|
-
name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
1080
|
+
name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}]${s.name ? ` 📎 ${s.name}` : ""} ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
974
1081
|
value: s.id,
|
|
975
1082
|
}));
|
|
976
1083
|
|
|
@@ -1019,7 +1126,7 @@ if (command === "fork") {
|
|
|
1019
1126
|
}
|
|
1020
1127
|
|
|
1021
1128
|
const choices = sessions.map(s => ({
|
|
1022
|
-
name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
1129
|
+
name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}]${s.name ? ` 📎 ${s.name}` : ""} ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
|
|
1023
1130
|
value: s.id,
|
|
1024
1131
|
}));
|
|
1025
1132
|
|
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,21 @@ 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
|
+
// System prompt overrides from config
|
|
106
|
+
this._systemPrompt = config.systemPrompt ?? null; // full replacement
|
|
107
|
+
this._appendSystemPrompt = config.appendSystemPrompt ?? null; // append to default
|
|
108
|
+
// Project settings cache (loaded lazily)
|
|
109
|
+
this._projectSettings = null;
|
|
110
|
+
this._projectSettingsLoaded = false;
|
|
111
|
+
// Agent manager
|
|
112
|
+
this.agentManager = new AgentManager(config);
|
|
113
|
+
// Effort level: low | medium | high | max
|
|
114
|
+
this._effort = config.effort ?? process.env.WISPY_EFFORT ?? "medium";
|
|
115
|
+
// Budget manager
|
|
116
|
+
this.budget = new BudgetManager({
|
|
117
|
+
maxBudgetUsd: config.maxBudgetUsd ?? null,
|
|
118
|
+
budgetPath: config.budgetPath,
|
|
119
|
+
});
|
|
77
120
|
}
|
|
78
121
|
|
|
79
122
|
get activeWorkstream() { return this._activeWorkstream; }
|
|
@@ -168,13 +211,25 @@ export class WispyEngine {
|
|
|
168
211
|
if (sessionId) {
|
|
169
212
|
session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
|
|
170
213
|
if (!session) {
|
|
171
|
-
session = this.sessions.create({
|
|
214
|
+
session = this.sessions.create({
|
|
215
|
+
workstream: opts.workstream ?? this._activeWorkstream,
|
|
216
|
+
name: opts.sessionName ?? null,
|
|
217
|
+
});
|
|
218
|
+
} else if (opts.sessionName && !session.name) {
|
|
219
|
+
// Set name on existing session if not already named
|
|
220
|
+
session.name = opts.sessionName;
|
|
172
221
|
}
|
|
173
222
|
} else {
|
|
174
|
-
session = this.sessions.create({
|
|
223
|
+
session = this.sessions.create({
|
|
224
|
+
workstream: opts.workstream ?? this._activeWorkstream,
|
|
225
|
+
name: opts.sessionName ?? null,
|
|
226
|
+
});
|
|
175
227
|
}
|
|
176
228
|
} catch (err) {
|
|
177
|
-
session = this.sessions.create({
|
|
229
|
+
session = this.sessions.create({
|
|
230
|
+
workstream: opts.workstream ?? this._activeWorkstream,
|
|
231
|
+
name: opts.sessionName ?? null,
|
|
232
|
+
});
|
|
178
233
|
}
|
|
179
234
|
|
|
180
235
|
// JSONL emitter (no-op by default)
|
|
@@ -192,10 +247,44 @@ export class WispyEngine {
|
|
|
192
247
|
// Resolve personality for this call
|
|
193
248
|
const personality = opts.personality ?? this._personality ?? null;
|
|
194
249
|
|
|
250
|
+
// Resolve effort level for this call
|
|
251
|
+
const effort = opts.effort ?? this._effort ?? "medium";
|
|
252
|
+
const effortConfig = EFFORT_LEVELS[effort] ?? EFFORT_LEVELS.medium;
|
|
253
|
+
|
|
254
|
+
// Resolve agent for this call
|
|
255
|
+
const agentName = opts.agent ?? null;
|
|
256
|
+
const agentDef = agentName ? this.agentManager.get(agentName) : null;
|
|
257
|
+
if (agentName && !agentDef) {
|
|
258
|
+
return {
|
|
259
|
+
role: "assistant",
|
|
260
|
+
content: `⚠️ Unknown agent: "${agentName}". Run \`wispy agents\` to list available agents.`,
|
|
261
|
+
sessionId: session.id,
|
|
262
|
+
error: "UNKNOWN_AGENT",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check budget before processing
|
|
267
|
+
const budgetCheck = this.budget.checkBudget();
|
|
268
|
+
if (!budgetCheck.ok) {
|
|
269
|
+
return {
|
|
270
|
+
role: "assistant",
|
|
271
|
+
content: this.budget.formatExceededWarning(),
|
|
272
|
+
sessionId: session.id,
|
|
273
|
+
error: "BUDGET_EXCEEDED",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
195
277
|
// Build messages array for the provider
|
|
278
|
+
// opts.systemPrompt = full override, opts.appendSystemPrompt = append to default
|
|
196
279
|
let systemPrompt;
|
|
197
280
|
try {
|
|
198
|
-
systemPrompt =
|
|
281
|
+
systemPrompt = await this._buildSystemPrompt(userMessage, {
|
|
282
|
+
personality,
|
|
283
|
+
agentDef,
|
|
284
|
+
effortConfig,
|
|
285
|
+
systemPrompt: opts.systemPrompt ?? null,
|
|
286
|
+
appendSystemPrompt: opts.appendSystemPrompt ?? null,
|
|
287
|
+
});
|
|
199
288
|
} catch {
|
|
200
289
|
systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
|
|
201
290
|
}
|
|
@@ -236,7 +325,7 @@ export class WispyEngine {
|
|
|
236
325
|
const _startMs = Date.now();
|
|
237
326
|
let responseText;
|
|
238
327
|
try {
|
|
239
|
-
responseText = await this._agentLoop(messages, session, { ...opts, emitter });
|
|
328
|
+
responseText = await this._agentLoop(messages, session, { ...opts, emitter, effortConfig, agentDef });
|
|
240
329
|
} catch (err) {
|
|
241
330
|
responseText = this._friendlyError(err);
|
|
242
331
|
emitter.error(err);
|
|
@@ -345,7 +434,11 @@ export class WispyEngine {
|
|
|
345
434
|
const loopDetector = new LoopDetector();
|
|
346
435
|
let loopWarned = false;
|
|
347
436
|
|
|
348
|
-
|
|
437
|
+
// Effort level config
|
|
438
|
+
const effortConfig = opts.effortConfig ?? EFFORT_LEVELS.medium;
|
|
439
|
+
const maxRounds = effortConfig.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
440
|
+
|
|
441
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
349
442
|
// ── Loop detection check before LLM call ─────────────────────────────
|
|
350
443
|
if (loopDetector.size >= 2) {
|
|
351
444
|
const loopCheck = loopDetector.check();
|
|
@@ -373,11 +466,25 @@ export class WispyEngine {
|
|
|
373
466
|
}
|
|
374
467
|
}
|
|
375
468
|
|
|
469
|
+
// Use agent model if specified
|
|
470
|
+
const agentDef = opts.agentDef ?? null;
|
|
471
|
+
const resolvedModel = agentDef?.model ?? opts.model;
|
|
472
|
+
|
|
376
473
|
const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
|
|
377
474
|
onChunk: opts.onChunk,
|
|
378
|
-
model:
|
|
475
|
+
model: resolvedModel,
|
|
476
|
+
maxTokens: effortConfig.maxTokens,
|
|
379
477
|
});
|
|
380
478
|
|
|
479
|
+
// Record spend if usage info available
|
|
480
|
+
if (result?.usage) {
|
|
481
|
+
this.budget.record(
|
|
482
|
+
result.usage.inputTokens ?? result.usage.prompt_tokens ?? 0,
|
|
483
|
+
result.usage.outputTokens ?? result.usage.completion_tokens ?? 0,
|
|
484
|
+
resolvedModel ?? this.model,
|
|
485
|
+
).catch(() => {});
|
|
486
|
+
}
|
|
487
|
+
|
|
381
488
|
if (result.type === "text") {
|
|
382
489
|
return result.text;
|
|
383
490
|
}
|
|
@@ -426,7 +533,8 @@ export class WispyEngine {
|
|
|
426
533
|
}
|
|
427
534
|
}
|
|
428
535
|
|
|
429
|
-
|
|
536
|
+
const effortName = Object.entries(EFFORT_LEVELS).find(([, v]) => v === effortConfig)?.[0] ?? "medium";
|
|
537
|
+
return `(tool call limit reached — effort: ${effortName}, max rounds: ${maxRounds})`;
|
|
430
538
|
}
|
|
431
539
|
|
|
432
540
|
/**
|
|
@@ -872,6 +980,52 @@ export class WispyEngine {
|
|
|
872
980
|
|
|
873
981
|
async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
|
|
874
982
|
const personality = opts.personality ?? this._personality ?? null;
|
|
983
|
+
const agentDef = opts.agentDef ?? null;
|
|
984
|
+
const effortConfig = opts.effortConfig ?? null;
|
|
985
|
+
|
|
986
|
+
// Resolve system prompt override with precedence:
|
|
987
|
+
// opts.systemPrompt (call-level) > this._systemPrompt (engine config) > default
|
|
988
|
+
// Resolve append with precedence:
|
|
989
|
+
// opts.appendSystemPrompt (call-level) > this._appendSystemPrompt (engine config) > project settings
|
|
990
|
+
const systemPromptOverride = opts.systemPrompt ?? this._systemPrompt ?? null;
|
|
991
|
+
const appendSystemPrompt = opts.appendSystemPrompt ?? this._appendSystemPrompt ?? null;
|
|
992
|
+
|
|
993
|
+
// If a full system prompt override is provided (not from agent), use it as-is
|
|
994
|
+
// then apply appends
|
|
995
|
+
if (systemPromptOverride && !agentDef?.prompt) {
|
|
996
|
+
const parts = [systemPromptOverride];
|
|
997
|
+
if (appendSystemPrompt) {
|
|
998
|
+
parts.push("\n" + appendSystemPrompt);
|
|
999
|
+
}
|
|
1000
|
+
if (personality && PERSONALITIES[personality]) {
|
|
1001
|
+
parts.push(`\n## Personality Override (${personality})\n${PERSONALITIES[personality]}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (effortConfig?.systemSuffix) {
|
|
1004
|
+
parts.push("\n## Effort Level\n" + effortConfig.systemSuffix);
|
|
1005
|
+
}
|
|
1006
|
+
return parts.join("");
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// If an agent with a custom prompt is active, use that as the system prompt base
|
|
1010
|
+
if (agentDef?.prompt) {
|
|
1011
|
+
const parts = [agentDef.prompt];
|
|
1012
|
+
// Append effort suffix if effort is not medium/default
|
|
1013
|
+
if (effortConfig?.systemSuffix) {
|
|
1014
|
+
parts.push("\n" + effortConfig.systemSuffix);
|
|
1015
|
+
}
|
|
1016
|
+
// Append custom system prompt addition if provided
|
|
1017
|
+
if (appendSystemPrompt) {
|
|
1018
|
+
parts.push("\n" + appendSystemPrompt);
|
|
1019
|
+
}
|
|
1020
|
+
// Still inject memories for context
|
|
1021
|
+
try {
|
|
1022
|
+
const memories = await this.memory.getContextForPrompt(lastUserMessage);
|
|
1023
|
+
if (memories) {
|
|
1024
|
+
parts.push("\n## Persistent Memory\n" + memories);
|
|
1025
|
+
}
|
|
1026
|
+
} catch { /* ignore */ }
|
|
1027
|
+
return parts.join("\n");
|
|
1028
|
+
}
|
|
875
1029
|
|
|
876
1030
|
const parts = [
|
|
877
1031
|
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
@@ -879,6 +1033,13 @@ export class WispyEngine {
|
|
|
879
1033
|
"",
|
|
880
1034
|
];
|
|
881
1035
|
|
|
1036
|
+
// Inject effort level modifier if not default (medium)
|
|
1037
|
+
if (effortConfig?.systemSuffix) {
|
|
1038
|
+
parts.push("## Effort Level");
|
|
1039
|
+
parts.push(effortConfig.systemSuffix);
|
|
1040
|
+
parts.push("");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
882
1043
|
// Inject personality override if set
|
|
883
1044
|
if (personality && PERSONALITIES[personality]) {
|
|
884
1045
|
parts.push(`## Personality Override (${personality})`);
|
|
@@ -943,6 +1104,11 @@ export class WispyEngine {
|
|
|
943
1104
|
}
|
|
944
1105
|
}
|
|
945
1106
|
|
|
1107
|
+
// Append custom system prompt addition (from opts, engine config, or project settings)
|
|
1108
|
+
if (appendSystemPrompt) {
|
|
1109
|
+
parts.push("## Additional Instructions", appendSystemPrompt, "");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
946
1112
|
return parts.join("\n");
|
|
947
1113
|
}
|
|
948
1114
|
|
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/lib/wispy-repl.mjs
CHANGED
|
@@ -327,6 +327,7 @@ ${bold("Wispy Commands:")}
|
|
|
327
327
|
${cyan("/search")} <keyword> Search across workstreams
|
|
328
328
|
${cyan("/skills")} List installed skills
|
|
329
329
|
${cyan("/sessions")} List sessions
|
|
330
|
+
${cyan("/name")} <name> Give this session a display name (e.g. /name refactor-auth)
|
|
330
331
|
${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP management
|
|
331
332
|
${cyan("/remember")} <text> Save text to main memory (MEMORY.md)
|
|
332
333
|
${cyan("/forget")} <key> Delete a memory file
|
|
@@ -1069,6 +1070,28 @@ ${bold("Permissions & Audit (v1.1):")}
|
|
|
1069
1070
|
return true;
|
|
1070
1071
|
}
|
|
1071
1072
|
|
|
1073
|
+
if (cmd.startsWith("/name")) {
|
|
1074
|
+
const nameParts = input.split(" ").slice(1);
|
|
1075
|
+
const sessionName = nameParts.join(" ").trim();
|
|
1076
|
+
if (!sessionName) {
|
|
1077
|
+
console.log(yellow("Usage: /name <display-name> (e.g. /name refactor-auth)"));
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
// Find the active session and set its name
|
|
1081
|
+
try {
|
|
1082
|
+
const currentSession = engine.sessions?.list?.()?.find(Boolean);
|
|
1083
|
+
if (currentSession) {
|
|
1084
|
+
await engine.sessions.setName(currentSession.id, sessionName);
|
|
1085
|
+
console.log(green(`📎 Session named: "${sessionName}" (${currentSession.id})`));
|
|
1086
|
+
} else {
|
|
1087
|
+
console.log(yellow("No active session found."));
|
|
1088
|
+
}
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.log(red(`Failed to set session name: ${err.message}`));
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1072
1095
|
if (cmd === "/quit" || cmd === "/exit") {
|
|
1073
1096
|
console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
|
|
1074
1097
|
engine.destroy();
|
|
@@ -1293,6 +1316,7 @@ async function runRepl(engine) {
|
|
|
1293
1316
|
noSave: true,
|
|
1294
1317
|
dryRun: engine.dryRunMode ?? false,
|
|
1295
1318
|
images: pendingImages,
|
|
1319
|
+
sessionName: process.env.WISPY_SESSION_NAME ?? null,
|
|
1296
1320
|
onSkillLearned: (skill) => {
|
|
1297
1321
|
console.log(cyan(`\n💡 Learned new skill: '${skill.name}' — use /${skill.name} next time`));
|
|
1298
1322
|
},
|
|
@@ -1476,8 +1500,29 @@ if (args[0] && operatorCommands.has(args[0])) {
|
|
|
1476
1500
|
process.exit(0);
|
|
1477
1501
|
}
|
|
1478
1502
|
|
|
1479
|
-
// Initialize engine
|
|
1480
|
-
|
|
1503
|
+
// Initialize engine — merge project settings + env flags
|
|
1504
|
+
let _engineConfig = { workstream: ACTIVE_WORKSTREAM };
|
|
1505
|
+
try {
|
|
1506
|
+
const { findProjectSettings, mergeSettings } = await import("../core/project-settings.mjs");
|
|
1507
|
+
const { loadConfigWithProfile } = await import("../core/config.mjs");
|
|
1508
|
+
const projectSettings = await findProjectSettings();
|
|
1509
|
+
if (projectSettings) {
|
|
1510
|
+
const baseConfig = await loadConfigWithProfile(process.env.WISPY_PROFILE ?? null);
|
|
1511
|
+
_engineConfig = mergeSettings(baseConfig, projectSettings, null, {
|
|
1512
|
+
...(process.env.WISPY_SYSTEM_PROMPT ? { systemPrompt: process.env.WISPY_SYSTEM_PROMPT } : {}),
|
|
1513
|
+
...(process.env.WISPY_APPEND_SYSTEM_PROMPT ? { appendSystemPrompt: process.env.WISPY_APPEND_SYSTEM_PROMPT } : {}),
|
|
1514
|
+
...(process.env.WISPY_EFFORT ? { effort: process.env.WISPY_EFFORT } : {}),
|
|
1515
|
+
});
|
|
1516
|
+
_engineConfig.workstream = _engineConfig.workstream ?? ACTIVE_WORKSTREAM;
|
|
1517
|
+
} else {
|
|
1518
|
+
// No project settings, still pick up env flags
|
|
1519
|
+
if (process.env.WISPY_SYSTEM_PROMPT) _engineConfig.systemPrompt = process.env.WISPY_SYSTEM_PROMPT;
|
|
1520
|
+
if (process.env.WISPY_APPEND_SYSTEM_PROMPT) _engineConfig.appendSystemPrompt = process.env.WISPY_APPEND_SYSTEM_PROMPT;
|
|
1521
|
+
if (process.env.WISPY_EFFORT) _engineConfig.effort = process.env.WISPY_EFFORT;
|
|
1522
|
+
}
|
|
1523
|
+
} catch { /* project settings are optional */ }
|
|
1524
|
+
|
|
1525
|
+
const engine = new WispyEngine(_engineConfig);
|
|
1481
1526
|
const initResult = await engine.init();
|
|
1482
1527
|
|
|
1483
1528
|
if (!initResult) {
|
package/package.json
CHANGED