wispy-cli 0.6.1 → 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.
@@ -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 { MCPManager, ensureDefaultMcpConfig } from "./mcp-client.mjs";
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
- let detected = await detectProvider();
274
- let PROVIDER = detected?.provider ?? "none";
275
- let API_KEY = detected?.key ?? null;
276
- let MODEL = detected?.model ?? "unknown";
277
- const MAX_CONTEXT_CHARS = 40_000;
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, border = "rounded" } = {}) {
294
- const chars = border === "rounded"
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
- // File helpers
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
- // Keep last 50 messages to prevent unbounded growth
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
- const raw = await readFile(wsPath, "utf8");
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
- // System prompt builder
471
- // ---------------------------------------------------------------------------
472
-
473
- // ---------------------------------------------------------------------------
474
- // Token / cost tracking
103
+ // Server management (background AWOS server)
475
104
  // ---------------------------------------------------------------------------
476
105
 
477
- let sessionTokens = { input: 0, output: 0 };
478
-
479
- function estimateTokens(text) {
480
- // Rough estimate: ~4 chars per token
481
- return Math.ceil((text?.length ?? 0) / 4);
482
- }
483
-
484
- // Model pricing database (per 1M tokens)
485
- const MODEL_PRICING = {
486
- // Google
487
- "gemini-2.5-flash": { input: 0.15, output: 0.60, tier: "cheap" },
488
- "gemini-2.5-pro": { input: 1.25, output: 10.0, tier: "mid" },
489
- "gemini-2.0-flash": { input: 0.10, output: 0.40, tier: "cheap" },
490
- // Anthropic
491
- "claude-sonnet-4-20250514": { input: 3.0, output: 15.0, tier: "mid" },
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 loadBudgets() {
122
+ async function isServerRunning() {
563
123
  try {
564
- return JSON.parse(await readFile(BUDGET_FILE, "utf8"));
565
- } catch { return {}; }
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
- const TOOL_DEFINITIONS = [
633
- {
634
- name: "read_file",
635
- description: "Read the contents of a file at the given path",
636
- parameters: {
637
- type: "object",
638
- properties: {
639
- path: { type: "string", description: "File path to read" },
640
- },
641
- required: ["path"],
642
- },
643
- },
644
- {
645
- name: "write_file",
646
- description: "Write content to a file, creating it if it doesn't exist",
647
- parameters: {
648
- type: "object",
649
- properties: {
650
- path: { type: "string", description: "File path to write" },
651
- content: { type: "string", description: "Content to write" },
652
- },
653
- required: ["path", "content"],
654
- },
655
- },
656
- {
657
- name: "run_command",
658
- description: "Execute a shell command and return stdout/stderr",
659
- parameters: {
660
- type: "object",
661
- properties: {
662
- command: { type: "string", description: "Shell command to execute" },
663
- },
664
- required: ["command"],
665
- },
666
- },
667
- {
668
- name: "list_directory",
669
- description: "List files and directories at the given path",
670
- parameters: {
671
- type: "object",
672
- properties: {
673
- path: { type: "string", description: "Directory path (default: current dir)" },
674
- },
675
- required: [],
676
- },
677
- },
678
- {
679
- name: "web_search",
680
- description: "Search the web and return results",
681
- parameters: {
682
- type: "object",
683
- properties: {
684
- query: { type: "string", description: "Search query" },
685
- },
686
- required: ["query"],
687
- },
688
- },
689
- {
690
- name: "file_edit",
691
- description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
692
- parameters: {
693
- type: "object",
694
- properties: {
695
- path: { type: "string", description: "File path" },
696
- old_text: { type: "string", description: "Exact text to find and replace" },
697
- new_text: { type: "string", description: "Replacement text" },
698
- },
699
- required: ["path", "old_text", "new_text"],
700
- },
701
- },
702
- {
703
- name: "file_search",
704
- description: "Search for text patterns in files recursively (like grep). Returns matching lines with file paths and line numbers.",
705
- parameters: {
706
- type: "object",
707
- properties: {
708
- pattern: { type: "string", description: "Text or regex pattern to search for" },
709
- path: { type: "string", description: "Directory to search in (default: current dir)" },
710
- file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
711
- },
712
- required: ["pattern"],
713
- },
714
- },
715
- {
716
- name: "git",
717
- description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout. Use for version control tasks.",
718
- parameters: {
719
- type: "object",
720
- properties: {
721
- command: { type: "string", description: "Git subcommand and args (e.g., 'status', 'diff --cached', 'log --oneline -10', 'commit -m \"msg\"')" },
722
- },
723
- required: ["command"],
724
- },
725
- },
726
- {
727
- name: "web_fetch",
728
- description: "Fetch content from a URL and return it as text/markdown. Use to read web pages, docs, APIs.",
729
- parameters: {
730
- type: "object",
731
- properties: {
732
- url: { type: "string", description: "URL to fetch" },
733
- },
734
- required: ["url"],
735
- },
736
- },
737
- {
738
- name: "keychain",
739
- description: "Manage macOS Keychain secrets. Read (masked), store, or delete credentials. Values are never shown in full — only first 4 + last 4 chars.",
740
- parameters: {
741
- type: "object",
742
- properties: {
743
- action: { type: "string", enum: ["get", "set", "delete", "list"], description: "get: read secret (masked), set: store secret, delete: remove, list: search" },
744
- service: { type: "string", description: "Service name (e.g., 'google-ai-key', 'my-api-token')" },
745
- account: { type: "string", description: "Account name (default: 'wispy')" },
746
- value: { type: "string", description: "Secret value (only for 'set' action)" },
747
- },
748
- required: ["action", "service"],
749
- },
750
- },
751
- {
752
- name: "clipboard",
753
- description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
754
- parameters: {
755
- type: "object",
756
- properties: {
757
- action: { type: "string", enum: ["copy", "paste"], description: "copy: write to clipboard, paste: read from clipboard" },
758
- text: { type: "string", description: "Text to copy (only for copy action)" },
759
- },
760
- required: ["action"],
761
- },
762
- },
763
- {
764
- name: "spawn_agent",
765
- description: "Spawn a sub-agent for a well-scoped task. Use for sidecar tasks that can run in parallel. Do NOT spawn for the immediate blocking step — do that yourself. Each agent gets its own context. Prefer concrete, bounded tasks with clear deliverables.",
766
- parameters: {
767
- type: "object",
768
- properties: {
769
- task: { type: "string", description: "Concrete task description for the sub-agent" },
770
- role: {
771
- type: "string",
772
- enum: ["explorer", "planner", "worker", "reviewer"],
773
- description: "explorer=codebase search, planner=strategy design, worker=implementation, reviewer=code review/QA",
774
- },
775
- model_tier: {
776
- type: "string",
777
- enum: ["cheap", "mid", "expensive"],
778
- description: "cheap for simple tasks, mid for coding, expensive for critical analysis. Default: auto based on role",
779
- },
780
- fork_context: { type: "boolean", description: "If true, copy current conversation context to the sub-agent" },
781
- },
782
- required: ["task", "role"],
783
- },
784
- },
785
- {
786
- name: "list_agents",
787
- description: "List all running/completed sub-agents and their status",
788
- parameters: { type: "object", properties: {}, required: [] },
789
- },
790
- {
791
- name: "get_agent_result",
792
- description: "Get the result from a completed sub-agent",
793
- parameters: {
794
- type: "object",
795
- properties: {
796
- agent_id: { type: "string", description: "ID of the sub-agent" },
797
- },
798
- required: ["agent_id"],
799
- },
800
- },
801
- {
802
- name: "update_plan",
803
- description: "Create or update a step-by-step plan for the current task. Use to track progress.",
804
- parameters: {
805
- type: "object",
806
- properties: {
807
- explanation: { type: "string", description: "Brief explanation of the plan" },
808
- steps: {
809
- type: "array",
810
- items: {
811
- type: "object",
812
- properties: {
813
- step: { type: "string" },
814
- status: { type: "string", enum: ["pending", "in_progress", "completed", "skipped"] },
815
- },
816
- },
817
- description: "List of plan steps with status",
818
- },
819
- },
820
- required: ["steps"],
821
- },
822
- },
823
- {
824
- name: "pipeline",
825
- description: "Run a sequential pipeline of agent roles. Each stage's output feeds into the next. Example: explore→planner→worker→reviewer. Use for complex multi-step tasks that need different specialists in sequence.",
826
- parameters: {
827
- type: "object",
828
- properties: {
829
- task: { type: "string", description: "The overall task to accomplish" },
830
- stages: {
831
- type: "array",
832
- items: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] },
833
- description: "Ordered list of agent roles to chain",
834
- },
835
- },
836
- required: ["task", "stages"],
837
- },
838
- },
839
- {
840
- name: "spawn_async_agent",
841
- description: "Spawn a sub-agent that runs in the background. Returns immediately with an agent_id. Check results later with get_agent_result. Use for sidecar tasks while you continue working on the main task.",
842
- parameters: {
843
- type: "object",
844
- properties: {
845
- task: { type: "string", description: "Task for the background agent" },
846
- role: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"], description: "Agent role" },
847
- },
848
- required: ["task", "role"],
849
- },
850
- },
851
- {
852
- name: "ralph_loop",
853
- description: "Persistence mode — keep retrying a task until it's verified complete. The worker agent executes, then a reviewer verifies. If not done, worker tries again. Max 5 iterations. Use for tasks that MUST be completed correctly.",
854
- parameters: {
855
- type: "object",
856
- properties: {
857
- task: { type: "string", description: "Task that must be completed" },
858
- success_criteria: { type: "string", description: "How to verify the task is truly done" },
859
- },
860
- required: ["task"],
861
- },
862
- },
863
- ];
864
-
865
- // ---------------------------------------------------------------------------
866
- // Tool execution
867
- // ---------------------------------------------------------------------------
868
-
869
- // Try server API first, fallback to local execution
870
- async function executeToolViaServer(name, args) {
871
- try {
872
- const serverUrl = `http://127.0.0.1:${DEFAULT_SERVER_PORT}`;
873
-
874
- if (name === "read_file") {
875
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
876
- method: "POST",
877
- headers: { "Content-Type": "application/json" },
878
- body: JSON.stringify({ subAction: "read_file", path: args.path }),
879
- signal: AbortSignal.timeout(10_000),
880
- });
881
- const data = await resp.json();
882
- if (data.success) return { success: true, content: data.data?.slice(0, 10_000) ?? "" };
883
- // Fallback to local if server rejects path
884
- return null;
885
- }
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
- if (name === "write_file") {
888
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
889
- method: "POST",
890
- headers: { "Content-Type": "application/json" },
891
- body: JSON.stringify({ subAction: "write_file", path: args.path, content: args.content }),
892
- signal: AbortSignal.timeout(10_000),
893
- });
894
- const data = await resp.json();
895
- if (data.success) return { success: true, message: `Written to ${args.path} (via server)` };
896
- return null;
897
- }
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
- if (name === "list_directory") {
900
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
901
- method: "POST",
902
- headers: { "Content-Type": "application/json" },
903
- body: JSON.stringify({ subAction: "list_dir", path: args.path || "." }),
904
- signal: AbortSignal.timeout(10_000),
905
- });
906
- const data = await resp.json();
907
- if (data.success && data.entries) {
908
- const listing = data.entries.map(e => `${e.isDir ? "📁" : "📄"} ${e.name}`).join("\n");
909
- return { success: true, listing };
910
- }
911
- return null;
912
- }
913
- } catch {
914
- // Server not available, fallback to local
915
- return null;
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 null; // Not handled by server
149
+ return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
918
150
  }
919
151
 
920
- async function executeTool(name, args) {
921
- // Try server first (sandboxed execution)
922
- const serverResult = await executeToolViaServer(name, args);
923
- if (serverResult) return serverResult;
924
-
925
- const { execFile } = await import("node:child_process");
926
- const { promisify } = await import("node:util");
927
- const execAsync = promisify(execFile);
928
-
152
+ async function stopServer() {
929
153
  try {
930
- switch (name) {
931
- case "read_file": {
932
- const filePath = args.path.replace(/^~/, os.homedir());
933
- const content = await readFile(filePath, "utf8");
934
- // Truncate large files
935
- const truncated = content.length > 10_000
936
- ? content.slice(0, 10_000) + `\n\n... (truncated, ${content.length} chars total)`
937
- : content;
938
- return { success: true, content: truncated };
939
- }
940
-
941
- case "write_file": {
942
- args.path = args.path.replace(/^~/, os.homedir());
943
- const dir = path.dirname(args.path);
944
- await mkdir(dir, { recursive: true });
945
- await writeFile(args.path, args.content, "utf8");
946
- return { success: true, message: `Written ${args.content.length} chars to ${args.path}` };
947
- }
948
-
949
- case "run_command": {
950
- // Block direct keychain password reads via run_command — use keychain tool instead
951
- if (/security\s+find-generic-password.*-w/i.test(args.command)) {
952
- return { success: false, error: "Use the 'keychain' tool instead of run_command for secrets. It masks sensitive values." };
953
- }
954
- console.log(dim(` $ ${args.command}`));
955
- const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
956
- timeout: 30_000,
957
- maxBuffer: 1024 * 1024,
958
- cwd: process.cwd(),
959
- });
960
- const result = (stdout + (stderr ? `\nSTDERR: ${stderr}` : "")).trim();
961
- const truncated = result.length > 5_000
962
- ? result.slice(0, 5_000) + "\n... (truncated)"
963
- : result;
964
- return { success: true, output: truncated };
965
- }
966
-
967
- case "list_directory": {
968
- const { readdir } = await import("node:fs/promises");
969
- const targetPath = (args.path || ".").replace(/^~/, os.homedir());
970
- const entries = await readdir(targetPath, { withFileTypes: true });
971
- const list = entries.map(e => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`).join("\n");
972
- return { success: true, listing: list };
973
- }
974
-
975
- case "web_search": {
976
- const { promisify } = await import("node:util");
977
- const { execFile: ef } = await import("node:child_process");
978
- const execP = promisify(ef);
979
-
980
- // Try DuckDuckGo Lite first (lighter HTML, easier to parse)
981
- const encoded = encodeURIComponent(args.query);
982
- try {
983
- const { stdout: html } = await execP("/usr/bin/curl", [
984
- "-sL", "--max-time", "10",
985
- "-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
986
- `https://lite.duckduckgo.com/lite/?q=${encoded}`,
987
- ], { timeout: 15_000 });
988
-
989
- // Parse DuckDuckGo Lite results
990
- const snippets = [];
991
- // Match result links and snippets
992
- const linkRegex = /<a[^>]*class="result-link"[^>]*>(.*?)<\/a>/gs;
993
- const snippetRegex = /<td class="result-snippet">(.*?)<\/td>/gs;
994
-
995
- const links = [];
996
- let m;
997
- while ((m = linkRegex.exec(html)) !== null) links.push(m[1].replace(/<[^>]+>/g, "").trim());
998
-
999
- const snips = [];
1000
- while ((m = snippetRegex.exec(html)) !== null) snips.push(m[1].replace(/<[^>]+>/g, "").trim());
1001
-
1002
- for (let i = 0; i < Math.min(links.length, 5); i++) {
1003
- const snippet = snips[i] ? `${links[i]}\n${snips[i]}` : links[i];
1004
- if (snippet) snippets.push(snippet);
1005
- }
1006
-
1007
- if (snippets.length > 0) {
1008
- return { success: true, results: snippets.join("\n\n") };
1009
- }
1010
-
1011
- // Fallback: extract any text content from result cells
1012
- const cellRegex = /<td[^>]*>(.*?)<\/td>/gs;
1013
- const cells = [];
1014
- while ((m = cellRegex.exec(html)) !== null && cells.length < 10) {
1015
- const text = m[1].replace(/<[^>]+>/g, "").trim();
1016
- if (text.length > 20) cells.push(text);
1017
- }
1018
- if (cells.length > 0) {
1019
- return { success: true, results: cells.slice(0, 5).join("\n\n") };
1020
- }
1021
- } catch { /* fallback below */ }
1022
-
1023
- // Fallback: use run_command with curl to a simple search API
1024
- return {
1025
- success: true,
1026
- results: `Search for "${args.query}" — try using run_command with: curl -s "https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1"`,
1027
- };
1028
- }
1029
-
1030
- case "file_edit": {
1031
- const filePath = args.path.replace(/^~/, os.homedir());
1032
- try {
1033
- const content = await readFile(filePath, "utf8");
1034
- if (!content.includes(args.old_text)) {
1035
- return { success: false, error: `Text not found in ${filePath}` };
1036
- }
1037
- const newContent = content.replace(args.old_text, args.new_text);
1038
- await writeFile(filePath, newContent, "utf8");
1039
- return { success: true, message: `Edited ${filePath}: replaced ${args.old_text.length} chars` };
1040
- } catch (err) {
1041
- return { success: false, error: err.message };
1042
- }
1043
- }
1044
-
1045
- case "file_search": {
1046
- const { promisify: prom } = await import("node:util");
1047
- const { execFile: ef2 } = await import("node:child_process");
1048
- const exec2 = prom(ef2);
1049
- const searchPath = (args.path || ".").replace(/^~/, os.homedir());
1050
- const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
1051
- try {
1052
- const { stdout } = await exec2("/bin/bash", ["-c",
1053
- `grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
1054
- ], { timeout: 10_000 });
1055
- const lines = stdout.trim().split("\n").filter(Boolean);
1056
- return { success: true, matches: lines.length, results: stdout.trim().slice(0, 5000) };
1057
- } catch {
1058
- return { success: true, matches: 0, results: "No matches found." };
1059
- }
1060
- }
1061
-
1062
- case "git": {
1063
- const { promisify: prom } = await import("node:util");
1064
- const { execFile: ef2 } = await import("node:child_process");
1065
- const exec2 = prom(ef2);
1066
- console.log(dim(` $ git ${args.command}`));
1067
- try {
1068
- const { stdout, stderr } = await exec2("/bin/bash", ["-c", `git ${args.command}`], {
1069
- timeout: 15_000, cwd: process.cwd(),
1070
- });
1071
- return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
1072
- } catch (err) {
1073
- return { success: false, error: err.stderr?.trim() || err.message };
1074
- }
1075
- }
1076
-
1077
- case "web_fetch": {
1078
- try {
1079
- const resp = await fetch(args.url, {
1080
- headers: { "User-Agent": "Wispy/0.2" },
1081
- signal: AbortSignal.timeout(15_000),
1082
- });
1083
- const contentType = resp.headers.get("content-type") ?? "";
1084
- const text = await resp.text();
1085
- // Basic HTML → text conversion
1086
- const cleaned = contentType.includes("html")
1087
- ? text.replace(/<script[\s\S]*?<\/script>/gi, "")
1088
- .replace(/<style[\s\S]*?<\/style>/gi, "")
1089
- .replace(/<[^>]+>/g, " ")
1090
- .replace(/\s+/g, " ")
1091
- .trim()
1092
- : text;
1093
- return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
1094
- } catch (err) {
1095
- return { success: false, error: err.message };
1096
- }
1097
- }
1098
-
1099
- case "keychain": {
1100
- const { promisify: prom } = await import("node:util");
1101
- const { execFile: ef2 } = await import("node:child_process");
1102
- const exec2 = prom(ef2);
1103
- const account = args.account ?? "wispy";
1104
-
1105
- if (process.platform !== "darwin") {
1106
- return { success: false, error: "Keychain is only supported on macOS" };
1107
- }
1108
-
1109
- if (args.action === "get") {
1110
- try {
1111
- const { stdout } = await exec2("security", [
1112
- "find-generic-password", "-s", args.service, "-a", account, "-w"
1113
- ], { timeout: 5000 });
1114
- const val = stdout.trim();
1115
- // NEVER expose full secret — mask middle
1116
- const masked = val.length > 8
1117
- ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 8, 20))}${val.slice(-4)}`
1118
- : "****";
1119
- return { success: true, service: args.service, account, value_masked: masked, length: val.length };
1120
- } catch {
1121
- return { success: false, error: `No keychain entry found for service="${args.service}" account="${account}"` };
1122
- }
1123
- }
1124
-
1125
- if (args.action === "set") {
1126
- if (!args.value) return { success: false, error: "value is required for set action" };
1127
- try {
1128
- // Delete existing first (ignore error if not found)
1129
- await exec2("security", [
1130
- "delete-generic-password", "-s", args.service, "-a", account
1131
- ]).catch(() => {});
1132
- await exec2("security", [
1133
- "add-generic-password", "-s", args.service, "-a", account, "-w", args.value
1134
- ], { timeout: 5000 });
1135
- return { success: true, message: `Stored secret for service="${args.service}" account="${account}"` };
1136
- } catch (err) {
1137
- return { success: false, error: err.message };
1138
- }
1139
- }
1140
-
1141
- if (args.action === "delete") {
1142
- try {
1143
- await exec2("security", [
1144
- "delete-generic-password", "-s", args.service, "-a", account
1145
- ], { timeout: 5000 });
1146
- return { success: true, message: `Deleted keychain entry for service="${args.service}"` };
1147
- } catch {
1148
- return { success: false, error: `No entry found for service="${args.service}"` };
1149
- }
1150
- }
1151
-
1152
- if (args.action === "list") {
1153
- try {
1154
- const { stdout } = await exec2("/bin/bash", ["-c",
1155
- `security dump-keychain 2>/dev/null | grep -A 4 "\"svce\"" | grep -E "svce|acct" | head -20`
1156
- ], { timeout: 5000 });
1157
- return { success: true, entries: stdout.trim() || "No entries found" };
1158
- } catch {
1159
- return { success: true, entries: "No entries found" };
1160
- }
1161
- }
1162
-
1163
- return { success: false, error: "action must be get, set, delete, or list" };
1164
- }
1165
-
1166
- case "clipboard": {
1167
- const { promisify: prom } = await import("node:util");
1168
- const { execFile: ef2 } = await import("node:child_process");
1169
- const exec2 = prom(ef2);
1170
- if (args.action === "copy") {
1171
- const { exec: execCb } = await import("node:child_process");
1172
- const execP = prom(execCb);
1173
- try {
1174
- // macOS: pbcopy, Linux: xclip
1175
- const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
1176
- await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
1177
- return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
1178
- } catch (err) {
1179
- return { success: false, error: err.message };
1180
- }
1181
- }
1182
- if (args.action === "paste") {
1183
- try {
1184
- const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
1185
- const { stdout } = await exec2("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
1186
- return { success: true, content: stdout.slice(0, 5000) };
1187
- } catch (err) {
1188
- return { success: false, error: err.message };
1189
- }
1190
- }
1191
- return { success: false, error: "action must be 'copy' or 'paste'" };
1192
- }
1193
-
1194
- case "spawn_agent": {
1195
- const role = args.role ?? "worker";
1196
- const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
1197
- const tier = args.model_tier ?? tierMap[role] ?? "mid";
1198
- const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : tier === "expensive" ? "critical" : "complex"];
1199
- const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
1200
-
1201
- const agentId = `agent-${Date.now().toString(36)}-${role}`;
1202
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1203
- let agents = [];
1204
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1205
-
1206
- const agent = {
1207
- id: agentId, role, task: args.task, model: agentModel,
1208
- status: "running", createdAt: new Date().toISOString(),
1209
- workstream: ACTIVE_WORKSTREAM, result: null,
1210
- };
1211
-
1212
- console.log(dim(` 🤖 Spawning ${role} agent (${agentModel})...`));
1213
-
1214
- // Run sub-agent — single-turn LLM call with the task
1215
- try {
1216
- const agentSystemPrompt = `You are a ${role} sub-agent for Wispy. Your role:
1217
- ${role === "explorer" ? "Search and analyze codebases, find relevant files and patterns." : ""}
1218
- ${role === "planner" ? "Design implementation strategies and create step-by-step plans." : ""}
1219
- ${role === "worker" ? "Implement code changes, write files, execute commands." : ""}
1220
- ${role === "reviewer" ? "Review code for bugs, security issues, and best practices." : ""}
1221
- Be concise and deliver actionable results. Respond in the same language as the task.`;
1222
-
1223
- const agentMessages = [
1224
- { role: "system", content: agentSystemPrompt },
1225
- ];
1226
-
1227
- // Fork context if requested
1228
- if (args.fork_context) {
1229
- const parentContext = await loadConversation();
1230
- const recentContext = parentContext.filter(m => m.role === "user" || m.role === "assistant").slice(-6);
1231
- for (const m of recentContext) {
1232
- agentMessages.push({ role: m.role, content: m.content });
1233
- }
1234
- }
1235
-
1236
- agentMessages.push({ role: "user", content: args.task });
1237
-
1238
- const agentResult = await chatWithTools(agentMessages, null);
1239
- agent.result = agentResult.type === "text" ? agentResult.text : JSON.stringify(agentResult);
1240
- agent.status = "completed";
1241
- agent.completedAt = new Date().toISOString();
1242
- } catch (err) {
1243
- agent.result = `Error: ${err.message}`;
1244
- agent.status = "failed";
1245
- }
1246
-
1247
- agents.push(agent);
1248
- // Keep last 50 agents
1249
- if (agents.length > 50) agents = agents.slice(-50);
1250
- await mkdir(WISPY_DIR, { recursive: true });
1251
- await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
1252
-
1253
- return {
1254
- success: true,
1255
- agent_id: agentId,
1256
- role,
1257
- model: agentModel,
1258
- status: agent.status,
1259
- result_preview: agent.result?.slice(0, 200),
1260
- };
1261
- }
1262
-
1263
- case "list_agents": {
1264
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1265
- let agents = [];
1266
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1267
- const wsAgents = agents.filter(a => a.workstream === ACTIVE_WORKSTREAM);
1268
- return {
1269
- success: true,
1270
- agents: wsAgents.map(a => ({
1271
- id: a.id, role: a.role, status: a.status,
1272
- task: a.task.slice(0, 60),
1273
- model: a.model,
1274
- createdAt: a.createdAt,
1275
- })),
1276
- };
1277
- }
1278
-
1279
- case "get_agent_result": {
1280
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1281
- let agents = [];
1282
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1283
- const found = agents.find(a => a.id === args.agent_id);
1284
- if (!found) return { success: false, error: `Agent not found: ${args.agent_id}` };
1285
- return { success: true, id: found.id, role: found.role, status: found.status, result: found.result };
1286
- }
1287
-
1288
- case "update_plan": {
1289
- const planFile = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.plan.json`);
1290
- const plan = { explanation: args.explanation, steps: args.steps, updatedAt: new Date().toISOString() };
1291
- await mkdir(CONVERSATIONS_DIR, { recursive: true });
1292
- await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
1293
- // Pretty print plan
1294
- if (args.steps) {
1295
- for (const s of args.steps) {
1296
- const icon = s.status === "completed" ? "✅" : s.status === "in_progress" ? "🔄" : s.status === "skipped" ? "⏭️" : "⬜";
1297
- console.log(dim(` ${icon} ${s.step}`));
1298
- }
1299
- }
1300
- return { success: true, message: "Plan updated" };
1301
- }
1302
-
1303
- case "pipeline": {
1304
- const stages = args.stages ?? ["explorer", "planner", "worker"];
1305
- let stageInput = args.task;
1306
- const results = [];
1307
-
1308
- console.log(dim(` 📋 Pipeline: ${stages.join(" → ")}`));
1309
-
1310
- for (let i = 0; i < stages.length; i++) {
1311
- const role = stages[i];
1312
- const icon = { explorer: "🔍", planner: "📋", worker: "🔨", reviewer: "🔎" }[role] ?? "🤖";
1313
- console.log(dim(`\n ${icon} Stage ${i + 1}/${stages.length}: ${role}`));
1314
-
1315
- // Build stage prompt with previous stage output
1316
- const stagePrompt = i === 0
1317
- ? stageInput
1318
- : `Previous stage (${stages[i-1]}) output:\n${results[i-1].slice(0, 3000)}\n\nYour task as ${role}: ${args.task}`;
1319
-
1320
- const stageSystem = `You are a ${role} agent in a pipeline. Stage ${i + 1} of ${stages.length}.
1321
- ${role === "explorer" ? "Find relevant files, patterns, and information." : ""}
1322
- ${role === "planner" ? "Design a concrete implementation plan based on the exploration results." : ""}
1323
- ${role === "worker" ? "Implement the plan. Write code, create files, run commands." : ""}
1324
- ${role === "reviewer" ? "Review the implementation. Check for bugs, security issues, completeness." : ""}
1325
- Be concise. Your output feeds into the next stage.`;
1326
-
1327
- const stageMessages = [
1328
- { role: "system", content: stageSystem },
1329
- { role: "user", content: stagePrompt },
1330
- ];
1331
-
1332
- try {
1333
- const result = await chatWithTools(stageMessages, null);
1334
- const output = result.type === "text" ? result.text : JSON.stringify(result);
1335
- results.push(output);
1336
- console.log(dim(` ✅ ${output.slice(0, 100)}...`));
1337
- stageInput = output;
1338
- } catch (err) {
1339
- results.push(`Error: ${err.message}`);
1340
- console.log(red(` ❌ ${err.message.slice(0, 100)}`));
1341
- break;
1342
- }
1343
- }
1344
-
1345
- return {
1346
- success: true,
1347
- stages: stages.map((role, i) => ({ role, output: results[i]?.slice(0, 500) ?? "skipped" })),
1348
- final_output: results[results.length - 1]?.slice(0, 1000),
1349
- };
1350
- }
1351
-
1352
- case "spawn_async_agent": {
1353
- const role = args.role ?? "worker";
1354
- const agentId = `async-${Date.now().toString(36)}-${role}`;
1355
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1356
- let agents = [];
1357
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1358
-
1359
- const agent = {
1360
- id: agentId, role, task: args.task,
1361
- status: "running", async: true,
1362
- createdAt: new Date().toISOString(),
1363
- workstream: ACTIVE_WORKSTREAM, result: null,
1364
- };
1365
-
1366
- // Save as "running" immediately
1367
- agents.push(agent);
1368
- if (agents.length > 50) agents = agents.slice(-50);
1369
- await mkdir(WISPY_DIR, { recursive: true });
1370
- await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
1371
-
1372
- console.log(dim(` 🤖 Async agent ${agentId} launched in background`));
1373
-
1374
- // Fire and forget — run in background
1375
- (async () => {
1376
- const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
1377
- const tier = tierMap[role] ?? "mid";
1378
- const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : "complex"];
1379
- const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
1380
-
1381
- const agentSystem = `You are a ${role} sub-agent. Be concise and actionable.`;
1382
- const agentMessages = [
1383
- { role: "system", content: agentSystem },
1384
- { role: "user", content: args.task },
1385
- ];
1386
-
1387
- try {
1388
- const result = await chatWithTools(agentMessages, null);
1389
- agent.result = result.type === "text" ? result.text : JSON.stringify(result);
1390
- agent.status = "completed";
1391
- } catch (err) {
1392
- agent.result = `Error: ${err.message}`;
1393
- agent.status = "failed";
1394
- }
1395
- agent.completedAt = new Date().toISOString();
1396
-
1397
- // Update agents file
1398
- let currentAgents = [];
1399
- try { currentAgents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1400
- const idx = currentAgents.findIndex(a => a.id === agentId);
1401
- if (idx !== -1) currentAgents[idx] = agent;
1402
- await writeFile(agentsFile, JSON.stringify(currentAgents, null, 2) + "\n", "utf8");
1403
- })();
1404
-
1405
- return {
1406
- success: true,
1407
- agent_id: agentId,
1408
- role,
1409
- status: "running",
1410
- message: "Agent launched in background. Use get_agent_result to check when done.",
1411
- };
1412
- }
1413
-
1414
- case "ralph_loop": {
1415
- const MAX_ITERATIONS = 5;
1416
- const criteria = args.success_criteria ?? "Task is fully completed and verified";
1417
- let lastResult = "";
1418
-
1419
- console.log(dim(` 🪨 Ralph mode: will retry up to ${MAX_ITERATIONS} times until verified complete`));
1420
-
1421
- for (let attempt = 1; attempt <= MAX_ITERATIONS; attempt++) {
1422
- // Worker attempt
1423
- console.log(dim(`\n 🔨 Attempt ${attempt}/${MAX_ITERATIONS}: worker executing...`));
1424
-
1425
- const workerPrompt = attempt === 1
1426
- ? args.task
1427
- : `Previous attempt output:\n${lastResult.slice(0, 2000)}\n\nThe reviewer said this is NOT complete yet. Try again.\nTask: ${args.task}\nSuccess criteria: ${criteria}`;
1428
-
1429
- const workerMessages = [
1430
- { role: "system", content: "You are a worker agent. Execute the task thoroughly. Do not stop until the task is fully done." },
1431
- { role: "user", content: workerPrompt },
1432
- ];
1433
-
1434
- try {
1435
- const workerResult = await chatWithTools(workerMessages, null);
1436
- lastResult = workerResult.type === "text" ? workerResult.text : JSON.stringify(workerResult);
1437
- console.log(dim(` ✅ Worker output: ${lastResult.slice(0, 100)}...`));
1438
- } catch (err) {
1439
- console.log(red(` ❌ Worker error: ${err.message.slice(0, 100)}`));
1440
- continue;
1441
- }
1442
-
1443
- // Reviewer verification
1444
- console.log(dim(` 🔎 Reviewer verifying...`));
1445
-
1446
- const reviewerMessages = [
1447
- { role: "system", content: "You are a reviewer agent. Your ONLY job is to determine if the task is TRULY complete. Reply with JSON: {\"complete\": true/false, \"reason\": \"why\"}" },
1448
- { role: "user", content: `Task: ${args.task}\nSuccess criteria: ${criteria}\n\nWorker output:\n${lastResult.slice(0, 3000)}\n\nIs this task TRULY complete? Reply with JSON only.` },
1449
- ];
1450
-
1451
- try {
1452
- const reviewResult = await chatWithTools(reviewerMessages, null);
1453
- const reviewText = reviewResult.type === "text" ? reviewResult.text : "";
1454
-
1455
- // Try to parse JSON from review
1456
- const jsonMatch = reviewText.match(/\{[\s\S]*"complete"[\s\S]*\}/);
1457
- if (jsonMatch) {
1458
- try {
1459
- const verdict = JSON.parse(jsonMatch[0]);
1460
- if (verdict.complete) {
1461
- console.log(green(` ✅ Reviewer: COMPLETE — ${verdict.reason?.slice(0, 80) ?? "verified"}`));
1462
- return { success: true, iterations: attempt, result: lastResult, verified: true };
1463
- }
1464
- console.log(yellow(` ⏳ Reviewer: NOT COMPLETE — ${verdict.reason?.slice(0, 80) ?? "needs more work"}`));
1465
- } catch { /* parse failed, continue */ }
1466
- }
1467
- } catch (err) {
1468
- console.log(dim(` ⚠️ Review error: ${err.message.slice(0, 80)}`));
1469
- }
1470
- }
1471
-
1472
- // Max iterations reached
1473
- console.log(yellow(` 🪨 Ralph: max iterations (${MAX_ITERATIONS}) reached`));
1474
- return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
1475
- }
1476
-
1477
- default: {
1478
- // Check MCP tools first
1479
- if (mcpManager.hasTool(name)) {
1480
- try {
1481
- const result = await mcpManager.callTool(name, args);
1482
- // MCP tools/call returns { content: [{type, text}], isError? }
1483
- if (result?.isError) {
1484
- const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
1485
- return { success: false, error: errText };
1486
- }
1487
- const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
1488
- return { success: true, output };
1489
- } catch (err) {
1490
- return { success: false, error: `MCP tool error: ${err.message}` };
1491
- }
1492
- }
1493
-
1494
- // Unknown tool — try to execute as a skill via run_command
1495
- // This handles cases where the AI hallucinates tools from skill descriptions
1496
- const skills = await loadSkills();
1497
- const matchedSkill = skills.find(s => s.name.toLowerCase() === name.toLowerCase());
1498
- if (matchedSkill) {
1499
- return {
1500
- success: false,
1501
- error: `"${name}" is a skill, not a tool. Use run_command to execute commands from the ${name} skill guide. Example from the skill: look for curl/bash commands in the skill description.`,
1502
- skill_hint: matchedSkill.body.slice(0, 500),
1503
- };
1504
- }
1505
- return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result, and MCP tools (see /mcp list)` };
1506
- }
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 (err) {
1509
- return { success: false, error: err.message };
1510
- }
161
+ } catch {}
1511
162
  }
1512
163
 
1513
164
  // ---------------------------------------------------------------------------
1514
- // System prompt builder
1515
- // ---------------------------------------------------------------------------
1516
-
1517
- // ---------------------------------------------------------------------------
1518
- // work.md — per-workstream context file
165
+ // Onboarding (first-run setup)
1519
166
  // ---------------------------------------------------------------------------
1520
167
 
1521
- async function loadWorkMd() {
1522
- const searchPaths = [
1523
- path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`),
1524
- path.resolve(`.wispy/${ACTIVE_WORKSTREAM}.work.md`),
1525
- path.resolve(`work.md`), // project root fallback
1526
- ];
1527
- for (const p of searchPaths) {
1528
- const content = await readFileOr(p);
1529
- if (content) return { path: p, content: content.slice(0, 20_000) };
1530
- }
1531
- return null;
1532
- }
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
- // Skill loader — loads SKILL.md files from multiple sources
1536
- // Compatible with OpenClaw and Claude Code skill formats
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
- async function loadSkills() {
1540
- const skillDirs = [
1541
- // OpenClaw built-in skills
1542
- "/opt/homebrew/lib/node_modules/openclaw/skills",
1543
- // OpenClaw user skills
1544
- path.join(os.homedir(), ".openclaw", "workspace", "skills"),
1545
- // Wispy skills
1546
- path.join(WISPY_DIR, "skills"),
1547
- // Project-local skills
1548
- path.resolve(".wispy", "skills"),
1549
- // Claude Code skills (if installed)
1550
- path.join(os.homedir(), ".claude", "skills"),
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 entries = await rd(dir);
1559
- for (const entry of entries) {
1560
- const skillMdPath = path.join(dir, entry, "SKILL.md");
1561
- try {
1562
- const content = await readFile(skillMdPath, "utf8");
1563
- // Parse frontmatter
1564
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1565
- let name = entry;
1566
- let description = "";
1567
- let body = content;
1568
-
1569
- if (fmMatch) {
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
- const text = choice.message?.content ?? "";
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
- const inputText = anthropicMessages.map(m => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join("");
1906
- sessionTokens.input += estimateTokens(systemPrompt + inputText);
1907
-
1908
- const anthropicTools = getAllToolDefinitions().map(t => ({
1909
- name: t.name,
1910
- description: t.description,
1911
- input_schema: t.parameters,
1912
- }));
1913
-
1914
- const response = await fetch("https://api.anthropic.com/v1/messages", {
1915
- method: "POST",
1916
- headers: {
1917
- "Content-Type": "application/json",
1918
- "x-api-key": API_KEY,
1919
- "anthropic-version": "2023-06-01",
1920
- },
1921
- body: JSON.stringify({
1922
- model: MODEL,
1923
- max_tokens: 4096,
1924
- system: systemPrompt,
1925
- messages: anthropicMessages,
1926
- tools: anthropicTools,
1927
- stream: true,
1928
- }),
1929
- });
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
- if (!response.ok) {
1932
- const err = await response.text();
1933
- throw new Error(`Anthropic API error ${response.status}: ${err.slice(0, 300)}`);
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
- // Parse SSE stream
1937
- const reader = response.body.getReader();
1938
- const decoder = new TextDecoder();
1939
- let buffer = "";
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
- try {
1959
- const event = JSON.parse(data);
1960
-
1961
- if (event.type === "content_block_start") {
1962
- if (event.content_block?.type === "tool_use") {
1963
- currentToolCall = { id: event.content_block.id, name: event.content_block.name, args: {} };
1964
- currentToolInput = "";
1965
- }
1966
- } else if (event.type === "content_block_delta") {
1967
- if (event.delta?.type === "text_delta") {
1968
- fullText += event.delta.text;
1969
- onChunk?.(event.delta.text);
1970
- } else if (event.delta?.type === "input_json_delta") {
1971
- currentToolInput += event.delta.partial_json ?? "";
1972
- }
1973
- } else if (event.type === "content_block_stop") {
1974
- if (currentToolCall) {
1975
- try { currentToolCall.args = JSON.parse(currentToolInput); } catch { currentToolCall.args = {}; }
1976
- toolCalls.push(currentToolCall);
1977
- currentToolCall = null;
1978
- currentToolInput = "";
1979
- }
1980
- }
1981
- } catch { /* skip */ }
1982
- }
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
- sessionTokens.output += estimateTokens(fullText + JSON.stringify(toolCalls));
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
- if (toolCalls.length > 0) {
1988
- return { type: "tool_calls", calls: toolCalls };
1989
- }
1990
- return { type: "text", text: fullText };
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
- // Agentic loop — handles tool calls iteratively
262
+ // Director mode
1995
263
  // ---------------------------------------------------------------------------
1996
264
 
1997
- async function agentLoop(messages, onChunk) {
1998
- const MAX_TOOL_ROUNDS = 10;
1999
-
2000
- // Optimize context window before sending
2001
- const lastUserMsg = messages.filter(m => m.role === "user").pop();
2002
- const optimizedMessages = optimizeContext(messages);
2003
- if (optimizedMessages.length < messages.length) {
2004
- messages.length = 0;
2005
- messages.push(...optimizedMessages);
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
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
2009
- // Check budget before calling API
2010
- const budgetCheck = await loadBudgets();
2011
- const wsBudget = budgetCheck[ACTIVE_WORKSTREAM];
2012
- if (wsBudget?.limitUsd !== null && wsBudget?.spentUsd > wsBudget?.limitUsd) {
2013
- return `⚠️ Budget exceeded for workstream "${ACTIVE_WORKSTREAM}" ($${wsBudget.spentUsd.toFixed(4)} / $${wsBudget.limitUsd.toFixed(4)}). Use /budget to adjust.`;
2014
- }
2015
-
2016
- const result = await chatWithTools(messages, onChunk);
2017
-
2018
- if (result.type === "text") {
2019
- // Track spending for this workstream
2020
- await trackSpending(ACTIVE_WORKSTREAM, sessionTokens.input, sessionTokens.output, MODEL);
2021
- return result.text;
2022
- }
2023
-
2024
- // Handle tool calls
2025
- console.log(""); // newline before tool output
2026
- const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
2027
- messages.push(toolCallMsg);
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 commands
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("/mcp")} [list|connect|disconnect|config|reload] MCP server management
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
- process.env.WISPY_MODEL = parts[1];
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: ${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
- // Ask the AI to summarize, then replace history with summary
2117
- const summaryMessages = [
2118
- { role: "system", content: "Summarize the following conversation in 3-5 bullet points. Be concise." },
2119
- ...conversation.filter(m => m.role !== "system").slice(-20),
2120
- { role: "user", content: "Summarize our conversation so far." },
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
- console.log(dim("No workstreams yet."));
2145
- } else {
2146
- console.log(bold("\n📋 Workstreams:\n"));
2147
- for (const ws of wsList) {
2148
- const marker = ws === ACTIVE_WORKSTREAM ? green("") : " ";
2149
- // Show last message preview
2150
- const wsConv = await loadWorkstreamConversation(ws);
2151
- const lastMsg = wsConv.filter(m => m.role === "user").pop();
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
- console.log(dim("No sessions yet."));
2283
- } else {
2284
- console.log(bold("\n📂 Sessions:\n"));
2285
- for (const ws of wsList) {
2286
- const conv = await loadWorkstreamConversation(ws);
2287
- const msgs = conv.filter(m => m.role === "user").length;
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 wsPath = path.join(CONVERSATIONS_DIR, `${target}.json`);
429
+ const { unlink } = await import("node:fs/promises");
2300
430
  try {
2301
- const { unlink } = await import("node:fs/promises");
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 === "/export") {
2314
- const conv = await loadConversation();
2315
- const userAssistant = conv.filter(m => m.role === "user" || m.role === "assistant");
2316
- if (userAssistant.length === 0) { console.log(dim("Nothing to export.")); return true; }
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
- if (format === "clipboard" || format === "copy") {
2325
- const { execSync: es } = await import("node:child_process");
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 text = lines.join("\n\n");
2328
- es(`echo "${text.replace(/"/g, '\\"').slice(0, 50000)}" | pbcopy`);
2329
- console.log(green(`📋 Copied ${userAssistant.length} messages to clipboard`));
2330
- } catch { console.log(red("Clipboard copy failed.")); }
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
- const exportPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.export.md`);
2333
- await writeFile(exportPath, `# ${ACTIVE_WORKSTREAM}\n\n${lines.join("\n\n")}\n`, "utf8");
2334
- console.log(green(`📄 Exported to ${exportPath}`));
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
- if (cmd === "/provider") {
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
- const toolList = s.tools.length > 0
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
- try {
2425
- const cfg = await mcpManager.loadConfig();
2426
- const servers = cfg.mcpServers ?? {};
2427
- console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
2428
- for (const [name, s] of Object.entries(servers)) {
2429
- const status = s.disabled ? dim("disabled") : green("enabled");
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
- // Unknown subcommand
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(`· ${MODEL}`)}
2476
- ${dim(`${providerLabel} · /help for commands · Ctrl+C to exit`)}
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
- const response = await agentLoop(conversation, (chunk) => {
2515
- process.stdout.write(chunk);
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 agentLoop(conversation, (chunk) => {
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; } // skip flag + value
666
+ if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
2682
667
  args.push(rawArgs[i]);
2683
668
  }
2684
669
 
2685
- // Route to legacy CLI for operator commands
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
- console.log(green(`🌿 Server started on port ${status.port} (PID: ${status.pid})`));
2715
- } else if (status.noBinary) {
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
- stdio: "inherit",
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
- // Check API key — if none found, run interactive onboarding
2742
- if (!API_KEY && PROVIDER !== "ollama") {
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
- // Re-detect after onboarding saved config
2745
- const redetected = await detectProvider();
2746
- if (redetected) {
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
- // Auto-connect MCP servers from ~/.wispy/mcp.json
2762
- await ensureDefaultMcpConfig(MCP_CONFIG_PATH);
2763
- {
2764
- const mcpResults = await mcpManager.autoConnect();
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 before entering REPL or one-shot
724
+ // Auto-start background server
2784
725
  const serverStatus = await startServerIfNeeded();
2785
- if (serverStatus.started) {
2786
- if (serverStatus.slow) {
2787
- console.log(yellow(`⚠ Server starting on port ${serverStatus.port} (may take a moment)...`));
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
- // Server runs as a background daemon survives CLI exit.
2796
- // Use `wispy server stop` to stop it explicitly.
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] === "overview" || args[0] === "dashboard") {
2799
- await showOverview();
2800
- process.exit(0);
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 -w <name> "msg" Workstream + message
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 session token usage
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 — all workstreams at a glance
2867
- /search <keyword> Search across all workstreams
2868
- /skills List installed skills (OpenClaw/Claude compatible)
755
+ /overview Director view
756
+ /search <keyword> Search across workstreams
757
+ /skills List installed skills
2869
758
  /sessions List all sessions
2870
- /delete <name> Delete a session
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
- // Interactive REPL
2892
- await runRepl();
777
+ await runRepl(engine);
2893
778
  }