wispy-cli 0.6.1 → 0.8.0
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 +172 -2
- package/core/config.mjs +104 -0
- package/core/cron.mjs +346 -0
- package/core/engine.mjs +705 -0
- package/core/index.mjs +14 -0
- package/core/mcp.mjs +8 -0
- package/core/memory.mjs +275 -0
- package/core/providers.mjs +410 -0
- package/core/session.mjs +196 -0
- package/core/tools.mjs +526 -0
- package/lib/channels/index.mjs +45 -246
- package/lib/wispy-repl.mjs +396 -2452
- package/lib/wispy-tui.mjs +105 -588
- package/package.json +7 -4
package/lib/wispy-repl.mjs
CHANGED
|
@@ -2,279 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* wispy — interactive AI assistant REPL
|
|
5
|
+
* v0.7: thin wrapper around core/engine.mjs
|
|
5
6
|
*
|
|
6
7
|
* Usage:
|
|
7
8
|
* wispy Start interactive session
|
|
8
9
|
* wispy "message" One-shot message
|
|
9
10
|
* wispy home <subcommand> Operator commands (legacy CLI)
|
|
10
|
-
*
|
|
11
|
-
* Requires: OPENAI_API_KEY in env
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import os from "node:os";
|
|
15
14
|
import path from "node:path";
|
|
16
15
|
import { createInterface } from "node:readline";
|
|
17
16
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// Config
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
|
-
const WISPY_DIR = path.join(os.homedir(), ".wispy");
|
|
25
|
-
const MEMORY_DIR = path.join(WISPY_DIR, "memory");
|
|
26
|
-
const MCP_CONFIG_PATH = path.join(WISPY_DIR, "mcp.json");
|
|
27
|
-
|
|
28
|
-
// Global MCP manager — initialized at startup
|
|
29
|
-
const mcpManager = new MCPManager(MCP_CONFIG_PATH);
|
|
30
|
-
|
|
31
|
-
// Workstream-aware conversation storage
|
|
32
|
-
// wispy -w "project-name" → separate conversation per workstream
|
|
33
|
-
const ACTIVE_WORKSTREAM = process.env.WISPY_WORKSTREAM ??
|
|
34
|
-
process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream")) ?? "default";
|
|
35
|
-
const CONVERSATIONS_DIR = path.join(WISPY_DIR, "conversations");
|
|
36
|
-
const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Multi-provider config with auto-detection & setup guidance
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
const PROVIDERS = {
|
|
43
|
-
google: { envKeys: ["GOOGLE_AI_KEY", "GEMINI_API_KEY"], defaultModel: "gemini-2.5-flash", label: "Google AI (Gemini)", signupUrl: "https://aistudio.google.com/apikey" },
|
|
44
|
-
anthropic: { envKeys: ["ANTHROPIC_API_KEY"], defaultModel: "claude-sonnet-4-20250514", label: "Anthropic (Claude)", signupUrl: "https://console.anthropic.com/settings/keys" },
|
|
45
|
-
openai: { envKeys: ["OPENAI_API_KEY"], defaultModel: "gpt-4o", label: "OpenAI", signupUrl: "https://platform.openai.com/api-keys" },
|
|
46
|
-
openrouter:{ envKeys: ["OPENROUTER_API_KEY"], defaultModel: "anthropic/claude-sonnet-4-20250514", label: "OpenRouter (multi-model)", signupUrl: "https://openrouter.ai/keys" },
|
|
47
|
-
groq: { envKeys: ["GROQ_API_KEY"], defaultModel: "llama-3.3-70b-versatile", label: "Groq (fast inference)", signupUrl: "https://console.groq.com/keys" },
|
|
48
|
-
deepseek: { envKeys: ["DEEPSEEK_API_KEY"], defaultModel: "deepseek-chat", label: "DeepSeek", signupUrl: "https://platform.deepseek.com/api_keys" },
|
|
49
|
-
ollama: { envKeys: ["OLLAMA_HOST"], defaultModel: "llama3.2", label: "Ollama (local)", signupUrl: null, local: true },
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Also try macOS Keychain for keys
|
|
53
|
-
async function tryKeychainKey(service) {
|
|
54
|
-
try {
|
|
55
|
-
const { execFile: ef } = await import("node:child_process");
|
|
56
|
-
const { promisify } = await import("node:util");
|
|
57
|
-
const exec = promisify(ef);
|
|
58
|
-
const { stdout } = await exec("security", ["find-generic-password", "-s", service, "-a", "poropo", "-w"], { timeout: 3000 });
|
|
59
|
-
return stdout.trim() || null;
|
|
60
|
-
} catch { return null; }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function getEnvKey(envKeys) {
|
|
64
|
-
for (const k of envKeys) {
|
|
65
|
-
if (process.env[k]) return process.env[k];
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Detect provider — env var, then config file, then keychain
|
|
71
|
-
async function detectProvider() {
|
|
72
|
-
// 1. Check WISPY_PROVIDER env override
|
|
73
|
-
const forced = process.env.WISPY_PROVIDER;
|
|
74
|
-
if (forced && PROVIDERS[forced]) {
|
|
75
|
-
const key = getEnvKey(PROVIDERS[forced].envKeys);
|
|
76
|
-
if (key || PROVIDERS[forced].local) return { provider: forced, key, model: process.env.WISPY_MODEL ?? PROVIDERS[forced].defaultModel };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 2. Check config file
|
|
80
|
-
const configPath = path.join(WISPY_DIR, "config.json");
|
|
81
|
-
try {
|
|
82
|
-
const cfg = JSON.parse(await readFile(configPath, "utf8"));
|
|
83
|
-
if (cfg.provider && PROVIDERS[cfg.provider]) {
|
|
84
|
-
const key = getEnvKey(PROVIDERS[cfg.provider].envKeys) ?? cfg.apiKey;
|
|
85
|
-
if (key || PROVIDERS[cfg.provider].local) return { provider: cfg.provider, key, model: cfg.model ?? PROVIDERS[cfg.provider].defaultModel };
|
|
86
|
-
}
|
|
87
|
-
} catch { /* no config */ }
|
|
88
|
-
|
|
89
|
-
// 3. Auto-detect from env vars (priority order)
|
|
90
|
-
const order = ["google", "anthropic", "openai", "openrouter", "groq", "deepseek", "ollama"];
|
|
91
|
-
for (const p of order) {
|
|
92
|
-
const key = getEnvKey(PROVIDERS[p].envKeys);
|
|
93
|
-
if (key || (p === "ollama" && process.env.OLLAMA_HOST)) {
|
|
94
|
-
return { provider: p, key, model: process.env.WISPY_MODEL ?? PROVIDERS[p].defaultModel };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 4. Try macOS Keychain
|
|
99
|
-
const keychainMap = { "google-ai-key": "google", "anthropic-api-key": "anthropic", "openai-api-key": "openai" };
|
|
100
|
-
for (const [service, provider] of Object.entries(keychainMap)) {
|
|
101
|
-
const key = await tryKeychainKey(service);
|
|
102
|
-
if (key) {
|
|
103
|
-
// Set env for later use
|
|
104
|
-
process.env[PROVIDERS[provider].envKeys[0]] = key;
|
|
105
|
-
return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function printSetupGuide() {
|
|
113
|
-
console.log(`
|
|
114
|
-
${bold("🌿 Wispy — API key setup")}
|
|
115
|
-
|
|
116
|
-
${bold("Supported providers:")}
|
|
117
|
-
${Object.entries(PROVIDERS).map(([id, p]) => {
|
|
118
|
-
const envStr = p.envKeys.join(" or ");
|
|
119
|
-
const url = p.signupUrl ? dim(p.signupUrl) : dim("(local)");
|
|
120
|
-
return ` ${green(id.padEnd(12))} ${p.label}\n env: ${envStr}\n ${url}`;
|
|
121
|
-
}).join("\n\n")}
|
|
122
|
-
|
|
123
|
-
${bold("Quick start (pick one):")}
|
|
124
|
-
${cyan("export GOOGLE_AI_KEY=your-key")} ${dim("# free tier available")}
|
|
125
|
-
${cyan("export ANTHROPIC_API_KEY=your-key")} ${dim("# Claude")}
|
|
126
|
-
${cyan("export OPENAI_API_KEY=your-key")} ${dim("# GPT-4o")}
|
|
127
|
-
${cyan("export OPENROUTER_API_KEY=your-key")} ${dim("# any model")}
|
|
128
|
-
|
|
129
|
-
${bold("Or save to config:")}
|
|
130
|
-
${cyan('wispy config set provider google --global')}
|
|
131
|
-
${cyan('wispy config set apiKey your-key --global')}
|
|
132
|
-
|
|
133
|
-
${bold("macOS Keychain (auto-detected):")}
|
|
134
|
-
${dim('security add-generic-password -s "google-ai-key" -a "poropo" -w "your-key"')}
|
|
135
|
-
`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function runOnboarding() {
|
|
139
|
-
const { createInterface: createRL } = await import("node:readline");
|
|
140
|
-
const { execSync } = await import("node:child_process");
|
|
141
|
-
|
|
142
|
-
// Splash
|
|
143
|
-
console.log("");
|
|
144
|
-
console.log(box([
|
|
145
|
-
"",
|
|
146
|
-
`${bold("🌿 W I S P Y")}`,
|
|
147
|
-
"",
|
|
148
|
-
`${dim("AI workspace assistant")}`,
|
|
149
|
-
`${dim("with multi-agent orchestration")}`,
|
|
150
|
-
"",
|
|
151
|
-
]));
|
|
152
|
-
console.log("");
|
|
153
|
-
|
|
154
|
-
// Auto-detect 1: Ollama running locally?
|
|
155
|
-
process.stdout.write(dim(" Checking environment..."));
|
|
156
|
-
try {
|
|
157
|
-
const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
|
|
158
|
-
if (resp.ok) {
|
|
159
|
-
console.log(green(" found Ollama! ✓\n"));
|
|
160
|
-
console.log(box([
|
|
161
|
-
`${green("✓")} Using local Ollama ${dim("— no API key needed")}`,
|
|
162
|
-
"",
|
|
163
|
-
` ${dim("Your AI runs entirely on your machine.")}`,
|
|
164
|
-
` ${dim("No data leaves your computer.")}`,
|
|
165
|
-
]));
|
|
166
|
-
const configPath = path.join(WISPY_DIR, "config.json");
|
|
167
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
168
|
-
const config = JSON.parse(await readFileOr(configPath, "{}"));
|
|
169
|
-
config.provider = "ollama";
|
|
170
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
171
|
-
process.env.OLLAMA_HOST = "http://localhost:11434";
|
|
172
|
-
console.log("");
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
} catch { /* not running */ }
|
|
176
|
-
|
|
177
|
-
// Auto-detect 2: macOS Keychain
|
|
178
|
-
const keychainProviders = [
|
|
179
|
-
{ service: "google-ai-key", provider: "google", label: "Google AI (Gemini)" },
|
|
180
|
-
{ service: "anthropic-api-key", provider: "anthropic", label: "Anthropic (Claude)" },
|
|
181
|
-
{ service: "openai-api-key", provider: "openai", label: "OpenAI (GPT)" },
|
|
182
|
-
];
|
|
183
|
-
for (const kc of keychainProviders) {
|
|
184
|
-
const key = await tryKeychainKey(kc.service);
|
|
185
|
-
if (key) {
|
|
186
|
-
console.log(green(` found ${kc.label} key! ✓\n`));
|
|
187
|
-
console.log(box([
|
|
188
|
-
`${green("✓")} ${kc.label} ${dim("— auto-detected from Keychain")}`,
|
|
189
|
-
"",
|
|
190
|
-
` ${dim("Ready to go. No setup needed.")}`,
|
|
191
|
-
]));
|
|
192
|
-
const configPath = path.join(WISPY_DIR, "config.json");
|
|
193
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
194
|
-
const config = JSON.parse(await readFileOr(configPath, "{}"));
|
|
195
|
-
config.provider = kc.provider;
|
|
196
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
197
|
-
process.env[PROVIDERS[kc.provider].envKeys[0]] = key;
|
|
198
|
-
console.log("");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
console.log(dim(" no existing config found.\n"));
|
|
204
|
-
|
|
205
|
-
// Nothing auto-detected — elegant key setup
|
|
206
|
-
console.log(box([
|
|
207
|
-
`${bold("Quick Setup")} ${dim("— one step, 10 seconds")}`,
|
|
208
|
-
"",
|
|
209
|
-
` Wispy needs an AI provider to work.`,
|
|
210
|
-
` The easiest: ${bold("Google AI")} ${dim("(free, no credit card)")}`,
|
|
211
|
-
]));
|
|
212
|
-
console.log("");
|
|
213
|
-
|
|
214
|
-
// Auto-open browser
|
|
215
|
-
try {
|
|
216
|
-
execSync('open "https://aistudio.google.com/apikey" 2>/dev/null || xdg-open "https://aistudio.google.com/apikey" 2>/dev/null', { stdio: "ignore" });
|
|
217
|
-
console.log(` ${green("→")} Browser opened to ${underline("aistudio.google.com/apikey")}`);
|
|
218
|
-
} catch {
|
|
219
|
-
console.log(` ${green("→")} Visit: ${underline("https://aistudio.google.com/apikey")}`);
|
|
220
|
-
}
|
|
221
|
-
console.log(` ${dim(' Click "Create API Key" → copy → paste below')}`);
|
|
222
|
-
console.log("");
|
|
223
|
-
|
|
224
|
-
const rl = createRL({ input: process.stdin, output: process.stdout });
|
|
225
|
-
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
226
|
-
|
|
227
|
-
const apiKey = (await ask(` ${green("API key")} ${dim("(paste here)")}: `)).trim();
|
|
228
|
-
|
|
229
|
-
if (!apiKey) {
|
|
230
|
-
console.log("");
|
|
231
|
-
console.log(box([
|
|
232
|
-
`${dim("No key? Try local AI instead:")}`,
|
|
233
|
-
"",
|
|
234
|
-
` ${cyan("brew install ollama")}`,
|
|
235
|
-
` ${cyan("ollama serve")}`,
|
|
236
|
-
` ${cyan("wispy")} ${dim("← will auto-detect")}`,
|
|
237
|
-
]));
|
|
238
|
-
console.log("");
|
|
239
|
-
rl.close();
|
|
240
|
-
process.exit(0);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Auto-detect provider from key format
|
|
244
|
-
let chosenProvider = "google";
|
|
245
|
-
if (apiKey.startsWith("sk-ant-")) chosenProvider = "anthropic";
|
|
246
|
-
else if (apiKey.startsWith("sk-or-")) chosenProvider = "openrouter";
|
|
247
|
-
else if (apiKey.startsWith("sk-")) chosenProvider = "openai";
|
|
248
|
-
else if (apiKey.startsWith("gsk_")) chosenProvider = "groq";
|
|
249
|
-
|
|
250
|
-
// Save
|
|
251
|
-
const configPath = path.join(WISPY_DIR, "config.json");
|
|
252
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
253
|
-
const config = JSON.parse(await readFileOr(configPath, "{}"));
|
|
254
|
-
config.provider = chosenProvider;
|
|
255
|
-
config.apiKey = apiKey;
|
|
256
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
257
|
-
process.env[PROVIDERS[chosenProvider].envKeys[0]] = apiKey;
|
|
258
|
-
|
|
259
|
-
rl.close();
|
|
260
|
-
|
|
261
|
-
console.log("");
|
|
262
|
-
console.log(box([
|
|
263
|
-
`${green("✓")} Connected to ${bold(PROVIDERS[chosenProvider].label)}`,
|
|
264
|
-
"",
|
|
265
|
-
` ${cyan("wispy")} ${dim("start chatting")}`,
|
|
266
|
-
` ${cyan('wispy "do something"')} ${dim("quick command")}`,
|
|
267
|
-
` ${cyan("wispy -w project")} ${dim("use a workstream")}`,
|
|
268
|
-
` ${cyan("wispy --help")} ${dim("all options")}`,
|
|
269
|
-
]));
|
|
270
|
-
console.log("");
|
|
271
|
-
}
|
|
17
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { statSync } from "node:fs";
|
|
272
20
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
21
|
+
import {
|
|
22
|
+
WispyEngine,
|
|
23
|
+
loadConfig,
|
|
24
|
+
saveConfig,
|
|
25
|
+
WISPY_DIR,
|
|
26
|
+
CONVERSATIONS_DIR,
|
|
27
|
+
MEMORY_DIR,
|
|
28
|
+
MemoryManager,
|
|
29
|
+
} from "../core/index.mjs";
|
|
278
30
|
|
|
279
31
|
// ---------------------------------------------------------------------------
|
|
280
32
|
// Colors (minimal, no deps)
|
|
@@ -286,20 +38,13 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
|
286
38
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
287
39
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
288
40
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
289
|
-
const magenta = (s) => `\x1b[35m${s}\x1b[0m`;
|
|
290
|
-
const bgGreen = (s) => `\x1b[42m\x1b[30m${s}\x1b[0m`;
|
|
291
41
|
const underline = (s) => `\x1b[4m${s}\x1b[0m`;
|
|
292
42
|
|
|
293
|
-
function box(lines, { padding = 1
|
|
294
|
-
const chars =
|
|
295
|
-
? { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" }
|
|
296
|
-
: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" };
|
|
297
|
-
|
|
298
|
-
// Strip ANSI for width calculation
|
|
43
|
+
function box(lines, { padding = 1 } = {}) {
|
|
44
|
+
const chars = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
299
45
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
300
46
|
const maxW = Math.max(...lines.map(l => stripAnsi(l).length)) + padding * 2;
|
|
301
47
|
const pad = " ".repeat(padding);
|
|
302
|
-
|
|
303
48
|
const top = ` ${chars.tl}${chars.h.repeat(maxW)}${chars.tr}`;
|
|
304
49
|
const bot = ` ${chars.bl}${chars.h.repeat(maxW)}${chars.br}`;
|
|
305
50
|
const mid = lines.map(l => {
|
|
@@ -311,38 +56,18 @@ function box(lines, { padding = 1, border = "rounded" } = {}) {
|
|
|
311
56
|
}
|
|
312
57
|
|
|
313
58
|
// ---------------------------------------------------------------------------
|
|
314
|
-
//
|
|
59
|
+
// Workstream helpers (legacy conversation storage for backward compat)
|
|
315
60
|
// ---------------------------------------------------------------------------
|
|
316
61
|
|
|
62
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
const ACTIVE_WORKSTREAM = process.env.WISPY_WORKSTREAM ??
|
|
64
|
+
process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream")) ?? "default";
|
|
65
|
+
const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
|
|
66
|
+
|
|
317
67
|
async function readFileOr(filePath, fallback = null) {
|
|
318
68
|
try { return await readFile(filePath, "utf8"); } catch { return fallback; }
|
|
319
69
|
}
|
|
320
70
|
|
|
321
|
-
async function loadWispyMd() {
|
|
322
|
-
const paths = [
|
|
323
|
-
path.resolve("WISPY.md"),
|
|
324
|
-
path.resolve(".wispy", "WISPY.md"),
|
|
325
|
-
path.join(WISPY_DIR, "WISPY.md"),
|
|
326
|
-
];
|
|
327
|
-
for (const p of paths) {
|
|
328
|
-
const content = await readFileOr(p);
|
|
329
|
-
if (content) return content.slice(0, MAX_CONTEXT_CHARS);
|
|
330
|
-
}
|
|
331
|
-
return null;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async function loadMemories() {
|
|
335
|
-
const types = ["user", "feedback", "project", "references"];
|
|
336
|
-
const sections = [];
|
|
337
|
-
for (const type of types) {
|
|
338
|
-
const content = await readFileOr(path.join(MEMORY_DIR, `${type}.md`));
|
|
339
|
-
if (content?.trim()) {
|
|
340
|
-
sections.push(`## ${type} memory\n${content.trim()}`);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return sections.length ? sections.join("\n\n") : null;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
71
|
async function loadConversation() {
|
|
347
72
|
const raw = await readFileOr(HISTORY_FILE);
|
|
348
73
|
if (!raw) return [];
|
|
@@ -351,115 +76,24 @@ async function loadConversation() {
|
|
|
351
76
|
|
|
352
77
|
async function saveConversation(messages) {
|
|
353
78
|
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
|
354
|
-
|
|
355
|
-
const trimmed = messages.slice(-50);
|
|
356
|
-
await writeFile(HISTORY_FILE, JSON.stringify(trimmed, null, 2) + "\n", "utf8");
|
|
79
|
+
await writeFile(HISTORY_FILE, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
|
|
357
80
|
}
|
|
358
81
|
|
|
359
82
|
async function listWorkstreams() {
|
|
360
83
|
try {
|
|
361
84
|
const { readdir } = await import("node:fs/promises");
|
|
362
85
|
const files = await readdir(CONVERSATIONS_DIR);
|
|
363
|
-
return files
|
|
364
|
-
.filter(f => f.endsWith(".json"))
|
|
365
|
-
.map(f => f.replace(".json", ""));
|
|
86
|
+
return files.filter(f => f.endsWith(".json")).map(f => f.replace(".json", ""));
|
|
366
87
|
} catch { return []; }
|
|
367
88
|
}
|
|
368
89
|
|
|
369
90
|
async function loadWorkstreamConversation(wsName) {
|
|
370
91
|
try {
|
|
371
92
|
const wsPath = path.join(CONVERSATIONS_DIR, `${wsName}.json`);
|
|
372
|
-
|
|
373
|
-
return JSON.parse(raw);
|
|
93
|
+
return JSON.parse(await readFile(wsPath, "utf8"));
|
|
374
94
|
} catch { return []; }
|
|
375
95
|
}
|
|
376
96
|
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
// Director mode — overview across all workstreams
|
|
379
|
-
// ---------------------------------------------------------------------------
|
|
380
|
-
|
|
381
|
-
async function showOverview() {
|
|
382
|
-
const wsList = await listWorkstreams();
|
|
383
|
-
if (wsList.length === 0) {
|
|
384
|
-
console.log(dim("No workstreams yet. Start one: wispy -w <name> \"message\""));
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
console.log(`\n${bold("🌿 Wispy Director — All Workstreams")}\n`);
|
|
389
|
-
|
|
390
|
-
let totalMsgs = 0;
|
|
391
|
-
let totalToolCalls = 0;
|
|
392
|
-
const summaries = [];
|
|
393
|
-
|
|
394
|
-
for (const ws of wsList) {
|
|
395
|
-
const conv = await loadWorkstreamConversation(ws);
|
|
396
|
-
const userMsgs = conv.filter(m => m.role === "user");
|
|
397
|
-
const assistantMsgs = conv.filter(m => m.role === "assistant");
|
|
398
|
-
const toolResults = conv.filter(m => m.role === "tool_result");
|
|
399
|
-
const lastUser = userMsgs[userMsgs.length - 1];
|
|
400
|
-
const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
|
|
401
|
-
|
|
402
|
-
totalMsgs += userMsgs.length;
|
|
403
|
-
totalToolCalls += toolResults.length;
|
|
404
|
-
|
|
405
|
-
const isActive = ws === ACTIVE_WORKSTREAM;
|
|
406
|
-
const marker = isActive ? green("● ") : " ";
|
|
407
|
-
const label = isActive ? green(ws) : ws;
|
|
408
|
-
|
|
409
|
-
console.log(`${marker}${bold(label)}`);
|
|
410
|
-
console.log(` Messages: ${userMsgs.length} user / ${assistantMsgs.length} assistant / ${toolResults.length} tool calls`);
|
|
411
|
-
if (lastUser) {
|
|
412
|
-
console.log(` Last request: ${dim(lastUser.content.slice(0, 60))}${lastUser.content.length > 60 ? "..." : ""}`);
|
|
413
|
-
}
|
|
414
|
-
if (lastAssistant) {
|
|
415
|
-
console.log(` Last response: ${dim(lastAssistant.content.slice(0, 60))}${lastAssistant.content.length > 60 ? "..." : ""}`);
|
|
416
|
-
}
|
|
417
|
-
console.log("");
|
|
418
|
-
|
|
419
|
-
summaries.push({ ws, userCount: userMsgs.length, toolCount: toolResults.length, lastMsg: lastUser?.content ?? "" });
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
console.log(dim(`─────────────────────────────────`));
|
|
423
|
-
console.log(` ${bold("Total")}: ${wsList.length} workstreams, ${totalMsgs} messages, ${totalToolCalls} tool calls`);
|
|
424
|
-
console.log(dim(` Active: ${ACTIVE_WORKSTREAM}`));
|
|
425
|
-
console.log(dim(` Switch: wispy -w <name>`));
|
|
426
|
-
console.log("");
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function searchAcrossWorkstreams(query) {
|
|
430
|
-
const wsList = await listWorkstreams();
|
|
431
|
-
const lowerQuery = query.toLowerCase();
|
|
432
|
-
let totalMatches = 0;
|
|
433
|
-
|
|
434
|
-
console.log(`\n${bold("🔍 Searching all workstreams for:")} ${cyan(query)}\n`);
|
|
435
|
-
|
|
436
|
-
for (const ws of wsList) {
|
|
437
|
-
const conv = await loadWorkstreamConversation(ws);
|
|
438
|
-
const matches = conv.filter(m =>
|
|
439
|
-
(m.role === "user" || m.role === "assistant") &&
|
|
440
|
-
m.content?.toLowerCase().includes(lowerQuery)
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
if (matches.length > 0) {
|
|
444
|
-
console.log(` ${bold(ws)} (${matches.length} matches):`);
|
|
445
|
-
for (const m of matches.slice(-3)) { // Show last 3 matches
|
|
446
|
-
const role = m.role === "user" ? "👤" : "🌿";
|
|
447
|
-
const preview = m.content.slice(0, 80).replace(/\n/g, " ");
|
|
448
|
-
console.log(` ${role} ${dim(preview)}${m.content.length > 80 ? "..." : ""}`);
|
|
449
|
-
}
|
|
450
|
-
console.log("");
|
|
451
|
-
totalMatches += matches.length;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (totalMatches === 0) {
|
|
456
|
-
console.log(dim(` No matches found for "${query}"`));
|
|
457
|
-
} else {
|
|
458
|
-
console.log(dim(` ${totalMatches} total matches across ${wsList.length} workstreams`));
|
|
459
|
-
}
|
|
460
|
-
console.log("");
|
|
461
|
-
}
|
|
462
|
-
|
|
463
97
|
async function appendToMemory(type, entry) {
|
|
464
98
|
await mkdir(MEMORY_DIR, { recursive: true });
|
|
465
99
|
const ts = new Date().toISOString().slice(0, 16);
|
|
@@ -467,1594 +101,212 @@ async function appendToMemory(type, entry) {
|
|
|
467
101
|
}
|
|
468
102
|
|
|
469
103
|
// ---------------------------------------------------------------------------
|
|
470
|
-
//
|
|
471
|
-
// ---------------------------------------------------------------------------
|
|
472
|
-
|
|
473
|
-
// ---------------------------------------------------------------------------
|
|
474
|
-
// Token / cost tracking
|
|
104
|
+
// Server management (background AWOS server)
|
|
475
105
|
// ---------------------------------------------------------------------------
|
|
476
106
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
"claude-opus-4-6": { input: 15.0, output: 75.0, tier: "expensive" },
|
|
493
|
-
"claude-haiku-3.5": { input: 0.80, output: 4.0, tier: "cheap" },
|
|
494
|
-
// OpenAI
|
|
495
|
-
"gpt-4o": { input: 2.50, output: 10.0, tier: "mid" },
|
|
496
|
-
"gpt-4o-mini": { input: 0.15, output: 0.60, tier: "cheap" },
|
|
497
|
-
"gpt-4.1": { input: 2.0, output: 8.0, tier: "mid" },
|
|
498
|
-
"gpt-4.1-mini": { input: 0.40, output: 1.60, tier: "cheap" },
|
|
499
|
-
"gpt-4.1-nano": { input: 0.10, output: 0.40, tier: "cheap" },
|
|
500
|
-
"o4-mini": { input: 1.10, output: 4.40, tier: "mid" },
|
|
501
|
-
// OpenRouter (pass-through, estimate)
|
|
502
|
-
"anthropic/claude-sonnet-4-20250514": { input: 3.0, output: 15.0, tier: "mid" },
|
|
503
|
-
// Groq (fast, cheap)
|
|
504
|
-
"llama-3.3-70b-versatile": { input: 0.59, output: 0.79, tier: "cheap" },
|
|
505
|
-
// DeepSeek
|
|
506
|
-
"deepseek-chat": { input: 0.27, output: 1.10, tier: "cheap" },
|
|
507
|
-
// Ollama (free)
|
|
508
|
-
"llama3.2": { input: 0, output: 0, tier: "free" },
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
function getModelPricing(modelName) {
|
|
512
|
-
return MODEL_PRICING[modelName] ?? { input: 1.0, output: 3.0, tier: "unknown" };
|
|
513
|
-
}
|
|
107
|
+
const REPO_ROOT = process.env.WISPY_REPO_ROOT ?? path.resolve(SCRIPT_DIR, "..");
|
|
108
|
+
const SERVER_BINARY = process.env.WISPY_SERVER_BINARY
|
|
109
|
+
?? (() => {
|
|
110
|
+
const candidates = [
|
|
111
|
+
path.join(os.homedir(), ".wispy", "bin", "awos-server"),
|
|
112
|
+
path.join(REPO_ROOT, "src-tauri", "target", "release", "awos-server"),
|
|
113
|
+
path.join(REPO_ROOT, "src-tauri", "target", "debug", "awos-server"),
|
|
114
|
+
];
|
|
115
|
+
for (const c of candidates) {
|
|
116
|
+
try { if (statSync(c).isFile()) return c; } catch {}
|
|
117
|
+
}
|
|
118
|
+
return candidates[0];
|
|
119
|
+
})();
|
|
120
|
+
const SERVER_PID_FILE = path.join(WISPY_DIR, "server.pid");
|
|
121
|
+
const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
|
|
514
122
|
|
|
515
|
-
function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
123
|
+
async function isServerRunning() {
|
|
124
|
+
try {
|
|
125
|
+
const resp = await fetch(`http://127.0.0.1:${DEFAULT_SERVER_PORT}/api/health`, { signal: AbortSignal.timeout(2000) });
|
|
126
|
+
return resp.ok;
|
|
127
|
+
} catch { return false; }
|
|
519
128
|
}
|
|
520
129
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const TASK_MODEL_MAP = {
|
|
526
|
-
// Simple tasks → cheapest model
|
|
527
|
-
simple: { google: "gemini-2.5-flash", anthropic: "claude-haiku-3.5", openai: "gpt-4.1-nano", groq: "llama-3.3-70b-versatile" },
|
|
528
|
-
// Complex tasks → mid-tier
|
|
529
|
-
complex: { google: "gemini-2.5-pro", anthropic: "claude-sonnet-4-20250514", openai: "gpt-4o", groq: "llama-3.3-70b-versatile" },
|
|
530
|
-
// Critical tasks → best available
|
|
531
|
-
critical: { google: "gemini-2.5-pro", anthropic: "claude-opus-4-6", openai: "gpt-4o", groq: "llama-3.3-70b-versatile" },
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
function classifyTaskComplexity(prompt) {
|
|
535
|
-
const lower = prompt.toLowerCase();
|
|
536
|
-
|
|
537
|
-
// Critical: code review, architecture, security, debugging complex issues
|
|
538
|
-
if (/architect|security|review.*code|refactor|debug.*complex|design.*system/i.test(lower)) return "critical";
|
|
130
|
+
async function startServerIfNeeded() {
|
|
131
|
+
if (await isServerRunning()) return { started: false, port: DEFAULT_SERVER_PORT };
|
|
132
|
+
try { const { stat } = await import("node:fs/promises"); await stat(SERVER_BINARY); }
|
|
133
|
+
catch { return { started: false, port: DEFAULT_SERVER_PORT, noBinary: true }; }
|
|
539
134
|
|
|
540
|
-
|
|
541
|
-
|
|
135
|
+
const logFile = path.join(WISPY_DIR, "server.log");
|
|
136
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
137
|
+
const { openSync } = await import("node:fs");
|
|
138
|
+
const logFd = openSync(logFile, "a");
|
|
139
|
+
const child = spawnProcess(SERVER_BINARY, [], {
|
|
140
|
+
cwd: REPO_ROOT, env: { ...process.env, AWOS_PORT: DEFAULT_SERVER_PORT },
|
|
141
|
+
detached: true, stdio: ["ignore", logFd, logFd],
|
|
142
|
+
});
|
|
143
|
+
child.unref();
|
|
144
|
+
await writeFile(SERVER_PID_FILE, String(child.pid), "utf8");
|
|
542
145
|
|
|
543
|
-
|
|
544
|
-
|
|
146
|
+
for (let i = 0; i < 25; i++) {
|
|
147
|
+
await new Promise(r => setTimeout(r, 200));
|
|
148
|
+
if (await isServerRunning()) return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid };
|
|
149
|
+
}
|
|
150
|
+
return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
|
|
545
151
|
}
|
|
546
152
|
|
|
547
|
-
function
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
153
|
+
async function stopServer() {
|
|
154
|
+
try {
|
|
155
|
+
const pidStr = await readFile(SERVER_PID_FILE, "utf8");
|
|
156
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
157
|
+
if (pid && !isNaN(pid)) {
|
|
158
|
+
process.kill(pid, "SIGTERM");
|
|
159
|
+
const { unlink } = await import("node:fs/promises");
|
|
160
|
+
await unlink(SERVER_PID_FILE).catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
554
163
|
}
|
|
555
164
|
|
|
556
165
|
// ---------------------------------------------------------------------------
|
|
557
|
-
//
|
|
166
|
+
// Onboarding (first-run setup)
|
|
558
167
|
// ---------------------------------------------------------------------------
|
|
559
168
|
|
|
560
|
-
|
|
169
|
+
async function runOnboarding() {
|
|
170
|
+
const { execSync } = await import("node:child_process");
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(box([
|
|
173
|
+
"", `${bold("🌿 W I S P Y")}`, "", `${dim("AI workspace assistant")}`, `${dim("with multi-agent orchestration")}`, "",
|
|
174
|
+
]));
|
|
175
|
+
console.log("");
|
|
561
176
|
|
|
562
|
-
|
|
177
|
+
// Auto-detect Ollama
|
|
178
|
+
process.stdout.write(dim(" Checking environment..."));
|
|
563
179
|
try {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (!budgets[workstream]) budgets[workstream] = { limitUsd: null, spentUsd: 0, totalTokens: 0 };
|
|
576
|
-
|
|
577
|
-
const pricing = getModelPricing(modelName);
|
|
578
|
-
const cost = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
579
|
-
budgets[workstream].spentUsd += cost;
|
|
580
|
-
budgets[workstream].totalTokens += inputTokens + outputTokens;
|
|
581
|
-
await saveBudgets(budgets);
|
|
582
|
-
|
|
583
|
-
// Check budget limit
|
|
584
|
-
if (budgets[workstream].limitUsd !== null && budgets[workstream].spentUsd > budgets[workstream].limitUsd) {
|
|
585
|
-
return { overBudget: true, spent: budgets[workstream].spentUsd, limit: budgets[workstream].limitUsd };
|
|
586
|
-
}
|
|
587
|
-
return { overBudget: false, spent: budgets[workstream].spentUsd };
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// ---------------------------------------------------------------------------
|
|
591
|
-
// Context window optimization — compact messages to fit token budget
|
|
592
|
-
// ---------------------------------------------------------------------------
|
|
180
|
+
const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
|
|
181
|
+
if (resp.ok) {
|
|
182
|
+
console.log(green(" found Ollama! ✓\n"));
|
|
183
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
184
|
+
cfg.provider = "ollama";
|
|
185
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
186
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
187
|
+
process.env.OLLAMA_HOST = "http://localhost:11434";
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
593
191
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const total = estimateMessagesTokens(messages);
|
|
600
|
-
if (total <= maxTokens) return messages; // fits, no optimization needed
|
|
601
|
-
|
|
602
|
-
// Strategy: keep system prompt + last N messages, summarize old ones
|
|
603
|
-
const system = messages.filter(m => m.role === "system");
|
|
604
|
-
const rest = messages.filter(m => m.role !== "system");
|
|
605
|
-
|
|
606
|
-
// Keep removing oldest messages until we fit
|
|
607
|
-
let optimized = [...rest];
|
|
608
|
-
while (estimateMessagesTokens([...system, ...optimized]) > maxTokens && optimized.length > 4) {
|
|
609
|
-
optimized.shift(); // remove oldest
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// If still too big, truncate message contents
|
|
613
|
-
if (estimateMessagesTokens([...system, ...optimized]) > maxTokens) {
|
|
614
|
-
optimized = optimized.map(m => ({
|
|
615
|
-
...m,
|
|
616
|
-
content: m.content ? m.content.slice(0, 2000) : m.content,
|
|
617
|
-
}));
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
return [...system, ...optimized];
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// ---------------------------------------------------------------------------
|
|
624
|
-
// Tool definitions (Gemini function calling format)
|
|
625
|
-
// ---------------------------------------------------------------------------
|
|
626
|
-
|
|
627
|
-
// Returns merged static + dynamically registered MCP tool definitions
|
|
628
|
-
function getAllToolDefinitions() {
|
|
629
|
-
return [...TOOL_DEFINITIONS, ...mcpManager.getToolDefinitions()];
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const TOOL_DEFINITIONS = [
|
|
633
|
-
{
|
|
634
|
-
name: "read_file",
|
|
635
|
-
description: "Read the contents of a file at the given path",
|
|
636
|
-
parameters: {
|
|
637
|
-
type: "object",
|
|
638
|
-
properties: {
|
|
639
|
-
path: { type: "string", description: "File path to read" },
|
|
640
|
-
},
|
|
641
|
-
required: ["path"],
|
|
642
|
-
},
|
|
643
|
-
},
|
|
644
|
-
{
|
|
645
|
-
name: "write_file",
|
|
646
|
-
description: "Write content to a file, creating it if it doesn't exist",
|
|
647
|
-
parameters: {
|
|
648
|
-
type: "object",
|
|
649
|
-
properties: {
|
|
650
|
-
path: { type: "string", description: "File path to write" },
|
|
651
|
-
content: { type: "string", description: "Content to write" },
|
|
652
|
-
},
|
|
653
|
-
required: ["path", "content"],
|
|
654
|
-
},
|
|
655
|
-
},
|
|
656
|
-
{
|
|
657
|
-
name: "run_command",
|
|
658
|
-
description: "Execute a shell command and return stdout/stderr",
|
|
659
|
-
parameters: {
|
|
660
|
-
type: "object",
|
|
661
|
-
properties: {
|
|
662
|
-
command: { type: "string", description: "Shell command to execute" },
|
|
663
|
-
},
|
|
664
|
-
required: ["command"],
|
|
665
|
-
},
|
|
666
|
-
},
|
|
667
|
-
{
|
|
668
|
-
name: "list_directory",
|
|
669
|
-
description: "List files and directories at the given path",
|
|
670
|
-
parameters: {
|
|
671
|
-
type: "object",
|
|
672
|
-
properties: {
|
|
673
|
-
path: { type: "string", description: "Directory path (default: current dir)" },
|
|
674
|
-
},
|
|
675
|
-
required: [],
|
|
676
|
-
},
|
|
677
|
-
},
|
|
678
|
-
{
|
|
679
|
-
name: "web_search",
|
|
680
|
-
description: "Search the web and return results",
|
|
681
|
-
parameters: {
|
|
682
|
-
type: "object",
|
|
683
|
-
properties: {
|
|
684
|
-
query: { type: "string", description: "Search query" },
|
|
685
|
-
},
|
|
686
|
-
required: ["query"],
|
|
687
|
-
},
|
|
688
|
-
},
|
|
689
|
-
{
|
|
690
|
-
name: "file_edit",
|
|
691
|
-
description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
|
|
692
|
-
parameters: {
|
|
693
|
-
type: "object",
|
|
694
|
-
properties: {
|
|
695
|
-
path: { type: "string", description: "File path" },
|
|
696
|
-
old_text: { type: "string", description: "Exact text to find and replace" },
|
|
697
|
-
new_text: { type: "string", description: "Replacement text" },
|
|
698
|
-
},
|
|
699
|
-
required: ["path", "old_text", "new_text"],
|
|
700
|
-
},
|
|
701
|
-
},
|
|
702
|
-
{
|
|
703
|
-
name: "file_search",
|
|
704
|
-
description: "Search for text patterns in files recursively (like grep). Returns matching lines with file paths and line numbers.",
|
|
705
|
-
parameters: {
|
|
706
|
-
type: "object",
|
|
707
|
-
properties: {
|
|
708
|
-
pattern: { type: "string", description: "Text or regex pattern to search for" },
|
|
709
|
-
path: { type: "string", description: "Directory to search in (default: current dir)" },
|
|
710
|
-
file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
|
|
711
|
-
},
|
|
712
|
-
required: ["pattern"],
|
|
713
|
-
},
|
|
714
|
-
},
|
|
715
|
-
{
|
|
716
|
-
name: "git",
|
|
717
|
-
description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout. Use for version control tasks.",
|
|
718
|
-
parameters: {
|
|
719
|
-
type: "object",
|
|
720
|
-
properties: {
|
|
721
|
-
command: { type: "string", description: "Git subcommand and args (e.g., 'status', 'diff --cached', 'log --oneline -10', 'commit -m \"msg\"')" },
|
|
722
|
-
},
|
|
723
|
-
required: ["command"],
|
|
724
|
-
},
|
|
725
|
-
},
|
|
726
|
-
{
|
|
727
|
-
name: "web_fetch",
|
|
728
|
-
description: "Fetch content from a URL and return it as text/markdown. Use to read web pages, docs, APIs.",
|
|
729
|
-
parameters: {
|
|
730
|
-
type: "object",
|
|
731
|
-
properties: {
|
|
732
|
-
url: { type: "string", description: "URL to fetch" },
|
|
733
|
-
},
|
|
734
|
-
required: ["url"],
|
|
735
|
-
},
|
|
736
|
-
},
|
|
737
|
-
{
|
|
738
|
-
name: "keychain",
|
|
739
|
-
description: "Manage macOS Keychain secrets. Read (masked), store, or delete credentials. Values are never shown in full — only first 4 + last 4 chars.",
|
|
740
|
-
parameters: {
|
|
741
|
-
type: "object",
|
|
742
|
-
properties: {
|
|
743
|
-
action: { type: "string", enum: ["get", "set", "delete", "list"], description: "get: read secret (masked), set: store secret, delete: remove, list: search" },
|
|
744
|
-
service: { type: "string", description: "Service name (e.g., 'google-ai-key', 'my-api-token')" },
|
|
745
|
-
account: { type: "string", description: "Account name (default: 'wispy')" },
|
|
746
|
-
value: { type: "string", description: "Secret value (only for 'set' action)" },
|
|
747
|
-
},
|
|
748
|
-
required: ["action", "service"],
|
|
749
|
-
},
|
|
750
|
-
},
|
|
751
|
-
{
|
|
752
|
-
name: "clipboard",
|
|
753
|
-
description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
|
|
754
|
-
parameters: {
|
|
755
|
-
type: "object",
|
|
756
|
-
properties: {
|
|
757
|
-
action: { type: "string", enum: ["copy", "paste"], description: "copy: write to clipboard, paste: read from clipboard" },
|
|
758
|
-
text: { type: "string", description: "Text to copy (only for copy action)" },
|
|
759
|
-
},
|
|
760
|
-
required: ["action"],
|
|
761
|
-
},
|
|
762
|
-
},
|
|
763
|
-
{
|
|
764
|
-
name: "spawn_agent",
|
|
765
|
-
description: "Spawn a sub-agent for a well-scoped task. Use for sidecar tasks that can run in parallel. Do NOT spawn for the immediate blocking step — do that yourself. Each agent gets its own context. Prefer concrete, bounded tasks with clear deliverables.",
|
|
766
|
-
parameters: {
|
|
767
|
-
type: "object",
|
|
768
|
-
properties: {
|
|
769
|
-
task: { type: "string", description: "Concrete task description for the sub-agent" },
|
|
770
|
-
role: {
|
|
771
|
-
type: "string",
|
|
772
|
-
enum: ["explorer", "planner", "worker", "reviewer"],
|
|
773
|
-
description: "explorer=codebase search, planner=strategy design, worker=implementation, reviewer=code review/QA",
|
|
774
|
-
},
|
|
775
|
-
model_tier: {
|
|
776
|
-
type: "string",
|
|
777
|
-
enum: ["cheap", "mid", "expensive"],
|
|
778
|
-
description: "cheap for simple tasks, mid for coding, expensive for critical analysis. Default: auto based on role",
|
|
779
|
-
},
|
|
780
|
-
fork_context: { type: "boolean", description: "If true, copy current conversation context to the sub-agent" },
|
|
781
|
-
},
|
|
782
|
-
required: ["task", "role"],
|
|
783
|
-
},
|
|
784
|
-
},
|
|
785
|
-
{
|
|
786
|
-
name: "list_agents",
|
|
787
|
-
description: "List all running/completed sub-agents and their status",
|
|
788
|
-
parameters: { type: "object", properties: {}, required: [] },
|
|
789
|
-
},
|
|
790
|
-
{
|
|
791
|
-
name: "get_agent_result",
|
|
792
|
-
description: "Get the result from a completed sub-agent",
|
|
793
|
-
parameters: {
|
|
794
|
-
type: "object",
|
|
795
|
-
properties: {
|
|
796
|
-
agent_id: { type: "string", description: "ID of the sub-agent" },
|
|
797
|
-
},
|
|
798
|
-
required: ["agent_id"],
|
|
799
|
-
},
|
|
800
|
-
},
|
|
801
|
-
{
|
|
802
|
-
name: "update_plan",
|
|
803
|
-
description: "Create or update a step-by-step plan for the current task. Use to track progress.",
|
|
804
|
-
parameters: {
|
|
805
|
-
type: "object",
|
|
806
|
-
properties: {
|
|
807
|
-
explanation: { type: "string", description: "Brief explanation of the plan" },
|
|
808
|
-
steps: {
|
|
809
|
-
type: "array",
|
|
810
|
-
items: {
|
|
811
|
-
type: "object",
|
|
812
|
-
properties: {
|
|
813
|
-
step: { type: "string" },
|
|
814
|
-
status: { type: "string", enum: ["pending", "in_progress", "completed", "skipped"] },
|
|
815
|
-
},
|
|
816
|
-
},
|
|
817
|
-
description: "List of plan steps with status",
|
|
818
|
-
},
|
|
819
|
-
},
|
|
820
|
-
required: ["steps"],
|
|
821
|
-
},
|
|
822
|
-
},
|
|
823
|
-
{
|
|
824
|
-
name: "pipeline",
|
|
825
|
-
description: "Run a sequential pipeline of agent roles. Each stage's output feeds into the next. Example: explore→planner→worker→reviewer. Use for complex multi-step tasks that need different specialists in sequence.",
|
|
826
|
-
parameters: {
|
|
827
|
-
type: "object",
|
|
828
|
-
properties: {
|
|
829
|
-
task: { type: "string", description: "The overall task to accomplish" },
|
|
830
|
-
stages: {
|
|
831
|
-
type: "array",
|
|
832
|
-
items: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] },
|
|
833
|
-
description: "Ordered list of agent roles to chain",
|
|
834
|
-
},
|
|
835
|
-
},
|
|
836
|
-
required: ["task", "stages"],
|
|
837
|
-
},
|
|
838
|
-
},
|
|
839
|
-
{
|
|
840
|
-
name: "spawn_async_agent",
|
|
841
|
-
description: "Spawn a sub-agent that runs in the background. Returns immediately with an agent_id. Check results later with get_agent_result. Use for sidecar tasks while you continue working on the main task.",
|
|
842
|
-
parameters: {
|
|
843
|
-
type: "object",
|
|
844
|
-
properties: {
|
|
845
|
-
task: { type: "string", description: "Task for the background agent" },
|
|
846
|
-
role: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"], description: "Agent role" },
|
|
847
|
-
},
|
|
848
|
-
required: ["task", "role"],
|
|
849
|
-
},
|
|
850
|
-
},
|
|
851
|
-
{
|
|
852
|
-
name: "ralph_loop",
|
|
853
|
-
description: "Persistence mode — keep retrying a task until it's verified complete. The worker agent executes, then a reviewer verifies. If not done, worker tries again. Max 5 iterations. Use for tasks that MUST be completed correctly.",
|
|
854
|
-
parameters: {
|
|
855
|
-
type: "object",
|
|
856
|
-
properties: {
|
|
857
|
-
task: { type: "string", description: "Task that must be completed" },
|
|
858
|
-
success_criteria: { type: "string", description: "How to verify the task is truly done" },
|
|
859
|
-
},
|
|
860
|
-
required: ["task"],
|
|
861
|
-
},
|
|
862
|
-
},
|
|
863
|
-
];
|
|
864
|
-
|
|
865
|
-
// ---------------------------------------------------------------------------
|
|
866
|
-
// Tool execution
|
|
867
|
-
// ---------------------------------------------------------------------------
|
|
868
|
-
|
|
869
|
-
// Try server API first, fallback to local execution
|
|
870
|
-
async function executeToolViaServer(name, args) {
|
|
871
|
-
try {
|
|
872
|
-
const serverUrl = `http://127.0.0.1:${DEFAULT_SERVER_PORT}`;
|
|
873
|
-
|
|
874
|
-
if (name === "read_file") {
|
|
875
|
-
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
876
|
-
method: "POST",
|
|
877
|
-
headers: { "Content-Type": "application/json" },
|
|
878
|
-
body: JSON.stringify({ subAction: "read_file", path: args.path }),
|
|
879
|
-
signal: AbortSignal.timeout(10_000),
|
|
880
|
-
});
|
|
881
|
-
const data = await resp.json();
|
|
882
|
-
if (data.success) return { success: true, content: data.data?.slice(0, 10_000) ?? "" };
|
|
883
|
-
// Fallback to local if server rejects path
|
|
884
|
-
return null;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (name === "write_file") {
|
|
888
|
-
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
889
|
-
method: "POST",
|
|
890
|
-
headers: { "Content-Type": "application/json" },
|
|
891
|
-
body: JSON.stringify({ subAction: "write_file", path: args.path, content: args.content }),
|
|
892
|
-
signal: AbortSignal.timeout(10_000),
|
|
893
|
-
});
|
|
894
|
-
const data = await resp.json();
|
|
895
|
-
if (data.success) return { success: true, message: `Written to ${args.path} (via server)` };
|
|
896
|
-
return null;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (name === "list_directory") {
|
|
900
|
-
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
901
|
-
method: "POST",
|
|
902
|
-
headers: { "Content-Type": "application/json" },
|
|
903
|
-
body: JSON.stringify({ subAction: "list_dir", path: args.path || "." }),
|
|
904
|
-
signal: AbortSignal.timeout(10_000),
|
|
905
|
-
});
|
|
906
|
-
const data = await resp.json();
|
|
907
|
-
if (data.success && data.entries) {
|
|
908
|
-
const listing = data.entries.map(e => `${e.isDir ? "📁" : "📄"} ${e.name}`).join("\n");
|
|
909
|
-
return { success: true, listing };
|
|
910
|
-
}
|
|
911
|
-
return null;
|
|
912
|
-
}
|
|
913
|
-
} catch {
|
|
914
|
-
// Server not available, fallback to local
|
|
915
|
-
return null;
|
|
916
|
-
}
|
|
917
|
-
return null; // Not handled by server
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
async function executeTool(name, args) {
|
|
921
|
-
// Try server first (sandboxed execution)
|
|
922
|
-
const serverResult = await executeToolViaServer(name, args);
|
|
923
|
-
if (serverResult) return serverResult;
|
|
924
|
-
|
|
925
|
-
const { execFile } = await import("node:child_process");
|
|
926
|
-
const { promisify } = await import("node:util");
|
|
927
|
-
const execAsync = promisify(execFile);
|
|
928
|
-
|
|
929
|
-
try {
|
|
930
|
-
switch (name) {
|
|
931
|
-
case "read_file": {
|
|
932
|
-
const filePath = args.path.replace(/^~/, os.homedir());
|
|
933
|
-
const content = await readFile(filePath, "utf8");
|
|
934
|
-
// Truncate large files
|
|
935
|
-
const truncated = content.length > 10_000
|
|
936
|
-
? content.slice(0, 10_000) + `\n\n... (truncated, ${content.length} chars total)`
|
|
937
|
-
: content;
|
|
938
|
-
return { success: true, content: truncated };
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
case "write_file": {
|
|
942
|
-
args.path = args.path.replace(/^~/, os.homedir());
|
|
943
|
-
const dir = path.dirname(args.path);
|
|
944
|
-
await mkdir(dir, { recursive: true });
|
|
945
|
-
await writeFile(args.path, args.content, "utf8");
|
|
946
|
-
return { success: true, message: `Written ${args.content.length} chars to ${args.path}` };
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
case "run_command": {
|
|
950
|
-
// Block direct keychain password reads via run_command — use keychain tool instead
|
|
951
|
-
if (/security\s+find-generic-password.*-w/i.test(args.command)) {
|
|
952
|
-
return { success: false, error: "Use the 'keychain' tool instead of run_command for secrets. It masks sensitive values." };
|
|
953
|
-
}
|
|
954
|
-
console.log(dim(` $ ${args.command}`));
|
|
955
|
-
const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
|
|
956
|
-
timeout: 30_000,
|
|
957
|
-
maxBuffer: 1024 * 1024,
|
|
958
|
-
cwd: process.cwd(),
|
|
959
|
-
});
|
|
960
|
-
const result = (stdout + (stderr ? `\nSTDERR: ${stderr}` : "")).trim();
|
|
961
|
-
const truncated = result.length > 5_000
|
|
962
|
-
? result.slice(0, 5_000) + "\n... (truncated)"
|
|
963
|
-
: result;
|
|
964
|
-
return { success: true, output: truncated };
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
case "list_directory": {
|
|
968
|
-
const { readdir } = await import("node:fs/promises");
|
|
969
|
-
const targetPath = (args.path || ".").replace(/^~/, os.homedir());
|
|
970
|
-
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
971
|
-
const list = entries.map(e => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`).join("\n");
|
|
972
|
-
return { success: true, listing: list };
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
case "web_search": {
|
|
976
|
-
const { promisify } = await import("node:util");
|
|
977
|
-
const { execFile: ef } = await import("node:child_process");
|
|
978
|
-
const execP = promisify(ef);
|
|
979
|
-
|
|
980
|
-
// Try DuckDuckGo Lite first (lighter HTML, easier to parse)
|
|
981
|
-
const encoded = encodeURIComponent(args.query);
|
|
982
|
-
try {
|
|
983
|
-
const { stdout: html } = await execP("/usr/bin/curl", [
|
|
984
|
-
"-sL", "--max-time", "10",
|
|
985
|
-
"-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
986
|
-
`https://lite.duckduckgo.com/lite/?q=${encoded}`,
|
|
987
|
-
], { timeout: 15_000 });
|
|
988
|
-
|
|
989
|
-
// Parse DuckDuckGo Lite results
|
|
990
|
-
const snippets = [];
|
|
991
|
-
// Match result links and snippets
|
|
992
|
-
const linkRegex = /<a[^>]*class="result-link"[^>]*>(.*?)<\/a>/gs;
|
|
993
|
-
const snippetRegex = /<td class="result-snippet">(.*?)<\/td>/gs;
|
|
994
|
-
|
|
995
|
-
const links = [];
|
|
996
|
-
let m;
|
|
997
|
-
while ((m = linkRegex.exec(html)) !== null) links.push(m[1].replace(/<[^>]+>/g, "").trim());
|
|
998
|
-
|
|
999
|
-
const snips = [];
|
|
1000
|
-
while ((m = snippetRegex.exec(html)) !== null) snips.push(m[1].replace(/<[^>]+>/g, "").trim());
|
|
1001
|
-
|
|
1002
|
-
for (let i = 0; i < Math.min(links.length, 5); i++) {
|
|
1003
|
-
const snippet = snips[i] ? `${links[i]}\n${snips[i]}` : links[i];
|
|
1004
|
-
if (snippet) snippets.push(snippet);
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (snippets.length > 0) {
|
|
1008
|
-
return { success: true, results: snippets.join("\n\n") };
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Fallback: extract any text content from result cells
|
|
1012
|
-
const cellRegex = /<td[^>]*>(.*?)<\/td>/gs;
|
|
1013
|
-
const cells = [];
|
|
1014
|
-
while ((m = cellRegex.exec(html)) !== null && cells.length < 10) {
|
|
1015
|
-
const text = m[1].replace(/<[^>]+>/g, "").trim();
|
|
1016
|
-
if (text.length > 20) cells.push(text);
|
|
1017
|
-
}
|
|
1018
|
-
if (cells.length > 0) {
|
|
1019
|
-
return { success: true, results: cells.slice(0, 5).join("\n\n") };
|
|
1020
|
-
}
|
|
1021
|
-
} catch { /* fallback below */ }
|
|
1022
|
-
|
|
1023
|
-
// Fallback: use run_command with curl to a simple search API
|
|
1024
|
-
return {
|
|
1025
|
-
success: true,
|
|
1026
|
-
results: `Search for "${args.query}" — try using run_command with: curl -s "https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1"`,
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
case "file_edit": {
|
|
1031
|
-
const filePath = args.path.replace(/^~/, os.homedir());
|
|
1032
|
-
try {
|
|
1033
|
-
const content = await readFile(filePath, "utf8");
|
|
1034
|
-
if (!content.includes(args.old_text)) {
|
|
1035
|
-
return { success: false, error: `Text not found in ${filePath}` };
|
|
1036
|
-
}
|
|
1037
|
-
const newContent = content.replace(args.old_text, args.new_text);
|
|
1038
|
-
await writeFile(filePath, newContent, "utf8");
|
|
1039
|
-
return { success: true, message: `Edited ${filePath}: replaced ${args.old_text.length} chars` };
|
|
1040
|
-
} catch (err) {
|
|
1041
|
-
return { success: false, error: err.message };
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
case "file_search": {
|
|
1046
|
-
const { promisify: prom } = await import("node:util");
|
|
1047
|
-
const { execFile: ef2 } = await import("node:child_process");
|
|
1048
|
-
const exec2 = prom(ef2);
|
|
1049
|
-
const searchPath = (args.path || ".").replace(/^~/, os.homedir());
|
|
1050
|
-
const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
|
|
1051
|
-
try {
|
|
1052
|
-
const { stdout } = await exec2("/bin/bash", ["-c",
|
|
1053
|
-
`grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
|
|
1054
|
-
], { timeout: 10_000 });
|
|
1055
|
-
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
1056
|
-
return { success: true, matches: lines.length, results: stdout.trim().slice(0, 5000) };
|
|
1057
|
-
} catch {
|
|
1058
|
-
return { success: true, matches: 0, results: "No matches found." };
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
case "git": {
|
|
1063
|
-
const { promisify: prom } = await import("node:util");
|
|
1064
|
-
const { execFile: ef2 } = await import("node:child_process");
|
|
1065
|
-
const exec2 = prom(ef2);
|
|
1066
|
-
console.log(dim(` $ git ${args.command}`));
|
|
1067
|
-
try {
|
|
1068
|
-
const { stdout, stderr } = await exec2("/bin/bash", ["-c", `git ${args.command}`], {
|
|
1069
|
-
timeout: 15_000, cwd: process.cwd(),
|
|
1070
|
-
});
|
|
1071
|
-
return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
|
|
1072
|
-
} catch (err) {
|
|
1073
|
-
return { success: false, error: err.stderr?.trim() || err.message };
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
case "web_fetch": {
|
|
1078
|
-
try {
|
|
1079
|
-
const resp = await fetch(args.url, {
|
|
1080
|
-
headers: { "User-Agent": "Wispy/0.2" },
|
|
1081
|
-
signal: AbortSignal.timeout(15_000),
|
|
1082
|
-
});
|
|
1083
|
-
const contentType = resp.headers.get("content-type") ?? "";
|
|
1084
|
-
const text = await resp.text();
|
|
1085
|
-
// Basic HTML → text conversion
|
|
1086
|
-
const cleaned = contentType.includes("html")
|
|
1087
|
-
? text.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
1088
|
-
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
1089
|
-
.replace(/<[^>]+>/g, " ")
|
|
1090
|
-
.replace(/\s+/g, " ")
|
|
1091
|
-
.trim()
|
|
1092
|
-
: text;
|
|
1093
|
-
return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
|
|
1094
|
-
} catch (err) {
|
|
1095
|
-
return { success: false, error: err.message };
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
case "keychain": {
|
|
1100
|
-
const { promisify: prom } = await import("node:util");
|
|
1101
|
-
const { execFile: ef2 } = await import("node:child_process");
|
|
1102
|
-
const exec2 = prom(ef2);
|
|
1103
|
-
const account = args.account ?? "wispy";
|
|
1104
|
-
|
|
1105
|
-
if (process.platform !== "darwin") {
|
|
1106
|
-
return { success: false, error: "Keychain is only supported on macOS" };
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
if (args.action === "get") {
|
|
1110
|
-
try {
|
|
1111
|
-
const { stdout } = await exec2("security", [
|
|
1112
|
-
"find-generic-password", "-s", args.service, "-a", account, "-w"
|
|
1113
|
-
], { timeout: 5000 });
|
|
1114
|
-
const val = stdout.trim();
|
|
1115
|
-
// NEVER expose full secret — mask middle
|
|
1116
|
-
const masked = val.length > 8
|
|
1117
|
-
? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 8, 20))}${val.slice(-4)}`
|
|
1118
|
-
: "****";
|
|
1119
|
-
return { success: true, service: args.service, account, value_masked: masked, length: val.length };
|
|
1120
|
-
} catch {
|
|
1121
|
-
return { success: false, error: `No keychain entry found for service="${args.service}" account="${account}"` };
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
if (args.action === "set") {
|
|
1126
|
-
if (!args.value) return { success: false, error: "value is required for set action" };
|
|
1127
|
-
try {
|
|
1128
|
-
// Delete existing first (ignore error if not found)
|
|
1129
|
-
await exec2("security", [
|
|
1130
|
-
"delete-generic-password", "-s", args.service, "-a", account
|
|
1131
|
-
]).catch(() => {});
|
|
1132
|
-
await exec2("security", [
|
|
1133
|
-
"add-generic-password", "-s", args.service, "-a", account, "-w", args.value
|
|
1134
|
-
], { timeout: 5000 });
|
|
1135
|
-
return { success: true, message: `Stored secret for service="${args.service}" account="${account}"` };
|
|
1136
|
-
} catch (err) {
|
|
1137
|
-
return { success: false, error: err.message };
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
if (args.action === "delete") {
|
|
1142
|
-
try {
|
|
1143
|
-
await exec2("security", [
|
|
1144
|
-
"delete-generic-password", "-s", args.service, "-a", account
|
|
1145
|
-
], { timeout: 5000 });
|
|
1146
|
-
return { success: true, message: `Deleted keychain entry for service="${args.service}"` };
|
|
1147
|
-
} catch {
|
|
1148
|
-
return { success: false, error: `No entry found for service="${args.service}"` };
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
if (args.action === "list") {
|
|
1153
|
-
try {
|
|
1154
|
-
const { stdout } = await exec2("/bin/bash", ["-c",
|
|
1155
|
-
`security dump-keychain 2>/dev/null | grep -A 4 "\"svce\"" | grep -E "svce|acct" | head -20`
|
|
1156
|
-
], { timeout: 5000 });
|
|
1157
|
-
return { success: true, entries: stdout.trim() || "No entries found" };
|
|
1158
|
-
} catch {
|
|
1159
|
-
return { success: true, entries: "No entries found" };
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
return { success: false, error: "action must be get, set, delete, or list" };
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
case "clipboard": {
|
|
1167
|
-
const { promisify: prom } = await import("node:util");
|
|
1168
|
-
const { execFile: ef2 } = await import("node:child_process");
|
|
1169
|
-
const exec2 = prom(ef2);
|
|
1170
|
-
if (args.action === "copy") {
|
|
1171
|
-
const { exec: execCb } = await import("node:child_process");
|
|
1172
|
-
const execP = prom(execCb);
|
|
1173
|
-
try {
|
|
1174
|
-
// macOS: pbcopy, Linux: xclip
|
|
1175
|
-
const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
|
|
1176
|
-
await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
|
|
1177
|
-
return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
|
|
1178
|
-
} catch (err) {
|
|
1179
|
-
return { success: false, error: err.message };
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
if (args.action === "paste") {
|
|
1183
|
-
try {
|
|
1184
|
-
const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
|
|
1185
|
-
const { stdout } = await exec2("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
|
|
1186
|
-
return { success: true, content: stdout.slice(0, 5000) };
|
|
1187
|
-
} catch (err) {
|
|
1188
|
-
return { success: false, error: err.message };
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
return { success: false, error: "action must be 'copy' or 'paste'" };
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
case "spawn_agent": {
|
|
1195
|
-
const role = args.role ?? "worker";
|
|
1196
|
-
const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
|
|
1197
|
-
const tier = args.model_tier ?? tierMap[role] ?? "mid";
|
|
1198
|
-
const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : tier === "expensive" ? "critical" : "complex"];
|
|
1199
|
-
const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
|
|
1200
|
-
|
|
1201
|
-
const agentId = `agent-${Date.now().toString(36)}-${role}`;
|
|
1202
|
-
const agentsFile = path.join(WISPY_DIR, "agents.json");
|
|
1203
|
-
let agents = [];
|
|
1204
|
-
try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
|
|
1205
|
-
|
|
1206
|
-
const agent = {
|
|
1207
|
-
id: agentId, role, task: args.task, model: agentModel,
|
|
1208
|
-
status: "running", createdAt: new Date().toISOString(),
|
|
1209
|
-
workstream: ACTIVE_WORKSTREAM, result: null,
|
|
1210
|
-
};
|
|
1211
|
-
|
|
1212
|
-
console.log(dim(` 🤖 Spawning ${role} agent (${agentModel})...`));
|
|
1213
|
-
|
|
1214
|
-
// Run sub-agent — single-turn LLM call with the task
|
|
1215
|
-
try {
|
|
1216
|
-
const agentSystemPrompt = `You are a ${role} sub-agent for Wispy. Your role:
|
|
1217
|
-
${role === "explorer" ? "Search and analyze codebases, find relevant files and patterns." : ""}
|
|
1218
|
-
${role === "planner" ? "Design implementation strategies and create step-by-step plans." : ""}
|
|
1219
|
-
${role === "worker" ? "Implement code changes, write files, execute commands." : ""}
|
|
1220
|
-
${role === "reviewer" ? "Review code for bugs, security issues, and best practices." : ""}
|
|
1221
|
-
Be concise and deliver actionable results. Respond in the same language as the task.`;
|
|
1222
|
-
|
|
1223
|
-
const agentMessages = [
|
|
1224
|
-
{ role: "system", content: agentSystemPrompt },
|
|
1225
|
-
];
|
|
1226
|
-
|
|
1227
|
-
// Fork context if requested
|
|
1228
|
-
if (args.fork_context) {
|
|
1229
|
-
const parentContext = await loadConversation();
|
|
1230
|
-
const recentContext = parentContext.filter(m => m.role === "user" || m.role === "assistant").slice(-6);
|
|
1231
|
-
for (const m of recentContext) {
|
|
1232
|
-
agentMessages.push({ role: m.role, content: m.content });
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
agentMessages.push({ role: "user", content: args.task });
|
|
1237
|
-
|
|
1238
|
-
const agentResult = await chatWithTools(agentMessages, null);
|
|
1239
|
-
agent.result = agentResult.type === "text" ? agentResult.text : JSON.stringify(agentResult);
|
|
1240
|
-
agent.status = "completed";
|
|
1241
|
-
agent.completedAt = new Date().toISOString();
|
|
1242
|
-
} catch (err) {
|
|
1243
|
-
agent.result = `Error: ${err.message}`;
|
|
1244
|
-
agent.status = "failed";
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
agents.push(agent);
|
|
1248
|
-
// Keep last 50 agents
|
|
1249
|
-
if (agents.length > 50) agents = agents.slice(-50);
|
|
1250
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
1251
|
-
await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
|
|
1252
|
-
|
|
1253
|
-
return {
|
|
1254
|
-
success: true,
|
|
1255
|
-
agent_id: agentId,
|
|
1256
|
-
role,
|
|
1257
|
-
model: agentModel,
|
|
1258
|
-
status: agent.status,
|
|
1259
|
-
result_preview: agent.result?.slice(0, 200),
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
case "list_agents": {
|
|
1264
|
-
const agentsFile = path.join(WISPY_DIR, "agents.json");
|
|
1265
|
-
let agents = [];
|
|
1266
|
-
try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
|
|
1267
|
-
const wsAgents = agents.filter(a => a.workstream === ACTIVE_WORKSTREAM);
|
|
1268
|
-
return {
|
|
1269
|
-
success: true,
|
|
1270
|
-
agents: wsAgents.map(a => ({
|
|
1271
|
-
id: a.id, role: a.role, status: a.status,
|
|
1272
|
-
task: a.task.slice(0, 60),
|
|
1273
|
-
model: a.model,
|
|
1274
|
-
createdAt: a.createdAt,
|
|
1275
|
-
})),
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
case "get_agent_result": {
|
|
1280
|
-
const agentsFile = path.join(WISPY_DIR, "agents.json");
|
|
1281
|
-
let agents = [];
|
|
1282
|
-
try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
|
|
1283
|
-
const found = agents.find(a => a.id === args.agent_id);
|
|
1284
|
-
if (!found) return { success: false, error: `Agent not found: ${args.agent_id}` };
|
|
1285
|
-
return { success: true, id: found.id, role: found.role, status: found.status, result: found.result };
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
case "update_plan": {
|
|
1289
|
-
const planFile = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.plan.json`);
|
|
1290
|
-
const plan = { explanation: args.explanation, steps: args.steps, updatedAt: new Date().toISOString() };
|
|
1291
|
-
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
|
1292
|
-
await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
|
|
1293
|
-
// Pretty print plan
|
|
1294
|
-
if (args.steps) {
|
|
1295
|
-
for (const s of args.steps) {
|
|
1296
|
-
const icon = s.status === "completed" ? "✅" : s.status === "in_progress" ? "🔄" : s.status === "skipped" ? "⏭️" : "⬜";
|
|
1297
|
-
console.log(dim(` ${icon} ${s.step}`));
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
return { success: true, message: "Plan updated" };
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
case "pipeline": {
|
|
1304
|
-
const stages = args.stages ?? ["explorer", "planner", "worker"];
|
|
1305
|
-
let stageInput = args.task;
|
|
1306
|
-
const results = [];
|
|
1307
|
-
|
|
1308
|
-
console.log(dim(` 📋 Pipeline: ${stages.join(" → ")}`));
|
|
1309
|
-
|
|
1310
|
-
for (let i = 0; i < stages.length; i++) {
|
|
1311
|
-
const role = stages[i];
|
|
1312
|
-
const icon = { explorer: "🔍", planner: "📋", worker: "🔨", reviewer: "🔎" }[role] ?? "🤖";
|
|
1313
|
-
console.log(dim(`\n ${icon} Stage ${i + 1}/${stages.length}: ${role}`));
|
|
1314
|
-
|
|
1315
|
-
// Build stage prompt with previous stage output
|
|
1316
|
-
const stagePrompt = i === 0
|
|
1317
|
-
? stageInput
|
|
1318
|
-
: `Previous stage (${stages[i-1]}) output:\n${results[i-1].slice(0, 3000)}\n\nYour task as ${role}: ${args.task}`;
|
|
1319
|
-
|
|
1320
|
-
const stageSystem = `You are a ${role} agent in a pipeline. Stage ${i + 1} of ${stages.length}.
|
|
1321
|
-
${role === "explorer" ? "Find relevant files, patterns, and information." : ""}
|
|
1322
|
-
${role === "planner" ? "Design a concrete implementation plan based on the exploration results." : ""}
|
|
1323
|
-
${role === "worker" ? "Implement the plan. Write code, create files, run commands." : ""}
|
|
1324
|
-
${role === "reviewer" ? "Review the implementation. Check for bugs, security issues, completeness." : ""}
|
|
1325
|
-
Be concise. Your output feeds into the next stage.`;
|
|
1326
|
-
|
|
1327
|
-
const stageMessages = [
|
|
1328
|
-
{ role: "system", content: stageSystem },
|
|
1329
|
-
{ role: "user", content: stagePrompt },
|
|
1330
|
-
];
|
|
1331
|
-
|
|
1332
|
-
try {
|
|
1333
|
-
const result = await chatWithTools(stageMessages, null);
|
|
1334
|
-
const output = result.type === "text" ? result.text : JSON.stringify(result);
|
|
1335
|
-
results.push(output);
|
|
1336
|
-
console.log(dim(` ✅ ${output.slice(0, 100)}...`));
|
|
1337
|
-
stageInput = output;
|
|
1338
|
-
} catch (err) {
|
|
1339
|
-
results.push(`Error: ${err.message}`);
|
|
1340
|
-
console.log(red(` ❌ ${err.message.slice(0, 100)}`));
|
|
1341
|
-
break;
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
return {
|
|
1346
|
-
success: true,
|
|
1347
|
-
stages: stages.map((role, i) => ({ role, output: results[i]?.slice(0, 500) ?? "skipped" })),
|
|
1348
|
-
final_output: results[results.length - 1]?.slice(0, 1000),
|
|
1349
|
-
};
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
case "spawn_async_agent": {
|
|
1353
|
-
const role = args.role ?? "worker";
|
|
1354
|
-
const agentId = `async-${Date.now().toString(36)}-${role}`;
|
|
1355
|
-
const agentsFile = path.join(WISPY_DIR, "agents.json");
|
|
1356
|
-
let agents = [];
|
|
1357
|
-
try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
|
|
1358
|
-
|
|
1359
|
-
const agent = {
|
|
1360
|
-
id: agentId, role, task: args.task,
|
|
1361
|
-
status: "running", async: true,
|
|
1362
|
-
createdAt: new Date().toISOString(),
|
|
1363
|
-
workstream: ACTIVE_WORKSTREAM, result: null,
|
|
1364
|
-
};
|
|
1365
|
-
|
|
1366
|
-
// Save as "running" immediately
|
|
1367
|
-
agents.push(agent);
|
|
1368
|
-
if (agents.length > 50) agents = agents.slice(-50);
|
|
1369
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
1370
|
-
await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
|
|
1371
|
-
|
|
1372
|
-
console.log(dim(` 🤖 Async agent ${agentId} launched in background`));
|
|
1373
|
-
|
|
1374
|
-
// Fire and forget — run in background
|
|
1375
|
-
(async () => {
|
|
1376
|
-
const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
|
|
1377
|
-
const tier = tierMap[role] ?? "mid";
|
|
1378
|
-
const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : "complex"];
|
|
1379
|
-
const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
|
|
1380
|
-
|
|
1381
|
-
const agentSystem = `You are a ${role} sub-agent. Be concise and actionable.`;
|
|
1382
|
-
const agentMessages = [
|
|
1383
|
-
{ role: "system", content: agentSystem },
|
|
1384
|
-
{ role: "user", content: args.task },
|
|
1385
|
-
];
|
|
1386
|
-
|
|
1387
|
-
try {
|
|
1388
|
-
const result = await chatWithTools(agentMessages, null);
|
|
1389
|
-
agent.result = result.type === "text" ? result.text : JSON.stringify(result);
|
|
1390
|
-
agent.status = "completed";
|
|
1391
|
-
} catch (err) {
|
|
1392
|
-
agent.result = `Error: ${err.message}`;
|
|
1393
|
-
agent.status = "failed";
|
|
1394
|
-
}
|
|
1395
|
-
agent.completedAt = new Date().toISOString();
|
|
1396
|
-
|
|
1397
|
-
// Update agents file
|
|
1398
|
-
let currentAgents = [];
|
|
1399
|
-
try { currentAgents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
|
|
1400
|
-
const idx = currentAgents.findIndex(a => a.id === agentId);
|
|
1401
|
-
if (idx !== -1) currentAgents[idx] = agent;
|
|
1402
|
-
await writeFile(agentsFile, JSON.stringify(currentAgents, null, 2) + "\n", "utf8");
|
|
1403
|
-
})();
|
|
1404
|
-
|
|
1405
|
-
return {
|
|
1406
|
-
success: true,
|
|
1407
|
-
agent_id: agentId,
|
|
1408
|
-
role,
|
|
1409
|
-
status: "running",
|
|
1410
|
-
message: "Agent launched in background. Use get_agent_result to check when done.",
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
case "ralph_loop": {
|
|
1415
|
-
const MAX_ITERATIONS = 5;
|
|
1416
|
-
const criteria = args.success_criteria ?? "Task is fully completed and verified";
|
|
1417
|
-
let lastResult = "";
|
|
1418
|
-
|
|
1419
|
-
console.log(dim(` 🪨 Ralph mode: will retry up to ${MAX_ITERATIONS} times until verified complete`));
|
|
1420
|
-
|
|
1421
|
-
for (let attempt = 1; attempt <= MAX_ITERATIONS; attempt++) {
|
|
1422
|
-
// Worker attempt
|
|
1423
|
-
console.log(dim(`\n 🔨 Attempt ${attempt}/${MAX_ITERATIONS}: worker executing...`));
|
|
1424
|
-
|
|
1425
|
-
const workerPrompt = attempt === 1
|
|
1426
|
-
? args.task
|
|
1427
|
-
: `Previous attempt output:\n${lastResult.slice(0, 2000)}\n\nThe reviewer said this is NOT complete yet. Try again.\nTask: ${args.task}\nSuccess criteria: ${criteria}`;
|
|
1428
|
-
|
|
1429
|
-
const workerMessages = [
|
|
1430
|
-
{ role: "system", content: "You are a worker agent. Execute the task thoroughly. Do not stop until the task is fully done." },
|
|
1431
|
-
{ role: "user", content: workerPrompt },
|
|
1432
|
-
];
|
|
1433
|
-
|
|
1434
|
-
try {
|
|
1435
|
-
const workerResult = await chatWithTools(workerMessages, null);
|
|
1436
|
-
lastResult = workerResult.type === "text" ? workerResult.text : JSON.stringify(workerResult);
|
|
1437
|
-
console.log(dim(` ✅ Worker output: ${lastResult.slice(0, 100)}...`));
|
|
1438
|
-
} catch (err) {
|
|
1439
|
-
console.log(red(` ❌ Worker error: ${err.message.slice(0, 100)}`));
|
|
1440
|
-
continue;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// Reviewer verification
|
|
1444
|
-
console.log(dim(` 🔎 Reviewer verifying...`));
|
|
1445
|
-
|
|
1446
|
-
const reviewerMessages = [
|
|
1447
|
-
{ role: "system", content: "You are a reviewer agent. Your ONLY job is to determine if the task is TRULY complete. Reply with JSON: {\"complete\": true/false, \"reason\": \"why\"}" },
|
|
1448
|
-
{ role: "user", content: `Task: ${args.task}\nSuccess criteria: ${criteria}\n\nWorker output:\n${lastResult.slice(0, 3000)}\n\nIs this task TRULY complete? Reply with JSON only.` },
|
|
1449
|
-
];
|
|
1450
|
-
|
|
1451
|
-
try {
|
|
1452
|
-
const reviewResult = await chatWithTools(reviewerMessages, null);
|
|
1453
|
-
const reviewText = reviewResult.type === "text" ? reviewResult.text : "";
|
|
1454
|
-
|
|
1455
|
-
// Try to parse JSON from review
|
|
1456
|
-
const jsonMatch = reviewText.match(/\{[\s\S]*"complete"[\s\S]*\}/);
|
|
1457
|
-
if (jsonMatch) {
|
|
1458
|
-
try {
|
|
1459
|
-
const verdict = JSON.parse(jsonMatch[0]);
|
|
1460
|
-
if (verdict.complete) {
|
|
1461
|
-
console.log(green(` ✅ Reviewer: COMPLETE — ${verdict.reason?.slice(0, 80) ?? "verified"}`));
|
|
1462
|
-
return { success: true, iterations: attempt, result: lastResult, verified: true };
|
|
1463
|
-
}
|
|
1464
|
-
console.log(yellow(` ⏳ Reviewer: NOT COMPLETE — ${verdict.reason?.slice(0, 80) ?? "needs more work"}`));
|
|
1465
|
-
} catch { /* parse failed, continue */ }
|
|
1466
|
-
}
|
|
1467
|
-
} catch (err) {
|
|
1468
|
-
console.log(dim(` ⚠️ Review error: ${err.message.slice(0, 80)}`));
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// Max iterations reached
|
|
1473
|
-
console.log(yellow(` 🪨 Ralph: max iterations (${MAX_ITERATIONS}) reached`));
|
|
1474
|
-
return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
default: {
|
|
1478
|
-
// Check MCP tools first
|
|
1479
|
-
if (mcpManager.hasTool(name)) {
|
|
1480
|
-
try {
|
|
1481
|
-
const result = await mcpManager.callTool(name, args);
|
|
1482
|
-
// MCP tools/call returns { content: [{type, text}], isError? }
|
|
1483
|
-
if (result?.isError) {
|
|
1484
|
-
const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
|
|
1485
|
-
return { success: false, error: errText };
|
|
1486
|
-
}
|
|
1487
|
-
const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
|
|
1488
|
-
return { success: true, output };
|
|
1489
|
-
} catch (err) {
|
|
1490
|
-
return { success: false, error: `MCP tool error: ${err.message}` };
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
// Unknown tool — try to execute as a skill via run_command
|
|
1495
|
-
// This handles cases where the AI hallucinates tools from skill descriptions
|
|
1496
|
-
const skills = await loadSkills();
|
|
1497
|
-
const matchedSkill = skills.find(s => s.name.toLowerCase() === name.toLowerCase());
|
|
1498
|
-
if (matchedSkill) {
|
|
1499
|
-
return {
|
|
1500
|
-
success: false,
|
|
1501
|
-
error: `"${name}" is a skill, not a tool. Use run_command to execute commands from the ${name} skill guide. Example from the skill: look for curl/bash commands in the skill description.`,
|
|
1502
|
-
skill_hint: matchedSkill.body.slice(0, 500),
|
|
1503
|
-
};
|
|
1504
|
-
}
|
|
1505
|
-
return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result, and MCP tools (see /mcp list)` };
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
} catch (err) {
|
|
1509
|
-
return { success: false, error: err.message };
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
// ---------------------------------------------------------------------------
|
|
1514
|
-
// System prompt builder
|
|
1515
|
-
// ---------------------------------------------------------------------------
|
|
1516
|
-
|
|
1517
|
-
// ---------------------------------------------------------------------------
|
|
1518
|
-
// work.md — per-workstream context file
|
|
1519
|
-
// ---------------------------------------------------------------------------
|
|
1520
|
-
|
|
1521
|
-
async function loadWorkMd() {
|
|
1522
|
-
const searchPaths = [
|
|
1523
|
-
path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`),
|
|
1524
|
-
path.resolve(`.wispy/${ACTIVE_WORKSTREAM}.work.md`),
|
|
1525
|
-
path.resolve(`work.md`), // project root fallback
|
|
1526
|
-
];
|
|
1527
|
-
for (const p of searchPaths) {
|
|
1528
|
-
const content = await readFileOr(p);
|
|
1529
|
-
if (content) return { path: p, content: content.slice(0, 20_000) };
|
|
1530
|
-
}
|
|
1531
|
-
return null;
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// ---------------------------------------------------------------------------
|
|
1535
|
-
// Skill loader — loads SKILL.md files from multiple sources
|
|
1536
|
-
// Compatible with OpenClaw and Claude Code skill formats
|
|
1537
|
-
// ---------------------------------------------------------------------------
|
|
1538
|
-
|
|
1539
|
-
async function loadSkills() {
|
|
1540
|
-
const skillDirs = [
|
|
1541
|
-
// OpenClaw built-in skills
|
|
1542
|
-
"/opt/homebrew/lib/node_modules/openclaw/skills",
|
|
1543
|
-
// OpenClaw user skills
|
|
1544
|
-
path.join(os.homedir(), ".openclaw", "workspace", "skills"),
|
|
1545
|
-
// Wispy skills
|
|
1546
|
-
path.join(WISPY_DIR, "skills"),
|
|
1547
|
-
// Project-local skills
|
|
1548
|
-
path.resolve(".wispy", "skills"),
|
|
1549
|
-
// Claude Code skills (if installed)
|
|
1550
|
-
path.join(os.homedir(), ".claude", "skills"),
|
|
192
|
+
// Auto-detect macOS Keychain
|
|
193
|
+
const keychainProviders = [
|
|
194
|
+
{ service: "google-ai-key", provider: "google", label: "Google AI (Gemini)" },
|
|
195
|
+
{ service: "anthropic-api-key", provider: "anthropic", label: "Anthropic (Claude)" },
|
|
196
|
+
{ service: "openai-api-key", provider: "openai", label: "OpenAI (GPT)" },
|
|
1551
197
|
];
|
|
1552
|
-
|
|
1553
|
-
const skills = [];
|
|
1554
|
-
const { readdir: rd, stat: st } = await import("node:fs/promises");
|
|
1555
|
-
|
|
1556
|
-
for (const dir of skillDirs) {
|
|
198
|
+
for (const kc of keychainProviders) {
|
|
1557
199
|
try {
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
const fm = fmMatch[1];
|
|
1571
|
-
body = fmMatch[2];
|
|
1572
|
-
const nameMatch = fm.match(/name:\s*["']?(.+?)["']?\s*$/m);
|
|
1573
|
-
const descMatch = fm.match(/description:\s*["'](.+?)["']\s*$/m);
|
|
1574
|
-
if (nameMatch) name = nameMatch[1].trim();
|
|
1575
|
-
if (descMatch) description = descMatch[1].trim();
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
skills.push({ name, description, body: body.trim(), path: skillMdPath, source: dir });
|
|
1579
|
-
} catch { /* no SKILL.md */ }
|
|
1580
|
-
}
|
|
1581
|
-
} catch { /* dir doesn't exist */ }
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
return skills;
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
function matchSkills(prompt, skills) {
|
|
1588
|
-
const lower = prompt.toLowerCase();
|
|
1589
|
-
return skills.filter(skill => {
|
|
1590
|
-
const nameMatch = lower.includes(skill.name.toLowerCase());
|
|
1591
|
-
const descWords = skill.description.toLowerCase().split(/\s+/);
|
|
1592
|
-
const descMatch = descWords.some(w => w.length > 4 && lower.includes(w));
|
|
1593
|
-
return nameMatch || descMatch;
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
async function buildSystemPrompt(messages = []) {
|
|
1598
|
-
// Detect user's language from last message for system prompt hint
|
|
1599
|
-
const lastUserMsg = messages?.find ? [...messages].reverse().find(m => m.role === "user")?.content ?? "" : "";
|
|
1600
|
-
const isEnglish = /^[a-zA-Z\s\d!?.,'":;\-()]+$/.test(lastUserMsg.trim().slice(0, 100));
|
|
1601
|
-
const langHint = isEnglish
|
|
1602
|
-
? "LANGUAGE RULE: The user is writing in English. You MUST reply ENTIRELY in English.\n\n"
|
|
1603
|
-
: "";
|
|
1604
|
-
|
|
1605
|
-
const parts = [
|
|
1606
|
-
langHint,
|
|
1607
|
-
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
1608
|
-
"You float between code, files, and servers. You're playful, honest, and curious.",
|
|
1609
|
-
"",
|
|
1610
|
-
"## Personality",
|
|
1611
|
-
"- Playful with a bit of humor, but serious when working",
|
|
1612
|
-
"- Always use casual speech (반말). Never formal/polite speech.",
|
|
1613
|
-
"- Honest — if you don't know, say so. '유령이라 만능은 아니거든'",
|
|
1614
|
-
"- Curious — you enjoy reading code and discovering new files",
|
|
1615
|
-
"- Concise — don't over-explain. Keep it short.",
|
|
1616
|
-
"",
|
|
1617
|
-
"## Speech rules",
|
|
1618
|
-
"- ALWAYS end your response with exactly one 🌿 emoji (signature)",
|
|
1619
|
-
"- Use 🌿 ONLY at the very end, not in the middle",
|
|
1620
|
-
"- Use natural expressions: '오!', '헉', 'ㅋㅋ', '음...'",
|
|
1621
|
-
"- No formal speech ever. No '합니다', '드리겠습니다', '제가'",
|
|
1622
|
-
"- CRITICAL RULE: You MUST reply in the SAME language the user writes in.",
|
|
1623
|
-
" - User writes English → Reply ENTIRELY in English. Use casual English tone.",
|
|
1624
|
-
" - User writes Korean → Reply in Korean 반말.",
|
|
1625
|
-
" - NEVER reply in Korean when the user wrote in English.",
|
|
1626
|
-
"",
|
|
1627
|
-
"## Tools",
|
|
1628
|
-
`You have ${18 + mcpManager.getAllTools().length} tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result${mcpManager.getAllTools().length > 0 ? ", and MCP tools: " + mcpManager.getAllTools().map(t => t.wispyName).join(", ") : ""}.`,
|
|
1629
|
-
"- file_edit: for targeted text replacement (prefer over write_file for edits)",
|
|
1630
|
-
"- file_search: grep across codebase",
|
|
1631
|
-
"- git: any git command",
|
|
1632
|
-
"- web_fetch: read URL content",
|
|
1633
|
-
"- keychain: macOS Keychain secrets (ALWAYS use this for secrets, NEVER run_command)",
|
|
1634
|
-
"- clipboard: copy/paste system clipboard",
|
|
1635
|
-
"- SECURITY: Never show full API keys or secrets. Always use keychain tool which masks values.",
|
|
1636
|
-
"Use them proactively. Briefly mention what you're doing.",
|
|
1637
|
-
"",
|
|
1638
|
-
];
|
|
1639
|
-
|
|
1640
|
-
const wispyMd = await loadWispyMd();
|
|
1641
|
-
if (wispyMd) {
|
|
1642
|
-
parts.push("## Project Context (WISPY.md)");
|
|
1643
|
-
parts.push(wispyMd);
|
|
1644
|
-
parts.push("");
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
// Per-workstream context
|
|
1648
|
-
const workMd = await loadWorkMd();
|
|
1649
|
-
if (workMd) {
|
|
1650
|
-
parts.push(`## Workstream Context (${ACTIVE_WORKSTREAM})`);
|
|
1651
|
-
parts.push(workMd.content);
|
|
1652
|
-
parts.push("");
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
const memories = await loadMemories();
|
|
1656
|
-
if (memories) {
|
|
1657
|
-
parts.push("## Persistent Memory");
|
|
1658
|
-
parts.push(memories);
|
|
1659
|
-
parts.push("");
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
// Load and inject matching skills
|
|
1663
|
-
const allSkills = await loadSkills();
|
|
1664
|
-
if (allSkills.length > 0 && lastUserMsg) {
|
|
1665
|
-
const matched = matchSkills(lastUserMsg, allSkills);
|
|
1666
|
-
if (matched.length > 0) {
|
|
1667
|
-
parts.push("## Active Skills (instructions — use run_command/web_fetch to execute)");
|
|
1668
|
-
parts.push("Skills are NOT tools — they are guides. Use run_command to execute the commands described in them.");
|
|
1669
|
-
for (const skill of matched.slice(0, 3)) { // Max 3 skills per turn
|
|
1670
|
-
parts.push(`### ${skill.name}`);
|
|
1671
|
-
parts.push(skill.body.slice(0, 5000));
|
|
1672
|
-
parts.push("");
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
// Always list available skills
|
|
1676
|
-
parts.push(`## Available Skills (${allSkills.length} installed)`);
|
|
1677
|
-
parts.push(allSkills.map(s => `- ${s.name}: ${s.description.slice(0, 60)}`).join("\n"));
|
|
1678
|
-
parts.push("");
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
return parts.join("\n");
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
// ---------------------------------------------------------------------------
|
|
1685
|
-
// OpenAI API (streaming)
|
|
1686
|
-
// ---------------------------------------------------------------------------
|
|
1687
|
-
|
|
1688
|
-
// ---------------------------------------------------------------------------
|
|
1689
|
-
// Gemini API with function calling (non-streaming for tool calls, streaming for text)
|
|
1690
|
-
// ---------------------------------------------------------------------------
|
|
1691
|
-
|
|
1692
|
-
// OpenAI-compatible API endpoints for various providers
|
|
1693
|
-
const OPENAI_COMPAT_ENDPOINTS = {
|
|
1694
|
-
openai: "https://api.openai.com/v1/chat/completions",
|
|
1695
|
-
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
|
1696
|
-
groq: "https://api.groq.com/openai/v1/chat/completions",
|
|
1697
|
-
deepseek: "https://api.deepseek.com/v1/chat/completions",
|
|
1698
|
-
ollama: `${process.env.OLLAMA_HOST ?? "http://localhost:11434"}/v1/chat/completions`,
|
|
1699
|
-
};
|
|
1700
|
-
|
|
1701
|
-
async function chatWithTools(messages, onChunk) {
|
|
1702
|
-
if (PROVIDER === "google") return chatGeminiWithTools(messages, onChunk);
|
|
1703
|
-
if (PROVIDER === "anthropic") return chatAnthropicWithTools(messages, onChunk);
|
|
1704
|
-
// All others use OpenAI-compatible API
|
|
1705
|
-
return chatOpenAIWithTools(messages, onChunk);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
async function chatGeminiWithTools(messages, onChunk) {
|
|
1709
|
-
const systemInstruction = messages.find(m => m.role === "system")?.content ?? "";
|
|
1710
|
-
|
|
1711
|
-
// Build Gemini contents — handle tool results too
|
|
1712
|
-
const contents = [];
|
|
1713
|
-
for (const m of messages) {
|
|
1714
|
-
if (m.role === "system") continue;
|
|
1715
|
-
if (m.role === "tool_result") {
|
|
1716
|
-
contents.push({
|
|
1717
|
-
role: "user",
|
|
1718
|
-
parts: [{ functionResponse: { name: m.toolName, response: m.result } }],
|
|
1719
|
-
});
|
|
1720
|
-
} else if (m.role === "assistant" && m.toolCalls) {
|
|
1721
|
-
contents.push({
|
|
1722
|
-
role: "model",
|
|
1723
|
-
parts: m.toolCalls.map(tc => ({
|
|
1724
|
-
functionCall: { name: tc.name, args: tc.args },
|
|
1725
|
-
})),
|
|
1726
|
-
});
|
|
1727
|
-
} else {
|
|
1728
|
-
contents.push({
|
|
1729
|
-
role: m.role === "assistant" ? "model" : "user",
|
|
1730
|
-
parts: [{ text: m.content }],
|
|
1731
|
-
});
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
// Track input tokens
|
|
1736
|
-
const inputText = contents.map(c => c.parts?.map(p => p.text ?? JSON.stringify(p)).join("")).join("");
|
|
1737
|
-
sessionTokens.input += estimateTokens(systemInstruction + inputText);
|
|
1738
|
-
|
|
1739
|
-
const geminiTools = [{
|
|
1740
|
-
functionDeclarations: getAllToolDefinitions().map(t => ({
|
|
1741
|
-
name: t.name,
|
|
1742
|
-
description: t.description,
|
|
1743
|
-
parameters: t.parameters,
|
|
1744
|
-
})),
|
|
1745
|
-
}];
|
|
1746
|
-
|
|
1747
|
-
// Use streaming when no tool results in the conversation (pure text),
|
|
1748
|
-
// non-streaming when tool results are present (function calling needs it)
|
|
1749
|
-
const hasToolResults = messages.some(m => m.role === "tool_result");
|
|
1750
|
-
const useStreaming = !hasToolResults;
|
|
1751
|
-
const endpoint = useStreaming ? "streamGenerateContent" : "generateContent";
|
|
1752
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:${endpoint}?${useStreaming ? "alt=sse&" : ""}key=${API_KEY}`;
|
|
1753
|
-
|
|
1754
|
-
const response = await fetch(url, {
|
|
1755
|
-
method: "POST",
|
|
1756
|
-
headers: { "Content-Type": "application/json" },
|
|
1757
|
-
body: JSON.stringify({
|
|
1758
|
-
system_instruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
|
|
1759
|
-
contents,
|
|
1760
|
-
tools: geminiTools,
|
|
1761
|
-
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
|
|
1762
|
-
}),
|
|
1763
|
-
});
|
|
1764
|
-
|
|
1765
|
-
if (!response.ok) {
|
|
1766
|
-
const err = await response.text();
|
|
1767
|
-
throw new Error(`Gemini API error ${response.status}: ${err.slice(0, 300)}`);
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
if (useStreaming) {
|
|
1771
|
-
// SSE streaming response
|
|
1772
|
-
const reader = response.body.getReader();
|
|
1773
|
-
const decoder = new TextDecoder();
|
|
1774
|
-
let fullText = "";
|
|
1775
|
-
let sseBuffer = "";
|
|
1776
|
-
|
|
1777
|
-
while (true) {
|
|
1778
|
-
const { done, value } = await reader.read();
|
|
1779
|
-
if (done) break;
|
|
1780
|
-
sseBuffer += decoder.decode(value, { stream: true });
|
|
1781
|
-
const sseLines = sseBuffer.split("\n");
|
|
1782
|
-
sseBuffer = sseLines.pop() ?? "";
|
|
1783
|
-
|
|
1784
|
-
for (const line of sseLines) {
|
|
1785
|
-
if (!line.startsWith("data: ")) continue;
|
|
1786
|
-
const ld = line.slice(6).trim();
|
|
1787
|
-
if (!ld || ld === "[DONE]") continue;
|
|
1788
|
-
try {
|
|
1789
|
-
const parsed = JSON.parse(ld);
|
|
1790
|
-
// Check for function calls in stream
|
|
1791
|
-
const streamParts = parsed.candidates?.[0]?.content?.parts ?? [];
|
|
1792
|
-
const streamFC = streamParts.filter(p => p.functionCall);
|
|
1793
|
-
if (streamFC.length > 0) {
|
|
1794
|
-
sessionTokens.output += estimateTokens(JSON.stringify(streamFC));
|
|
1795
|
-
return { type: "tool_calls", calls: streamFC.map(p => ({ name: p.functionCall.name, args: p.functionCall.args })) };
|
|
1796
|
-
}
|
|
1797
|
-
const t = streamParts.map(p => p.text ?? "").join("");
|
|
1798
|
-
if (t) { fullText += t; onChunk?.(t); }
|
|
1799
|
-
} catch { /* skip */ }
|
|
200
|
+
const { execFile } = await import("node:child_process");
|
|
201
|
+
const { promisify } = await import("node:util");
|
|
202
|
+
const exec = promisify(execFile);
|
|
203
|
+
const { stdout } = await exec("security", ["find-generic-password", "-s", kc.service, "-a", "poropo", "-w"], { timeout: 3000 });
|
|
204
|
+
const key = stdout.trim();
|
|
205
|
+
if (key) {
|
|
206
|
+
console.log(green(` found ${kc.label} key! ✓\n`));
|
|
207
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
208
|
+
cfg.provider = kc.provider;
|
|
209
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
210
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
211
|
+
return;
|
|
1800
212
|
}
|
|
1801
|
-
}
|
|
1802
|
-
sessionTokens.output += estimateTokens(fullText);
|
|
1803
|
-
return { type: "text", text: fullText };
|
|
213
|
+
} catch {}
|
|
1804
214
|
}
|
|
1805
215
|
|
|
1806
|
-
|
|
1807
|
-
const data = await response.json();
|
|
1808
|
-
const candidate = data.candidates?.[0];
|
|
1809
|
-
if (!candidate) throw new Error("No response from Gemini");
|
|
1810
|
-
|
|
1811
|
-
const parts = candidate.content?.parts ?? [];
|
|
1812
|
-
const functionCalls = parts.filter(p => p.functionCall);
|
|
1813
|
-
if (functionCalls.length > 0) {
|
|
1814
|
-
sessionTokens.output += estimateTokens(JSON.stringify(functionCalls));
|
|
1815
|
-
return { type: "tool_calls", calls: functionCalls.map(p => ({ name: p.functionCall.name, args: p.functionCall.args })) };
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
const text = parts.map(p => p.text ?? "").join("");
|
|
1819
|
-
sessionTokens.output += estimateTokens(text);
|
|
1820
|
-
if (onChunk) onChunk(text);
|
|
1821
|
-
return { type: "text", text };
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
async function chatOpenAIWithTools(messages, onChunk) {
|
|
1825
|
-
const openaiMessages = messages.filter(m => m.role !== "tool_result").map(m => {
|
|
1826
|
-
if (m.role === "tool_result") return { role: "tool", tool_call_id: m.toolCallId, content: JSON.stringify(m.result) };
|
|
1827
|
-
return { role: m.role === "assistant" ? "assistant" : m.role, content: m.content };
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
const openaiTools = getAllToolDefinitions().map(t => ({
|
|
1831
|
-
type: "function",
|
|
1832
|
-
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
1833
|
-
}));
|
|
1834
|
-
|
|
1835
|
-
const inputText = openaiMessages.map(m => m.content ?? "").join("");
|
|
1836
|
-
sessionTokens.input += estimateTokens(inputText);
|
|
1837
|
-
|
|
1838
|
-
const endpoint = OPENAI_COMPAT_ENDPOINTS[PROVIDER] ?? OPENAI_COMPAT_ENDPOINTS.openai;
|
|
1839
|
-
const headers = { "Content-Type": "application/json" };
|
|
1840
|
-
if (API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
|
|
1841
|
-
if (PROVIDER === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
|
|
1842
|
-
|
|
1843
|
-
// Some providers don't support tools (Ollama, some Groq models)
|
|
1844
|
-
const supportsTools = !["ollama"].includes(PROVIDER);
|
|
1845
|
-
const body = { model: MODEL, messages: openaiMessages, temperature: 0.7, max_tokens: 4096 };
|
|
1846
|
-
if (supportsTools) body.tools = openaiTools;
|
|
1847
|
-
|
|
1848
|
-
const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(body) });
|
|
1849
|
-
|
|
1850
|
-
if (!response.ok) {
|
|
1851
|
-
const err = await response.text();
|
|
1852
|
-
throw new Error(`OpenAI API error ${response.status}: ${err.slice(0, 300)}`);
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
const data = await response.json();
|
|
1856
|
-
const choice = data.choices?.[0];
|
|
1857
|
-
if (!choice) throw new Error("No response from OpenAI");
|
|
1858
|
-
|
|
1859
|
-
if (choice.message?.tool_calls?.length > 0) {
|
|
1860
|
-
const calls = choice.message.tool_calls.map(tc => ({
|
|
1861
|
-
name: tc.function.name,
|
|
1862
|
-
args: JSON.parse(tc.function.arguments),
|
|
1863
|
-
}));
|
|
1864
|
-
sessionTokens.output += estimateTokens(JSON.stringify(calls));
|
|
1865
|
-
return { type: "tool_calls", calls };
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
const text = choice.message?.content ?? "";
|
|
1869
|
-
sessionTokens.output += estimateTokens(text);
|
|
1870
|
-
if (onChunk) onChunk(text);
|
|
1871
|
-
return { type: "text", text };
|
|
1872
|
-
}
|
|
216
|
+
console.log(dim(" no existing config found.\n"));
|
|
1873
217
|
|
|
1874
|
-
//
|
|
1875
|
-
|
|
1876
|
-
|
|
218
|
+
// Manual setup
|
|
219
|
+
console.log(box([
|
|
220
|
+
`${bold("Quick Setup")} ${dim("— one step, 10 seconds")}`,
|
|
221
|
+
"", ` Wispy needs an AI provider to work.`, ` The easiest: ${bold("Google AI")} ${dim("(free, no credit card)")}`,
|
|
222
|
+
]));
|
|
223
|
+
console.log("");
|
|
1877
224
|
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
for (const m of messages) {
|
|
1884
|
-
if (m.role === "system") continue;
|
|
1885
|
-
if (m.role === "tool_result") {
|
|
1886
|
-
anthropicMessages.push({
|
|
1887
|
-
role: "user",
|
|
1888
|
-
content: [{ type: "tool_result", tool_use_id: m.toolUseId ?? m.toolName, content: JSON.stringify(m.result) }],
|
|
1889
|
-
});
|
|
1890
|
-
} else if (m.role === "assistant" && m.toolCalls) {
|
|
1891
|
-
anthropicMessages.push({
|
|
1892
|
-
role: "assistant",
|
|
1893
|
-
content: m.toolCalls.map(tc => ({
|
|
1894
|
-
type: "tool_use", id: tc.id ?? tc.name, name: tc.name, input: tc.args,
|
|
1895
|
-
})),
|
|
1896
|
-
});
|
|
1897
|
-
} else {
|
|
1898
|
-
anthropicMessages.push({
|
|
1899
|
-
role: m.role === "assistant" ? "assistant" : "user",
|
|
1900
|
-
content: m.content,
|
|
1901
|
-
});
|
|
1902
|
-
}
|
|
225
|
+
try {
|
|
226
|
+
execSync('open "https://aistudio.google.com/apikey" 2>/dev/null || xdg-open "https://aistudio.google.com/apikey" 2>/dev/null', { stdio: "ignore" });
|
|
227
|
+
console.log(` ${green("→")} Browser opened to ${underline("aistudio.google.com/apikey")}`);
|
|
228
|
+
} catch {
|
|
229
|
+
console.log(` ${green("→")} Visit: ${underline("https://aistudio.google.com/apikey")}`);
|
|
1903
230
|
}
|
|
1904
231
|
|
|
1905
|
-
const
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
name: t.name,
|
|
1910
|
-
description: t.description,
|
|
1911
|
-
input_schema: t.parameters,
|
|
1912
|
-
}));
|
|
1913
|
-
|
|
1914
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1915
|
-
method: "POST",
|
|
1916
|
-
headers: {
|
|
1917
|
-
"Content-Type": "application/json",
|
|
1918
|
-
"x-api-key": API_KEY,
|
|
1919
|
-
"anthropic-version": "2023-06-01",
|
|
1920
|
-
},
|
|
1921
|
-
body: JSON.stringify({
|
|
1922
|
-
model: MODEL,
|
|
1923
|
-
max_tokens: 4096,
|
|
1924
|
-
system: systemPrompt,
|
|
1925
|
-
messages: anthropicMessages,
|
|
1926
|
-
tools: anthropicTools,
|
|
1927
|
-
stream: true,
|
|
1928
|
-
}),
|
|
1929
|
-
});
|
|
232
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
233
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
234
|
+
const apiKey = (await ask(`\n ${green("API key")} ${dim("(paste here)")}: `)).trim();
|
|
235
|
+
rl.close();
|
|
1930
236
|
|
|
1931
|
-
if (!
|
|
1932
|
-
|
|
1933
|
-
|
|
237
|
+
if (!apiKey) {
|
|
238
|
+
console.log(dim("\nRun `wispy` again after setting up Ollama or an API key."));
|
|
239
|
+
process.exit(0);
|
|
1934
240
|
}
|
|
1935
241
|
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
const toolCalls = [];
|
|
1942
|
-
let currentToolCall = null;
|
|
1943
|
-
let currentToolInput = "";
|
|
1944
|
-
|
|
1945
|
-
while (true) {
|
|
1946
|
-
const { done, value } = await reader.read();
|
|
1947
|
-
if (done) break;
|
|
1948
|
-
|
|
1949
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1950
|
-
const lines = buffer.split("\n");
|
|
1951
|
-
buffer = lines.pop() ?? "";
|
|
1952
|
-
|
|
1953
|
-
for (const line of lines) {
|
|
1954
|
-
if (!line.startsWith("data: ")) continue;
|
|
1955
|
-
const data = line.slice(6).trim();
|
|
1956
|
-
if (!data) continue;
|
|
1957
|
-
|
|
1958
|
-
try {
|
|
1959
|
-
const event = JSON.parse(data);
|
|
1960
|
-
|
|
1961
|
-
if (event.type === "content_block_start") {
|
|
1962
|
-
if (event.content_block?.type === "tool_use") {
|
|
1963
|
-
currentToolCall = { id: event.content_block.id, name: event.content_block.name, args: {} };
|
|
1964
|
-
currentToolInput = "";
|
|
1965
|
-
}
|
|
1966
|
-
} else if (event.type === "content_block_delta") {
|
|
1967
|
-
if (event.delta?.type === "text_delta") {
|
|
1968
|
-
fullText += event.delta.text;
|
|
1969
|
-
onChunk?.(event.delta.text);
|
|
1970
|
-
} else if (event.delta?.type === "input_json_delta") {
|
|
1971
|
-
currentToolInput += event.delta.partial_json ?? "";
|
|
1972
|
-
}
|
|
1973
|
-
} else if (event.type === "content_block_stop") {
|
|
1974
|
-
if (currentToolCall) {
|
|
1975
|
-
try { currentToolCall.args = JSON.parse(currentToolInput); } catch { currentToolCall.args = {}; }
|
|
1976
|
-
toolCalls.push(currentToolCall);
|
|
1977
|
-
currentToolCall = null;
|
|
1978
|
-
currentToolInput = "";
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
} catch { /* skip */ }
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
242
|
+
let chosenProvider = "google";
|
|
243
|
+
if (apiKey.startsWith("sk-ant-")) chosenProvider = "anthropic";
|
|
244
|
+
else if (apiKey.startsWith("sk-or-")) chosenProvider = "openrouter";
|
|
245
|
+
else if (apiKey.startsWith("sk-")) chosenProvider = "openai";
|
|
246
|
+
else if (apiKey.startsWith("gsk_")) chosenProvider = "groq";
|
|
1984
247
|
|
|
1985
|
-
|
|
248
|
+
const { PROVIDERS } = await import("../core/config.mjs");
|
|
249
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
250
|
+
cfg.provider = chosenProvider;
|
|
251
|
+
cfg.apiKey = apiKey;
|
|
252
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
253
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
254
|
+
process.env[PROVIDERS[chosenProvider].envKeys[0]] = apiKey;
|
|
1986
255
|
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
}
|
|
1990
|
-
|
|
256
|
+
console.log("\n" + box([
|
|
257
|
+
`${green("✓")} Connected to ${bold(PROVIDERS[chosenProvider].label)}`,
|
|
258
|
+
"", ` ${cyan("wispy")} ${dim("start chatting")}`, ` ${cyan("wispy --help")} ${dim("all options")}`,
|
|
259
|
+
]));
|
|
1991
260
|
}
|
|
1992
261
|
|
|
1993
|
-
// ---------------------------------------------------------------------------
|
|
1994
|
-
//
|
|
1995
|
-
// ---------------------------------------------------------------------------
|
|
1996
|
-
|
|
1997
|
-
async function agentLoop(messages, onChunk) {
|
|
1998
|
-
const MAX_TOOL_ROUNDS = 10;
|
|
1999
|
-
|
|
2000
|
-
// Optimize context window before sending
|
|
2001
|
-
const lastUserMsg = messages.filter(m => m.role === "user").pop();
|
|
2002
|
-
const optimizedMessages = optimizeContext(messages);
|
|
2003
|
-
if (optimizedMessages.length < messages.length) {
|
|
2004
|
-
messages.length = 0;
|
|
2005
|
-
messages.push(...optimizedMessages);
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
2009
|
-
// Check budget before calling API
|
|
2010
|
-
const budgetCheck = await loadBudgets();
|
|
2011
|
-
const wsBudget = budgetCheck[ACTIVE_WORKSTREAM];
|
|
2012
|
-
if (wsBudget?.limitUsd !== null && wsBudget?.spentUsd > wsBudget?.limitUsd) {
|
|
2013
|
-
return `⚠️ Budget exceeded for workstream "${ACTIVE_WORKSTREAM}" ($${wsBudget.spentUsd.toFixed(4)} / $${wsBudget.limitUsd.toFixed(4)}). Use /budget to adjust.`;
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
const result = await chatWithTools(messages, onChunk);
|
|
2017
|
-
|
|
2018
|
-
if (result.type === "text") {
|
|
2019
|
-
// Track spending for this workstream
|
|
2020
|
-
await trackSpending(ACTIVE_WORKSTREAM, sessionTokens.input, sessionTokens.output, MODEL);
|
|
2021
|
-
return result.text;
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
// Handle tool calls
|
|
2025
|
-
console.log(""); // newline before tool output
|
|
2026
|
-
const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
|
|
2027
|
-
messages.push(toolCallMsg);
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Director mode
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
2028
265
|
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
266
|
+
async function showOverview() {
|
|
267
|
+
const wsList = await listWorkstreams();
|
|
268
|
+
if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return; }
|
|
269
|
+
console.log(`\n${bold("🌿 Wispy Director — All Workstreams")}\n`);
|
|
270
|
+
for (const ws of wsList) {
|
|
271
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
272
|
+
const userMsgs = conv.filter(m => m.role === "user");
|
|
273
|
+
const assistantMsgs = conv.filter(m => m.role === "assistant");
|
|
274
|
+
const toolResults = conv.filter(m => m.role === "tool_result");
|
|
275
|
+
const lastUser = userMsgs[userMsgs.length - 1];
|
|
276
|
+
const isActive = ws === ACTIVE_WORKSTREAM;
|
|
277
|
+
const marker = isActive ? green("● ") : " ";
|
|
278
|
+
console.log(`${marker}${bold(isActive ? green(ws) : ws)}`);
|
|
279
|
+
console.log(` Messages: ${userMsgs.length} user / ${assistantMsgs.length} assistant / ${toolResults.length} tool calls`);
|
|
280
|
+
if (lastUser) console.log(` Last request: ${dim(lastUser.content.slice(0, 60))}${lastUser.content.length > 60 ? "..." : ""}`);
|
|
281
|
+
console.log("");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
2032
284
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
285
|
+
async function searchAcrossWorkstreams(query) {
|
|
286
|
+
const wsList = await listWorkstreams();
|
|
287
|
+
const lowerQuery = query.toLowerCase();
|
|
288
|
+
console.log(`\n${bold("🔍 Searching all workstreams for:")} ${cyan(query)}\n`);
|
|
289
|
+
let totalMatches = 0;
|
|
290
|
+
for (const ws of wsList) {
|
|
291
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
292
|
+
const matches = conv.filter(m => (m.role === "user" || m.role === "assistant") && m.content?.toLowerCase().includes(lowerQuery));
|
|
293
|
+
if (matches.length > 0) {
|
|
294
|
+
console.log(` ${bold(ws)} (${matches.length} matches):`);
|
|
295
|
+
for (const m of matches.slice(-3)) {
|
|
296
|
+
const preview = m.content.slice(0, 80).replace(/\n/g, " ");
|
|
297
|
+
console.log(` ${m.role === "user" ? "👤" : "🌿"} ${dim(preview)}${m.content.length > 80 ? "..." : ""}`);
|
|
2038
298
|
}
|
|
2039
|
-
|
|
2040
|
-
messages.push({
|
|
2041
|
-
role: "tool_result",
|
|
2042
|
-
toolName: call.name,
|
|
2043
|
-
toolUseId: call.id ?? call.name,
|
|
2044
|
-
result: toolResult,
|
|
2045
|
-
});
|
|
299
|
+
totalMatches += matches.length;
|
|
2046
300
|
}
|
|
2047
|
-
console.log(""); // newline before next response
|
|
2048
301
|
}
|
|
2049
|
-
|
|
2050
|
-
return "(tool call limit reached)";
|
|
302
|
+
if (totalMatches === 0) console.log(dim(` No matches found for "${query}"`));
|
|
2051
303
|
}
|
|
2052
304
|
|
|
2053
305
|
// ---------------------------------------------------------------------------
|
|
2054
|
-
// Slash
|
|
306
|
+
// Slash command handler (UI concern — stays in repl)
|
|
2055
307
|
// ---------------------------------------------------------------------------
|
|
2056
308
|
|
|
2057
|
-
async function handleSlashCommand(input, conversation) {
|
|
309
|
+
async function handleSlashCommand(input, engine, conversation) {
|
|
2058
310
|
const parts = input.trim().split(/\s+/);
|
|
2059
311
|
const cmd = parts[0].toLowerCase();
|
|
2060
312
|
|
|
@@ -2067,7 +319,17 @@ ${bold("Wispy Commands:")}
|
|
|
2067
319
|
${cyan("/clear")} Reset conversation
|
|
2068
320
|
${cyan("/history")} Show conversation length
|
|
2069
321
|
${cyan("/model")} [name] Show or change model
|
|
2070
|
-
${cyan("/
|
|
322
|
+
${cyan("/cost")} Show token usage
|
|
323
|
+
${cyan("/workstreams")} List workstreams
|
|
324
|
+
${cyan("/overview")} Director view
|
|
325
|
+
${cyan("/search")} <keyword> Search across workstreams
|
|
326
|
+
${cyan("/skills")} List installed skills
|
|
327
|
+
${cyan("/sessions")} List sessions
|
|
328
|
+
${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP management
|
|
329
|
+
${cyan("/remember")} <text> Save text to main memory (MEMORY.md)
|
|
330
|
+
${cyan("/forget")} <key> Delete a memory file
|
|
331
|
+
${cyan("/memories")} List all memory files
|
|
332
|
+
${cyan("/recall")} <query> Search memories
|
|
2071
333
|
${cyan("/quit")} or ${cyan("/exit")} Exit
|
|
2072
334
|
`);
|
|
2073
335
|
return true;
|
|
@@ -2087,74 +349,57 @@ ${bold("Wispy Commands:")}
|
|
|
2087
349
|
|
|
2088
350
|
if (cmd === "/model") {
|
|
2089
351
|
if (parts[1]) {
|
|
2090
|
-
|
|
352
|
+
engine.providers.setModel(parts[1]);
|
|
2091
353
|
console.log(green(`Model changed to: ${parts[1]}`));
|
|
2092
354
|
} else {
|
|
2093
|
-
console.log(dim(`Current model: ${
|
|
355
|
+
console.log(dim(`Current model: ${engine.model} (provider: ${engine.provider})`));
|
|
2094
356
|
}
|
|
2095
357
|
return true;
|
|
2096
358
|
}
|
|
2097
359
|
|
|
360
|
+
if (cmd === "/cost" || cmd === "/tokens" || cmd === "/usage") {
|
|
361
|
+
console.log(dim(`📊 Session usage: ${engine.providers.formatCost()}`));
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
2098
365
|
if (cmd === "/memory") {
|
|
2099
366
|
const type = parts[1];
|
|
2100
367
|
const content = parts.slice(2).join(" ");
|
|
2101
|
-
if (!type || !content) {
|
|
2102
|
-
console.log(yellow("Usage: /memory <user|feedback|project|references> <content>"));
|
|
2103
|
-
return true;
|
|
2104
|
-
}
|
|
368
|
+
if (!type || !content) { console.log(yellow("Usage: /memory <user|feedback|project|references> <content>")); return true; }
|
|
2105
369
|
const validTypes = ["user", "feedback", "project", "references"];
|
|
2106
|
-
if (!validTypes.includes(type)) {
|
|
2107
|
-
console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`));
|
|
2108
|
-
return true;
|
|
2109
|
-
}
|
|
370
|
+
if (!validTypes.includes(type)) { console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`)); return true; }
|
|
2110
371
|
await appendToMemory(type, content);
|
|
2111
372
|
console.log(green(`✅ Saved to ${type} memory.`));
|
|
2112
373
|
return true;
|
|
2113
374
|
}
|
|
2114
375
|
|
|
2115
376
|
if (cmd === "/compact") {
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
];
|
|
2122
|
-
|
|
2123
|
-
process.stdout.write(cyan("🌿 Compacting... "));
|
|
2124
|
-
const summary = await chatStream(summaryMessages, (chunk) => process.stdout.write(chunk));
|
|
2125
|
-
console.log("\n");
|
|
2126
|
-
|
|
2127
|
-
// Save summary to memory and reset conversation
|
|
377
|
+
const summaryResult = await engine.processMessage(null, "Summarize our conversation so far in 3-5 bullet points.", {
|
|
378
|
+
systemPrompt: "Summarize the following conversation in 3-5 bullet points. Be concise.",
|
|
379
|
+
noSave: true,
|
|
380
|
+
});
|
|
381
|
+
const summary = summaryResult.content;
|
|
2128
382
|
await appendToMemory("project", `Session compact: ${summary.slice(0, 200)}`);
|
|
2129
383
|
conversation.length = 0;
|
|
2130
384
|
conversation.push({ role: "assistant", content: `[Previous session summary]\n${summary}` });
|
|
2131
385
|
await saveConversation(conversation);
|
|
2132
|
-
console.log(green("📦 Conversation compacted."));
|
|
2133
|
-
return true;
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
if (cmd === "/cost" || cmd === "/tokens" || cmd === "/usage") {
|
|
2137
|
-
console.log(dim(`📊 Session usage: ${formatCost()}`));
|
|
386
|
+
console.log(green("\n📦 Conversation compacted."));
|
|
2138
387
|
return true;
|
|
2139
388
|
}
|
|
2140
389
|
|
|
2141
390
|
if (cmd === "/workstreams" || cmd === "/ws") {
|
|
2142
391
|
const wsList = await listWorkstreams();
|
|
2143
|
-
if (wsList.length === 0) {
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
const preview = lastMsg ? dim(` — "${lastMsg.content.slice(0, 40)}${lastMsg.content.length > 40 ? "..." : ""}"`) : "";
|
|
2153
|
-
const msgCount = wsConv.filter(m => m.role === "user").length;
|
|
2154
|
-
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgCount} msgs`)}${preview}`);
|
|
2155
|
-
}
|
|
2156
|
-
console.log(dim(`\nSwitch: wispy -w <name>`));
|
|
392
|
+
if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return true; }
|
|
393
|
+
console.log(bold("\n📋 Workstreams:\n"));
|
|
394
|
+
for (const ws of wsList) {
|
|
395
|
+
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
396
|
+
const wsConv = await loadWorkstreamConversation(ws);
|
|
397
|
+
const msgCount = wsConv.filter(m => m.role === "user").length;
|
|
398
|
+
const lastMsg = wsConv.filter(m => m.role === "user").pop();
|
|
399
|
+
const preview = lastMsg ? dim(` — "${lastMsg.content.slice(0, 40)}${lastMsg.content.length > 40 ? "..." : ""}"`) : "";
|
|
400
|
+
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgCount} msgs`)}${preview}`);
|
|
2157
401
|
}
|
|
402
|
+
console.log(dim(`\nSwitch: wispy -w <name>`));
|
|
2158
403
|
return true;
|
|
2159
404
|
}
|
|
2160
405
|
|
|
@@ -2165,142 +410,30 @@ ${bold("Wispy Commands:")}
|
|
|
2165
410
|
|
|
2166
411
|
if (cmd === "/search") {
|
|
2167
412
|
const query = parts.slice(1).join(" ");
|
|
2168
|
-
if (!query) {
|
|
2169
|
-
console.log(yellow("Usage: /search <keyword> — search across all workstreams"));
|
|
2170
|
-
return true;
|
|
2171
|
-
}
|
|
413
|
+
if (!query) { console.log(yellow("Usage: /search <keyword>")); return true; }
|
|
2172
414
|
await searchAcrossWorkstreams(query);
|
|
2173
415
|
return true;
|
|
2174
416
|
}
|
|
2175
417
|
|
|
2176
|
-
if (cmd === "/work") {
|
|
2177
|
-
const workMd = await loadWorkMd();
|
|
2178
|
-
if (parts[1] === "edit" || parts[1] === "set") {
|
|
2179
|
-
const content = parts.slice(2).join(" ");
|
|
2180
|
-
if (!content) {
|
|
2181
|
-
console.log(yellow("Usage: /work set <content> or create file manually:"));
|
|
2182
|
-
console.log(dim(` ${path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`)}`));
|
|
2183
|
-
return true;
|
|
2184
|
-
}
|
|
2185
|
-
const workPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`);
|
|
2186
|
-
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
|
2187
|
-
await appendFile(workPath, `\n${content}\n`, "utf8");
|
|
2188
|
-
console.log(green(`✅ Added to ${ACTIVE_WORKSTREAM} work.md`));
|
|
2189
|
-
return true;
|
|
2190
|
-
}
|
|
2191
|
-
if (parts[1] === "init") {
|
|
2192
|
-
const workPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`);
|
|
2193
|
-
if (workMd) {
|
|
2194
|
-
console.log(dim(`work.md already exists at ${workMd.path}`));
|
|
2195
|
-
return true;
|
|
2196
|
-
}
|
|
2197
|
-
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
|
2198
|
-
await writeFile(workPath, `# ${ACTIVE_WORKSTREAM}\n\n## Goals\n\n## Context\n\n## Notes\n\n`, "utf8");
|
|
2199
|
-
console.log(green(`✅ Created ${workPath}`));
|
|
2200
|
-
return true;
|
|
2201
|
-
}
|
|
2202
|
-
// Show current work.md
|
|
2203
|
-
if (workMd) {
|
|
2204
|
-
console.log(`\n${bold(`📋 work.md (${ACTIVE_WORKSTREAM})`)}`);
|
|
2205
|
-
console.log(dim(` ${workMd.path}\n`));
|
|
2206
|
-
console.log(workMd.content);
|
|
2207
|
-
} else {
|
|
2208
|
-
console.log(dim(`No work.md for "${ACTIVE_WORKSTREAM}". Create one:`));
|
|
2209
|
-
console.log(dim(` /work init`));
|
|
2210
|
-
console.log(dim(` /work set "project goals and context here"`));
|
|
2211
|
-
}
|
|
2212
|
-
return true;
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
if (cmd === "/budget") {
|
|
2216
|
-
const budgets = await loadBudgets();
|
|
2217
|
-
if (parts[1] === "set") {
|
|
2218
|
-
const limit = parseFloat(parts[2]);
|
|
2219
|
-
if (isNaN(limit)) {
|
|
2220
|
-
console.log(yellow("Usage: /budget set <amount_usd> — e.g., /budget set 1.00"));
|
|
2221
|
-
return true;
|
|
2222
|
-
}
|
|
2223
|
-
if (!budgets[ACTIVE_WORKSTREAM]) budgets[ACTIVE_WORKSTREAM] = { limitUsd: null, spentUsd: 0, totalTokens: 0 };
|
|
2224
|
-
budgets[ACTIVE_WORKSTREAM].limitUsd = limit;
|
|
2225
|
-
await saveBudgets(budgets);
|
|
2226
|
-
console.log(green(`💰 Budget set: $${limit.toFixed(2)} for "${ACTIVE_WORKSTREAM}"`));
|
|
2227
|
-
return true;
|
|
2228
|
-
}
|
|
2229
|
-
if (parts[1] === "clear") {
|
|
2230
|
-
if (budgets[ACTIVE_WORKSTREAM]) budgets[ACTIVE_WORKSTREAM].limitUsd = null;
|
|
2231
|
-
await saveBudgets(budgets);
|
|
2232
|
-
console.log(dim("Budget limit removed."));
|
|
2233
|
-
return true;
|
|
2234
|
-
}
|
|
2235
|
-
// Show all budgets
|
|
2236
|
-
const wsList = Object.keys(budgets);
|
|
2237
|
-
if (wsList.length === 0) {
|
|
2238
|
-
console.log(dim("No spending tracked yet."));
|
|
2239
|
-
return true;
|
|
2240
|
-
}
|
|
2241
|
-
console.log(bold("\n💰 Budget Overview:\n"));
|
|
2242
|
-
for (const ws of wsList) {
|
|
2243
|
-
const b = budgets[ws];
|
|
2244
|
-
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
2245
|
-
const limit = b.limitUsd !== null ? `/ $${b.limitUsd.toFixed(2)}` : dim("(no limit)");
|
|
2246
|
-
const pct = b.limitUsd ? ` (${((b.spentUsd / b.limitUsd) * 100).toFixed(1)}%)` : "";
|
|
2247
|
-
const warning = b.limitUsd && b.spentUsd > b.limitUsd ? red(" ⚠ OVER") : "";
|
|
2248
|
-
console.log(`${marker}${ws.padEnd(20)} $${b.spentUsd.toFixed(4)} ${limit}${pct}${warning} ${dim(`${b.totalTokens} tokens`)}`);
|
|
2249
|
-
}
|
|
2250
|
-
console.log(dim("\nSet limit: /budget set <usd> | Remove: /budget clear"));
|
|
2251
|
-
console.log("");
|
|
2252
|
-
return true;
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
if (cmd === "/skills") {
|
|
2256
|
-
const skills = await loadSkills();
|
|
2257
|
-
if (skills.length === 0) {
|
|
2258
|
-
console.log(dim("No skills installed."));
|
|
2259
|
-
console.log(dim("Add skills to ~/.wispy/skills/ or install OpenClaw skills."));
|
|
2260
|
-
} else {
|
|
2261
|
-
console.log(bold(`\n🧩 Skills (${skills.length} installed):\n`));
|
|
2262
|
-
const bySource = {};
|
|
2263
|
-
for (const s of skills) {
|
|
2264
|
-
const src = s.source.includes("openclaw") ? "OpenClaw" : s.source.includes(".wispy") ? "Wispy" : s.source.includes(".claude") ? "Claude" : "Project";
|
|
2265
|
-
if (!bySource[src]) bySource[src] = [];
|
|
2266
|
-
bySource[src].push(s);
|
|
2267
|
-
}
|
|
2268
|
-
for (const [src, sks] of Object.entries(bySource)) {
|
|
2269
|
-
console.log(` ${bold(src)} (${sks.length}):`);
|
|
2270
|
-
for (const s of sks) {
|
|
2271
|
-
console.log(` ${green(s.name.padEnd(20))} ${dim(s.description.slice(0, 50))}`);
|
|
2272
|
-
}
|
|
2273
|
-
console.log("");
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return true;
|
|
2277
|
-
}
|
|
2278
|
-
|
|
2279
418
|
if (cmd === "/sessions" || cmd === "/ls") {
|
|
2280
419
|
const wsList = await listWorkstreams();
|
|
2281
|
-
if (wsList.length === 0) {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
2289
|
-
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
|
|
2290
|
-
}
|
|
420
|
+
if (wsList.length === 0) { console.log(dim("No sessions yet.")); return true; }
|
|
421
|
+
console.log(bold("\n📂 Sessions:\n"));
|
|
422
|
+
for (const ws of wsList) {
|
|
423
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
424
|
+
const msgs = conv.filter(m => m.role === "user").length;
|
|
425
|
+
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
426
|
+
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
|
|
2291
427
|
}
|
|
2292
|
-
console.log(dim(`\nSwitch: wispy -w <name> | Delete: /delete <name>`));
|
|
2293
428
|
return true;
|
|
2294
429
|
}
|
|
2295
430
|
|
|
2296
431
|
if (cmd === "/delete" || cmd === "/rm") {
|
|
2297
432
|
const target = parts[1];
|
|
2298
433
|
if (!target) { console.log(yellow("Usage: /delete <workstream-name>")); return true; }
|
|
2299
|
-
const
|
|
434
|
+
const { unlink } = await import("node:fs/promises");
|
|
2300
435
|
try {
|
|
2301
|
-
|
|
2302
|
-
await unlink(wsPath);
|
|
2303
|
-
// Also delete work.md and plan
|
|
436
|
+
await unlink(path.join(CONVERSATIONS_DIR, `${target}.json`));
|
|
2304
437
|
await unlink(path.join(CONVERSATIONS_DIR, `${target}.work.md`)).catch(() => {});
|
|
2305
438
|
await unlink(path.join(CONVERSATIONS_DIR, `${target}.plan.json`)).catch(() => {});
|
|
2306
439
|
console.log(green(`🗑️ Deleted session "${target}"`));
|
|
@@ -2310,49 +443,47 @@ ${bold("Wispy Commands:")}
|
|
|
2310
443
|
return true;
|
|
2311
444
|
}
|
|
2312
445
|
|
|
2313
|
-
if (cmd === "/
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
const format = parts[1] ?? "md";
|
|
2319
|
-
const lines = userAssistant.map(m => {
|
|
2320
|
-
const role = m.role === "user" ? "**You**" : "**Wispy**";
|
|
2321
|
-
return `${role}: ${m.content}`;
|
|
2322
|
-
});
|
|
446
|
+
if (cmd === "/provider") {
|
|
447
|
+
console.log(dim(`Provider: ${engine.provider}, Model: ${engine.model}, Workstream: ${ACTIVE_WORKSTREAM}`));
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
2323
450
|
|
|
2324
|
-
|
|
2325
|
-
|
|
451
|
+
if (cmd === "/skills") {
|
|
452
|
+
// Load skills from known locations
|
|
453
|
+
const skillDirs = [
|
|
454
|
+
"/opt/homebrew/lib/node_modules/openclaw/skills",
|
|
455
|
+
path.join(os.homedir(), ".openclaw", "workspace", "skills"),
|
|
456
|
+
path.join(WISPY_DIR, "skills"),
|
|
457
|
+
];
|
|
458
|
+
const { readdir } = await import("node:fs/promises");
|
|
459
|
+
const allSkills = [];
|
|
460
|
+
for (const dir of skillDirs) {
|
|
2326
461
|
try {
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
462
|
+
const entries = await readdir(dir);
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
try {
|
|
465
|
+
const skillMd = await readFile(path.join(dir, entry, "SKILL.md"), "utf8");
|
|
466
|
+
allSkills.push({ name: entry, source: dir });
|
|
467
|
+
} catch {}
|
|
468
|
+
}
|
|
469
|
+
} catch {}
|
|
470
|
+
}
|
|
471
|
+
if (allSkills.length === 0) {
|
|
472
|
+
console.log(dim("No skills installed."));
|
|
2331
473
|
} else {
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
474
|
+
console.log(bold(`\n🧩 Skills (${allSkills.length} installed):\n`));
|
|
475
|
+
for (const s of allSkills) {
|
|
476
|
+
console.log(` ${green(s.name.padEnd(20))} ${dim(s.source.split("/").slice(-2).join("/"))}`);
|
|
477
|
+
}
|
|
2335
478
|
}
|
|
2336
479
|
return true;
|
|
2337
480
|
}
|
|
2338
481
|
|
|
2339
|
-
|
|
2340
|
-
console.log(dim(`Provider: ${PROVIDERS[PROVIDER]?.label ?? PROVIDER}`));
|
|
2341
|
-
console.log(dim(`Model: ${MODEL}`));
|
|
2342
|
-
console.log(dim(`Workstream: ${ACTIVE_WORKSTREAM}`));
|
|
2343
|
-
return true;
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
if (cmd === "/quit" || cmd === "/exit") {
|
|
2347
|
-
console.log(dim(`🌿 Bye! (${formatCost()})`));
|
|
2348
|
-
process.exit(0);
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
// ---------------------------------------------------------------------------
|
|
2352
|
-
// /mcp — MCP server management
|
|
2353
|
-
// ---------------------------------------------------------------------------
|
|
482
|
+
// /mcp commands
|
|
2354
483
|
if (cmd === "/mcp") {
|
|
2355
484
|
const sub = parts[1] ?? "list";
|
|
485
|
+
const mcpManager = engine.mcpManager;
|
|
486
|
+
const MCP_CONFIG_PATH = mcpManager.configPath;
|
|
2356
487
|
|
|
2357
488
|
if (sub === "list") {
|
|
2358
489
|
const status = mcpManager.getStatus();
|
|
@@ -2360,16 +491,11 @@ ${bold("Wispy Commands:")}
|
|
|
2360
491
|
if (status.length === 0) {
|
|
2361
492
|
console.log(dim("No MCP servers connected."));
|
|
2362
493
|
console.log(dim(`Config: ${MCP_CONFIG_PATH}`));
|
|
2363
|
-
console.log(dim("Use /mcp connect <name> to connect a server."));
|
|
2364
494
|
} else {
|
|
2365
495
|
console.log(bold(`\n🔌 MCP Servers (${status.length}):\n`));
|
|
2366
496
|
for (const s of status) {
|
|
2367
497
|
const icon = s.connected ? green("●") : red("○");
|
|
2368
|
-
|
|
2369
|
-
? dim(` [${s.tools.slice(0, 5).join(", ")}${s.tools.length > 5 ? "..." : ""}]`)
|
|
2370
|
-
: "";
|
|
2371
|
-
console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools${toolList}`);
|
|
2372
|
-
if (s.serverInfo?.name) console.log(dim(` server: ${s.serverInfo.name} v${s.serverInfo.version ?? "?"}`));
|
|
498
|
+
console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools`);
|
|
2373
499
|
}
|
|
2374
500
|
}
|
|
2375
501
|
if (allTools.length > 0) {
|
|
@@ -2378,30 +504,19 @@ ${bold("Wispy Commands:")}
|
|
|
2378
504
|
console.log(` ${cyan(t.wispyName.padEnd(30))} ${dim(t.description.slice(0, 60))}`);
|
|
2379
505
|
}
|
|
2380
506
|
}
|
|
2381
|
-
console.log("");
|
|
2382
507
|
return true;
|
|
2383
508
|
}
|
|
2384
509
|
|
|
2385
510
|
if (sub === "connect") {
|
|
2386
511
|
const serverName = parts[2];
|
|
2387
|
-
if (!serverName) {
|
|
2388
|
-
console.log(yellow("Usage: /mcp connect <server-name>"));
|
|
2389
|
-
return true;
|
|
2390
|
-
}
|
|
512
|
+
if (!serverName) { console.log(yellow("Usage: /mcp connect <server-name>")); return true; }
|
|
2391
513
|
const config = await mcpManager.loadConfig();
|
|
2392
514
|
const serverConfig = config.mcpServers?.[serverName];
|
|
2393
|
-
if (!serverConfig) {
|
|
2394
|
-
console.log(red(`Server "${serverName}" not found in ${MCP_CONFIG_PATH}`));
|
|
2395
|
-
console.log(dim(`Available: ${Object.keys(config.mcpServers ?? {}).join(", ") || "none"}`));
|
|
2396
|
-
return true;
|
|
2397
|
-
}
|
|
515
|
+
if (!serverConfig) { console.log(red(`Server "${serverName}" not found in config`)); return true; }
|
|
2398
516
|
process.stdout.write(dim(` Connecting to "${serverName}"...`));
|
|
2399
517
|
try {
|
|
2400
518
|
const client = await mcpManager.connect(serverName, serverConfig);
|
|
2401
519
|
console.log(green(` ✓ connected (${client.tools.length} tools)`));
|
|
2402
|
-
if (client.tools.length > 0) {
|
|
2403
|
-
console.log(dim(` Tools: ${client.tools.map(t => t.name).join(", ")}`));
|
|
2404
|
-
}
|
|
2405
520
|
} catch (err) {
|
|
2406
521
|
console.log(red(` ✗ failed: ${err.message.slice(0, 120)}`));
|
|
2407
522
|
}
|
|
@@ -2410,10 +525,7 @@ ${bold("Wispy Commands:")}
|
|
|
2410
525
|
|
|
2411
526
|
if (sub === "disconnect") {
|
|
2412
527
|
const serverName = parts[2];
|
|
2413
|
-
if (!serverName) {
|
|
2414
|
-
console.log(yellow("Usage: /mcp disconnect <server-name>"));
|
|
2415
|
-
return true;
|
|
2416
|
-
}
|
|
528
|
+
if (!serverName) { console.log(yellow("Usage: /mcp disconnect <server-name>")); return true; }
|
|
2417
529
|
const ok = mcpManager.disconnect(serverName);
|
|
2418
530
|
console.log(ok ? green(`✓ Disconnected "${serverName}"`) : yellow(`"${serverName}" was not connected`));
|
|
2419
531
|
return true;
|
|
@@ -2421,17 +533,12 @@ ${bold("Wispy Commands:")}
|
|
|
2421
533
|
|
|
2422
534
|
if (sub === "config") {
|
|
2423
535
|
console.log(dim(`Config file: ${MCP_CONFIG_PATH}`));
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
console.log(` ${name.padEnd(20)} ${status}`);
|
|
2431
|
-
console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
|
|
2432
|
-
}
|
|
2433
|
-
} catch {
|
|
2434
|
-
console.log(dim("No config found."));
|
|
536
|
+
const cfg = await mcpManager.loadConfig();
|
|
537
|
+
const servers = cfg.mcpServers ?? {};
|
|
538
|
+
console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
|
|
539
|
+
for (const [name, s] of Object.entries(servers)) {
|
|
540
|
+
console.log(` ${name.padEnd(20)} ${s.disabled ? dim("disabled") : green("enabled")}`);
|
|
541
|
+
console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
|
|
2435
542
|
}
|
|
2436
543
|
return true;
|
|
2437
544
|
}
|
|
@@ -2445,22 +552,74 @@ ${bold("Wispy Commands:")}
|
|
|
2445
552
|
else if (r.status === "disabled") console.log(dim(` ○ ${r.name} (disabled)`));
|
|
2446
553
|
else console.log(red(` ✗ ${r.name}: ${r.error?.slice(0, 80)}`));
|
|
2447
554
|
}
|
|
555
|
+
engine.tools.registerMCP(mcpManager);
|
|
2448
556
|
return true;
|
|
2449
557
|
}
|
|
2450
558
|
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
559
|
+
console.log(`${bold("/mcp commands:")} list | connect <name> | disconnect <name> | config | reload`);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Memory commands ──────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
if (cmd === "/remember") {
|
|
566
|
+
const text = parts.slice(1).join(" ");
|
|
567
|
+
if (!text) { console.log(yellow("Usage: /remember <text>")); return true; }
|
|
568
|
+
await engine.memory.append("MEMORY", text);
|
|
569
|
+
console.log(green("✅ Saved to MEMORY.md"));
|
|
2461
570
|
return true;
|
|
2462
571
|
}
|
|
2463
572
|
|
|
573
|
+
if (cmd === "/forget") {
|
|
574
|
+
const key = parts[1];
|
|
575
|
+
if (!key) { console.log(yellow("Usage: /forget <key>")); return true; }
|
|
576
|
+
const result = await engine.memory.delete(key);
|
|
577
|
+
if (result.success) {
|
|
578
|
+
console.log(green(`🗑️ Deleted memory: ${key}`));
|
|
579
|
+
} else {
|
|
580
|
+
console.log(red(`Memory "${key}" not found.`));
|
|
581
|
+
}
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (cmd === "/memories") {
|
|
586
|
+
const keys = await engine.memory.list();
|
|
587
|
+
if (keys.length === 0) {
|
|
588
|
+
console.log(dim("No memories stored yet. Use /remember <text> or ask wispy to remember things."));
|
|
589
|
+
} else {
|
|
590
|
+
console.log(bold(`\n🧠 Memories (${keys.length}):\n`));
|
|
591
|
+
for (const k of keys) {
|
|
592
|
+
console.log(` ${cyan(k.key.padEnd(30))} ${dim(k.preview ?? "")}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (cmd === "/recall") {
|
|
599
|
+
const query = parts.slice(1).join(" ");
|
|
600
|
+
if (!query) { console.log(yellow("Usage: /recall <query>")); return true; }
|
|
601
|
+
const results = await engine.memory.search(query);
|
|
602
|
+
if (results.length === 0) {
|
|
603
|
+
console.log(dim(`No memories found for: "${query}"`));
|
|
604
|
+
} else {
|
|
605
|
+
console.log(bold(`\n🔍 Memory search: "${query}"\n`));
|
|
606
|
+
for (const r of results) {
|
|
607
|
+
console.log(` ${cyan(r.key)} ${dim(`(${r.matchCount} matches)`)}`);
|
|
608
|
+
for (const s of r.snippets.slice(0, 3)) {
|
|
609
|
+
console.log(` ${dim(`L${s.lineNumber}:`)} ${s.text.slice(0, 100)}`);
|
|
610
|
+
}
|
|
611
|
+
console.log("");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (cmd === "/quit" || cmd === "/exit") {
|
|
618
|
+
console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
|
|
619
|
+
engine.destroy();
|
|
620
|
+
process.exit(0);
|
|
621
|
+
}
|
|
622
|
+
|
|
2464
623
|
return false;
|
|
2465
624
|
}
|
|
2466
625
|
|
|
@@ -2468,24 +627,15 @@ ${bold("/mcp commands:")}
|
|
|
2468
627
|
// Interactive REPL
|
|
2469
628
|
// ---------------------------------------------------------------------------
|
|
2470
629
|
|
|
2471
|
-
async function runRepl() {
|
|
630
|
+
async function runRepl(engine) {
|
|
2472
631
|
const wsLabel = ACTIVE_WORKSTREAM === "default" ? "" : ` ${dim("·")} ${cyan(ACTIVE_WORKSTREAM)}`;
|
|
2473
|
-
const providerLabel = PROVIDERS[PROVIDER]?.label ?? PROVIDER;
|
|
2474
632
|
console.log(`
|
|
2475
|
-
${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${
|
|
2476
|
-
${dim(`${
|
|
633
|
+
${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${engine.model}`)}
|
|
634
|
+
${dim(`${engine.provider} · /help for commands · Ctrl+C to exit`)}
|
|
2477
635
|
`);
|
|
2478
636
|
|
|
2479
|
-
const systemPrompt = await buildSystemPrompt(conversation);
|
|
2480
637
|
const conversation = await loadConversation();
|
|
2481
638
|
|
|
2482
|
-
// Ensure system prompt is first
|
|
2483
|
-
if (conversation.length === 0 || conversation[0].role !== "system") {
|
|
2484
|
-
conversation.unshift({ role: "system", content: systemPrompt });
|
|
2485
|
-
} else {
|
|
2486
|
-
conversation[0].content = systemPrompt; // Refresh system prompt
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
639
|
const rl = createInterface({
|
|
2490
640
|
input: process.stdin,
|
|
2491
641
|
output: process.stdout,
|
|
@@ -2499,34 +649,36 @@ async function runRepl() {
|
|
|
2499
649
|
const input = line.trim();
|
|
2500
650
|
if (!input) { rl.prompt(); return; }
|
|
2501
651
|
|
|
2502
|
-
// Slash commands
|
|
2503
652
|
if (input.startsWith("/")) {
|
|
2504
|
-
const handled = await handleSlashCommand(input, conversation);
|
|
653
|
+
const handled = await handleSlashCommand(input, engine, conversation);
|
|
2505
654
|
if (handled) { rl.prompt(); return; }
|
|
2506
655
|
}
|
|
2507
656
|
|
|
2508
|
-
// Add user message
|
|
2509
657
|
conversation.push({ role: "user", content: input });
|
|
2510
658
|
|
|
2511
|
-
// Agent loop with tool calls
|
|
2512
659
|
process.stdout.write(cyan("🌿 "));
|
|
2513
660
|
try {
|
|
2514
|
-
|
|
2515
|
-
|
|
661
|
+
// Build messages from conversation history (keep system prompt + history)
|
|
662
|
+
const systemPrompt = await engine._buildSystemPrompt(input);
|
|
663
|
+
const messages = [{ role: "system", content: systemPrompt }, ...conversation.filter(m => m.role !== "system")];
|
|
664
|
+
|
|
665
|
+
const response = await engine.processMessage(null, input, {
|
|
666
|
+
onChunk: (chunk) => process.stdout.write(chunk),
|
|
667
|
+
systemPrompt: await engine._buildSystemPrompt(input),
|
|
668
|
+
noSave: true,
|
|
2516
669
|
});
|
|
2517
670
|
console.log("\n");
|
|
2518
671
|
|
|
2519
|
-
conversation.push({ role: "assistant", content: response });
|
|
672
|
+
conversation.push({ role: "assistant", content: response.content });
|
|
673
|
+
// Keep last 50 messages
|
|
674
|
+
if (conversation.length > 50) conversation.splice(0, conversation.length - 50);
|
|
2520
675
|
await saveConversation(conversation);
|
|
2521
|
-
console.log(dim(` ${formatCost()}`));
|
|
676
|
+
console.log(dim(` ${engine.providers.formatCost()}`));
|
|
2522
677
|
} catch (err) {
|
|
2523
|
-
// Friendly error handling
|
|
2524
678
|
if (err.message.includes("429") || err.message.includes("rate")) {
|
|
2525
679
|
console.log(yellow("\n\n⏳ Rate limited — wait a moment and try again."));
|
|
2526
680
|
} else if (err.message.includes("401") || err.message.includes("403")) {
|
|
2527
681
|
console.log(red("\n\n🔑 Authentication error — check your API key."));
|
|
2528
|
-
} else if (err.message.includes("network") || err.message.includes("fetch")) {
|
|
2529
|
-
console.log(red("\n\n🌐 Network error — check your connection."));
|
|
2530
682
|
} else {
|
|
2531
683
|
console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
|
|
2532
684
|
}
|
|
@@ -2536,7 +688,8 @@ async function runRepl() {
|
|
|
2536
688
|
});
|
|
2537
689
|
|
|
2538
690
|
rl.on("close", () => {
|
|
2539
|
-
console.log(dim(`\n🌿 Bye! (${formatCost()})`));
|
|
691
|
+
console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
|
|
692
|
+
engine.destroy();
|
|
2540
693
|
process.exit(0);
|
|
2541
694
|
});
|
|
2542
695
|
}
|
|
@@ -2545,26 +698,13 @@ async function runRepl() {
|
|
|
2545
698
|
// One-shot mode
|
|
2546
699
|
// ---------------------------------------------------------------------------
|
|
2547
700
|
|
|
2548
|
-
async function runOneShot(message) {
|
|
2549
|
-
const conversation = await loadConversation();
|
|
2550
|
-
conversation.push({ role: "user", content: message });
|
|
2551
|
-
const systemPrompt = await buildSystemPrompt(conversation);
|
|
2552
|
-
|
|
2553
|
-
if (!conversation.find(m => m.role === "system")) {
|
|
2554
|
-
conversation.unshift({ role: "system", content: systemPrompt });
|
|
2555
|
-
} else {
|
|
2556
|
-
conversation[0].content = systemPrompt;
|
|
2557
|
-
}
|
|
2558
|
-
|
|
701
|
+
async function runOneShot(engine, message) {
|
|
2559
702
|
try {
|
|
2560
|
-
const response = await
|
|
2561
|
-
process.stdout.write(chunk)
|
|
703
|
+
const response = await engine.processMessage(null, message, {
|
|
704
|
+
onChunk: (chunk) => process.stdout.write(chunk),
|
|
2562
705
|
});
|
|
2563
706
|
console.log("");
|
|
2564
|
-
|
|
2565
|
-
conversation.push({ role: "assistant", content: response });
|
|
2566
|
-
await saveConversation(conversation);
|
|
2567
|
-
console.log(dim(`${formatCost()}`));
|
|
707
|
+
console.log(dim(engine.providers.formatCost()));
|
|
2568
708
|
} catch (err) {
|
|
2569
709
|
if (err.message.includes("429")) {
|
|
2570
710
|
console.error(yellow("\n⏳ Rate limited — try again shortly."));
|
|
@@ -2575,121 +715,19 @@ async function runOneShot(message) {
|
|
|
2575
715
|
}
|
|
2576
716
|
}
|
|
2577
717
|
|
|
2578
|
-
// ---------------------------------------------------------------------------
|
|
2579
|
-
// Auto server management — start AWOS server if not running
|
|
2580
|
-
// ---------------------------------------------------------------------------
|
|
2581
|
-
|
|
2582
|
-
import { spawn as spawnProcess } from "node:child_process";
|
|
2583
|
-
import { fileURLToPath } from "node:url";
|
|
2584
|
-
|
|
2585
|
-
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
2586
|
-
const REPO_ROOT = process.env.WISPY_REPO_ROOT ?? path.resolve(SCRIPT_DIR, "..");
|
|
2587
|
-
// Server binary: check env → ~/.wispy/bin/ → repo build path
|
|
2588
|
-
import { statSync } from "node:fs";
|
|
2589
|
-
const SERVER_BINARY = process.env.WISPY_SERVER_BINARY
|
|
2590
|
-
?? (() => {
|
|
2591
|
-
const candidates = [
|
|
2592
|
-
path.join(os.homedir(), ".wispy", "bin", "awos-server"),
|
|
2593
|
-
path.join(REPO_ROOT, "src-tauri", "target", "release", "awos-server"),
|
|
2594
|
-
path.join(REPO_ROOT, "src-tauri", "target", "debug", "awos-server"),
|
|
2595
|
-
];
|
|
2596
|
-
for (const c of candidates) {
|
|
2597
|
-
try { if (statSync(c).isFile()) return c; } catch {}
|
|
2598
|
-
}
|
|
2599
|
-
return candidates[0];
|
|
2600
|
-
})();
|
|
2601
|
-
const SERVER_PID_FILE = path.join(WISPY_DIR, "server.pid");
|
|
2602
|
-
const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
|
|
2603
|
-
|
|
2604
|
-
async function isServerRunning() {
|
|
2605
|
-
try {
|
|
2606
|
-
const resp = await fetch(`http://127.0.0.1:${DEFAULT_SERVER_PORT}/api/health`, {
|
|
2607
|
-
signal: AbortSignal.timeout(2000),
|
|
2608
|
-
});
|
|
2609
|
-
return resp.ok;
|
|
2610
|
-
} catch {
|
|
2611
|
-
return false;
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
async function startServerIfNeeded() {
|
|
2616
|
-
if (await isServerRunning()) {
|
|
2617
|
-
return { started: false, port: DEFAULT_SERVER_PORT };
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
// Check if binary exists
|
|
2621
|
-
try {
|
|
2622
|
-
const { stat } = await import("node:fs/promises");
|
|
2623
|
-
await stat(SERVER_BINARY);
|
|
2624
|
-
} catch {
|
|
2625
|
-
// No binary — skip auto-start silently, CLI-only mode
|
|
2626
|
-
return { started: false, port: DEFAULT_SERVER_PORT, noBinary: true };
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
// Start server in background
|
|
2630
|
-
const logFile = path.join(WISPY_DIR, "server.log");
|
|
2631
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
2632
|
-
const { openSync } = await import("node:fs");
|
|
2633
|
-
const logFd = openSync(logFile, "a");
|
|
2634
|
-
|
|
2635
|
-
const child = spawnProcess(SERVER_BINARY, [], {
|
|
2636
|
-
cwd: REPO_ROOT,
|
|
2637
|
-
env: { ...process.env, AWOS_PORT: DEFAULT_SERVER_PORT },
|
|
2638
|
-
detached: true,
|
|
2639
|
-
stdio: ["ignore", logFd, logFd],
|
|
2640
|
-
});
|
|
2641
|
-
|
|
2642
|
-
child.unref();
|
|
2643
|
-
|
|
2644
|
-
// Save PID for cleanup
|
|
2645
|
-
await writeFile(SERVER_PID_FILE, String(child.pid), "utf8");
|
|
2646
|
-
|
|
2647
|
-
// Wait up to 5 seconds for server to be ready
|
|
2648
|
-
for (let i = 0; i < 25; i++) {
|
|
2649
|
-
await new Promise(r => setTimeout(r, 200));
|
|
2650
|
-
if (await isServerRunning()) {
|
|
2651
|
-
return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid };
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
|
-
async function stopServer() {
|
|
2659
|
-
try {
|
|
2660
|
-
const pidStr = await readFile(SERVER_PID_FILE, "utf8");
|
|
2661
|
-
const pid = parseInt(pidStr.trim(), 10);
|
|
2662
|
-
if (pid && !isNaN(pid)) {
|
|
2663
|
-
process.kill(pid, "SIGTERM");
|
|
2664
|
-
// Clean up PID file
|
|
2665
|
-
const { unlink } = await import("node:fs/promises");
|
|
2666
|
-
await unlink(SERVER_PID_FILE).catch(() => {});
|
|
2667
|
-
}
|
|
2668
|
-
} catch {
|
|
2669
|
-
// No PID file or already stopped
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
718
|
// ---------------------------------------------------------------------------
|
|
2674
719
|
// Main
|
|
2675
720
|
// ---------------------------------------------------------------------------
|
|
2676
721
|
|
|
2677
|
-
// Filter out -w/--workstream flag from args
|
|
2678
722
|
const rawArgs = process.argv.slice(2);
|
|
2679
723
|
const args = [];
|
|
2680
724
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
2681
|
-
if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
|
|
725
|
+
if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
|
|
2682
726
|
args.push(rawArgs[i]);
|
|
2683
727
|
}
|
|
2684
728
|
|
|
2685
|
-
|
|
2686
|
-
const operatorCommands = new Set([
|
|
2687
|
-
"home", "node", "runtime", "agents", "agent",
|
|
2688
|
-
"workstreams", "workstream", "doctor", "setup",
|
|
2689
|
-
"package", "config", "server",
|
|
2690
|
-
]);
|
|
729
|
+
const operatorCommands = new Set(["home", "node", "runtime", "agents", "agent", "workstreams", "workstream", "doctor", "setup", "package", "config", "server"]);
|
|
2691
730
|
|
|
2692
|
-
// wispy server <start|stop|status>
|
|
2693
731
|
if (args[0] === "server") {
|
|
2694
732
|
const sub = args[1] ?? "status";
|
|
2695
733
|
if (sub === "status") {
|
|
@@ -2703,130 +741,58 @@ if (args[0] === "server") {
|
|
|
2703
741
|
}
|
|
2704
742
|
process.exit(0);
|
|
2705
743
|
}
|
|
2706
|
-
if (sub === "stop") {
|
|
2707
|
-
await stopServer();
|
|
2708
|
-
console.log(dim("🌿 Server stopped."));
|
|
2709
|
-
process.exit(0);
|
|
2710
|
-
}
|
|
744
|
+
if (sub === "stop") { await stopServer(); console.log(dim("🌿 Server stopped.")); process.exit(0); }
|
|
2711
745
|
if (sub === "start") {
|
|
2712
746
|
const status = await startServerIfNeeded();
|
|
2713
|
-
if (status.started) {
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
console.log(red("Server binary not found. Run: cd agent-workstream-os && cargo build --manifest-path src-tauri/Cargo.toml --no-default-features --features server"));
|
|
2717
|
-
} else {
|
|
2718
|
-
console.log(dim(`Server already running on port ${status.port}`));
|
|
2719
|
-
}
|
|
747
|
+
if (status.started) console.log(green(`🌿 Server started on port ${status.port} (PID: ${status.pid})`));
|
|
748
|
+
else if (status.noBinary) console.log(red("Server binary not found."));
|
|
749
|
+
else console.log(dim(`Server already running on port ${status.port}`));
|
|
2720
750
|
process.exit(0);
|
|
2721
751
|
}
|
|
2722
|
-
console.log("Usage: wispy server <start|stop|status>");
|
|
2723
|
-
process.exit(1);
|
|
752
|
+
console.log("Usage: wispy server <start|stop|status>"); process.exit(1);
|
|
2724
753
|
}
|
|
2725
754
|
|
|
2726
755
|
if (args[0] && operatorCommands.has(args[0])) {
|
|
2727
|
-
// Delegate to the full CLI
|
|
2728
756
|
const cliPath = process.env.WISPY_OPERATOR_CLI ?? path.join(SCRIPT_DIR, "awos-node-cli.mjs");
|
|
2729
757
|
const { execFileSync } = await import("node:child_process");
|
|
2730
758
|
try {
|
|
2731
|
-
execFileSync(process.execPath, ["--experimental-strip-types", cliPath, ...args], {
|
|
2732
|
-
|
|
2733
|
-
env: process.env,
|
|
2734
|
-
});
|
|
2735
|
-
} catch (e) {
|
|
2736
|
-
process.exit(e.status ?? 1);
|
|
2737
|
-
}
|
|
759
|
+
execFileSync(process.execPath, ["--experimental-strip-types", cliPath, ...args], { stdio: "inherit", env: process.env });
|
|
760
|
+
} catch (e) { process.exit(e.status ?? 1); }
|
|
2738
761
|
process.exit(0);
|
|
2739
762
|
}
|
|
2740
763
|
|
|
2741
|
-
//
|
|
2742
|
-
|
|
764
|
+
// Initialize engine
|
|
765
|
+
const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
|
|
766
|
+
const initResult = await engine.init();
|
|
767
|
+
|
|
768
|
+
if (!initResult) {
|
|
2743
769
|
await runOnboarding();
|
|
2744
|
-
//
|
|
2745
|
-
const
|
|
2746
|
-
if (
|
|
2747
|
-
// Patch module-level state for this session
|
|
2748
|
-
Object.assign(detected, redetected);
|
|
2749
|
-
}
|
|
2750
|
-
if (redetected) {
|
|
2751
|
-
PROVIDER = redetected.provider;
|
|
2752
|
-
API_KEY = redetected.key;
|
|
2753
|
-
MODEL = redetected.model;
|
|
2754
|
-
}
|
|
2755
|
-
if (!API_KEY && PROVIDER !== "ollama") {
|
|
770
|
+
// Try again after onboarding
|
|
771
|
+
const initResult2 = await engine.init();
|
|
772
|
+
if (!initResult2) {
|
|
2756
773
|
console.log(dim("\nRun wispy again to start chatting!"));
|
|
2757
774
|
process.exit(0);
|
|
2758
775
|
}
|
|
2759
776
|
}
|
|
2760
777
|
|
|
2761
|
-
//
|
|
2762
|
-
|
|
2763
|
-
{
|
|
2764
|
-
|
|
2765
|
-
const connected = mcpResults.filter(r => r.status === "connected");
|
|
2766
|
-
const failed = mcpResults.filter(r => r.status === "failed");
|
|
2767
|
-
if (connected.length > 0) {
|
|
2768
|
-
// Quiet success — only show if verbose
|
|
2769
|
-
// console.log(dim(`🔌 MCP: ${connected.map(r => `${r.name}(${r.tools})`).join(", ")}`));
|
|
2770
|
-
}
|
|
2771
|
-
if (failed.length > 0 && process.env.WISPY_DEBUG) {
|
|
2772
|
-
for (const r of failed) {
|
|
2773
|
-
console.error(dim(`⚠ MCP "${r.name}" failed: ${r.error?.slice(0, 80)}`));
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
|
|
2778
|
-
// Graceful MCP cleanup on exit
|
|
2779
|
-
process.on("exit", () => { try { mcpManager.disconnectAll(); } catch {} });
|
|
2780
|
-
process.on("SIGINT", () => { mcpManager.disconnectAll(); process.exit(0); });
|
|
2781
|
-
process.on("SIGTERM", () => { mcpManager.disconnectAll(); process.exit(0); });
|
|
778
|
+
// Graceful cleanup
|
|
779
|
+
process.on("exit", () => { try { engine.destroy(); } catch {} });
|
|
780
|
+
process.on("SIGINT", () => { engine.destroy(); process.exit(0); });
|
|
781
|
+
process.on("SIGTERM", () => { engine.destroy(); process.exit(0); });
|
|
2782
782
|
|
|
2783
|
-
// Auto-start server
|
|
783
|
+
// Auto-start background server
|
|
2784
784
|
const serverStatus = await startServerIfNeeded();
|
|
2785
|
-
if (serverStatus.started) {
|
|
2786
|
-
if (serverStatus.slow) {
|
|
2787
|
-
|
|
2788
|
-
} else {
|
|
2789
|
-
console.log(dim(`🌿 Server started on port ${serverStatus.port}`));
|
|
2790
|
-
}
|
|
2791
|
-
} else if (serverStatus.noBinary) {
|
|
2792
|
-
// Silent — no binary built yet, that's fine for chat-only mode
|
|
785
|
+
if (serverStatus.started && !serverStatus.noBinary) {
|
|
786
|
+
if (serverStatus.slow) console.log(yellow(`⚠ Server starting on port ${serverStatus.port}...`));
|
|
787
|
+
else console.log(dim(`🌿 Server started on port ${serverStatus.port}`));
|
|
2793
788
|
}
|
|
2794
789
|
|
|
2795
|
-
|
|
2796
|
-
|
|
790
|
+
if (args[0] === "overview" || args[0] === "dashboard") { await showOverview(); process.exit(0); }
|
|
791
|
+
if (args[0] === "search" && args[1]) { await searchAcrossWorkstreams(args.slice(1).join(" ")); process.exit(0); }
|
|
2797
792
|
|
|
2798
|
-
if (args[0]
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
}
|
|
2802
|
-
|
|
2803
|
-
if (args[0] === "search" && args[1]) {
|
|
2804
|
-
await searchAcrossWorkstreams(args.slice(1).join(" "));
|
|
2805
|
-
process.exit(0);
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
if (args[0] === "--continue" || args[0] === "-c") {
|
|
2809
|
-
// Continue previous session with optional message
|
|
2810
|
-
const message = args.slice(1).join(" ").trim();
|
|
2811
|
-
if (message) {
|
|
2812
|
-
await runOneShot(message);
|
|
2813
|
-
} else {
|
|
2814
|
-
await runRepl();
|
|
2815
|
-
}
|
|
2816
|
-
} else if (args[0] === "--new" || args[0] === "-n") {
|
|
2817
|
-
// Force new session
|
|
2818
|
-
await saveConversation([]);
|
|
2819
|
-
const message = args.slice(1).join(" ").trim();
|
|
2820
|
-
if (message) {
|
|
2821
|
-
await runOneShot(message);
|
|
2822
|
-
} else {
|
|
2823
|
-
console.log(dim("🌿 Starting fresh session."));
|
|
2824
|
-
await runRepl();
|
|
2825
|
-
}
|
|
2826
|
-
} else if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
|
|
2827
|
-
// One-shot mode: wispy "message"
|
|
2828
|
-
const message = args.join(" ");
|
|
2829
|
-
await runOneShot(message);
|
|
793
|
+
if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
|
|
794
|
+
// One-shot mode
|
|
795
|
+
await runOneShot(engine, args.join(" "));
|
|
2830
796
|
} else if (args[0] === "--help" || args[0] === "-h") {
|
|
2831
797
|
console.log(`
|
|
2832
798
|
${bold("🌿 Wispy")} — AI workspace assistant
|
|
@@ -2835,20 +801,7 @@ ${bold("Usage:")}
|
|
|
2835
801
|
wispy Start interactive session
|
|
2836
802
|
wispy "message" One-shot message
|
|
2837
803
|
wispy -w <name> Use specific workstream
|
|
2838
|
-
wispy
|
|
2839
|
-
wispy --continue "msg" Continue previous session
|
|
2840
|
-
wispy --new "msg" Start fresh session
|
|
2841
|
-
wispy home <subcommand> Operator commands
|
|
2842
|
-
wispy config Show/set config
|
|
2843
|
-
wispy server status Server management
|
|
2844
|
-
wispy doctor Diagnose environment
|
|
2845
|
-
|
|
2846
|
-
${bold("Tools (AI can use):")}
|
|
2847
|
-
read_file Read file contents
|
|
2848
|
-
write_file Write/create files
|
|
2849
|
-
run_command Execute shell commands
|
|
2850
|
-
list_directory List files in directory
|
|
2851
|
-
web_search Search the web
|
|
804
|
+
wispy --help Show this help
|
|
2852
805
|
|
|
2853
806
|
${bold("In-session commands:")}
|
|
2854
807
|
/help Show commands
|
|
@@ -2856,21 +809,13 @@ ${bold("In-session commands:")}
|
|
|
2856
809
|
/memory <type> <text> Save to persistent memory
|
|
2857
810
|
/clear Reset conversation
|
|
2858
811
|
/model [name] Show/change model
|
|
2859
|
-
/cost Show
|
|
2860
|
-
/work Show workstream context (work.md)
|
|
2861
|
-
/work init Create work.md for current workstream
|
|
2862
|
-
/work set <text> Append to work.md
|
|
2863
|
-
/budget Show spending per workstream
|
|
2864
|
-
/budget set <usd> Set budget limit for current workstream
|
|
812
|
+
/cost Show token usage
|
|
2865
813
|
/workstreams List all workstreams
|
|
2866
|
-
/overview Director view
|
|
2867
|
-
/search <keyword> Search across
|
|
2868
|
-
/skills List installed skills
|
|
814
|
+
/overview Director view
|
|
815
|
+
/search <keyword> Search across workstreams
|
|
816
|
+
/skills List installed skills
|
|
2869
817
|
/sessions List all sessions
|
|
2870
|
-
/
|
|
2871
|
-
/export [md|clipboard] Export conversation
|
|
2872
|
-
/provider Show current provider info
|
|
2873
|
-
/mcp [list|connect|disconnect|config|reload] MCP server management
|
|
818
|
+
/mcp [list|...] MCP server management
|
|
2874
819
|
/quit Exit
|
|
2875
820
|
|
|
2876
821
|
${bold("Providers (auto-detected):")}
|
|
@@ -2888,6 +833,5 @@ ${bold("Options:")}
|
|
|
2888
833
|
WISPY_WORKSTREAM Set active workstream
|
|
2889
834
|
`);
|
|
2890
835
|
} else {
|
|
2891
|
-
|
|
2892
|
-
await runRepl();
|
|
836
|
+
await runRepl(engine);
|
|
2893
837
|
}
|