wispy-cli 0.6.0 → 0.7.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 +3 -4
- package/core/config.mjs +104 -0
- package/core/engine.mjs +532 -0
- package/core/index.mjs +12 -0
- package/core/mcp.mjs +8 -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 +332 -2447
- package/lib/wispy-tui.mjs +105 -588
- package/package.json +2 -1
package/lib/wispy-repl.mjs
CHANGED
|
@@ -2,279 +2,30 @@
|
|
|
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
|
+
} from "../core/index.mjs";
|
|
278
29
|
|
|
279
30
|
// ---------------------------------------------------------------------------
|
|
280
31
|
// Colors (minimal, no deps)
|
|
@@ -286,20 +37,13 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
|
286
37
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
287
38
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
288
39
|
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
40
|
const underline = (s) => `\x1b[4m${s}\x1b[0m`;
|
|
292
41
|
|
|
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
|
|
42
|
+
function box(lines, { padding = 1 } = {}) {
|
|
43
|
+
const chars = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
299
44
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
300
45
|
const maxW = Math.max(...lines.map(l => stripAnsi(l).length)) + padding * 2;
|
|
301
46
|
const pad = " ".repeat(padding);
|
|
302
|
-
|
|
303
47
|
const top = ` ${chars.tl}${chars.h.repeat(maxW)}${chars.tr}`;
|
|
304
48
|
const bot = ` ${chars.bl}${chars.h.repeat(maxW)}${chars.br}`;
|
|
305
49
|
const mid = lines.map(l => {
|
|
@@ -311,38 +55,18 @@ function box(lines, { padding = 1, border = "rounded" } = {}) {
|
|
|
311
55
|
}
|
|
312
56
|
|
|
313
57
|
// ---------------------------------------------------------------------------
|
|
314
|
-
//
|
|
58
|
+
// Workstream helpers (legacy conversation storage for backward compat)
|
|
315
59
|
// ---------------------------------------------------------------------------
|
|
316
60
|
|
|
61
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const ACTIVE_WORKSTREAM = process.env.WISPY_WORKSTREAM ??
|
|
63
|
+
process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream")) ?? "default";
|
|
64
|
+
const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
|
|
65
|
+
|
|
317
66
|
async function readFileOr(filePath, fallback = null) {
|
|
318
67
|
try { return await readFile(filePath, "utf8"); } catch { return fallback; }
|
|
319
68
|
}
|
|
320
69
|
|
|
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
70
|
async function loadConversation() {
|
|
347
71
|
const raw = await readFileOr(HISTORY_FILE);
|
|
348
72
|
if (!raw) return [];
|
|
@@ -351,115 +75,24 @@ async function loadConversation() {
|
|
|
351
75
|
|
|
352
76
|
async function saveConversation(messages) {
|
|
353
77
|
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");
|
|
78
|
+
await writeFile(HISTORY_FILE, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
|
|
357
79
|
}
|
|
358
80
|
|
|
359
81
|
async function listWorkstreams() {
|
|
360
82
|
try {
|
|
361
83
|
const { readdir } = await import("node:fs/promises");
|
|
362
84
|
const files = await readdir(CONVERSATIONS_DIR);
|
|
363
|
-
return files
|
|
364
|
-
.filter(f => f.endsWith(".json"))
|
|
365
|
-
.map(f => f.replace(".json", ""));
|
|
85
|
+
return files.filter(f => f.endsWith(".json")).map(f => f.replace(".json", ""));
|
|
366
86
|
} catch { return []; }
|
|
367
87
|
}
|
|
368
88
|
|
|
369
89
|
async function loadWorkstreamConversation(wsName) {
|
|
370
90
|
try {
|
|
371
91
|
const wsPath = path.join(CONVERSATIONS_DIR, `${wsName}.json`);
|
|
372
|
-
|
|
373
|
-
return JSON.parse(raw);
|
|
92
|
+
return JSON.parse(await readFile(wsPath, "utf8"));
|
|
374
93
|
} catch { return []; }
|
|
375
94
|
}
|
|
376
95
|
|
|
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
96
|
async function appendToMemory(type, entry) {
|
|
464
97
|
await mkdir(MEMORY_DIR, { recursive: true });
|
|
465
98
|
const ts = new Date().toISOString().slice(0, 16);
|
|
@@ -467,1594 +100,212 @@ async function appendToMemory(type, entry) {
|
|
|
467
100
|
}
|
|
468
101
|
|
|
469
102
|
// ---------------------------------------------------------------------------
|
|
470
|
-
//
|
|
471
|
-
// ---------------------------------------------------------------------------
|
|
472
|
-
|
|
473
|
-
// ---------------------------------------------------------------------------
|
|
474
|
-
// Token / cost tracking
|
|
103
|
+
// Server management (background AWOS server)
|
|
475
104
|
// ---------------------------------------------------------------------------
|
|
476
105
|
|
|
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
|
-
}
|
|
514
|
-
|
|
515
|
-
function formatCost() {
|
|
516
|
-
const pricing = getModelPricing(MODEL);
|
|
517
|
-
const cost = (sessionTokens.input * pricing.input + sessionTokens.output * pricing.output) / 1_000_000;
|
|
518
|
-
return `${sessionTokens.input + sessionTokens.output} tokens (~$${cost.toFixed(4)})`;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// ---------------------------------------------------------------------------
|
|
522
|
-
// Task-aware model routing — pick cheapest model for the job
|
|
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";
|
|
539
|
-
|
|
540
|
-
// Complex: code writing, analysis, multi-step reasoning
|
|
541
|
-
if (/write.*code|implement|analyze|compare|explain.*detail|create.*plan|build/i.test(lower)) return "complex";
|
|
542
|
-
|
|
543
|
-
// Simple: questions, formatting, translation, simple file ops
|
|
544
|
-
return "simple";
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function getOptimalModel(prompt) {
|
|
548
|
-
// If user explicitly set a model, respect it
|
|
549
|
-
if (process.env.WISPY_MODEL) return process.env.WISPY_MODEL;
|
|
550
|
-
|
|
551
|
-
const complexity = classifyTaskComplexity(prompt);
|
|
552
|
-
const taskModels = TASK_MODEL_MAP[complexity];
|
|
553
|
-
return taskModels[PROVIDER] ?? MODEL;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
// Budget management (per-workstream)
|
|
558
|
-
// ---------------------------------------------------------------------------
|
|
559
|
-
|
|
560
|
-
const BUDGET_FILE = path.join(WISPY_DIR, "budgets.json");
|
|
106
|
+
const REPO_ROOT = process.env.WISPY_REPO_ROOT ?? path.resolve(SCRIPT_DIR, "..");
|
|
107
|
+
const SERVER_BINARY = process.env.WISPY_SERVER_BINARY
|
|
108
|
+
?? (() => {
|
|
109
|
+
const candidates = [
|
|
110
|
+
path.join(os.homedir(), ".wispy", "bin", "awos-server"),
|
|
111
|
+
path.join(REPO_ROOT, "src-tauri", "target", "release", "awos-server"),
|
|
112
|
+
path.join(REPO_ROOT, "src-tauri", "target", "debug", "awos-server"),
|
|
113
|
+
];
|
|
114
|
+
for (const c of candidates) {
|
|
115
|
+
try { if (statSync(c).isFile()) return c; } catch {}
|
|
116
|
+
}
|
|
117
|
+
return candidates[0];
|
|
118
|
+
})();
|
|
119
|
+
const SERVER_PID_FILE = path.join(WISPY_DIR, "server.pid");
|
|
120
|
+
const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
|
|
561
121
|
|
|
562
|
-
async function
|
|
122
|
+
async function isServerRunning() {
|
|
563
123
|
try {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async function saveBudgets(budgets) {
|
|
569
|
-
await mkdir(WISPY_DIR, { recursive: true });
|
|
570
|
-
await writeFile(BUDGET_FILE, JSON.stringify(budgets, null, 2) + "\n", "utf8");
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
async function trackSpending(workstream, inputTokens, outputTokens, modelName) {
|
|
574
|
-
const budgets = await loadBudgets();
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
593
|
-
|
|
594
|
-
function estimateMessagesTokens(messages) {
|
|
595
|
-
return messages.reduce((sum, m) => sum + estimateTokens(m.content ?? JSON.stringify(m)), 0);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function optimizeContext(messages, maxTokens = 30_000) {
|
|
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()];
|
|
124
|
+
const resp = await fetch(`http://127.0.0.1:${DEFAULT_SERVER_PORT}/api/health`, { signal: AbortSignal.timeout(2000) });
|
|
125
|
+
return resp.ok;
|
|
126
|
+
} catch { return false; }
|
|
630
127
|
}
|
|
631
128
|
|
|
632
|
-
|
|
633
|
-
{
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
}
|
|
129
|
+
async function startServerIfNeeded() {
|
|
130
|
+
if (await isServerRunning()) return { started: false, port: DEFAULT_SERVER_PORT };
|
|
131
|
+
try { const { stat } = await import("node:fs/promises"); await stat(SERVER_BINARY); }
|
|
132
|
+
catch { return { started: false, port: DEFAULT_SERVER_PORT, noBinary: true }; }
|
|
886
133
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
}
|
|
134
|
+
const logFile = path.join(WISPY_DIR, "server.log");
|
|
135
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
136
|
+
const { openSync } = await import("node:fs");
|
|
137
|
+
const logFd = openSync(logFile, "a");
|
|
138
|
+
const child = spawnProcess(SERVER_BINARY, [], {
|
|
139
|
+
cwd: REPO_ROOT, env: { ...process.env, AWOS_PORT: DEFAULT_SERVER_PORT },
|
|
140
|
+
detached: true, stdio: ["ignore", logFd, logFd],
|
|
141
|
+
});
|
|
142
|
+
child.unref();
|
|
143
|
+
await writeFile(SERVER_PID_FILE, String(child.pid), "utf8");
|
|
898
144
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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;
|
|
145
|
+
for (let i = 0; i < 25; i++) {
|
|
146
|
+
await new Promise(r => setTimeout(r, 200));
|
|
147
|
+
if (await isServerRunning()) return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid };
|
|
916
148
|
}
|
|
917
|
-
return
|
|
149
|
+
return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
|
|
918
150
|
}
|
|
919
151
|
|
|
920
|
-
async function
|
|
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
|
-
|
|
152
|
+
async function stopServer() {
|
|
929
153
|
try {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
}
|
|
154
|
+
const pidStr = await readFile(SERVER_PID_FILE, "utf8");
|
|
155
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
156
|
+
if (pid && !isNaN(pid)) {
|
|
157
|
+
process.kill(pid, "SIGTERM");
|
|
158
|
+
const { unlink } = await import("node:fs/promises");
|
|
159
|
+
await unlink(SERVER_PID_FILE).catch(() => {});
|
|
1507
160
|
}
|
|
1508
|
-
} catch
|
|
1509
|
-
return { success: false, error: err.message };
|
|
1510
|
-
}
|
|
161
|
+
} catch {}
|
|
1511
162
|
}
|
|
1512
163
|
|
|
1513
164
|
// ---------------------------------------------------------------------------
|
|
1514
|
-
//
|
|
1515
|
-
// ---------------------------------------------------------------------------
|
|
1516
|
-
|
|
1517
|
-
// ---------------------------------------------------------------------------
|
|
1518
|
-
// work.md — per-workstream context file
|
|
165
|
+
// Onboarding (first-run setup)
|
|
1519
166
|
// ---------------------------------------------------------------------------
|
|
1520
167
|
|
|
1521
|
-
async function
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
];
|
|
1527
|
-
|
|
1528
|
-
const content = await readFileOr(p);
|
|
1529
|
-
if (content) return { path: p, content: content.slice(0, 20_000) };
|
|
1530
|
-
}
|
|
1531
|
-
return null;
|
|
1532
|
-
}
|
|
168
|
+
async function runOnboarding() {
|
|
169
|
+
const { execSync } = await import("node:child_process");
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log(box([
|
|
172
|
+
"", `${bold("🌿 W I S P Y")}`, "", `${dim("AI workspace assistant")}`, `${dim("with multi-agent orchestration")}`, "",
|
|
173
|
+
]));
|
|
174
|
+
console.log("");
|
|
1533
175
|
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
176
|
+
// Auto-detect Ollama
|
|
177
|
+
process.stdout.write(dim(" Checking environment..."));
|
|
178
|
+
try {
|
|
179
|
+
const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
|
|
180
|
+
if (resp.ok) {
|
|
181
|
+
console.log(green(" found Ollama! ✓\n"));
|
|
182
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
183
|
+
cfg.provider = "ollama";
|
|
184
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
185
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
186
|
+
process.env.OLLAMA_HOST = "http://localhost:11434";
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
1538
190
|
|
|
1539
|
-
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
"
|
|
1543
|
-
|
|
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"),
|
|
191
|
+
// Auto-detect macOS Keychain
|
|
192
|
+
const keychainProviders = [
|
|
193
|
+
{ service: "google-ai-key", provider: "google", label: "Google AI (Gemini)" },
|
|
194
|
+
{ service: "anthropic-api-key", provider: "anthropic", label: "Anthropic (Claude)" },
|
|
195
|
+
{ service: "openai-api-key", provider: "openai", label: "OpenAI (GPT)" },
|
|
1551
196
|
];
|
|
1552
|
-
|
|
1553
|
-
const skills = [];
|
|
1554
|
-
const { readdir: rd, stat: st } = await import("node:fs/promises");
|
|
1555
|
-
|
|
1556
|
-
for (const dir of skillDirs) {
|
|
197
|
+
for (const kc of keychainProviders) {
|
|
1557
198
|
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 */ }
|
|
199
|
+
const { execFile } = await import("node:child_process");
|
|
200
|
+
const { promisify } = await import("node:util");
|
|
201
|
+
const exec = promisify(execFile);
|
|
202
|
+
const { stdout } = await exec("security", ["find-generic-password", "-s", kc.service, "-a", "poropo", "-w"], { timeout: 3000 });
|
|
203
|
+
const key = stdout.trim();
|
|
204
|
+
if (key) {
|
|
205
|
+
console.log(green(` found ${kc.label} key! ✓\n`));
|
|
206
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
207
|
+
cfg.provider = kc.provider;
|
|
208
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
209
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
210
|
+
return;
|
|
1800
211
|
}
|
|
1801
|
-
}
|
|
1802
|
-
sessionTokens.output += estimateTokens(fullText);
|
|
1803
|
-
return { type: "text", text: fullText };
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
// Non-streaming response (when tool results present)
|
|
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 };
|
|
212
|
+
} catch {}
|
|
1866
213
|
}
|
|
1867
214
|
|
|
1868
|
-
|
|
1869
|
-
sessionTokens.output += estimateTokens(text);
|
|
1870
|
-
if (onChunk) onChunk(text);
|
|
1871
|
-
return { type: "text", text };
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
// ---------------------------------------------------------------------------
|
|
1875
|
-
// Anthropic API with tool use (streaming text + tool calls)
|
|
1876
|
-
// ---------------------------------------------------------------------------
|
|
1877
|
-
|
|
1878
|
-
async function chatAnthropicWithTools(messages, onChunk) {
|
|
1879
|
-
const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
|
|
1880
|
-
|
|
1881
|
-
// Build Anthropic messages
|
|
1882
|
-
const anthropicMessages = [];
|
|
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
|
-
}
|
|
1903
|
-
}
|
|
215
|
+
console.log(dim(" no existing config found.\n"));
|
|
1904
216
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
});
|
|
217
|
+
// Manual setup
|
|
218
|
+
console.log(box([
|
|
219
|
+
`${bold("Quick Setup")} ${dim("— one step, 10 seconds")}`,
|
|
220
|
+
"", ` Wispy needs an AI provider to work.`, ` The easiest: ${bold("Google AI")} ${dim("(free, no credit card)")}`,
|
|
221
|
+
]));
|
|
222
|
+
console.log("");
|
|
1930
223
|
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
224
|
+
try {
|
|
225
|
+
execSync('open "https://aistudio.google.com/apikey" 2>/dev/null || xdg-open "https://aistudio.google.com/apikey" 2>/dev/null', { stdio: "ignore" });
|
|
226
|
+
console.log(` ${green("→")} Browser opened to ${underline("aistudio.google.com/apikey")}`);
|
|
227
|
+
} catch {
|
|
228
|
+
console.log(` ${green("→")} Visit: ${underline("https://aistudio.google.com/apikey")}`);
|
|
1934
229
|
}
|
|
1935
230
|
|
|
1936
|
-
|
|
1937
|
-
const
|
|
1938
|
-
const
|
|
1939
|
-
|
|
1940
|
-
let fullText = "";
|
|
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;
|
|
231
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
232
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
233
|
+
const apiKey = (await ask(`\n ${green("API key")} ${dim("(paste here)")}: `)).trim();
|
|
234
|
+
rl.close();
|
|
1957
235
|
|
|
1958
|
-
|
|
1959
|
-
|
|
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
|
-
}
|
|
236
|
+
if (!apiKey) {
|
|
237
|
+
console.log(dim("\nRun `wispy` again after setting up Ollama or an API key."));
|
|
238
|
+
process.exit(0);
|
|
1983
239
|
}
|
|
1984
240
|
|
|
1985
|
-
|
|
241
|
+
let chosenProvider = "google";
|
|
242
|
+
if (apiKey.startsWith("sk-ant-")) chosenProvider = "anthropic";
|
|
243
|
+
else if (apiKey.startsWith("sk-or-")) chosenProvider = "openrouter";
|
|
244
|
+
else if (apiKey.startsWith("sk-")) chosenProvider = "openai";
|
|
245
|
+
else if (apiKey.startsWith("gsk_")) chosenProvider = "groq";
|
|
1986
246
|
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
247
|
+
const { PROVIDERS } = await import("../core/config.mjs");
|
|
248
|
+
const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
|
|
249
|
+
cfg.provider = chosenProvider;
|
|
250
|
+
cfg.apiKey = apiKey;
|
|
251
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
252
|
+
await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
253
|
+
process.env[PROVIDERS[chosenProvider].envKeys[0]] = apiKey;
|
|
254
|
+
|
|
255
|
+
console.log("\n" + box([
|
|
256
|
+
`${green("✓")} Connected to ${bold(PROVIDERS[chosenProvider].label)}`,
|
|
257
|
+
"", ` ${cyan("wispy")} ${dim("start chatting")}`, ` ${cyan("wispy --help")} ${dim("all options")}`,
|
|
258
|
+
]));
|
|
1991
259
|
}
|
|
1992
260
|
|
|
1993
261
|
// ---------------------------------------------------------------------------
|
|
1994
|
-
//
|
|
262
|
+
// Director mode
|
|
1995
263
|
// ---------------------------------------------------------------------------
|
|
1996
264
|
|
|
1997
|
-
async function
|
|
1998
|
-
const
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
265
|
+
async function showOverview() {
|
|
266
|
+
const wsList = await listWorkstreams();
|
|
267
|
+
if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return; }
|
|
268
|
+
console.log(`\n${bold("🌿 Wispy Director — All Workstreams")}\n`);
|
|
269
|
+
for (const ws of wsList) {
|
|
270
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
271
|
+
const userMsgs = conv.filter(m => m.role === "user");
|
|
272
|
+
const assistantMsgs = conv.filter(m => m.role === "assistant");
|
|
273
|
+
const toolResults = conv.filter(m => m.role === "tool_result");
|
|
274
|
+
const lastUser = userMsgs[userMsgs.length - 1];
|
|
275
|
+
const isActive = ws === ACTIVE_WORKSTREAM;
|
|
276
|
+
const marker = isActive ? green("● ") : " ";
|
|
277
|
+
console.log(`${marker}${bold(isActive ? green(ws) : ws)}`);
|
|
278
|
+
console.log(` Messages: ${userMsgs.length} user / ${assistantMsgs.length} assistant / ${toolResults.length} tool calls`);
|
|
279
|
+
if (lastUser) console.log(` Last request: ${dim(lastUser.content.slice(0, 60))}${lastUser.content.length > 60 ? "..." : ""}`);
|
|
280
|
+
console.log("");
|
|
2006
281
|
}
|
|
282
|
+
}
|
|
2007
283
|
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
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);
|
|
2028
|
-
|
|
2029
|
-
for (const call of result.calls) {
|
|
2030
|
-
console.log(dim(` 🔧 ${call.name}(${JSON.stringify(call.args).slice(0, 80)})`));
|
|
2031
|
-
const toolResult = await executeTool(call.name, call.args);
|
|
2032
|
-
|
|
2033
|
-
if (toolResult.success) {
|
|
2034
|
-
const preview = JSON.stringify(toolResult).slice(0, 100);
|
|
2035
|
-
console.log(dim(` ✅ ${preview}${preview.length >= 100 ? "..." : ""}`));
|
|
2036
|
-
} else {
|
|
2037
|
-
console.log(red(` ❌ ${toolResult.error}`));
|
|
284
|
+
async function searchAcrossWorkstreams(query) {
|
|
285
|
+
const wsList = await listWorkstreams();
|
|
286
|
+
const lowerQuery = query.toLowerCase();
|
|
287
|
+
console.log(`\n${bold("🔍 Searching all workstreams for:")} ${cyan(query)}\n`);
|
|
288
|
+
let totalMatches = 0;
|
|
289
|
+
for (const ws of wsList) {
|
|
290
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
291
|
+
const matches = conv.filter(m => (m.role === "user" || m.role === "assistant") && m.content?.toLowerCase().includes(lowerQuery));
|
|
292
|
+
if (matches.length > 0) {
|
|
293
|
+
console.log(` ${bold(ws)} (${matches.length} matches):`);
|
|
294
|
+
for (const m of matches.slice(-3)) {
|
|
295
|
+
const preview = m.content.slice(0, 80).replace(/\n/g, " ");
|
|
296
|
+
console.log(` ${m.role === "user" ? "👤" : "🌿"} ${dim(preview)}${m.content.length > 80 ? "..." : ""}`);
|
|
2038
297
|
}
|
|
2039
|
-
|
|
2040
|
-
messages.push({
|
|
2041
|
-
role: "tool_result",
|
|
2042
|
-
toolName: call.name,
|
|
2043
|
-
toolUseId: call.id ?? call.name,
|
|
2044
|
-
result: toolResult,
|
|
2045
|
-
});
|
|
298
|
+
totalMatches += matches.length;
|
|
2046
299
|
}
|
|
2047
|
-
console.log(""); // newline before next response
|
|
2048
300
|
}
|
|
2049
|
-
|
|
2050
|
-
return "(tool call limit reached)";
|
|
301
|
+
if (totalMatches === 0) console.log(dim(` No matches found for "${query}"`));
|
|
2051
302
|
}
|
|
2052
303
|
|
|
2053
304
|
// ---------------------------------------------------------------------------
|
|
2054
|
-
// Slash
|
|
305
|
+
// Slash command handler (UI concern — stays in repl)
|
|
2055
306
|
// ---------------------------------------------------------------------------
|
|
2056
307
|
|
|
2057
|
-
async function handleSlashCommand(input, conversation) {
|
|
308
|
+
async function handleSlashCommand(input, engine, conversation) {
|
|
2058
309
|
const parts = input.trim().split(/\s+/);
|
|
2059
310
|
const cmd = parts[0].toLowerCase();
|
|
2060
311
|
|
|
@@ -2067,7 +318,13 @@ ${bold("Wispy Commands:")}
|
|
|
2067
318
|
${cyan("/clear")} Reset conversation
|
|
2068
319
|
${cyan("/history")} Show conversation length
|
|
2069
320
|
${cyan("/model")} [name] Show or change model
|
|
2070
|
-
${cyan("/
|
|
321
|
+
${cyan("/cost")} Show token usage
|
|
322
|
+
${cyan("/workstreams")} List workstreams
|
|
323
|
+
${cyan("/overview")} Director view
|
|
324
|
+
${cyan("/search")} <keyword> Search across workstreams
|
|
325
|
+
${cyan("/skills")} List installed skills
|
|
326
|
+
${cyan("/sessions")} List sessions
|
|
327
|
+
${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP management
|
|
2071
328
|
${cyan("/quit")} or ${cyan("/exit")} Exit
|
|
2072
329
|
`);
|
|
2073
330
|
return true;
|
|
@@ -2087,74 +344,57 @@ ${bold("Wispy Commands:")}
|
|
|
2087
344
|
|
|
2088
345
|
if (cmd === "/model") {
|
|
2089
346
|
if (parts[1]) {
|
|
2090
|
-
|
|
347
|
+
engine.providers.setModel(parts[1]);
|
|
2091
348
|
console.log(green(`Model changed to: ${parts[1]}`));
|
|
2092
349
|
} else {
|
|
2093
|
-
console.log(dim(`Current model: ${
|
|
350
|
+
console.log(dim(`Current model: ${engine.model} (provider: ${engine.provider})`));
|
|
2094
351
|
}
|
|
2095
352
|
return true;
|
|
2096
353
|
}
|
|
2097
354
|
|
|
355
|
+
if (cmd === "/cost" || cmd === "/tokens" || cmd === "/usage") {
|
|
356
|
+
console.log(dim(`📊 Session usage: ${engine.providers.formatCost()}`));
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
2098
360
|
if (cmd === "/memory") {
|
|
2099
361
|
const type = parts[1];
|
|
2100
362
|
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
|
-
}
|
|
363
|
+
if (!type || !content) { console.log(yellow("Usage: /memory <user|feedback|project|references> <content>")); return true; }
|
|
2105
364
|
const validTypes = ["user", "feedback", "project", "references"];
|
|
2106
|
-
if (!validTypes.includes(type)) {
|
|
2107
|
-
console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`));
|
|
2108
|
-
return true;
|
|
2109
|
-
}
|
|
365
|
+
if (!validTypes.includes(type)) { console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`)); return true; }
|
|
2110
366
|
await appendToMemory(type, content);
|
|
2111
367
|
console.log(green(`✅ Saved to ${type} memory.`));
|
|
2112
368
|
return true;
|
|
2113
369
|
}
|
|
2114
370
|
|
|
2115
371
|
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
|
|
372
|
+
const summaryResult = await engine.processMessage(null, "Summarize our conversation so far in 3-5 bullet points.", {
|
|
373
|
+
systemPrompt: "Summarize the following conversation in 3-5 bullet points. Be concise.",
|
|
374
|
+
noSave: true,
|
|
375
|
+
});
|
|
376
|
+
const summary = summaryResult.content;
|
|
2128
377
|
await appendToMemory("project", `Session compact: ${summary.slice(0, 200)}`);
|
|
2129
378
|
conversation.length = 0;
|
|
2130
379
|
conversation.push({ role: "assistant", content: `[Previous session summary]\n${summary}` });
|
|
2131
380
|
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()}`));
|
|
381
|
+
console.log(green("\n📦 Conversation compacted."));
|
|
2138
382
|
return true;
|
|
2139
383
|
}
|
|
2140
384
|
|
|
2141
385
|
if (cmd === "/workstreams" || cmd === "/ws") {
|
|
2142
386
|
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>`));
|
|
387
|
+
if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return true; }
|
|
388
|
+
console.log(bold("\n📋 Workstreams:\n"));
|
|
389
|
+
for (const ws of wsList) {
|
|
390
|
+
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
391
|
+
const wsConv = await loadWorkstreamConversation(ws);
|
|
392
|
+
const msgCount = wsConv.filter(m => m.role === "user").length;
|
|
393
|
+
const lastMsg = wsConv.filter(m => m.role === "user").pop();
|
|
394
|
+
const preview = lastMsg ? dim(` — "${lastMsg.content.slice(0, 40)}${lastMsg.content.length > 40 ? "..." : ""}"`) : "";
|
|
395
|
+
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgCount} msgs`)}${preview}`);
|
|
2157
396
|
}
|
|
397
|
+
console.log(dim(`\nSwitch: wispy -w <name>`));
|
|
2158
398
|
return true;
|
|
2159
399
|
}
|
|
2160
400
|
|
|
@@ -2165,142 +405,30 @@ ${bold("Wispy Commands:")}
|
|
|
2165
405
|
|
|
2166
406
|
if (cmd === "/search") {
|
|
2167
407
|
const query = parts.slice(1).join(" ");
|
|
2168
|
-
if (!query) {
|
|
2169
|
-
console.log(yellow("Usage: /search <keyword> — search across all workstreams"));
|
|
2170
|
-
return true;
|
|
2171
|
-
}
|
|
408
|
+
if (!query) { console.log(yellow("Usage: /search <keyword>")); return true; }
|
|
2172
409
|
await searchAcrossWorkstreams(query);
|
|
2173
410
|
return true;
|
|
2174
411
|
}
|
|
2175
412
|
|
|
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
413
|
if (cmd === "/sessions" || cmd === "/ls") {
|
|
2280
414
|
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
|
-
}
|
|
415
|
+
if (wsList.length === 0) { console.log(dim("No sessions yet.")); return true; }
|
|
416
|
+
console.log(bold("\n📂 Sessions:\n"));
|
|
417
|
+
for (const ws of wsList) {
|
|
418
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
419
|
+
const msgs = conv.filter(m => m.role === "user").length;
|
|
420
|
+
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
421
|
+
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
|
|
2291
422
|
}
|
|
2292
|
-
console.log(dim(`\nSwitch: wispy -w <name> | Delete: /delete <name>`));
|
|
2293
423
|
return true;
|
|
2294
424
|
}
|
|
2295
425
|
|
|
2296
426
|
if (cmd === "/delete" || cmd === "/rm") {
|
|
2297
427
|
const target = parts[1];
|
|
2298
428
|
if (!target) { console.log(yellow("Usage: /delete <workstream-name>")); return true; }
|
|
2299
|
-
const
|
|
429
|
+
const { unlink } = await import("node:fs/promises");
|
|
2300
430
|
try {
|
|
2301
|
-
|
|
2302
|
-
await unlink(wsPath);
|
|
2303
|
-
// Also delete work.md and plan
|
|
431
|
+
await unlink(path.join(CONVERSATIONS_DIR, `${target}.json`));
|
|
2304
432
|
await unlink(path.join(CONVERSATIONS_DIR, `${target}.work.md`)).catch(() => {});
|
|
2305
433
|
await unlink(path.join(CONVERSATIONS_DIR, `${target}.plan.json`)).catch(() => {});
|
|
2306
434
|
console.log(green(`🗑️ Deleted session "${target}"`));
|
|
@@ -2310,49 +438,47 @@ ${bold("Wispy Commands:")}
|
|
|
2310
438
|
return true;
|
|
2311
439
|
}
|
|
2312
440
|
|
|
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
|
-
});
|
|
441
|
+
if (cmd === "/provider") {
|
|
442
|
+
console.log(dim(`Provider: ${engine.provider}, Model: ${engine.model}, Workstream: ${ACTIVE_WORKSTREAM}`));
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
2323
445
|
|
|
2324
|
-
|
|
2325
|
-
|
|
446
|
+
if (cmd === "/skills") {
|
|
447
|
+
// Load skills from known locations
|
|
448
|
+
const skillDirs = [
|
|
449
|
+
"/opt/homebrew/lib/node_modules/openclaw/skills",
|
|
450
|
+
path.join(os.homedir(), ".openclaw", "workspace", "skills"),
|
|
451
|
+
path.join(WISPY_DIR, "skills"),
|
|
452
|
+
];
|
|
453
|
+
const { readdir } = await import("node:fs/promises");
|
|
454
|
+
const allSkills = [];
|
|
455
|
+
for (const dir of skillDirs) {
|
|
2326
456
|
try {
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
457
|
+
const entries = await readdir(dir);
|
|
458
|
+
for (const entry of entries) {
|
|
459
|
+
try {
|
|
460
|
+
const skillMd = await readFile(path.join(dir, entry, "SKILL.md"), "utf8");
|
|
461
|
+
allSkills.push({ name: entry, source: dir });
|
|
462
|
+
} catch {}
|
|
463
|
+
}
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
if (allSkills.length === 0) {
|
|
467
|
+
console.log(dim("No skills installed."));
|
|
2331
468
|
} else {
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
469
|
+
console.log(bold(`\n🧩 Skills (${allSkills.length} installed):\n`));
|
|
470
|
+
for (const s of allSkills) {
|
|
471
|
+
console.log(` ${green(s.name.padEnd(20))} ${dim(s.source.split("/").slice(-2).join("/"))}`);
|
|
472
|
+
}
|
|
2335
473
|
}
|
|
2336
474
|
return true;
|
|
2337
475
|
}
|
|
2338
476
|
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
477
|
+
// /mcp commands
|
|
2354
478
|
if (cmd === "/mcp") {
|
|
2355
479
|
const sub = parts[1] ?? "list";
|
|
480
|
+
const mcpManager = engine.mcpManager;
|
|
481
|
+
const MCP_CONFIG_PATH = mcpManager.configPath;
|
|
2356
482
|
|
|
2357
483
|
if (sub === "list") {
|
|
2358
484
|
const status = mcpManager.getStatus();
|
|
@@ -2360,16 +486,11 @@ ${bold("Wispy Commands:")}
|
|
|
2360
486
|
if (status.length === 0) {
|
|
2361
487
|
console.log(dim("No MCP servers connected."));
|
|
2362
488
|
console.log(dim(`Config: ${MCP_CONFIG_PATH}`));
|
|
2363
|
-
console.log(dim("Use /mcp connect <name> to connect a server."));
|
|
2364
489
|
} else {
|
|
2365
490
|
console.log(bold(`\n🔌 MCP Servers (${status.length}):\n`));
|
|
2366
491
|
for (const s of status) {
|
|
2367
492
|
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 ?? "?"}`));
|
|
493
|
+
console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools`);
|
|
2373
494
|
}
|
|
2374
495
|
}
|
|
2375
496
|
if (allTools.length > 0) {
|
|
@@ -2378,30 +499,19 @@ ${bold("Wispy Commands:")}
|
|
|
2378
499
|
console.log(` ${cyan(t.wispyName.padEnd(30))} ${dim(t.description.slice(0, 60))}`);
|
|
2379
500
|
}
|
|
2380
501
|
}
|
|
2381
|
-
console.log("");
|
|
2382
502
|
return true;
|
|
2383
503
|
}
|
|
2384
504
|
|
|
2385
505
|
if (sub === "connect") {
|
|
2386
506
|
const serverName = parts[2];
|
|
2387
|
-
if (!serverName) {
|
|
2388
|
-
console.log(yellow("Usage: /mcp connect <server-name>"));
|
|
2389
|
-
return true;
|
|
2390
|
-
}
|
|
507
|
+
if (!serverName) { console.log(yellow("Usage: /mcp connect <server-name>")); return true; }
|
|
2391
508
|
const config = await mcpManager.loadConfig();
|
|
2392
509
|
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
|
-
}
|
|
510
|
+
if (!serverConfig) { console.log(red(`Server "${serverName}" not found in config`)); return true; }
|
|
2398
511
|
process.stdout.write(dim(` Connecting to "${serverName}"...`));
|
|
2399
512
|
try {
|
|
2400
513
|
const client = await mcpManager.connect(serverName, serverConfig);
|
|
2401
514
|
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
515
|
} catch (err) {
|
|
2406
516
|
console.log(red(` ✗ failed: ${err.message.slice(0, 120)}`));
|
|
2407
517
|
}
|
|
@@ -2410,10 +520,7 @@ ${bold("Wispy Commands:")}
|
|
|
2410
520
|
|
|
2411
521
|
if (sub === "disconnect") {
|
|
2412
522
|
const serverName = parts[2];
|
|
2413
|
-
if (!serverName) {
|
|
2414
|
-
console.log(yellow("Usage: /mcp disconnect <server-name>"));
|
|
2415
|
-
return true;
|
|
2416
|
-
}
|
|
523
|
+
if (!serverName) { console.log(yellow("Usage: /mcp disconnect <server-name>")); return true; }
|
|
2417
524
|
const ok = mcpManager.disconnect(serverName);
|
|
2418
525
|
console.log(ok ? green(`✓ Disconnected "${serverName}"`) : yellow(`"${serverName}" was not connected`));
|
|
2419
526
|
return true;
|
|
@@ -2421,17 +528,12 @@ ${bold("Wispy Commands:")}
|
|
|
2421
528
|
|
|
2422
529
|
if (sub === "config") {
|
|
2423
530
|
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."));
|
|
531
|
+
const cfg = await mcpManager.loadConfig();
|
|
532
|
+
const servers = cfg.mcpServers ?? {};
|
|
533
|
+
console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
|
|
534
|
+
for (const [name, s] of Object.entries(servers)) {
|
|
535
|
+
console.log(` ${name.padEnd(20)} ${s.disabled ? dim("disabled") : green("enabled")}`);
|
|
536
|
+
console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
|
|
2435
537
|
}
|
|
2436
538
|
return true;
|
|
2437
539
|
}
|
|
@@ -2445,22 +547,20 @@ ${bold("Wispy Commands:")}
|
|
|
2445
547
|
else if (r.status === "disabled") console.log(dim(` ○ ${r.name} (disabled)`));
|
|
2446
548
|
else console.log(red(` ✗ ${r.name}: ${r.error?.slice(0, 80)}`));
|
|
2447
549
|
}
|
|
550
|
+
engine.tools.registerMCP(mcpManager);
|
|
2448
551
|
return true;
|
|
2449
552
|
}
|
|
2450
553
|
|
|
2451
|
-
|
|
2452
|
-
console.log(`
|
|
2453
|
-
${bold("/mcp commands:")}
|
|
2454
|
-
${cyan("/mcp list")} List connected servers and tools
|
|
2455
|
-
${cyan("/mcp connect <name>")} Connect a server from config
|
|
2456
|
-
${cyan("/mcp disconnect <name>")} Disconnect a server
|
|
2457
|
-
${cyan("/mcp config")} Show mcp.json config
|
|
2458
|
-
${cyan("/mcp reload")} Reconnect all servers
|
|
2459
|
-
${dim(`Config: ${MCP_CONFIG_PATH}`)}
|
|
2460
|
-
`);
|
|
554
|
+
console.log(`${bold("/mcp commands:")} list | connect <name> | disconnect <name> | config | reload`);
|
|
2461
555
|
return true;
|
|
2462
556
|
}
|
|
2463
557
|
|
|
558
|
+
if (cmd === "/quit" || cmd === "/exit") {
|
|
559
|
+
console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
|
|
560
|
+
engine.destroy();
|
|
561
|
+
process.exit(0);
|
|
562
|
+
}
|
|
563
|
+
|
|
2464
564
|
return false;
|
|
2465
565
|
}
|
|
2466
566
|
|
|
@@ -2468,24 +568,15 @@ ${bold("/mcp commands:")}
|
|
|
2468
568
|
// Interactive REPL
|
|
2469
569
|
// ---------------------------------------------------------------------------
|
|
2470
570
|
|
|
2471
|
-
async function runRepl() {
|
|
571
|
+
async function runRepl(engine) {
|
|
2472
572
|
const wsLabel = ACTIVE_WORKSTREAM === "default" ? "" : ` ${dim("·")} ${cyan(ACTIVE_WORKSTREAM)}`;
|
|
2473
|
-
const providerLabel = PROVIDERS[PROVIDER]?.label ?? PROVIDER;
|
|
2474
573
|
console.log(`
|
|
2475
|
-
${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${
|
|
2476
|
-
${dim(`${
|
|
574
|
+
${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${engine.model}`)}
|
|
575
|
+
${dim(`${engine.provider} · /help for commands · Ctrl+C to exit`)}
|
|
2477
576
|
`);
|
|
2478
577
|
|
|
2479
|
-
const systemPrompt = await buildSystemPrompt(conversation);
|
|
2480
578
|
const conversation = await loadConversation();
|
|
2481
579
|
|
|
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
580
|
const rl = createInterface({
|
|
2490
581
|
input: process.stdin,
|
|
2491
582
|
output: process.stdout,
|
|
@@ -2499,34 +590,36 @@ async function runRepl() {
|
|
|
2499
590
|
const input = line.trim();
|
|
2500
591
|
if (!input) { rl.prompt(); return; }
|
|
2501
592
|
|
|
2502
|
-
// Slash commands
|
|
2503
593
|
if (input.startsWith("/")) {
|
|
2504
|
-
const handled = await handleSlashCommand(input, conversation);
|
|
594
|
+
const handled = await handleSlashCommand(input, engine, conversation);
|
|
2505
595
|
if (handled) { rl.prompt(); return; }
|
|
2506
596
|
}
|
|
2507
597
|
|
|
2508
|
-
// Add user message
|
|
2509
598
|
conversation.push({ role: "user", content: input });
|
|
2510
599
|
|
|
2511
|
-
// Agent loop with tool calls
|
|
2512
600
|
process.stdout.write(cyan("🌿 "));
|
|
2513
601
|
try {
|
|
2514
|
-
|
|
2515
|
-
|
|
602
|
+
// Build messages from conversation history (keep system prompt + history)
|
|
603
|
+
const systemPrompt = await engine._buildSystemPrompt(input);
|
|
604
|
+
const messages = [{ role: "system", content: systemPrompt }, ...conversation.filter(m => m.role !== "system")];
|
|
605
|
+
|
|
606
|
+
const response = await engine.processMessage(null, input, {
|
|
607
|
+
onChunk: (chunk) => process.stdout.write(chunk),
|
|
608
|
+
systemPrompt: await engine._buildSystemPrompt(input),
|
|
609
|
+
noSave: true,
|
|
2516
610
|
});
|
|
2517
611
|
console.log("\n");
|
|
2518
612
|
|
|
2519
|
-
conversation.push({ role: "assistant", content: response });
|
|
613
|
+
conversation.push({ role: "assistant", content: response.content });
|
|
614
|
+
// Keep last 50 messages
|
|
615
|
+
if (conversation.length > 50) conversation.splice(0, conversation.length - 50);
|
|
2520
616
|
await saveConversation(conversation);
|
|
2521
|
-
console.log(dim(` ${formatCost()}`));
|
|
617
|
+
console.log(dim(` ${engine.providers.formatCost()}`));
|
|
2522
618
|
} catch (err) {
|
|
2523
|
-
// Friendly error handling
|
|
2524
619
|
if (err.message.includes("429") || err.message.includes("rate")) {
|
|
2525
620
|
console.log(yellow("\n\n⏳ Rate limited — wait a moment and try again."));
|
|
2526
621
|
} else if (err.message.includes("401") || err.message.includes("403")) {
|
|
2527
622
|
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
623
|
} else {
|
|
2531
624
|
console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
|
|
2532
625
|
}
|
|
@@ -2536,7 +629,8 @@ async function runRepl() {
|
|
|
2536
629
|
});
|
|
2537
630
|
|
|
2538
631
|
rl.on("close", () => {
|
|
2539
|
-
console.log(dim(`\n🌿 Bye! (${formatCost()})`));
|
|
632
|
+
console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
|
|
633
|
+
engine.destroy();
|
|
2540
634
|
process.exit(0);
|
|
2541
635
|
});
|
|
2542
636
|
}
|
|
@@ -2545,26 +639,13 @@ async function runRepl() {
|
|
|
2545
639
|
// One-shot mode
|
|
2546
640
|
// ---------------------------------------------------------------------------
|
|
2547
641
|
|
|
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
|
-
|
|
642
|
+
async function runOneShot(engine, message) {
|
|
2559
643
|
try {
|
|
2560
|
-
const response = await
|
|
2561
|
-
process.stdout.write(chunk)
|
|
644
|
+
const response = await engine.processMessage(null, message, {
|
|
645
|
+
onChunk: (chunk) => process.stdout.write(chunk),
|
|
2562
646
|
});
|
|
2563
647
|
console.log("");
|
|
2564
|
-
|
|
2565
|
-
conversation.push({ role: "assistant", content: response });
|
|
2566
|
-
await saveConversation(conversation);
|
|
2567
|
-
console.log(dim(`${formatCost()}`));
|
|
648
|
+
console.log(dim(engine.providers.formatCost()));
|
|
2568
649
|
} catch (err) {
|
|
2569
650
|
if (err.message.includes("429")) {
|
|
2570
651
|
console.error(yellow("\n⏳ Rate limited — try again shortly."));
|
|
@@ -2575,121 +656,19 @@ async function runOneShot(message) {
|
|
|
2575
656
|
}
|
|
2576
657
|
}
|
|
2577
658
|
|
|
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
659
|
// ---------------------------------------------------------------------------
|
|
2674
660
|
// Main
|
|
2675
661
|
// ---------------------------------------------------------------------------
|
|
2676
662
|
|
|
2677
|
-
// Filter out -w/--workstream flag from args
|
|
2678
663
|
const rawArgs = process.argv.slice(2);
|
|
2679
664
|
const args = [];
|
|
2680
665
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
2681
|
-
if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
|
|
666
|
+
if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
|
|
2682
667
|
args.push(rawArgs[i]);
|
|
2683
668
|
}
|
|
2684
669
|
|
|
2685
|
-
|
|
2686
|
-
const operatorCommands = new Set([
|
|
2687
|
-
"home", "node", "runtime", "agents", "agent",
|
|
2688
|
-
"workstreams", "workstream", "doctor", "setup",
|
|
2689
|
-
"package", "config", "server",
|
|
2690
|
-
]);
|
|
670
|
+
const operatorCommands = new Set(["home", "node", "runtime", "agents", "agent", "workstreams", "workstream", "doctor", "setup", "package", "config", "server"]);
|
|
2691
671
|
|
|
2692
|
-
// wispy server <start|stop|status>
|
|
2693
672
|
if (args[0] === "server") {
|
|
2694
673
|
const sub = args[1] ?? "status";
|
|
2695
674
|
if (sub === "status") {
|
|
@@ -2703,130 +682,58 @@ if (args[0] === "server") {
|
|
|
2703
682
|
}
|
|
2704
683
|
process.exit(0);
|
|
2705
684
|
}
|
|
2706
|
-
if (sub === "stop") {
|
|
2707
|
-
await stopServer();
|
|
2708
|
-
console.log(dim("🌿 Server stopped."));
|
|
2709
|
-
process.exit(0);
|
|
2710
|
-
}
|
|
685
|
+
if (sub === "stop") { await stopServer(); console.log(dim("🌿 Server stopped.")); process.exit(0); }
|
|
2711
686
|
if (sub === "start") {
|
|
2712
687
|
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
|
-
}
|
|
688
|
+
if (status.started) console.log(green(`🌿 Server started on port ${status.port} (PID: ${status.pid})`));
|
|
689
|
+
else if (status.noBinary) console.log(red("Server binary not found."));
|
|
690
|
+
else console.log(dim(`Server already running on port ${status.port}`));
|
|
2720
691
|
process.exit(0);
|
|
2721
692
|
}
|
|
2722
|
-
console.log("Usage: wispy server <start|stop|status>");
|
|
2723
|
-
process.exit(1);
|
|
693
|
+
console.log("Usage: wispy server <start|stop|status>"); process.exit(1);
|
|
2724
694
|
}
|
|
2725
695
|
|
|
2726
696
|
if (args[0] && operatorCommands.has(args[0])) {
|
|
2727
|
-
// Delegate to the full CLI
|
|
2728
697
|
const cliPath = process.env.WISPY_OPERATOR_CLI ?? path.join(SCRIPT_DIR, "awos-node-cli.mjs");
|
|
2729
698
|
const { execFileSync } = await import("node:child_process");
|
|
2730
699
|
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
|
-
}
|
|
700
|
+
execFileSync(process.execPath, ["--experimental-strip-types", cliPath, ...args], { stdio: "inherit", env: process.env });
|
|
701
|
+
} catch (e) { process.exit(e.status ?? 1); }
|
|
2738
702
|
process.exit(0);
|
|
2739
703
|
}
|
|
2740
704
|
|
|
2741
|
-
//
|
|
2742
|
-
|
|
705
|
+
// Initialize engine
|
|
706
|
+
const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
|
|
707
|
+
const initResult = await engine.init();
|
|
708
|
+
|
|
709
|
+
if (!initResult) {
|
|
2743
710
|
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") {
|
|
711
|
+
// Try again after onboarding
|
|
712
|
+
const initResult2 = await engine.init();
|
|
713
|
+
if (!initResult2) {
|
|
2756
714
|
console.log(dim("\nRun wispy again to start chatting!"));
|
|
2757
715
|
process.exit(0);
|
|
2758
716
|
}
|
|
2759
717
|
}
|
|
2760
718
|
|
|
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); });
|
|
719
|
+
// Graceful cleanup
|
|
720
|
+
process.on("exit", () => { try { engine.destroy(); } catch {} });
|
|
721
|
+
process.on("SIGINT", () => { engine.destroy(); process.exit(0); });
|
|
722
|
+
process.on("SIGTERM", () => { engine.destroy(); process.exit(0); });
|
|
2782
723
|
|
|
2783
|
-
// Auto-start server
|
|
724
|
+
// Auto-start background server
|
|
2784
725
|
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
|
|
726
|
+
if (serverStatus.started && !serverStatus.noBinary) {
|
|
727
|
+
if (serverStatus.slow) console.log(yellow(`⚠ Server starting on port ${serverStatus.port}...`));
|
|
728
|
+
else console.log(dim(`🌿 Server started on port ${serverStatus.port}`));
|
|
2793
729
|
}
|
|
2794
730
|
|
|
2795
|
-
|
|
2796
|
-
|
|
731
|
+
if (args[0] === "overview" || args[0] === "dashboard") { await showOverview(); process.exit(0); }
|
|
732
|
+
if (args[0] === "search" && args[1]) { await searchAcrossWorkstreams(args.slice(1).join(" ")); process.exit(0); }
|
|
2797
733
|
|
|
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);
|
|
734
|
+
if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
|
|
735
|
+
// One-shot mode
|
|
736
|
+
await runOneShot(engine, args.join(" "));
|
|
2830
737
|
} else if (args[0] === "--help" || args[0] === "-h") {
|
|
2831
738
|
console.log(`
|
|
2832
739
|
${bold("🌿 Wispy")} — AI workspace assistant
|
|
@@ -2835,20 +742,7 @@ ${bold("Usage:")}
|
|
|
2835
742
|
wispy Start interactive session
|
|
2836
743
|
wispy "message" One-shot message
|
|
2837
744
|
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
|
|
745
|
+
wispy --help Show this help
|
|
2852
746
|
|
|
2853
747
|
${bold("In-session commands:")}
|
|
2854
748
|
/help Show commands
|
|
@@ -2856,21 +750,13 @@ ${bold("In-session commands:")}
|
|
|
2856
750
|
/memory <type> <text> Save to persistent memory
|
|
2857
751
|
/clear Reset conversation
|
|
2858
752
|
/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
|
|
753
|
+
/cost Show token usage
|
|
2865
754
|
/workstreams List all workstreams
|
|
2866
|
-
/overview Director view
|
|
2867
|
-
/search <keyword> Search across
|
|
2868
|
-
/skills List installed skills
|
|
755
|
+
/overview Director view
|
|
756
|
+
/search <keyword> Search across workstreams
|
|
757
|
+
/skills List installed skills
|
|
2869
758
|
/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
|
|
759
|
+
/mcp [list|...] MCP server management
|
|
2874
760
|
/quit Exit
|
|
2875
761
|
|
|
2876
762
|
${bold("Providers (auto-detected):")}
|
|
@@ -2888,6 +774,5 @@ ${bold("Options:")}
|
|
|
2888
774
|
WISPY_WORKSTREAM Set active workstream
|
|
2889
775
|
`);
|
|
2890
776
|
} else {
|
|
2891
|
-
|
|
2892
|
-
await runRepl();
|
|
777
|
+
await runRepl(engine);
|
|
2893
778
|
}
|