wispy-cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,279 +2,31 @@
2
2
 
3
3
  /**
4
4
  * wispy — interactive AI assistant REPL
5
+ * v0.7: thin wrapper around core/engine.mjs
5
6
  *
6
7
  * Usage:
7
8
  * wispy Start interactive session
8
9
  * wispy "message" One-shot message
9
10
  * wispy home <subcommand> Operator commands (legacy CLI)
10
- *
11
- * Requires: OPENAI_API_KEY in env
12
11
  */
13
12
 
14
13
  import os from "node:os";
15
14
  import path from "node:path";
16
15
  import { createInterface } from "node:readline";
17
16
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
18
- import { 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
+ MemoryManager,
29
+ } from "../core/index.mjs";
278
30
 
279
31
  // ---------------------------------------------------------------------------
280
32
  // Colors (minimal, no deps)
@@ -286,20 +38,13 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
286
38
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
287
39
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
288
40
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
289
- const magenta = (s) => `\x1b[35m${s}\x1b[0m`;
290
- const bgGreen = (s) => `\x1b[42m\x1b[30m${s}\x1b[0m`;
291
41
  const underline = (s) => `\x1b[4m${s}\x1b[0m`;
292
42
 
293
- function box(lines, { padding = 1, 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
43
+ function box(lines, { padding = 1 } = {}) {
44
+ const chars = { tl: "", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
299
45
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
300
46
  const maxW = Math.max(...lines.map(l => stripAnsi(l).length)) + padding * 2;
301
47
  const pad = " ".repeat(padding);
302
-
303
48
  const top = ` ${chars.tl}${chars.h.repeat(maxW)}${chars.tr}`;
304
49
  const bot = ` ${chars.bl}${chars.h.repeat(maxW)}${chars.br}`;
305
50
  const mid = lines.map(l => {
@@ -311,38 +56,18 @@ function box(lines, { padding = 1, border = "rounded" } = {}) {
311
56
  }
312
57
 
313
58
  // ---------------------------------------------------------------------------
314
- // File helpers
59
+ // Workstream helpers (legacy conversation storage for backward compat)
315
60
  // ---------------------------------------------------------------------------
316
61
 
62
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
63
+ const ACTIVE_WORKSTREAM = process.env.WISPY_WORKSTREAM ??
64
+ process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream")) ?? "default";
65
+ const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
66
+
317
67
  async function readFileOr(filePath, fallback = null) {
318
68
  try { return await readFile(filePath, "utf8"); } catch { return fallback; }
319
69
  }
320
70
 
321
- async function loadWispyMd() {
322
- const paths = [
323
- path.resolve("WISPY.md"),
324
- path.resolve(".wispy", "WISPY.md"),
325
- path.join(WISPY_DIR, "WISPY.md"),
326
- ];
327
- for (const p of paths) {
328
- const content = await readFileOr(p);
329
- if (content) return content.slice(0, MAX_CONTEXT_CHARS);
330
- }
331
- return null;
332
- }
333
-
334
- async function loadMemories() {
335
- const types = ["user", "feedback", "project", "references"];
336
- const sections = [];
337
- for (const type of types) {
338
- const content = await readFileOr(path.join(MEMORY_DIR, `${type}.md`));
339
- if (content?.trim()) {
340
- sections.push(`## ${type} memory\n${content.trim()}`);
341
- }
342
- }
343
- return sections.length ? sections.join("\n\n") : null;
344
- }
345
-
346
71
  async function loadConversation() {
347
72
  const raw = await readFileOr(HISTORY_FILE);
348
73
  if (!raw) return [];
@@ -351,115 +76,24 @@ async function loadConversation() {
351
76
 
352
77
  async function saveConversation(messages) {
353
78
  await mkdir(CONVERSATIONS_DIR, { recursive: true });
354
- // 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");
79
+ await writeFile(HISTORY_FILE, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
357
80
  }
358
81
 
359
82
  async function listWorkstreams() {
360
83
  try {
361
84
  const { readdir } = await import("node:fs/promises");
362
85
  const files = await readdir(CONVERSATIONS_DIR);
363
- return files
364
- .filter(f => f.endsWith(".json"))
365
- .map(f => f.replace(".json", ""));
86
+ return files.filter(f => f.endsWith(".json")).map(f => f.replace(".json", ""));
366
87
  } catch { return []; }
367
88
  }
368
89
 
369
90
  async function loadWorkstreamConversation(wsName) {
370
91
  try {
371
92
  const wsPath = path.join(CONVERSATIONS_DIR, `${wsName}.json`);
372
- const raw = await readFile(wsPath, "utf8");
373
- return JSON.parse(raw);
93
+ return JSON.parse(await readFile(wsPath, "utf8"));
374
94
  } catch { return []; }
375
95
  }
376
96
 
377
- // ---------------------------------------------------------------------------
378
- // Director mode — overview across all workstreams
379
- // ---------------------------------------------------------------------------
380
-
381
- async function showOverview() {
382
- const wsList = await listWorkstreams();
383
- if (wsList.length === 0) {
384
- console.log(dim("No workstreams yet. Start one: wispy -w <name> \"message\""));
385
- return;
386
- }
387
-
388
- console.log(`\n${bold("🌿 Wispy Director — All Workstreams")}\n`);
389
-
390
- let totalMsgs = 0;
391
- let totalToolCalls = 0;
392
- const summaries = [];
393
-
394
- for (const ws of wsList) {
395
- const conv = await loadWorkstreamConversation(ws);
396
- const userMsgs = conv.filter(m => m.role === "user");
397
- const assistantMsgs = conv.filter(m => m.role === "assistant");
398
- const toolResults = conv.filter(m => m.role === "tool_result");
399
- const lastUser = userMsgs[userMsgs.length - 1];
400
- const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
401
-
402
- totalMsgs += userMsgs.length;
403
- totalToolCalls += toolResults.length;
404
-
405
- const isActive = ws === ACTIVE_WORKSTREAM;
406
- const marker = isActive ? green("● ") : " ";
407
- const label = isActive ? green(ws) : ws;
408
-
409
- console.log(`${marker}${bold(label)}`);
410
- console.log(` Messages: ${userMsgs.length} user / ${assistantMsgs.length} assistant / ${toolResults.length} tool calls`);
411
- if (lastUser) {
412
- console.log(` Last request: ${dim(lastUser.content.slice(0, 60))}${lastUser.content.length > 60 ? "..." : ""}`);
413
- }
414
- if (lastAssistant) {
415
- console.log(` Last response: ${dim(lastAssistant.content.slice(0, 60))}${lastAssistant.content.length > 60 ? "..." : ""}`);
416
- }
417
- console.log("");
418
-
419
- summaries.push({ ws, userCount: userMsgs.length, toolCount: toolResults.length, lastMsg: lastUser?.content ?? "" });
420
- }
421
-
422
- console.log(dim(`─────────────────────────────────`));
423
- console.log(` ${bold("Total")}: ${wsList.length} workstreams, ${totalMsgs} messages, ${totalToolCalls} tool calls`);
424
- console.log(dim(` Active: ${ACTIVE_WORKSTREAM}`));
425
- console.log(dim(` Switch: wispy -w <name>`));
426
- console.log("");
427
- }
428
-
429
- async function searchAcrossWorkstreams(query) {
430
- const wsList = await listWorkstreams();
431
- const lowerQuery = query.toLowerCase();
432
- let totalMatches = 0;
433
-
434
- console.log(`\n${bold("🔍 Searching all workstreams for:")} ${cyan(query)}\n`);
435
-
436
- for (const ws of wsList) {
437
- const conv = await loadWorkstreamConversation(ws);
438
- const matches = conv.filter(m =>
439
- (m.role === "user" || m.role === "assistant") &&
440
- m.content?.toLowerCase().includes(lowerQuery)
441
- );
442
-
443
- if (matches.length > 0) {
444
- console.log(` ${bold(ws)} (${matches.length} matches):`);
445
- for (const m of matches.slice(-3)) { // Show last 3 matches
446
- const role = m.role === "user" ? "👤" : "🌿";
447
- const preview = m.content.slice(0, 80).replace(/\n/g, " ");
448
- console.log(` ${role} ${dim(preview)}${m.content.length > 80 ? "..." : ""}`);
449
- }
450
- console.log("");
451
- totalMatches += matches.length;
452
- }
453
- }
454
-
455
- if (totalMatches === 0) {
456
- console.log(dim(` No matches found for "${query}"`));
457
- } else {
458
- console.log(dim(` ${totalMatches} total matches across ${wsList.length} workstreams`));
459
- }
460
- console.log("");
461
- }
462
-
463
97
  async function appendToMemory(type, entry) {
464
98
  await mkdir(MEMORY_DIR, { recursive: true });
465
99
  const ts = new Date().toISOString().slice(0, 16);
@@ -467,1594 +101,212 @@ async function appendToMemory(type, entry) {
467
101
  }
468
102
 
469
103
  // ---------------------------------------------------------------------------
470
- // System prompt builder
471
- // ---------------------------------------------------------------------------
472
-
473
- // ---------------------------------------------------------------------------
474
- // Token / cost tracking
104
+ // Server management (background AWOS server)
475
105
  // ---------------------------------------------------------------------------
476
106
 
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
- }
107
+ const REPO_ROOT = process.env.WISPY_REPO_ROOT ?? path.resolve(SCRIPT_DIR, "..");
108
+ const SERVER_BINARY = process.env.WISPY_SERVER_BINARY
109
+ ?? (() => {
110
+ const candidates = [
111
+ path.join(os.homedir(), ".wispy", "bin", "awos-server"),
112
+ path.join(REPO_ROOT, "src-tauri", "target", "release", "awos-server"),
113
+ path.join(REPO_ROOT, "src-tauri", "target", "debug", "awos-server"),
114
+ ];
115
+ for (const c of candidates) {
116
+ try { if (statSync(c).isFile()) return c; } catch {}
117
+ }
118
+ return candidates[0];
119
+ })();
120
+ const SERVER_PID_FILE = path.join(WISPY_DIR, "server.pid");
121
+ const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
514
122
 
515
- function 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)})`;
123
+ async function isServerRunning() {
124
+ try {
125
+ const resp = await fetch(`http://127.0.0.1:${DEFAULT_SERVER_PORT}/api/health`, { signal: AbortSignal.timeout(2000) });
126
+ return resp.ok;
127
+ } catch { return false; }
519
128
  }
520
129
 
521
- // ---------------------------------------------------------------------------
522
- // 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";
130
+ async function startServerIfNeeded() {
131
+ if (await isServerRunning()) return { started: false, port: DEFAULT_SERVER_PORT };
132
+ try { const { stat } = await import("node:fs/promises"); await stat(SERVER_BINARY); }
133
+ catch { return { started: false, port: DEFAULT_SERVER_PORT, noBinary: true }; }
539
134
 
540
- // Complex: code writing, analysis, multi-step reasoning
541
- if (/write.*code|implement|analyze|compare|explain.*detail|create.*plan|build/i.test(lower)) return "complex";
135
+ const logFile = path.join(WISPY_DIR, "server.log");
136
+ await mkdir(WISPY_DIR, { recursive: true });
137
+ const { openSync } = await import("node:fs");
138
+ const logFd = openSync(logFile, "a");
139
+ const child = spawnProcess(SERVER_BINARY, [], {
140
+ cwd: REPO_ROOT, env: { ...process.env, AWOS_PORT: DEFAULT_SERVER_PORT },
141
+ detached: true, stdio: ["ignore", logFd, logFd],
142
+ });
143
+ child.unref();
144
+ await writeFile(SERVER_PID_FILE, String(child.pid), "utf8");
542
145
 
543
- // Simple: questions, formatting, translation, simple file ops
544
- return "simple";
146
+ for (let i = 0; i < 25; i++) {
147
+ await new Promise(r => setTimeout(r, 200));
148
+ if (await isServerRunning()) return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid };
149
+ }
150
+ return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
545
151
  }
546
152
 
547
- function 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;
153
+ async function stopServer() {
154
+ try {
155
+ const pidStr = await readFile(SERVER_PID_FILE, "utf8");
156
+ const pid = parseInt(pidStr.trim(), 10);
157
+ if (pid && !isNaN(pid)) {
158
+ process.kill(pid, "SIGTERM");
159
+ const { unlink } = await import("node:fs/promises");
160
+ await unlink(SERVER_PID_FILE).catch(() => {});
161
+ }
162
+ } catch {}
554
163
  }
555
164
 
556
165
  // ---------------------------------------------------------------------------
557
- // Budget management (per-workstream)
166
+ // Onboarding (first-run setup)
558
167
  // ---------------------------------------------------------------------------
559
168
 
560
- const BUDGET_FILE = path.join(WISPY_DIR, "budgets.json");
169
+ async function runOnboarding() {
170
+ const { execSync } = await import("node:child_process");
171
+ console.log("");
172
+ console.log(box([
173
+ "", `${bold("🌿 W I S P Y")}`, "", `${dim("AI workspace assistant")}`, `${dim("with multi-agent orchestration")}`, "",
174
+ ]));
175
+ console.log("");
561
176
 
562
- async function loadBudgets() {
177
+ // Auto-detect Ollama
178
+ process.stdout.write(dim(" Checking environment..."));
563
179
  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
- // ---------------------------------------------------------------------------
180
+ const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
181
+ if (resp.ok) {
182
+ console.log(green(" found Ollama! ✓\n"));
183
+ const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
184
+ cfg.provider = "ollama";
185
+ await mkdir(WISPY_DIR, { recursive: true });
186
+ await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
187
+ process.env.OLLAMA_HOST = "http://localhost:11434";
188
+ return;
189
+ }
190
+ } catch {}
593
191
 
594
- 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()];
630
- }
631
-
632
- const TOOL_DEFINITIONS = [
633
- {
634
- name: "read_file",
635
- description: "Read the contents of a file at the given path",
636
- parameters: {
637
- type: "object",
638
- properties: {
639
- path: { type: "string", description: "File path to read" },
640
- },
641
- required: ["path"],
642
- },
643
- },
644
- {
645
- name: "write_file",
646
- description: "Write content to a file, creating it if it doesn't exist",
647
- parameters: {
648
- type: "object",
649
- properties: {
650
- path: { type: "string", description: "File path to write" },
651
- content: { type: "string", description: "Content to write" },
652
- },
653
- required: ["path", "content"],
654
- },
655
- },
656
- {
657
- name: "run_command",
658
- description: "Execute a shell command and return stdout/stderr",
659
- parameters: {
660
- type: "object",
661
- properties: {
662
- command: { type: "string", description: "Shell command to execute" },
663
- },
664
- required: ["command"],
665
- },
666
- },
667
- {
668
- name: "list_directory",
669
- description: "List files and directories at the given path",
670
- parameters: {
671
- type: "object",
672
- properties: {
673
- path: { type: "string", description: "Directory path (default: current dir)" },
674
- },
675
- required: [],
676
- },
677
- },
678
- {
679
- name: "web_search",
680
- description: "Search the web and return results",
681
- parameters: {
682
- type: "object",
683
- properties: {
684
- query: { type: "string", description: "Search query" },
685
- },
686
- required: ["query"],
687
- },
688
- },
689
- {
690
- name: "file_edit",
691
- description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
692
- parameters: {
693
- type: "object",
694
- properties: {
695
- path: { type: "string", description: "File path" },
696
- old_text: { type: "string", description: "Exact text to find and replace" },
697
- new_text: { type: "string", description: "Replacement text" },
698
- },
699
- required: ["path", "old_text", "new_text"],
700
- },
701
- },
702
- {
703
- name: "file_search",
704
- description: "Search for text patterns in files recursively (like grep). Returns matching lines with file paths and line numbers.",
705
- parameters: {
706
- type: "object",
707
- properties: {
708
- pattern: { type: "string", description: "Text or regex pattern to search for" },
709
- path: { type: "string", description: "Directory to search in (default: current dir)" },
710
- file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
711
- },
712
- required: ["pattern"],
713
- },
714
- },
715
- {
716
- name: "git",
717
- description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout. Use for version control tasks.",
718
- parameters: {
719
- type: "object",
720
- properties: {
721
- command: { type: "string", description: "Git subcommand and args (e.g., 'status', 'diff --cached', 'log --oneline -10', 'commit -m \"msg\"')" },
722
- },
723
- required: ["command"],
724
- },
725
- },
726
- {
727
- name: "web_fetch",
728
- description: "Fetch content from a URL and return it as text/markdown. Use to read web pages, docs, APIs.",
729
- parameters: {
730
- type: "object",
731
- properties: {
732
- url: { type: "string", description: "URL to fetch" },
733
- },
734
- required: ["url"],
735
- },
736
- },
737
- {
738
- name: "keychain",
739
- description: "Manage macOS Keychain secrets. Read (masked), store, or delete credentials. Values are never shown in full — only first 4 + last 4 chars.",
740
- parameters: {
741
- type: "object",
742
- properties: {
743
- action: { type: "string", enum: ["get", "set", "delete", "list"], description: "get: read secret (masked), set: store secret, delete: remove, list: search" },
744
- service: { type: "string", description: "Service name (e.g., 'google-ai-key', 'my-api-token')" },
745
- account: { type: "string", description: "Account name (default: 'wispy')" },
746
- value: { type: "string", description: "Secret value (only for 'set' action)" },
747
- },
748
- required: ["action", "service"],
749
- },
750
- },
751
- {
752
- name: "clipboard",
753
- description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
754
- parameters: {
755
- type: "object",
756
- properties: {
757
- action: { type: "string", enum: ["copy", "paste"], description: "copy: write to clipboard, paste: read from clipboard" },
758
- text: { type: "string", description: "Text to copy (only for copy action)" },
759
- },
760
- required: ["action"],
761
- },
762
- },
763
- {
764
- name: "spawn_agent",
765
- description: "Spawn a sub-agent for a well-scoped task. Use for sidecar tasks that can run in parallel. Do NOT spawn for the immediate blocking step — do that yourself. Each agent gets its own context. Prefer concrete, bounded tasks with clear deliverables.",
766
- parameters: {
767
- type: "object",
768
- properties: {
769
- task: { type: "string", description: "Concrete task description for the sub-agent" },
770
- role: {
771
- type: "string",
772
- enum: ["explorer", "planner", "worker", "reviewer"],
773
- description: "explorer=codebase search, planner=strategy design, worker=implementation, reviewer=code review/QA",
774
- },
775
- model_tier: {
776
- type: "string",
777
- enum: ["cheap", "mid", "expensive"],
778
- description: "cheap for simple tasks, mid for coding, expensive for critical analysis. Default: auto based on role",
779
- },
780
- fork_context: { type: "boolean", description: "If true, copy current conversation context to the sub-agent" },
781
- },
782
- required: ["task", "role"],
783
- },
784
- },
785
- {
786
- name: "list_agents",
787
- description: "List all running/completed sub-agents and their status",
788
- parameters: { type: "object", properties: {}, required: [] },
789
- },
790
- {
791
- name: "get_agent_result",
792
- description: "Get the result from a completed sub-agent",
793
- parameters: {
794
- type: "object",
795
- properties: {
796
- agent_id: { type: "string", description: "ID of the sub-agent" },
797
- },
798
- required: ["agent_id"],
799
- },
800
- },
801
- {
802
- name: "update_plan",
803
- description: "Create or update a step-by-step plan for the current task. Use to track progress.",
804
- parameters: {
805
- type: "object",
806
- properties: {
807
- explanation: { type: "string", description: "Brief explanation of the plan" },
808
- steps: {
809
- type: "array",
810
- items: {
811
- type: "object",
812
- properties: {
813
- step: { type: "string" },
814
- status: { type: "string", enum: ["pending", "in_progress", "completed", "skipped"] },
815
- },
816
- },
817
- description: "List of plan steps with status",
818
- },
819
- },
820
- required: ["steps"],
821
- },
822
- },
823
- {
824
- name: "pipeline",
825
- description: "Run a sequential pipeline of agent roles. Each stage's output feeds into the next. Example: explore→planner→worker→reviewer. Use for complex multi-step tasks that need different specialists in sequence.",
826
- parameters: {
827
- type: "object",
828
- properties: {
829
- task: { type: "string", description: "The overall task to accomplish" },
830
- stages: {
831
- type: "array",
832
- items: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] },
833
- description: "Ordered list of agent roles to chain",
834
- },
835
- },
836
- required: ["task", "stages"],
837
- },
838
- },
839
- {
840
- name: "spawn_async_agent",
841
- description: "Spawn a sub-agent that runs in the background. Returns immediately with an agent_id. Check results later with get_agent_result. Use for sidecar tasks while you continue working on the main task.",
842
- parameters: {
843
- type: "object",
844
- properties: {
845
- task: { type: "string", description: "Task for the background agent" },
846
- role: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"], description: "Agent role" },
847
- },
848
- required: ["task", "role"],
849
- },
850
- },
851
- {
852
- name: "ralph_loop",
853
- description: "Persistence mode — keep retrying a task until it's verified complete. The worker agent executes, then a reviewer verifies. If not done, worker tries again. Max 5 iterations. Use for tasks that MUST be completed correctly.",
854
- parameters: {
855
- type: "object",
856
- properties: {
857
- task: { type: "string", description: "Task that must be completed" },
858
- success_criteria: { type: "string", description: "How to verify the task is truly done" },
859
- },
860
- required: ["task"],
861
- },
862
- },
863
- ];
864
-
865
- // ---------------------------------------------------------------------------
866
- // Tool execution
867
- // ---------------------------------------------------------------------------
868
-
869
- // Try server API first, fallback to local execution
870
- async function executeToolViaServer(name, args) {
871
- try {
872
- const serverUrl = `http://127.0.0.1:${DEFAULT_SERVER_PORT}`;
873
-
874
- if (name === "read_file") {
875
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
876
- method: "POST",
877
- headers: { "Content-Type": "application/json" },
878
- body: JSON.stringify({ subAction: "read_file", path: args.path }),
879
- signal: AbortSignal.timeout(10_000),
880
- });
881
- const data = await resp.json();
882
- if (data.success) return { success: true, content: data.data?.slice(0, 10_000) ?? "" };
883
- // Fallback to local if server rejects path
884
- return null;
885
- }
886
-
887
- if (name === "write_file") {
888
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
889
- method: "POST",
890
- headers: { "Content-Type": "application/json" },
891
- body: JSON.stringify({ subAction: "write_file", path: args.path, content: args.content }),
892
- signal: AbortSignal.timeout(10_000),
893
- });
894
- const data = await resp.json();
895
- if (data.success) return { success: true, message: `Written to ${args.path} (via server)` };
896
- return null;
897
- }
898
-
899
- if (name === "list_directory") {
900
- const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
901
- method: "POST",
902
- headers: { "Content-Type": "application/json" },
903
- body: JSON.stringify({ subAction: "list_dir", path: args.path || "." }),
904
- signal: AbortSignal.timeout(10_000),
905
- });
906
- const data = await resp.json();
907
- if (data.success && data.entries) {
908
- const listing = data.entries.map(e => `${e.isDir ? "📁" : "📄"} ${e.name}`).join("\n");
909
- return { success: true, listing };
910
- }
911
- return null;
912
- }
913
- } catch {
914
- // Server not available, fallback to local
915
- return null;
916
- }
917
- return null; // Not handled by server
918
- }
919
-
920
- async function executeTool(name, args) {
921
- // Try server first (sandboxed execution)
922
- const serverResult = await executeToolViaServer(name, args);
923
- if (serverResult) return serverResult;
924
-
925
- const { execFile } = await import("node:child_process");
926
- const { promisify } = await import("node:util");
927
- const execAsync = promisify(execFile);
928
-
929
- try {
930
- switch (name) {
931
- case "read_file": {
932
- const filePath = args.path.replace(/^~/, os.homedir());
933
- const content = await readFile(filePath, "utf8");
934
- // Truncate large files
935
- const truncated = content.length > 10_000
936
- ? content.slice(0, 10_000) + `\n\n... (truncated, ${content.length} chars total)`
937
- : content;
938
- return { success: true, content: truncated };
939
- }
940
-
941
- case "write_file": {
942
- args.path = args.path.replace(/^~/, os.homedir());
943
- const dir = path.dirname(args.path);
944
- await mkdir(dir, { recursive: true });
945
- await writeFile(args.path, args.content, "utf8");
946
- return { success: true, message: `Written ${args.content.length} chars to ${args.path}` };
947
- }
948
-
949
- case "run_command": {
950
- // Block direct keychain password reads via run_command — use keychain tool instead
951
- if (/security\s+find-generic-password.*-w/i.test(args.command)) {
952
- return { success: false, error: "Use the 'keychain' tool instead of run_command for secrets. It masks sensitive values." };
953
- }
954
- console.log(dim(` $ ${args.command}`));
955
- const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
956
- timeout: 30_000,
957
- maxBuffer: 1024 * 1024,
958
- cwd: process.cwd(),
959
- });
960
- const result = (stdout + (stderr ? `\nSTDERR: ${stderr}` : "")).trim();
961
- const truncated = result.length > 5_000
962
- ? result.slice(0, 5_000) + "\n... (truncated)"
963
- : result;
964
- return { success: true, output: truncated };
965
- }
966
-
967
- case "list_directory": {
968
- const { readdir } = await import("node:fs/promises");
969
- const targetPath = (args.path || ".").replace(/^~/, os.homedir());
970
- const entries = await readdir(targetPath, { withFileTypes: true });
971
- const list = entries.map(e => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`).join("\n");
972
- return { success: true, listing: list };
973
- }
974
-
975
- case "web_search": {
976
- const { promisify } = await import("node:util");
977
- const { execFile: ef } = await import("node:child_process");
978
- const execP = promisify(ef);
979
-
980
- // Try DuckDuckGo Lite first (lighter HTML, easier to parse)
981
- const encoded = encodeURIComponent(args.query);
982
- try {
983
- const { stdout: html } = await execP("/usr/bin/curl", [
984
- "-sL", "--max-time", "10",
985
- "-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
986
- `https://lite.duckduckgo.com/lite/?q=${encoded}`,
987
- ], { timeout: 15_000 });
988
-
989
- // Parse DuckDuckGo Lite results
990
- const snippets = [];
991
- // Match result links and snippets
992
- const linkRegex = /<a[^>]*class="result-link"[^>]*>(.*?)<\/a>/gs;
993
- const snippetRegex = /<td class="result-snippet">(.*?)<\/td>/gs;
994
-
995
- const links = [];
996
- let m;
997
- while ((m = linkRegex.exec(html)) !== null) links.push(m[1].replace(/<[^>]+>/g, "").trim());
998
-
999
- const snips = [];
1000
- while ((m = snippetRegex.exec(html)) !== null) snips.push(m[1].replace(/<[^>]+>/g, "").trim());
1001
-
1002
- for (let i = 0; i < Math.min(links.length, 5); i++) {
1003
- const snippet = snips[i] ? `${links[i]}\n${snips[i]}` : links[i];
1004
- if (snippet) snippets.push(snippet);
1005
- }
1006
-
1007
- if (snippets.length > 0) {
1008
- return { success: true, results: snippets.join("\n\n") };
1009
- }
1010
-
1011
- // Fallback: extract any text content from result cells
1012
- const cellRegex = /<td[^>]*>(.*?)<\/td>/gs;
1013
- const cells = [];
1014
- while ((m = cellRegex.exec(html)) !== null && cells.length < 10) {
1015
- const text = m[1].replace(/<[^>]+>/g, "").trim();
1016
- if (text.length > 20) cells.push(text);
1017
- }
1018
- if (cells.length > 0) {
1019
- return { success: true, results: cells.slice(0, 5).join("\n\n") };
1020
- }
1021
- } catch { /* fallback below */ }
1022
-
1023
- // Fallback: use run_command with curl to a simple search API
1024
- return {
1025
- success: true,
1026
- results: `Search for "${args.query}" — try using run_command with: curl -s "https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1"`,
1027
- };
1028
- }
1029
-
1030
- case "file_edit": {
1031
- const filePath = args.path.replace(/^~/, os.homedir());
1032
- try {
1033
- const content = await readFile(filePath, "utf8");
1034
- if (!content.includes(args.old_text)) {
1035
- return { success: false, error: `Text not found in ${filePath}` };
1036
- }
1037
- const newContent = content.replace(args.old_text, args.new_text);
1038
- await writeFile(filePath, newContent, "utf8");
1039
- return { success: true, message: `Edited ${filePath}: replaced ${args.old_text.length} chars` };
1040
- } catch (err) {
1041
- return { success: false, error: err.message };
1042
- }
1043
- }
1044
-
1045
- case "file_search": {
1046
- const { promisify: prom } = await import("node:util");
1047
- const { execFile: ef2 } = await import("node:child_process");
1048
- const exec2 = prom(ef2);
1049
- const searchPath = (args.path || ".").replace(/^~/, os.homedir());
1050
- const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
1051
- try {
1052
- const { stdout } = await exec2("/bin/bash", ["-c",
1053
- `grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
1054
- ], { timeout: 10_000 });
1055
- const lines = stdout.trim().split("\n").filter(Boolean);
1056
- return { success: true, matches: lines.length, results: stdout.trim().slice(0, 5000) };
1057
- } catch {
1058
- return { success: true, matches: 0, results: "No matches found." };
1059
- }
1060
- }
1061
-
1062
- case "git": {
1063
- const { promisify: prom } = await import("node:util");
1064
- const { execFile: ef2 } = await import("node:child_process");
1065
- const exec2 = prom(ef2);
1066
- console.log(dim(` $ git ${args.command}`));
1067
- try {
1068
- const { stdout, stderr } = await exec2("/bin/bash", ["-c", `git ${args.command}`], {
1069
- timeout: 15_000, cwd: process.cwd(),
1070
- });
1071
- return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
1072
- } catch (err) {
1073
- return { success: false, error: err.stderr?.trim() || err.message };
1074
- }
1075
- }
1076
-
1077
- case "web_fetch": {
1078
- try {
1079
- const resp = await fetch(args.url, {
1080
- headers: { "User-Agent": "Wispy/0.2" },
1081
- signal: AbortSignal.timeout(15_000),
1082
- });
1083
- const contentType = resp.headers.get("content-type") ?? "";
1084
- const text = await resp.text();
1085
- // Basic HTML → text conversion
1086
- const cleaned = contentType.includes("html")
1087
- ? text.replace(/<script[\s\S]*?<\/script>/gi, "")
1088
- .replace(/<style[\s\S]*?<\/style>/gi, "")
1089
- .replace(/<[^>]+>/g, " ")
1090
- .replace(/\s+/g, " ")
1091
- .trim()
1092
- : text;
1093
- return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
1094
- } catch (err) {
1095
- return { success: false, error: err.message };
1096
- }
1097
- }
1098
-
1099
- case "keychain": {
1100
- const { promisify: prom } = await import("node:util");
1101
- const { execFile: ef2 } = await import("node:child_process");
1102
- const exec2 = prom(ef2);
1103
- const account = args.account ?? "wispy";
1104
-
1105
- if (process.platform !== "darwin") {
1106
- return { success: false, error: "Keychain is only supported on macOS" };
1107
- }
1108
-
1109
- if (args.action === "get") {
1110
- try {
1111
- const { stdout } = await exec2("security", [
1112
- "find-generic-password", "-s", args.service, "-a", account, "-w"
1113
- ], { timeout: 5000 });
1114
- const val = stdout.trim();
1115
- // NEVER expose full secret — mask middle
1116
- const masked = val.length > 8
1117
- ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 8, 20))}${val.slice(-4)}`
1118
- : "****";
1119
- return { success: true, service: args.service, account, value_masked: masked, length: val.length };
1120
- } catch {
1121
- return { success: false, error: `No keychain entry found for service="${args.service}" account="${account}"` };
1122
- }
1123
- }
1124
-
1125
- if (args.action === "set") {
1126
- if (!args.value) return { success: false, error: "value is required for set action" };
1127
- try {
1128
- // Delete existing first (ignore error if not found)
1129
- await exec2("security", [
1130
- "delete-generic-password", "-s", args.service, "-a", account
1131
- ]).catch(() => {});
1132
- await exec2("security", [
1133
- "add-generic-password", "-s", args.service, "-a", account, "-w", args.value
1134
- ], { timeout: 5000 });
1135
- return { success: true, message: `Stored secret for service="${args.service}" account="${account}"` };
1136
- } catch (err) {
1137
- return { success: false, error: err.message };
1138
- }
1139
- }
1140
-
1141
- if (args.action === "delete") {
1142
- try {
1143
- await exec2("security", [
1144
- "delete-generic-password", "-s", args.service, "-a", account
1145
- ], { timeout: 5000 });
1146
- return { success: true, message: `Deleted keychain entry for service="${args.service}"` };
1147
- } catch {
1148
- return { success: false, error: `No entry found for service="${args.service}"` };
1149
- }
1150
- }
1151
-
1152
- if (args.action === "list") {
1153
- try {
1154
- const { stdout } = await exec2("/bin/bash", ["-c",
1155
- `security dump-keychain 2>/dev/null | grep -A 4 "\"svce\"" | grep -E "svce|acct" | head -20`
1156
- ], { timeout: 5000 });
1157
- return { success: true, entries: stdout.trim() || "No entries found" };
1158
- } catch {
1159
- return { success: true, entries: "No entries found" };
1160
- }
1161
- }
1162
-
1163
- return { success: false, error: "action must be get, set, delete, or list" };
1164
- }
1165
-
1166
- case "clipboard": {
1167
- const { promisify: prom } = await import("node:util");
1168
- const { execFile: ef2 } = await import("node:child_process");
1169
- const exec2 = prom(ef2);
1170
- if (args.action === "copy") {
1171
- const { exec: execCb } = await import("node:child_process");
1172
- const execP = prom(execCb);
1173
- try {
1174
- // macOS: pbcopy, Linux: xclip
1175
- const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
1176
- await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
1177
- return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
1178
- } catch (err) {
1179
- return { success: false, error: err.message };
1180
- }
1181
- }
1182
- if (args.action === "paste") {
1183
- try {
1184
- const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
1185
- const { stdout } = await exec2("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
1186
- return { success: true, content: stdout.slice(0, 5000) };
1187
- } catch (err) {
1188
- return { success: false, error: err.message };
1189
- }
1190
- }
1191
- return { success: false, error: "action must be 'copy' or 'paste'" };
1192
- }
1193
-
1194
- case "spawn_agent": {
1195
- const role = args.role ?? "worker";
1196
- const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
1197
- const tier = args.model_tier ?? tierMap[role] ?? "mid";
1198
- const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : tier === "expensive" ? "critical" : "complex"];
1199
- const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
1200
-
1201
- const agentId = `agent-${Date.now().toString(36)}-${role}`;
1202
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1203
- let agents = [];
1204
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1205
-
1206
- const agent = {
1207
- id: agentId, role, task: args.task, model: agentModel,
1208
- status: "running", createdAt: new Date().toISOString(),
1209
- workstream: ACTIVE_WORKSTREAM, result: null,
1210
- };
1211
-
1212
- console.log(dim(` 🤖 Spawning ${role} agent (${agentModel})...`));
1213
-
1214
- // Run sub-agent — single-turn LLM call with the task
1215
- try {
1216
- const agentSystemPrompt = `You are a ${role} sub-agent for Wispy. Your role:
1217
- ${role === "explorer" ? "Search and analyze codebases, find relevant files and patterns." : ""}
1218
- ${role === "planner" ? "Design implementation strategies and create step-by-step plans." : ""}
1219
- ${role === "worker" ? "Implement code changes, write files, execute commands." : ""}
1220
- ${role === "reviewer" ? "Review code for bugs, security issues, and best practices." : ""}
1221
- Be concise and deliver actionable results. Respond in the same language as the task.`;
1222
-
1223
- const agentMessages = [
1224
- { role: "system", content: agentSystemPrompt },
1225
- ];
1226
-
1227
- // Fork context if requested
1228
- if (args.fork_context) {
1229
- const parentContext = await loadConversation();
1230
- const recentContext = parentContext.filter(m => m.role === "user" || m.role === "assistant").slice(-6);
1231
- for (const m of recentContext) {
1232
- agentMessages.push({ role: m.role, content: m.content });
1233
- }
1234
- }
1235
-
1236
- agentMessages.push({ role: "user", content: args.task });
1237
-
1238
- const agentResult = await chatWithTools(agentMessages, null);
1239
- agent.result = agentResult.type === "text" ? agentResult.text : JSON.stringify(agentResult);
1240
- agent.status = "completed";
1241
- agent.completedAt = new Date().toISOString();
1242
- } catch (err) {
1243
- agent.result = `Error: ${err.message}`;
1244
- agent.status = "failed";
1245
- }
1246
-
1247
- agents.push(agent);
1248
- // Keep last 50 agents
1249
- if (agents.length > 50) agents = agents.slice(-50);
1250
- await mkdir(WISPY_DIR, { recursive: true });
1251
- await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
1252
-
1253
- return {
1254
- success: true,
1255
- agent_id: agentId,
1256
- role,
1257
- model: agentModel,
1258
- status: agent.status,
1259
- result_preview: agent.result?.slice(0, 200),
1260
- };
1261
- }
1262
-
1263
- case "list_agents": {
1264
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1265
- let agents = [];
1266
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1267
- const wsAgents = agents.filter(a => a.workstream === ACTIVE_WORKSTREAM);
1268
- return {
1269
- success: true,
1270
- agents: wsAgents.map(a => ({
1271
- id: a.id, role: a.role, status: a.status,
1272
- task: a.task.slice(0, 60),
1273
- model: a.model,
1274
- createdAt: a.createdAt,
1275
- })),
1276
- };
1277
- }
1278
-
1279
- case "get_agent_result": {
1280
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1281
- let agents = [];
1282
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1283
- const found = agents.find(a => a.id === args.agent_id);
1284
- if (!found) return { success: false, error: `Agent not found: ${args.agent_id}` };
1285
- return { success: true, id: found.id, role: found.role, status: found.status, result: found.result };
1286
- }
1287
-
1288
- case "update_plan": {
1289
- const planFile = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.plan.json`);
1290
- const plan = { explanation: args.explanation, steps: args.steps, updatedAt: new Date().toISOString() };
1291
- await mkdir(CONVERSATIONS_DIR, { recursive: true });
1292
- await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
1293
- // Pretty print plan
1294
- if (args.steps) {
1295
- for (const s of args.steps) {
1296
- const icon = s.status === "completed" ? "✅" : s.status === "in_progress" ? "🔄" : s.status === "skipped" ? "⏭️" : "⬜";
1297
- console.log(dim(` ${icon} ${s.step}`));
1298
- }
1299
- }
1300
- return { success: true, message: "Plan updated" };
1301
- }
1302
-
1303
- case "pipeline": {
1304
- const stages = args.stages ?? ["explorer", "planner", "worker"];
1305
- let stageInput = args.task;
1306
- const results = [];
1307
-
1308
- console.log(dim(` 📋 Pipeline: ${stages.join(" → ")}`));
1309
-
1310
- for (let i = 0; i < stages.length; i++) {
1311
- const role = stages[i];
1312
- const icon = { explorer: "🔍", planner: "📋", worker: "🔨", reviewer: "🔎" }[role] ?? "🤖";
1313
- console.log(dim(`\n ${icon} Stage ${i + 1}/${stages.length}: ${role}`));
1314
-
1315
- // Build stage prompt with previous stage output
1316
- const stagePrompt = i === 0
1317
- ? stageInput
1318
- : `Previous stage (${stages[i-1]}) output:\n${results[i-1].slice(0, 3000)}\n\nYour task as ${role}: ${args.task}`;
1319
-
1320
- const stageSystem = `You are a ${role} agent in a pipeline. Stage ${i + 1} of ${stages.length}.
1321
- ${role === "explorer" ? "Find relevant files, patterns, and information." : ""}
1322
- ${role === "planner" ? "Design a concrete implementation plan based on the exploration results." : ""}
1323
- ${role === "worker" ? "Implement the plan. Write code, create files, run commands." : ""}
1324
- ${role === "reviewer" ? "Review the implementation. Check for bugs, security issues, completeness." : ""}
1325
- Be concise. Your output feeds into the next stage.`;
1326
-
1327
- const stageMessages = [
1328
- { role: "system", content: stageSystem },
1329
- { role: "user", content: stagePrompt },
1330
- ];
1331
-
1332
- try {
1333
- const result = await chatWithTools(stageMessages, null);
1334
- const output = result.type === "text" ? result.text : JSON.stringify(result);
1335
- results.push(output);
1336
- console.log(dim(` ✅ ${output.slice(0, 100)}...`));
1337
- stageInput = output;
1338
- } catch (err) {
1339
- results.push(`Error: ${err.message}`);
1340
- console.log(red(` ❌ ${err.message.slice(0, 100)}`));
1341
- break;
1342
- }
1343
- }
1344
-
1345
- return {
1346
- success: true,
1347
- stages: stages.map((role, i) => ({ role, output: results[i]?.slice(0, 500) ?? "skipped" })),
1348
- final_output: results[results.length - 1]?.slice(0, 1000),
1349
- };
1350
- }
1351
-
1352
- case "spawn_async_agent": {
1353
- const role = args.role ?? "worker";
1354
- const agentId = `async-${Date.now().toString(36)}-${role}`;
1355
- const agentsFile = path.join(WISPY_DIR, "agents.json");
1356
- let agents = [];
1357
- try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1358
-
1359
- const agent = {
1360
- id: agentId, role, task: args.task,
1361
- status: "running", async: true,
1362
- createdAt: new Date().toISOString(),
1363
- workstream: ACTIVE_WORKSTREAM, result: null,
1364
- };
1365
-
1366
- // Save as "running" immediately
1367
- agents.push(agent);
1368
- if (agents.length > 50) agents = agents.slice(-50);
1369
- await mkdir(WISPY_DIR, { recursive: true });
1370
- await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
1371
-
1372
- console.log(dim(` 🤖 Async agent ${agentId} launched in background`));
1373
-
1374
- // Fire and forget — run in background
1375
- (async () => {
1376
- const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
1377
- const tier = tierMap[role] ?? "mid";
1378
- const modelForTier = TASK_MODEL_MAP[tier === "cheap" ? "simple" : "complex"];
1379
- const agentModel = modelForTier?.[PROVIDER] ?? MODEL;
1380
-
1381
- const agentSystem = `You are a ${role} sub-agent. Be concise and actionable.`;
1382
- const agentMessages = [
1383
- { role: "system", content: agentSystem },
1384
- { role: "user", content: args.task },
1385
- ];
1386
-
1387
- try {
1388
- const result = await chatWithTools(agentMessages, null);
1389
- agent.result = result.type === "text" ? result.text : JSON.stringify(result);
1390
- agent.status = "completed";
1391
- } catch (err) {
1392
- agent.result = `Error: ${err.message}`;
1393
- agent.status = "failed";
1394
- }
1395
- agent.completedAt = new Date().toISOString();
1396
-
1397
- // Update agents file
1398
- let currentAgents = [];
1399
- try { currentAgents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
1400
- const idx = currentAgents.findIndex(a => a.id === agentId);
1401
- if (idx !== -1) currentAgents[idx] = agent;
1402
- await writeFile(agentsFile, JSON.stringify(currentAgents, null, 2) + "\n", "utf8");
1403
- })();
1404
-
1405
- return {
1406
- success: true,
1407
- agent_id: agentId,
1408
- role,
1409
- status: "running",
1410
- message: "Agent launched in background. Use get_agent_result to check when done.",
1411
- };
1412
- }
1413
-
1414
- case "ralph_loop": {
1415
- const MAX_ITERATIONS = 5;
1416
- const criteria = args.success_criteria ?? "Task is fully completed and verified";
1417
- let lastResult = "";
1418
-
1419
- console.log(dim(` 🪨 Ralph mode: will retry up to ${MAX_ITERATIONS} times until verified complete`));
1420
-
1421
- for (let attempt = 1; attempt <= MAX_ITERATIONS; attempt++) {
1422
- // Worker attempt
1423
- console.log(dim(`\n 🔨 Attempt ${attempt}/${MAX_ITERATIONS}: worker executing...`));
1424
-
1425
- const workerPrompt = attempt === 1
1426
- ? args.task
1427
- : `Previous attempt output:\n${lastResult.slice(0, 2000)}\n\nThe reviewer said this is NOT complete yet. Try again.\nTask: ${args.task}\nSuccess criteria: ${criteria}`;
1428
-
1429
- const workerMessages = [
1430
- { role: "system", content: "You are a worker agent. Execute the task thoroughly. Do not stop until the task is fully done." },
1431
- { role: "user", content: workerPrompt },
1432
- ];
1433
-
1434
- try {
1435
- const workerResult = await chatWithTools(workerMessages, null);
1436
- lastResult = workerResult.type === "text" ? workerResult.text : JSON.stringify(workerResult);
1437
- console.log(dim(` ✅ Worker output: ${lastResult.slice(0, 100)}...`));
1438
- } catch (err) {
1439
- console.log(red(` ❌ Worker error: ${err.message.slice(0, 100)}`));
1440
- continue;
1441
- }
1442
-
1443
- // Reviewer verification
1444
- console.log(dim(` 🔎 Reviewer verifying...`));
1445
-
1446
- const reviewerMessages = [
1447
- { role: "system", content: "You are a reviewer agent. Your ONLY job is to determine if the task is TRULY complete. Reply with JSON: {\"complete\": true/false, \"reason\": \"why\"}" },
1448
- { role: "user", content: `Task: ${args.task}\nSuccess criteria: ${criteria}\n\nWorker output:\n${lastResult.slice(0, 3000)}\n\nIs this task TRULY complete? Reply with JSON only.` },
1449
- ];
1450
-
1451
- try {
1452
- const reviewResult = await chatWithTools(reviewerMessages, null);
1453
- const reviewText = reviewResult.type === "text" ? reviewResult.text : "";
1454
-
1455
- // Try to parse JSON from review
1456
- const jsonMatch = reviewText.match(/\{[\s\S]*"complete"[\s\S]*\}/);
1457
- if (jsonMatch) {
1458
- try {
1459
- const verdict = JSON.parse(jsonMatch[0]);
1460
- if (verdict.complete) {
1461
- console.log(green(` ✅ Reviewer: COMPLETE — ${verdict.reason?.slice(0, 80) ?? "verified"}`));
1462
- return { success: true, iterations: attempt, result: lastResult, verified: true };
1463
- }
1464
- console.log(yellow(` ⏳ Reviewer: NOT COMPLETE — ${verdict.reason?.slice(0, 80) ?? "needs more work"}`));
1465
- } catch { /* parse failed, continue */ }
1466
- }
1467
- } catch (err) {
1468
- console.log(dim(` ⚠️ Review error: ${err.message.slice(0, 80)}`));
1469
- }
1470
- }
1471
-
1472
- // Max iterations reached
1473
- console.log(yellow(` 🪨 Ralph: max iterations (${MAX_ITERATIONS}) reached`));
1474
- return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
1475
- }
1476
-
1477
- default: {
1478
- // Check MCP tools first
1479
- if (mcpManager.hasTool(name)) {
1480
- try {
1481
- const result = await mcpManager.callTool(name, args);
1482
- // MCP tools/call returns { content: [{type, text}], isError? }
1483
- if (result?.isError) {
1484
- const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
1485
- return { success: false, error: errText };
1486
- }
1487
- const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
1488
- return { success: true, output };
1489
- } catch (err) {
1490
- return { success: false, error: `MCP tool error: ${err.message}` };
1491
- }
1492
- }
1493
-
1494
- // Unknown tool — try to execute as a skill via run_command
1495
- // This handles cases where the AI hallucinates tools from skill descriptions
1496
- const skills = await loadSkills();
1497
- const matchedSkill = skills.find(s => s.name.toLowerCase() === name.toLowerCase());
1498
- if (matchedSkill) {
1499
- return {
1500
- success: false,
1501
- error: `"${name}" is a skill, not a tool. Use run_command to execute commands from the ${name} skill guide. Example from the skill: look for curl/bash commands in the skill description.`,
1502
- skill_hint: matchedSkill.body.slice(0, 500),
1503
- };
1504
- }
1505
- return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result, and MCP tools (see /mcp list)` };
1506
- }
1507
- }
1508
- } catch (err) {
1509
- return { success: false, error: err.message };
1510
- }
1511
- }
1512
-
1513
- // ---------------------------------------------------------------------------
1514
- // System prompt builder
1515
- // ---------------------------------------------------------------------------
1516
-
1517
- // ---------------------------------------------------------------------------
1518
- // work.md — per-workstream context file
1519
- // ---------------------------------------------------------------------------
1520
-
1521
- async function loadWorkMd() {
1522
- const searchPaths = [
1523
- path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`),
1524
- path.resolve(`.wispy/${ACTIVE_WORKSTREAM}.work.md`),
1525
- path.resolve(`work.md`), // project root fallback
1526
- ];
1527
- for (const p of searchPaths) {
1528
- const content = await readFileOr(p);
1529
- if (content) return { path: p, content: content.slice(0, 20_000) };
1530
- }
1531
- return null;
1532
- }
1533
-
1534
- // ---------------------------------------------------------------------------
1535
- // Skill loader — loads SKILL.md files from multiple sources
1536
- // Compatible with OpenClaw and Claude Code skill formats
1537
- // ---------------------------------------------------------------------------
1538
-
1539
- async function loadSkills() {
1540
- const skillDirs = [
1541
- // OpenClaw built-in skills
1542
- "/opt/homebrew/lib/node_modules/openclaw/skills",
1543
- // OpenClaw user skills
1544
- path.join(os.homedir(), ".openclaw", "workspace", "skills"),
1545
- // Wispy skills
1546
- path.join(WISPY_DIR, "skills"),
1547
- // Project-local skills
1548
- path.resolve(".wispy", "skills"),
1549
- // Claude Code skills (if installed)
1550
- path.join(os.homedir(), ".claude", "skills"),
192
+ // Auto-detect macOS Keychain
193
+ const keychainProviders = [
194
+ { service: "google-ai-key", provider: "google", label: "Google AI (Gemini)" },
195
+ { service: "anthropic-api-key", provider: "anthropic", label: "Anthropic (Claude)" },
196
+ { service: "openai-api-key", provider: "openai", label: "OpenAI (GPT)" },
1551
197
  ];
1552
-
1553
- const skills = [];
1554
- const { readdir: rd, stat: st } = await import("node:fs/promises");
1555
-
1556
- for (const dir of skillDirs) {
198
+ for (const kc of keychainProviders) {
1557
199
  try {
1558
- const 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 */ }
200
+ const { execFile } = await import("node:child_process");
201
+ const { promisify } = await import("node:util");
202
+ const exec = promisify(execFile);
203
+ const { stdout } = await exec("security", ["find-generic-password", "-s", kc.service, "-a", "poropo", "-w"], { timeout: 3000 });
204
+ const key = stdout.trim();
205
+ if (key) {
206
+ console.log(green(` found ${kc.label} key! ✓\n`));
207
+ const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
208
+ cfg.provider = kc.provider;
209
+ await mkdir(WISPY_DIR, { recursive: true });
210
+ await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
211
+ return;
1800
212
  }
1801
- }
1802
- sessionTokens.output += estimateTokens(fullText);
1803
- return { type: "text", text: fullText };
213
+ } catch {}
1804
214
  }
1805
215
 
1806
- // 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 };
1866
- }
1867
-
1868
- const text = choice.message?.content ?? "";
1869
- sessionTokens.output += estimateTokens(text);
1870
- if (onChunk) onChunk(text);
1871
- return { type: "text", text };
1872
- }
216
+ console.log(dim(" no existing config found.\n"));
1873
217
 
1874
- // ---------------------------------------------------------------------------
1875
- // Anthropic API with tool use (streaming text + tool calls)
1876
- // ---------------------------------------------------------------------------
218
+ // Manual setup
219
+ console.log(box([
220
+ `${bold("Quick Setup")} ${dim("— one step, 10 seconds")}`,
221
+ "", ` Wispy needs an AI provider to work.`, ` The easiest: ${bold("Google AI")} ${dim("(free, no credit card)")}`,
222
+ ]));
223
+ console.log("");
1877
224
 
1878
- 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
- }
225
+ try {
226
+ execSync('open "https://aistudio.google.com/apikey" 2>/dev/null || xdg-open "https://aistudio.google.com/apikey" 2>/dev/null', { stdio: "ignore" });
227
+ console.log(` ${green("→")} Browser opened to ${underline("aistudio.google.com/apikey")}`);
228
+ } catch {
229
+ console.log(` ${green("→")} Visit: ${underline("https://aistudio.google.com/apikey")}`);
1903
230
  }
1904
231
 
1905
- const 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
- });
232
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
233
+ const ask = (q) => new Promise(r => rl.question(q, r));
234
+ const apiKey = (await ask(`\n ${green("API key")} ${dim("(paste here)")}: `)).trim();
235
+ rl.close();
1930
236
 
1931
- if (!response.ok) {
1932
- const err = await response.text();
1933
- throw new Error(`Anthropic API error ${response.status}: ${err.slice(0, 300)}`);
237
+ if (!apiKey) {
238
+ console.log(dim("\nRun `wispy` again after setting up Ollama or an API key."));
239
+ process.exit(0);
1934
240
  }
1935
241
 
1936
- // 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;
1957
-
1958
- try {
1959
- const event = JSON.parse(data);
1960
-
1961
- if (event.type === "content_block_start") {
1962
- if (event.content_block?.type === "tool_use") {
1963
- currentToolCall = { id: event.content_block.id, name: event.content_block.name, args: {} };
1964
- currentToolInput = "";
1965
- }
1966
- } else if (event.type === "content_block_delta") {
1967
- if (event.delta?.type === "text_delta") {
1968
- fullText += event.delta.text;
1969
- onChunk?.(event.delta.text);
1970
- } else if (event.delta?.type === "input_json_delta") {
1971
- currentToolInput += event.delta.partial_json ?? "";
1972
- }
1973
- } else if (event.type === "content_block_stop") {
1974
- if (currentToolCall) {
1975
- try { currentToolCall.args = JSON.parse(currentToolInput); } catch { currentToolCall.args = {}; }
1976
- toolCalls.push(currentToolCall);
1977
- currentToolCall = null;
1978
- currentToolInput = "";
1979
- }
1980
- }
1981
- } catch { /* skip */ }
1982
- }
1983
- }
242
+ let chosenProvider = "google";
243
+ if (apiKey.startsWith("sk-ant-")) chosenProvider = "anthropic";
244
+ else if (apiKey.startsWith("sk-or-")) chosenProvider = "openrouter";
245
+ else if (apiKey.startsWith("sk-")) chosenProvider = "openai";
246
+ else if (apiKey.startsWith("gsk_")) chosenProvider = "groq";
1984
247
 
1985
- sessionTokens.output += estimateTokens(fullText + JSON.stringify(toolCalls));
248
+ const { PROVIDERS } = await import("../core/config.mjs");
249
+ const cfg = JSON.parse(await readFileOr(path.join(WISPY_DIR, "config.json"), "{}"));
250
+ cfg.provider = chosenProvider;
251
+ cfg.apiKey = apiKey;
252
+ await mkdir(WISPY_DIR, { recursive: true });
253
+ await writeFile(path.join(WISPY_DIR, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
254
+ process.env[PROVIDERS[chosenProvider].envKeys[0]] = apiKey;
1986
255
 
1987
- if (toolCalls.length > 0) {
1988
- return { type: "tool_calls", calls: toolCalls };
1989
- }
1990
- return { type: "text", text: fullText };
256
+ console.log("\n" + box([
257
+ `${green("")} Connected to ${bold(PROVIDERS[chosenProvider].label)}`,
258
+ "", ` ${cyan("wispy")} ${dim("start chatting")}`, ` ${cyan("wispy --help")} ${dim("all options")}`,
259
+ ]));
1991
260
  }
1992
261
 
1993
- // ---------------------------------------------------------------------------
1994
- // Agentic loop — handles tool calls iteratively
1995
- // ---------------------------------------------------------------------------
1996
-
1997
- async function agentLoop(messages, onChunk) {
1998
- const MAX_TOOL_ROUNDS = 10;
1999
-
2000
- // Optimize context window before sending
2001
- const lastUserMsg = messages.filter(m => m.role === "user").pop();
2002
- const optimizedMessages = optimizeContext(messages);
2003
- if (optimizedMessages.length < messages.length) {
2004
- messages.length = 0;
2005
- messages.push(...optimizedMessages);
2006
- }
2007
-
2008
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
2009
- // Check budget before calling API
2010
- const budgetCheck = await loadBudgets();
2011
- const wsBudget = budgetCheck[ACTIVE_WORKSTREAM];
2012
- if (wsBudget?.limitUsd !== null && wsBudget?.spentUsd > wsBudget?.limitUsd) {
2013
- return `⚠️ Budget exceeded for workstream "${ACTIVE_WORKSTREAM}" ($${wsBudget.spentUsd.toFixed(4)} / $${wsBudget.limitUsd.toFixed(4)}). Use /budget to adjust.`;
2014
- }
2015
-
2016
- const result = await chatWithTools(messages, onChunk);
2017
-
2018
- if (result.type === "text") {
2019
- // Track spending for this workstream
2020
- await trackSpending(ACTIVE_WORKSTREAM, sessionTokens.input, sessionTokens.output, MODEL);
2021
- return result.text;
2022
- }
2023
-
2024
- // Handle tool calls
2025
- console.log(""); // newline before tool output
2026
- const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
2027
- messages.push(toolCallMsg);
262
+ // ---------------------------------------------------------------------------
263
+ // Director mode
264
+ // ---------------------------------------------------------------------------
2028
265
 
2029
- 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);
266
+ async function showOverview() {
267
+ const wsList = await listWorkstreams();
268
+ if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return; }
269
+ console.log(`\n${bold("🌿 Wispy Director — All Workstreams")}\n`);
270
+ for (const ws of wsList) {
271
+ const conv = await loadWorkstreamConversation(ws);
272
+ const userMsgs = conv.filter(m => m.role === "user");
273
+ const assistantMsgs = conv.filter(m => m.role === "assistant");
274
+ const toolResults = conv.filter(m => m.role === "tool_result");
275
+ const lastUser = userMsgs[userMsgs.length - 1];
276
+ const isActive = ws === ACTIVE_WORKSTREAM;
277
+ const marker = isActive ? green("● ") : " ";
278
+ console.log(`${marker}${bold(isActive ? green(ws) : ws)}`);
279
+ console.log(` Messages: ${userMsgs.length} user / ${assistantMsgs.length} assistant / ${toolResults.length} tool calls`);
280
+ if (lastUser) console.log(` Last request: ${dim(lastUser.content.slice(0, 60))}${lastUser.content.length > 60 ? "..." : ""}`);
281
+ console.log("");
282
+ }
283
+ }
2032
284
 
2033
- 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}`));
285
+ async function searchAcrossWorkstreams(query) {
286
+ const wsList = await listWorkstreams();
287
+ const lowerQuery = query.toLowerCase();
288
+ console.log(`\n${bold("🔍 Searching all workstreams for:")} ${cyan(query)}\n`);
289
+ let totalMatches = 0;
290
+ for (const ws of wsList) {
291
+ const conv = await loadWorkstreamConversation(ws);
292
+ const matches = conv.filter(m => (m.role === "user" || m.role === "assistant") && m.content?.toLowerCase().includes(lowerQuery));
293
+ if (matches.length > 0) {
294
+ console.log(` ${bold(ws)} (${matches.length} matches):`);
295
+ for (const m of matches.slice(-3)) {
296
+ const preview = m.content.slice(0, 80).replace(/\n/g, " ");
297
+ console.log(` ${m.role === "user" ? "👤" : "🌿"} ${dim(preview)}${m.content.length > 80 ? "..." : ""}`);
2038
298
  }
2039
-
2040
- messages.push({
2041
- role: "tool_result",
2042
- toolName: call.name,
2043
- toolUseId: call.id ?? call.name,
2044
- result: toolResult,
2045
- });
299
+ totalMatches += matches.length;
2046
300
  }
2047
- console.log(""); // newline before next response
2048
301
  }
2049
-
2050
- return "(tool call limit reached)";
302
+ if (totalMatches === 0) console.log(dim(` No matches found for "${query}"`));
2051
303
  }
2052
304
 
2053
305
  // ---------------------------------------------------------------------------
2054
- // Slash commands
306
+ // Slash command handler (UI concern — stays in repl)
2055
307
  // ---------------------------------------------------------------------------
2056
308
 
2057
- async function handleSlashCommand(input, conversation) {
309
+ async function handleSlashCommand(input, engine, conversation) {
2058
310
  const parts = input.trim().split(/\s+/);
2059
311
  const cmd = parts[0].toLowerCase();
2060
312
 
@@ -2067,7 +319,17 @@ ${bold("Wispy Commands:")}
2067
319
  ${cyan("/clear")} Reset conversation
2068
320
  ${cyan("/history")} Show conversation length
2069
321
  ${cyan("/model")} [name] Show or change model
2070
- ${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP server management
322
+ ${cyan("/cost")} Show token usage
323
+ ${cyan("/workstreams")} List workstreams
324
+ ${cyan("/overview")} Director view
325
+ ${cyan("/search")} <keyword> Search across workstreams
326
+ ${cyan("/skills")} List installed skills
327
+ ${cyan("/sessions")} List sessions
328
+ ${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP management
329
+ ${cyan("/remember")} <text> Save text to main memory (MEMORY.md)
330
+ ${cyan("/forget")} <key> Delete a memory file
331
+ ${cyan("/memories")} List all memory files
332
+ ${cyan("/recall")} <query> Search memories
2071
333
  ${cyan("/quit")} or ${cyan("/exit")} Exit
2072
334
  `);
2073
335
  return true;
@@ -2087,74 +349,57 @@ ${bold("Wispy Commands:")}
2087
349
 
2088
350
  if (cmd === "/model") {
2089
351
  if (parts[1]) {
2090
- process.env.WISPY_MODEL = parts[1];
352
+ engine.providers.setModel(parts[1]);
2091
353
  console.log(green(`Model changed to: ${parts[1]}`));
2092
354
  } else {
2093
- console.log(dim(`Current model: ${MODEL}`));
355
+ console.log(dim(`Current model: ${engine.model} (provider: ${engine.provider})`));
2094
356
  }
2095
357
  return true;
2096
358
  }
2097
359
 
360
+ if (cmd === "/cost" || cmd === "/tokens" || cmd === "/usage") {
361
+ console.log(dim(`📊 Session usage: ${engine.providers.formatCost()}`));
362
+ return true;
363
+ }
364
+
2098
365
  if (cmd === "/memory") {
2099
366
  const type = parts[1];
2100
367
  const content = parts.slice(2).join(" ");
2101
- if (!type || !content) {
2102
- console.log(yellow("Usage: /memory <user|feedback|project|references> <content>"));
2103
- return true;
2104
- }
368
+ if (!type || !content) { console.log(yellow("Usage: /memory <user|feedback|project|references> <content>")); return true; }
2105
369
  const validTypes = ["user", "feedback", "project", "references"];
2106
- if (!validTypes.includes(type)) {
2107
- console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`));
2108
- return true;
2109
- }
370
+ if (!validTypes.includes(type)) { console.log(yellow(`Invalid type. Use: ${validTypes.join(", ")}`)); return true; }
2110
371
  await appendToMemory(type, content);
2111
372
  console.log(green(`✅ Saved to ${type} memory.`));
2112
373
  return true;
2113
374
  }
2114
375
 
2115
376
  if (cmd === "/compact") {
2116
- // 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
377
+ const summaryResult = await engine.processMessage(null, "Summarize our conversation so far in 3-5 bullet points.", {
378
+ systemPrompt: "Summarize the following conversation in 3-5 bullet points. Be concise.",
379
+ noSave: true,
380
+ });
381
+ const summary = summaryResult.content;
2128
382
  await appendToMemory("project", `Session compact: ${summary.slice(0, 200)}`);
2129
383
  conversation.length = 0;
2130
384
  conversation.push({ role: "assistant", content: `[Previous session summary]\n${summary}` });
2131
385
  await saveConversation(conversation);
2132
- console.log(green("📦 Conversation compacted."));
2133
- return true;
2134
- }
2135
-
2136
- if (cmd === "/cost" || cmd === "/tokens" || cmd === "/usage") {
2137
- console.log(dim(`📊 Session usage: ${formatCost()}`));
386
+ console.log(green("\n📦 Conversation compacted."));
2138
387
  return true;
2139
388
  }
2140
389
 
2141
390
  if (cmd === "/workstreams" || cmd === "/ws") {
2142
391
  const wsList = await listWorkstreams();
2143
- if (wsList.length === 0) {
2144
- 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>`));
392
+ if (wsList.length === 0) { console.log(dim("No workstreams yet.")); return true; }
393
+ console.log(bold("\n📋 Workstreams:\n"));
394
+ for (const ws of wsList) {
395
+ const marker = ws === ACTIVE_WORKSTREAM ? green(" ") : " ";
396
+ const wsConv = await loadWorkstreamConversation(ws);
397
+ const msgCount = wsConv.filter(m => m.role === "user").length;
398
+ const lastMsg = wsConv.filter(m => m.role === "user").pop();
399
+ const preview = lastMsg ? dim(` — "${lastMsg.content.slice(0, 40)}${lastMsg.content.length > 40 ? "..." : ""}"`) : "";
400
+ console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgCount} msgs`)}${preview}`);
2157
401
  }
402
+ console.log(dim(`\nSwitch: wispy -w <name>`));
2158
403
  return true;
2159
404
  }
2160
405
 
@@ -2165,142 +410,30 @@ ${bold("Wispy Commands:")}
2165
410
 
2166
411
  if (cmd === "/search") {
2167
412
  const query = parts.slice(1).join(" ");
2168
- if (!query) {
2169
- console.log(yellow("Usage: /search <keyword> — search across all workstreams"));
2170
- return true;
2171
- }
413
+ if (!query) { console.log(yellow("Usage: /search <keyword>")); return true; }
2172
414
  await searchAcrossWorkstreams(query);
2173
415
  return true;
2174
416
  }
2175
417
 
2176
- if (cmd === "/work") {
2177
- const workMd = await loadWorkMd();
2178
- if (parts[1] === "edit" || parts[1] === "set") {
2179
- const content = parts.slice(2).join(" ");
2180
- if (!content) {
2181
- console.log(yellow("Usage: /work set <content> or create file manually:"));
2182
- console.log(dim(` ${path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`)}`));
2183
- return true;
2184
- }
2185
- const workPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`);
2186
- await mkdir(CONVERSATIONS_DIR, { recursive: true });
2187
- await appendFile(workPath, `\n${content}\n`, "utf8");
2188
- console.log(green(`✅ Added to ${ACTIVE_WORKSTREAM} work.md`));
2189
- return true;
2190
- }
2191
- if (parts[1] === "init") {
2192
- const workPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.work.md`);
2193
- if (workMd) {
2194
- console.log(dim(`work.md already exists at ${workMd.path}`));
2195
- return true;
2196
- }
2197
- await mkdir(CONVERSATIONS_DIR, { recursive: true });
2198
- await writeFile(workPath, `# ${ACTIVE_WORKSTREAM}\n\n## Goals\n\n## Context\n\n## Notes\n\n`, "utf8");
2199
- console.log(green(`✅ Created ${workPath}`));
2200
- return true;
2201
- }
2202
- // Show current work.md
2203
- if (workMd) {
2204
- console.log(`\n${bold(`📋 work.md (${ACTIVE_WORKSTREAM})`)}`);
2205
- console.log(dim(` ${workMd.path}\n`));
2206
- console.log(workMd.content);
2207
- } else {
2208
- console.log(dim(`No work.md for "${ACTIVE_WORKSTREAM}". Create one:`));
2209
- console.log(dim(` /work init`));
2210
- console.log(dim(` /work set "project goals and context here"`));
2211
- }
2212
- return true;
2213
- }
2214
-
2215
- if (cmd === "/budget") {
2216
- const budgets = await loadBudgets();
2217
- if (parts[1] === "set") {
2218
- const limit = parseFloat(parts[2]);
2219
- if (isNaN(limit)) {
2220
- console.log(yellow("Usage: /budget set <amount_usd> — e.g., /budget set 1.00"));
2221
- return true;
2222
- }
2223
- if (!budgets[ACTIVE_WORKSTREAM]) budgets[ACTIVE_WORKSTREAM] = { limitUsd: null, spentUsd: 0, totalTokens: 0 };
2224
- budgets[ACTIVE_WORKSTREAM].limitUsd = limit;
2225
- await saveBudgets(budgets);
2226
- console.log(green(`💰 Budget set: $${limit.toFixed(2)} for "${ACTIVE_WORKSTREAM}"`));
2227
- return true;
2228
- }
2229
- if (parts[1] === "clear") {
2230
- if (budgets[ACTIVE_WORKSTREAM]) budgets[ACTIVE_WORKSTREAM].limitUsd = null;
2231
- await saveBudgets(budgets);
2232
- console.log(dim("Budget limit removed."));
2233
- return true;
2234
- }
2235
- // Show all budgets
2236
- const wsList = Object.keys(budgets);
2237
- if (wsList.length === 0) {
2238
- console.log(dim("No spending tracked yet."));
2239
- return true;
2240
- }
2241
- console.log(bold("\n💰 Budget Overview:\n"));
2242
- for (const ws of wsList) {
2243
- const b = budgets[ws];
2244
- const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
2245
- const limit = b.limitUsd !== null ? `/ $${b.limitUsd.toFixed(2)}` : dim("(no limit)");
2246
- const pct = b.limitUsd ? ` (${((b.spentUsd / b.limitUsd) * 100).toFixed(1)}%)` : "";
2247
- const warning = b.limitUsd && b.spentUsd > b.limitUsd ? red(" ⚠ OVER") : "";
2248
- console.log(`${marker}${ws.padEnd(20)} $${b.spentUsd.toFixed(4)} ${limit}${pct}${warning} ${dim(`${b.totalTokens} tokens`)}`);
2249
- }
2250
- console.log(dim("\nSet limit: /budget set <usd> | Remove: /budget clear"));
2251
- console.log("");
2252
- return true;
2253
- }
2254
-
2255
- if (cmd === "/skills") {
2256
- const skills = await loadSkills();
2257
- if (skills.length === 0) {
2258
- console.log(dim("No skills installed."));
2259
- console.log(dim("Add skills to ~/.wispy/skills/ or install OpenClaw skills."));
2260
- } else {
2261
- console.log(bold(`\n🧩 Skills (${skills.length} installed):\n`));
2262
- const bySource = {};
2263
- for (const s of skills) {
2264
- const src = s.source.includes("openclaw") ? "OpenClaw" : s.source.includes(".wispy") ? "Wispy" : s.source.includes(".claude") ? "Claude" : "Project";
2265
- if (!bySource[src]) bySource[src] = [];
2266
- bySource[src].push(s);
2267
- }
2268
- for (const [src, sks] of Object.entries(bySource)) {
2269
- console.log(` ${bold(src)} (${sks.length}):`);
2270
- for (const s of sks) {
2271
- console.log(` ${green(s.name.padEnd(20))} ${dim(s.description.slice(0, 50))}`);
2272
- }
2273
- console.log("");
2274
- }
2275
- }
2276
- return true;
2277
- }
2278
-
2279
418
  if (cmd === "/sessions" || cmd === "/ls") {
2280
419
  const wsList = await listWorkstreams();
2281
- if (wsList.length === 0) {
2282
- 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
- }
420
+ if (wsList.length === 0) { console.log(dim("No sessions yet.")); return true; }
421
+ console.log(bold("\n📂 Sessions:\n"));
422
+ for (const ws of wsList) {
423
+ const conv = await loadWorkstreamConversation(ws);
424
+ const msgs = conv.filter(m => m.role === "user").length;
425
+ const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
426
+ console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
2291
427
  }
2292
- console.log(dim(`\nSwitch: wispy -w <name> | Delete: /delete <name>`));
2293
428
  return true;
2294
429
  }
2295
430
 
2296
431
  if (cmd === "/delete" || cmd === "/rm") {
2297
432
  const target = parts[1];
2298
433
  if (!target) { console.log(yellow("Usage: /delete <workstream-name>")); return true; }
2299
- const wsPath = path.join(CONVERSATIONS_DIR, `${target}.json`);
434
+ const { unlink } = await import("node:fs/promises");
2300
435
  try {
2301
- const { unlink } = await import("node:fs/promises");
2302
- await unlink(wsPath);
2303
- // Also delete work.md and plan
436
+ await unlink(path.join(CONVERSATIONS_DIR, `${target}.json`));
2304
437
  await unlink(path.join(CONVERSATIONS_DIR, `${target}.work.md`)).catch(() => {});
2305
438
  await unlink(path.join(CONVERSATIONS_DIR, `${target}.plan.json`)).catch(() => {});
2306
439
  console.log(green(`🗑️ Deleted session "${target}"`));
@@ -2310,49 +443,47 @@ ${bold("Wispy Commands:")}
2310
443
  return true;
2311
444
  }
2312
445
 
2313
- if (cmd === "/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
- });
446
+ if (cmd === "/provider") {
447
+ console.log(dim(`Provider: ${engine.provider}, Model: ${engine.model}, Workstream: ${ACTIVE_WORKSTREAM}`));
448
+ return true;
449
+ }
2323
450
 
2324
- if (format === "clipboard" || format === "copy") {
2325
- const { execSync: es } = await import("node:child_process");
451
+ if (cmd === "/skills") {
452
+ // Load skills from known locations
453
+ const skillDirs = [
454
+ "/opt/homebrew/lib/node_modules/openclaw/skills",
455
+ path.join(os.homedir(), ".openclaw", "workspace", "skills"),
456
+ path.join(WISPY_DIR, "skills"),
457
+ ];
458
+ const { readdir } = await import("node:fs/promises");
459
+ const allSkills = [];
460
+ for (const dir of skillDirs) {
2326
461
  try {
2327
- const 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.")); }
462
+ const entries = await readdir(dir);
463
+ for (const entry of entries) {
464
+ try {
465
+ const skillMd = await readFile(path.join(dir, entry, "SKILL.md"), "utf8");
466
+ allSkills.push({ name: entry, source: dir });
467
+ } catch {}
468
+ }
469
+ } catch {}
470
+ }
471
+ if (allSkills.length === 0) {
472
+ console.log(dim("No skills installed."));
2331
473
  } else {
2332
- 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}`));
474
+ console.log(bold(`\n🧩 Skills (${allSkills.length} installed):\n`));
475
+ for (const s of allSkills) {
476
+ console.log(` ${green(s.name.padEnd(20))} ${dim(s.source.split("/").slice(-2).join("/"))}`);
477
+ }
2335
478
  }
2336
479
  return true;
2337
480
  }
2338
481
 
2339
- 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
- // ---------------------------------------------------------------------------
482
+ // /mcp commands
2354
483
  if (cmd === "/mcp") {
2355
484
  const sub = parts[1] ?? "list";
485
+ const mcpManager = engine.mcpManager;
486
+ const MCP_CONFIG_PATH = mcpManager.configPath;
2356
487
 
2357
488
  if (sub === "list") {
2358
489
  const status = mcpManager.getStatus();
@@ -2360,16 +491,11 @@ ${bold("Wispy Commands:")}
2360
491
  if (status.length === 0) {
2361
492
  console.log(dim("No MCP servers connected."));
2362
493
  console.log(dim(`Config: ${MCP_CONFIG_PATH}`));
2363
- console.log(dim("Use /mcp connect <name> to connect a server."));
2364
494
  } else {
2365
495
  console.log(bold(`\n🔌 MCP Servers (${status.length}):\n`));
2366
496
  for (const s of status) {
2367
497
  const icon = s.connected ? green("●") : red("○");
2368
- 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 ?? "?"}`));
498
+ console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools`);
2373
499
  }
2374
500
  }
2375
501
  if (allTools.length > 0) {
@@ -2378,30 +504,19 @@ ${bold("Wispy Commands:")}
2378
504
  console.log(` ${cyan(t.wispyName.padEnd(30))} ${dim(t.description.slice(0, 60))}`);
2379
505
  }
2380
506
  }
2381
- console.log("");
2382
507
  return true;
2383
508
  }
2384
509
 
2385
510
  if (sub === "connect") {
2386
511
  const serverName = parts[2];
2387
- if (!serverName) {
2388
- console.log(yellow("Usage: /mcp connect <server-name>"));
2389
- return true;
2390
- }
512
+ if (!serverName) { console.log(yellow("Usage: /mcp connect <server-name>")); return true; }
2391
513
  const config = await mcpManager.loadConfig();
2392
514
  const serverConfig = config.mcpServers?.[serverName];
2393
- if (!serverConfig) {
2394
- console.log(red(`Server "${serverName}" not found in ${MCP_CONFIG_PATH}`));
2395
- console.log(dim(`Available: ${Object.keys(config.mcpServers ?? {}).join(", ") || "none"}`));
2396
- return true;
2397
- }
515
+ if (!serverConfig) { console.log(red(`Server "${serverName}" not found in config`)); return true; }
2398
516
  process.stdout.write(dim(` Connecting to "${serverName}"...`));
2399
517
  try {
2400
518
  const client = await mcpManager.connect(serverName, serverConfig);
2401
519
  console.log(green(` ✓ connected (${client.tools.length} tools)`));
2402
- if (client.tools.length > 0) {
2403
- console.log(dim(` Tools: ${client.tools.map(t => t.name).join(", ")}`));
2404
- }
2405
520
  } catch (err) {
2406
521
  console.log(red(` ✗ failed: ${err.message.slice(0, 120)}`));
2407
522
  }
@@ -2410,10 +525,7 @@ ${bold("Wispy Commands:")}
2410
525
 
2411
526
  if (sub === "disconnect") {
2412
527
  const serverName = parts[2];
2413
- if (!serverName) {
2414
- console.log(yellow("Usage: /mcp disconnect <server-name>"));
2415
- return true;
2416
- }
528
+ if (!serverName) { console.log(yellow("Usage: /mcp disconnect <server-name>")); return true; }
2417
529
  const ok = mcpManager.disconnect(serverName);
2418
530
  console.log(ok ? green(`✓ Disconnected "${serverName}"`) : yellow(`"${serverName}" was not connected`));
2419
531
  return true;
@@ -2421,17 +533,12 @@ ${bold("Wispy Commands:")}
2421
533
 
2422
534
  if (sub === "config") {
2423
535
  console.log(dim(`Config file: ${MCP_CONFIG_PATH}`));
2424
- 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."));
536
+ const cfg = await mcpManager.loadConfig();
537
+ const servers = cfg.mcpServers ?? {};
538
+ console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
539
+ for (const [name, s] of Object.entries(servers)) {
540
+ console.log(` ${name.padEnd(20)} ${s.disabled ? dim("disabled") : green("enabled")}`);
541
+ console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
2435
542
  }
2436
543
  return true;
2437
544
  }
@@ -2445,22 +552,74 @@ ${bold("Wispy Commands:")}
2445
552
  else if (r.status === "disabled") console.log(dim(` ○ ${r.name} (disabled)`));
2446
553
  else console.log(red(` ✗ ${r.name}: ${r.error?.slice(0, 80)}`));
2447
554
  }
555
+ engine.tools.registerMCP(mcpManager);
2448
556
  return true;
2449
557
  }
2450
558
 
2451
- // 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
- `);
559
+ console.log(`${bold("/mcp commands:")} list | connect <name> | disconnect <name> | config | reload`);
560
+ return true;
561
+ }
562
+
563
+ // ── Memory commands ──────────────────────────────────────────────────────────
564
+
565
+ if (cmd === "/remember") {
566
+ const text = parts.slice(1).join(" ");
567
+ if (!text) { console.log(yellow("Usage: /remember <text>")); return true; }
568
+ await engine.memory.append("MEMORY", text);
569
+ console.log(green("✅ Saved to MEMORY.md"));
2461
570
  return true;
2462
571
  }
2463
572
 
573
+ if (cmd === "/forget") {
574
+ const key = parts[1];
575
+ if (!key) { console.log(yellow("Usage: /forget <key>")); return true; }
576
+ const result = await engine.memory.delete(key);
577
+ if (result.success) {
578
+ console.log(green(`🗑️ Deleted memory: ${key}`));
579
+ } else {
580
+ console.log(red(`Memory "${key}" not found.`));
581
+ }
582
+ return true;
583
+ }
584
+
585
+ if (cmd === "/memories") {
586
+ const keys = await engine.memory.list();
587
+ if (keys.length === 0) {
588
+ console.log(dim("No memories stored yet. Use /remember <text> or ask wispy to remember things."));
589
+ } else {
590
+ console.log(bold(`\n🧠 Memories (${keys.length}):\n`));
591
+ for (const k of keys) {
592
+ console.log(` ${cyan(k.key.padEnd(30))} ${dim(k.preview ?? "")}`);
593
+ }
594
+ }
595
+ return true;
596
+ }
597
+
598
+ if (cmd === "/recall") {
599
+ const query = parts.slice(1).join(" ");
600
+ if (!query) { console.log(yellow("Usage: /recall <query>")); return true; }
601
+ const results = await engine.memory.search(query);
602
+ if (results.length === 0) {
603
+ console.log(dim(`No memories found for: "${query}"`));
604
+ } else {
605
+ console.log(bold(`\n🔍 Memory search: "${query}"\n`));
606
+ for (const r of results) {
607
+ console.log(` ${cyan(r.key)} ${dim(`(${r.matchCount} matches)`)}`);
608
+ for (const s of r.snippets.slice(0, 3)) {
609
+ console.log(` ${dim(`L${s.lineNumber}:`)} ${s.text.slice(0, 100)}`);
610
+ }
611
+ console.log("");
612
+ }
613
+ }
614
+ return true;
615
+ }
616
+
617
+ if (cmd === "/quit" || cmd === "/exit") {
618
+ console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
619
+ engine.destroy();
620
+ process.exit(0);
621
+ }
622
+
2464
623
  return false;
2465
624
  }
2466
625
 
@@ -2468,24 +627,15 @@ ${bold("/mcp commands:")}
2468
627
  // Interactive REPL
2469
628
  // ---------------------------------------------------------------------------
2470
629
 
2471
- async function runRepl() {
630
+ async function runRepl(engine) {
2472
631
  const wsLabel = ACTIVE_WORKSTREAM === "default" ? "" : ` ${dim("·")} ${cyan(ACTIVE_WORKSTREAM)}`;
2473
- const providerLabel = PROVIDERS[PROVIDER]?.label ?? PROVIDER;
2474
632
  console.log(`
2475
- ${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${MODEL}`)}
2476
- ${dim(`${providerLabel} · /help for commands · Ctrl+C to exit`)}
633
+ ${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${engine.model}`)}
634
+ ${dim(`${engine.provider} · /help for commands · Ctrl+C to exit`)}
2477
635
  `);
2478
636
 
2479
- const systemPrompt = await buildSystemPrompt(conversation);
2480
637
  const conversation = await loadConversation();
2481
638
 
2482
- // Ensure system prompt is first
2483
- if (conversation.length === 0 || conversation[0].role !== "system") {
2484
- conversation.unshift({ role: "system", content: systemPrompt });
2485
- } else {
2486
- conversation[0].content = systemPrompt; // Refresh system prompt
2487
- }
2488
-
2489
639
  const rl = createInterface({
2490
640
  input: process.stdin,
2491
641
  output: process.stdout,
@@ -2499,34 +649,36 @@ async function runRepl() {
2499
649
  const input = line.trim();
2500
650
  if (!input) { rl.prompt(); return; }
2501
651
 
2502
- // Slash commands
2503
652
  if (input.startsWith("/")) {
2504
- const handled = await handleSlashCommand(input, conversation);
653
+ const handled = await handleSlashCommand(input, engine, conversation);
2505
654
  if (handled) { rl.prompt(); return; }
2506
655
  }
2507
656
 
2508
- // Add user message
2509
657
  conversation.push({ role: "user", content: input });
2510
658
 
2511
- // Agent loop with tool calls
2512
659
  process.stdout.write(cyan("🌿 "));
2513
660
  try {
2514
- const response = await agentLoop(conversation, (chunk) => {
2515
- process.stdout.write(chunk);
661
+ // Build messages from conversation history (keep system prompt + history)
662
+ const systemPrompt = await engine._buildSystemPrompt(input);
663
+ const messages = [{ role: "system", content: systemPrompt }, ...conversation.filter(m => m.role !== "system")];
664
+
665
+ const response = await engine.processMessage(null, input, {
666
+ onChunk: (chunk) => process.stdout.write(chunk),
667
+ systemPrompt: await engine._buildSystemPrompt(input),
668
+ noSave: true,
2516
669
  });
2517
670
  console.log("\n");
2518
671
 
2519
- conversation.push({ role: "assistant", content: response });
672
+ conversation.push({ role: "assistant", content: response.content });
673
+ // Keep last 50 messages
674
+ if (conversation.length > 50) conversation.splice(0, conversation.length - 50);
2520
675
  await saveConversation(conversation);
2521
- console.log(dim(` ${formatCost()}`));
676
+ console.log(dim(` ${engine.providers.formatCost()}`));
2522
677
  } catch (err) {
2523
- // Friendly error handling
2524
678
  if (err.message.includes("429") || err.message.includes("rate")) {
2525
679
  console.log(yellow("\n\n⏳ Rate limited — wait a moment and try again."));
2526
680
  } else if (err.message.includes("401") || err.message.includes("403")) {
2527
681
  console.log(red("\n\n🔑 Authentication error — check your API key."));
2528
- } else if (err.message.includes("network") || err.message.includes("fetch")) {
2529
- console.log(red("\n\n🌐 Network error — check your connection."));
2530
682
  } else {
2531
683
  console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
2532
684
  }
@@ -2536,7 +688,8 @@ async function runRepl() {
2536
688
  });
2537
689
 
2538
690
  rl.on("close", () => {
2539
- console.log(dim(`\n🌿 Bye! (${formatCost()})`));
691
+ console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
692
+ engine.destroy();
2540
693
  process.exit(0);
2541
694
  });
2542
695
  }
@@ -2545,26 +698,13 @@ async function runRepl() {
2545
698
  // One-shot mode
2546
699
  // ---------------------------------------------------------------------------
2547
700
 
2548
- async function runOneShot(message) {
2549
- const conversation = await loadConversation();
2550
- conversation.push({ role: "user", content: message });
2551
- const systemPrompt = await buildSystemPrompt(conversation);
2552
-
2553
- if (!conversation.find(m => m.role === "system")) {
2554
- conversation.unshift({ role: "system", content: systemPrompt });
2555
- } else {
2556
- conversation[0].content = systemPrompt;
2557
- }
2558
-
701
+ async function runOneShot(engine, message) {
2559
702
  try {
2560
- const response = await agentLoop(conversation, (chunk) => {
2561
- process.stdout.write(chunk);
703
+ const response = await engine.processMessage(null, message, {
704
+ onChunk: (chunk) => process.stdout.write(chunk),
2562
705
  });
2563
706
  console.log("");
2564
-
2565
- conversation.push({ role: "assistant", content: response });
2566
- await saveConversation(conversation);
2567
- console.log(dim(`${formatCost()}`));
707
+ console.log(dim(engine.providers.formatCost()));
2568
708
  } catch (err) {
2569
709
  if (err.message.includes("429")) {
2570
710
  console.error(yellow("\n⏳ Rate limited — try again shortly."));
@@ -2575,121 +715,19 @@ async function runOneShot(message) {
2575
715
  }
2576
716
  }
2577
717
 
2578
- // ---------------------------------------------------------------------------
2579
- // Auto server management — start AWOS server if not running
2580
- // ---------------------------------------------------------------------------
2581
-
2582
- import { spawn as spawnProcess } from "node:child_process";
2583
- import { fileURLToPath } from "node:url";
2584
-
2585
- const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
2586
- const REPO_ROOT = process.env.WISPY_REPO_ROOT ?? path.resolve(SCRIPT_DIR, "..");
2587
- // Server binary: check env → ~/.wispy/bin/ → repo build path
2588
- import { statSync } from "node:fs";
2589
- const SERVER_BINARY = process.env.WISPY_SERVER_BINARY
2590
- ?? (() => {
2591
- const candidates = [
2592
- path.join(os.homedir(), ".wispy", "bin", "awos-server"),
2593
- path.join(REPO_ROOT, "src-tauri", "target", "release", "awos-server"),
2594
- path.join(REPO_ROOT, "src-tauri", "target", "debug", "awos-server"),
2595
- ];
2596
- for (const c of candidates) {
2597
- try { if (statSync(c).isFile()) return c; } catch {}
2598
- }
2599
- return candidates[0];
2600
- })();
2601
- const SERVER_PID_FILE = path.join(WISPY_DIR, "server.pid");
2602
- const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
2603
-
2604
- async function isServerRunning() {
2605
- try {
2606
- const resp = await fetch(`http://127.0.0.1:${DEFAULT_SERVER_PORT}/api/health`, {
2607
- signal: AbortSignal.timeout(2000),
2608
- });
2609
- return resp.ok;
2610
- } catch {
2611
- return false;
2612
- }
2613
- }
2614
-
2615
- async function startServerIfNeeded() {
2616
- if (await isServerRunning()) {
2617
- return { started: false, port: DEFAULT_SERVER_PORT };
2618
- }
2619
-
2620
- // Check if binary exists
2621
- try {
2622
- const { stat } = await import("node:fs/promises");
2623
- await stat(SERVER_BINARY);
2624
- } catch {
2625
- // No binary — skip auto-start silently, CLI-only mode
2626
- return { started: false, port: DEFAULT_SERVER_PORT, noBinary: true };
2627
- }
2628
-
2629
- // Start server in background
2630
- const logFile = path.join(WISPY_DIR, "server.log");
2631
- await mkdir(WISPY_DIR, { recursive: true });
2632
- const { openSync } = await import("node:fs");
2633
- const logFd = openSync(logFile, "a");
2634
-
2635
- const child = spawnProcess(SERVER_BINARY, [], {
2636
- cwd: REPO_ROOT,
2637
- env: { ...process.env, AWOS_PORT: DEFAULT_SERVER_PORT },
2638
- detached: true,
2639
- stdio: ["ignore", logFd, logFd],
2640
- });
2641
-
2642
- child.unref();
2643
-
2644
- // Save PID for cleanup
2645
- await writeFile(SERVER_PID_FILE, String(child.pid), "utf8");
2646
-
2647
- // Wait up to 5 seconds for server to be ready
2648
- for (let i = 0; i < 25; i++) {
2649
- await new Promise(r => setTimeout(r, 200));
2650
- if (await isServerRunning()) {
2651
- return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid };
2652
- }
2653
- }
2654
-
2655
- return { started: true, port: DEFAULT_SERVER_PORT, pid: child.pid, slow: true };
2656
- }
2657
-
2658
- async function stopServer() {
2659
- try {
2660
- const pidStr = await readFile(SERVER_PID_FILE, "utf8");
2661
- const pid = parseInt(pidStr.trim(), 10);
2662
- if (pid && !isNaN(pid)) {
2663
- process.kill(pid, "SIGTERM");
2664
- // Clean up PID file
2665
- const { unlink } = await import("node:fs/promises");
2666
- await unlink(SERVER_PID_FILE).catch(() => {});
2667
- }
2668
- } catch {
2669
- // No PID file or already stopped
2670
- }
2671
- }
2672
-
2673
718
  // ---------------------------------------------------------------------------
2674
719
  // Main
2675
720
  // ---------------------------------------------------------------------------
2676
721
 
2677
- // Filter out -w/--workstream flag from args
2678
722
  const rawArgs = process.argv.slice(2);
2679
723
  const args = [];
2680
724
  for (let i = 0; i < rawArgs.length; i++) {
2681
- if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; } // skip flag + value
725
+ if (rawArgs[i] === "-w" || rawArgs[i] === "--workstream") { i++; continue; }
2682
726
  args.push(rawArgs[i]);
2683
727
  }
2684
728
 
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
- ]);
729
+ const operatorCommands = new Set(["home", "node", "runtime", "agents", "agent", "workstreams", "workstream", "doctor", "setup", "package", "config", "server"]);
2691
730
 
2692
- // wispy server <start|stop|status>
2693
731
  if (args[0] === "server") {
2694
732
  const sub = args[1] ?? "status";
2695
733
  if (sub === "status") {
@@ -2703,130 +741,58 @@ if (args[0] === "server") {
2703
741
  }
2704
742
  process.exit(0);
2705
743
  }
2706
- if (sub === "stop") {
2707
- await stopServer();
2708
- console.log(dim("🌿 Server stopped."));
2709
- process.exit(0);
2710
- }
744
+ if (sub === "stop") { await stopServer(); console.log(dim("🌿 Server stopped.")); process.exit(0); }
2711
745
  if (sub === "start") {
2712
746
  const status = await startServerIfNeeded();
2713
- if (status.started) {
2714
- 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
- }
747
+ if (status.started) console.log(green(`🌿 Server started on port ${status.port} (PID: ${status.pid})`));
748
+ else if (status.noBinary) console.log(red("Server binary not found."));
749
+ else console.log(dim(`Server already running on port ${status.port}`));
2720
750
  process.exit(0);
2721
751
  }
2722
- console.log("Usage: wispy server <start|stop|status>");
2723
- process.exit(1);
752
+ console.log("Usage: wispy server <start|stop|status>"); process.exit(1);
2724
753
  }
2725
754
 
2726
755
  if (args[0] && operatorCommands.has(args[0])) {
2727
- // Delegate to the full CLI
2728
756
  const cliPath = process.env.WISPY_OPERATOR_CLI ?? path.join(SCRIPT_DIR, "awos-node-cli.mjs");
2729
757
  const { execFileSync } = await import("node:child_process");
2730
758
  try {
2731
- execFileSync(process.execPath, ["--experimental-strip-types", cliPath, ...args], {
2732
- stdio: "inherit",
2733
- env: process.env,
2734
- });
2735
- } catch (e) {
2736
- process.exit(e.status ?? 1);
2737
- }
759
+ execFileSync(process.execPath, ["--experimental-strip-types", cliPath, ...args], { stdio: "inherit", env: process.env });
760
+ } catch (e) { process.exit(e.status ?? 1); }
2738
761
  process.exit(0);
2739
762
  }
2740
763
 
2741
- // Check API key — if none found, run interactive onboarding
2742
- if (!API_KEY && PROVIDER !== "ollama") {
764
+ // Initialize engine
765
+ const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
766
+ const initResult = await engine.init();
767
+
768
+ if (!initResult) {
2743
769
  await runOnboarding();
2744
- // 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") {
770
+ // Try again after onboarding
771
+ const initResult2 = await engine.init();
772
+ if (!initResult2) {
2756
773
  console.log(dim("\nRun wispy again to start chatting!"));
2757
774
  process.exit(0);
2758
775
  }
2759
776
  }
2760
777
 
2761
- // 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); });
778
+ // Graceful cleanup
779
+ process.on("exit", () => { try { engine.destroy(); } catch {} });
780
+ process.on("SIGINT", () => { engine.destroy(); process.exit(0); });
781
+ process.on("SIGTERM", () => { engine.destroy(); process.exit(0); });
2782
782
 
2783
- // Auto-start server before entering REPL or one-shot
783
+ // Auto-start background server
2784
784
  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
785
+ if (serverStatus.started && !serverStatus.noBinary) {
786
+ if (serverStatus.slow) console.log(yellow(`⚠ Server starting on port ${serverStatus.port}...`));
787
+ else console.log(dim(`🌿 Server started on port ${serverStatus.port}`));
2793
788
  }
2794
789
 
2795
- // Server runs as a background daemon survives CLI exit.
2796
- // Use `wispy server stop` to stop it explicitly.
790
+ if (args[0] === "overview" || args[0] === "dashboard") { await showOverview(); process.exit(0); }
791
+ if (args[0] === "search" && args[1]) { await searchAcrossWorkstreams(args.slice(1).join(" ")); process.exit(0); }
2797
792
 
2798
- if (args[0] === "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);
793
+ if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
794
+ // One-shot mode
795
+ await runOneShot(engine, args.join(" "));
2830
796
  } else if (args[0] === "--help" || args[0] === "-h") {
2831
797
  console.log(`
2832
798
  ${bold("🌿 Wispy")} — AI workspace assistant
@@ -2835,20 +801,7 @@ ${bold("Usage:")}
2835
801
  wispy Start interactive session
2836
802
  wispy "message" One-shot message
2837
803
  wispy -w <name> Use specific workstream
2838
- wispy -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
804
+ wispy --help Show this help
2852
805
 
2853
806
  ${bold("In-session commands:")}
2854
807
  /help Show commands
@@ -2856,21 +809,13 @@ ${bold("In-session commands:")}
2856
809
  /memory <type> <text> Save to persistent memory
2857
810
  /clear Reset conversation
2858
811
  /model [name] Show/change model
2859
- /cost Show 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
812
+ /cost Show token usage
2865
813
  /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)
814
+ /overview Director view
815
+ /search <keyword> Search across workstreams
816
+ /skills List installed skills
2869
817
  /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
818
+ /mcp [list|...] MCP server management
2874
819
  /quit Exit
2875
820
 
2876
821
  ${bold("Providers (auto-detected):")}
@@ -2888,6 +833,5 @@ ${bold("Options:")}
2888
833
  WISPY_WORKSTREAM Set active workstream
2889
834
  `);
2890
835
  } else {
2891
- // Interactive REPL
2892
- await runRepl();
836
+ await runRepl(engine);
2893
837
  }