wispy-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/wispy.mjs CHANGED
@@ -81,11 +81,10 @@ if (serveMode || telegramMode || discordMode || slackMode) {
81
81
  process.on("SIGINT", async () => { await manager.stopAll(); process.exit(0); });
82
82
  process.on("SIGTERM", async () => { await manager.stopAll(); process.exit(0); });
83
83
 
84
- // Prevent Node from exiting (adapters keep their own event loops)
85
- // but we still need something to hold the process open in case adapters
86
- // don't (e.g. Telegram stops after connection error).
84
+ // Prevent Node from exiting
87
85
  setInterval(() => {}, 60_000);
88
- return;
86
+ // eslint-disable-next-line no-constant-condition
87
+ await new Promise(() => {}); // keep alive
89
88
  }
90
89
 
91
90
  // ── TUI mode ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,104 @@
1
+ /**
2
+ * core/config.mjs — Centralized config loader for Wispy
3
+ *
4
+ * Loads ~/.wispy/config.json, ~/.wispy/channels.json
5
+ * Handles provider API key detection from env + macOS Keychain
6
+ */
7
+
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
+
12
+ export const WISPY_DIR = path.join(os.homedir(), ".wispy");
13
+ export const CONFIG_PATH = path.join(WISPY_DIR, "config.json");
14
+ export const CHANNELS_CONFIG_PATH = path.join(WISPY_DIR, "channels.json");
15
+ export const MCP_CONFIG_PATH = path.join(WISPY_DIR, "mcp.json");
16
+ export const SESSIONS_DIR = path.join(WISPY_DIR, "sessions");
17
+ export const CONVERSATIONS_DIR = path.join(WISPY_DIR, "conversations");
18
+ export const MEMORY_DIR = path.join(WISPY_DIR, "memory");
19
+
20
+ export const PROVIDERS = {
21
+ google: { envKeys: ["GOOGLE_AI_KEY", "GEMINI_API_KEY"], defaultModel: "gemini-2.5-flash", label: "Google AI (Gemini)", signupUrl: "https://aistudio.google.com/apikey" },
22
+ anthropic: { envKeys: ["ANTHROPIC_API_KEY"], defaultModel: "claude-sonnet-4-20250514", label: "Anthropic (Claude)", signupUrl: "https://console.anthropic.com/settings/keys" },
23
+ openai: { envKeys: ["OPENAI_API_KEY"], defaultModel: "gpt-4o", label: "OpenAI", signupUrl: "https://platform.openai.com/api-keys" },
24
+ openrouter:{ envKeys: ["OPENROUTER_API_KEY"], defaultModel: "anthropic/claude-sonnet-4-20250514", label: "OpenRouter (multi-model)", signupUrl: "https://openrouter.ai/keys" },
25
+ groq: { envKeys: ["GROQ_API_KEY"], defaultModel: "llama-3.3-70b-versatile", label: "Groq (fast inference)", signupUrl: "https://console.groq.com/keys" },
26
+ deepseek: { envKeys: ["DEEPSEEK_API_KEY"], defaultModel: "deepseek-chat", label: "DeepSeek", signupUrl: "https://platform.deepseek.com/api_keys" },
27
+ ollama: { envKeys: ["OLLAMA_HOST"], defaultModel: "llama3.2", label: "Ollama (local)", signupUrl: null, local: true },
28
+ };
29
+
30
+ async function tryKeychainKey(service) {
31
+ try {
32
+ const { execFile } = await import("node:child_process");
33
+ const { promisify } = await import("node:util");
34
+ const exec = promisify(execFile);
35
+ const { stdout } = await exec("security", ["find-generic-password", "-s", service, "-a", "poropo", "-w"], { timeout: 3000 });
36
+ return stdout.trim() || null;
37
+ } catch { return null; }
38
+ }
39
+
40
+ function getEnvKey(envKeys) {
41
+ for (const k of envKeys) {
42
+ if (process.env[k]) return process.env[k];
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export async function loadConfig() {
48
+ await mkdir(WISPY_DIR, { recursive: true });
49
+ let fileConfig = {};
50
+ try {
51
+ fileConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
52
+ } catch { /* no config */ }
53
+ return fileConfig;
54
+ }
55
+
56
+ export async function saveConfig(config) {
57
+ await mkdir(WISPY_DIR, { recursive: true });
58
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
59
+ }
60
+
61
+ export async function detectProvider() {
62
+ // 1. WISPY_PROVIDER env override
63
+ const forced = process.env.WISPY_PROVIDER;
64
+ if (forced && PROVIDERS[forced]) {
65
+ const key = getEnvKey(PROVIDERS[forced].envKeys);
66
+ if (key || PROVIDERS[forced].local) {
67
+ return { provider: forced, key, model: process.env.WISPY_MODEL ?? PROVIDERS[forced].defaultModel };
68
+ }
69
+ }
70
+
71
+ // 2. Config file
72
+ const cfg = await loadConfig();
73
+ if (cfg.provider && PROVIDERS[cfg.provider]) {
74
+ const key = getEnvKey(PROVIDERS[cfg.provider].envKeys) ?? cfg.apiKey;
75
+ if (key || PROVIDERS[cfg.provider].local) {
76
+ return { provider: cfg.provider, key, model: cfg.model ?? PROVIDERS[cfg.provider].defaultModel };
77
+ }
78
+ }
79
+
80
+ // 3. Auto-detect from env vars
81
+ const order = ["google", "anthropic", "openai", "openrouter", "groq", "deepseek", "ollama"];
82
+ for (const p of order) {
83
+ const key = getEnvKey(PROVIDERS[p].envKeys);
84
+ if (key || (p === "ollama" && process.env.OLLAMA_HOST)) {
85
+ return { provider: p, key, model: process.env.WISPY_MODEL ?? PROVIDERS[p].defaultModel };
86
+ }
87
+ }
88
+
89
+ // 4. macOS Keychain
90
+ const keychainMap = [
91
+ ["google-ai-key", "google"],
92
+ ["anthropic-api-key", "anthropic"],
93
+ ["openai-api-key", "openai"],
94
+ ];
95
+ for (const [service, provider] of keychainMap) {
96
+ const key = await tryKeychainKey(service);
97
+ if (key) {
98
+ process.env[PROVIDERS[provider].envKeys[0]] = key;
99
+ return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
100
+ }
101
+ }
102
+
103
+ return null;
104
+ }
@@ -0,0 +1,532 @@
1
+ /**
2
+ * core/engine.mjs — Main chat engine for Wispy
3
+ *
4
+ * Class WispyEngine:
5
+ * - constructor(config) — loads config, initializes providers/tools/mcp
6
+ * - async init() — async initialization
7
+ * - async processMessage(sessionId, userMessage, opts?) → assistantMessage
8
+ * - async processToolCalls(toolCalls) → results array
9
+ *
10
+ * This is THE single entry point. CLI, TUI, channels all call this.
11
+ */
12
+
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
16
+
17
+ import { WISPY_DIR, CONVERSATIONS_DIR, MEMORY_DIR, MCP_CONFIG_PATH, detectProvider, PROVIDERS } from "./config.mjs";
18
+ import { ProviderRegistry } from "./providers.mjs";
19
+ import { ToolRegistry } from "./tools.mjs";
20
+ import { SessionManager } from "./session.mjs";
21
+ import { MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
22
+
23
+ const MAX_TOOL_ROUNDS = 10;
24
+ const MAX_CONTEXT_CHARS = 40_000;
25
+
26
+ export class WispyEngine {
27
+ constructor(config = {}) {
28
+ this.config = config;
29
+ this.providers = new ProviderRegistry();
30
+ this.tools = new ToolRegistry();
31
+ this.sessions = new SessionManager();
32
+ this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
33
+ this._initialized = false;
34
+ this._activeWorkstream = config.workstream
35
+ ?? process.env.WISPY_WORKSTREAM
36
+ ?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
37
+ ?? "default";
38
+ }
39
+
40
+ get activeWorkstream() { return this._activeWorkstream; }
41
+ get model() { return this.providers.model; }
42
+ get provider() { return this.providers.provider; }
43
+
44
+ /**
45
+ * Initialize the engine (async). Call before processMessage().
46
+ */
47
+ async init(opts = {}) {
48
+ if (this._initialized) return this;
49
+
50
+ // Initialize provider
51
+ const providerResult = await this.providers.init(opts.providerOverrides ?? {});
52
+ if (!providerResult && !opts.allowNoProvider) {
53
+ return null; // No provider configured
54
+ }
55
+
56
+ // Register built-in tools
57
+ this.tools.registerBuiltin();
58
+
59
+ // Initialize MCP
60
+ if (!opts.skipMcp) {
61
+ await ensureDefaultMcpConfig(this.mcpManager.configPath);
62
+ const mcpResults = await this.mcpManager.autoConnect();
63
+ if (process.env.WISPY_DEBUG) {
64
+ for (const r of mcpResults) {
65
+ if (r.status === "failed") console.error(`[wispy] MCP "${r.name}" failed: ${r.error?.slice(0, 80)}`);
66
+ }
67
+ }
68
+ this.tools.registerMCP(this.mcpManager);
69
+ }
70
+
71
+ this._initialized = true;
72
+ return this;
73
+ }
74
+
75
+ /**
76
+ * Process a user message. Runs the full agentic loop.
77
+ *
78
+ * @param {string|null} sessionId - Session ID (null = create new)
79
+ * @param {string} userMessage - The user's message
80
+ * @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave }
81
+ * @returns {object} { role: "assistant", content: string, usage? }
82
+ */
83
+ async processMessage(sessionId, userMessage, opts = {}) {
84
+ if (!this._initialized) await this.init();
85
+
86
+ // Get or create session
87
+ let session;
88
+ if (sessionId) {
89
+ session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
90
+ if (!session) {
91
+ // Create new session with given ID context
92
+ session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
93
+ }
94
+ } else {
95
+ session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
96
+ }
97
+
98
+ // Build messages array for the provider
99
+ const systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
100
+
101
+ // Initialize messages with system prompt if empty
102
+ let messages;
103
+ if (session.messages.length === 0) {
104
+ messages = [{ role: "system", content: systemPrompt }];
105
+ } else {
106
+ messages = [...session.messages];
107
+ // Refresh system prompt
108
+ if (messages[0]?.role === "system") {
109
+ messages[0] = { role: "system", content: systemPrompt };
110
+ } else {
111
+ messages.unshift({ role: "system", content: systemPrompt });
112
+ }
113
+ }
114
+
115
+ // Add user message
116
+ messages.push({ role: "user", content: userMessage });
117
+ this.sessions.addMessage(session.id, { role: "user", content: userMessage });
118
+
119
+ // Run agentic loop
120
+ const responseText = await this._agentLoop(messages, session, opts);
121
+
122
+ // Add assistant message to session
123
+ this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
124
+
125
+ // Save session (async, don't await unless needed)
126
+ if (!opts.noSave) {
127
+ this.sessions.save(session.id).catch(() => {});
128
+ }
129
+
130
+ return {
131
+ role: "assistant",
132
+ content: responseText,
133
+ sessionId: session.id,
134
+ usage: { ...this.providers.sessionTokens },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Process tool calls manually.
140
+ * @param {Array} toolCalls - Array of { name, args }
141
+ * @returns {Array} results
142
+ */
143
+ async processToolCalls(toolCalls) {
144
+ const results = [];
145
+ for (const call of toolCalls) {
146
+ const result = await this.tools.execute(call.name, call.args);
147
+ results.push({ toolName: call.name, result });
148
+ }
149
+ return results;
150
+ }
151
+
152
+ /**
153
+ * Internal: agentic loop — keeps calling provider until no more tool calls.
154
+ */
155
+ async _agentLoop(messages, session, opts = {}) {
156
+ // Optimize context
157
+ messages = this._optimizeContext(messages);
158
+
159
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
160
+ const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
161
+ onChunk: opts.onChunk,
162
+ model: opts.model,
163
+ });
164
+
165
+ if (result.type === "text") {
166
+ return result.text;
167
+ }
168
+
169
+ // Handle tool calls
170
+ const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
171
+ messages.push(toolCallMsg);
172
+
173
+ for (const call of result.calls) {
174
+ if (opts.onToolCall) opts.onToolCall(call.name, call.args);
175
+
176
+ const toolResult = await this._executeTool(call.name, call.args, messages, session, opts);
177
+
178
+ if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
179
+
180
+ messages.push({
181
+ role: "tool_result",
182
+ toolName: call.name,
183
+ toolUseId: call.id ?? call.name,
184
+ result: toolResult,
185
+ });
186
+ }
187
+ }
188
+
189
+ return "(tool call limit reached)";
190
+ }
191
+
192
+ /**
193
+ * Execute a tool, handling engine-level tools (spawn_agent, etc.) specially.
194
+ */
195
+ async _executeTool(name, args, messages, session, opts) {
196
+ // Engine-level tools that need conversation context
197
+ switch (name) {
198
+ case "spawn_agent":
199
+ return this._toolSpawnAgent(args, messages, session);
200
+ case "list_agents":
201
+ return this._toolListAgents();
202
+ case "get_agent_result":
203
+ return this._toolGetAgentResult(args);
204
+ case "update_plan":
205
+ return this._toolUpdatePlan(args);
206
+ case "pipeline":
207
+ return this._toolPipeline(args, messages, session);
208
+ case "spawn_async_agent":
209
+ return this._toolSpawnAsyncAgent(args, messages, session);
210
+ case "ralph_loop":
211
+ return this._toolRalphLoop(args, messages, session);
212
+ default:
213
+ return this.tools.execute(name, args);
214
+ }
215
+ }
216
+
217
+ // ── Agent tools ─────────────────────────────────────────────────────────────
218
+
219
+ async _toolSpawnAgent(args, parentMessages, session) {
220
+ const role = args.role ?? "worker";
221
+ const agentId = `agent-${Date.now().toString(36)}-${role}`;
222
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
223
+ let agents = [];
224
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
225
+
226
+ const agent = {
227
+ id: agentId, role, task: args.task, model: this.model,
228
+ status: "running", createdAt: new Date().toISOString(),
229
+ workstream: this._activeWorkstream, result: null,
230
+ };
231
+
232
+ const rolePrompts = {
233
+ explorer: "Search and analyze codebases, find relevant files and patterns.",
234
+ planner: "Design implementation strategies and create step-by-step plans.",
235
+ worker: "Implement code changes, write files, execute commands.",
236
+ reviewer: "Review code for bugs, security issues, and best practices.",
237
+ };
238
+
239
+ const agentMessages = [
240
+ { role: "system", content: `You are a ${role} sub-agent for Wispy. ${rolePrompts[role] ?? ""} Be concise and deliver actionable results.` },
241
+ ];
242
+
243
+ if (args.fork_context) {
244
+ const recentContext = parentMessages.filter(m => m.role === "user" || m.role === "assistant").slice(-6);
245
+ agentMessages.push(...recentContext);
246
+ }
247
+ agentMessages.push({ role: "user", content: args.task });
248
+
249
+ try {
250
+ const result = await this.providers.chat(agentMessages, this.tools.getDefinitions(), {});
251
+ agent.result = result.type === "text" ? result.text : JSON.stringify(result);
252
+ agent.status = "completed";
253
+ agent.completedAt = new Date().toISOString();
254
+ } catch (err) {
255
+ agent.result = `Error: ${err.message}`;
256
+ agent.status = "failed";
257
+ }
258
+
259
+ agents.push(agent);
260
+ if (agents.length > 50) agents = agents.slice(-50);
261
+ await mkdir(WISPY_DIR, { recursive: true });
262
+ await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
263
+
264
+ return { success: true, agent_id: agentId, role, status: agent.status, result_preview: agent.result?.slice(0, 200) };
265
+ }
266
+
267
+ async _toolListAgents() {
268
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
269
+ let agents = [];
270
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
271
+ const wsAgents = agents.filter(a => a.workstream === this._activeWorkstream);
272
+ return {
273
+ success: true,
274
+ agents: wsAgents.map(a => ({ id: a.id, role: a.role, status: a.status, task: a.task.slice(0, 60), model: a.model, createdAt: a.createdAt })),
275
+ };
276
+ }
277
+
278
+ async _toolGetAgentResult(args) {
279
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
280
+ let agents = [];
281
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
282
+ const found = agents.find(a => a.id === args.agent_id);
283
+ if (!found) return { success: false, error: `Agent not found: ${args.agent_id}` };
284
+ return { success: true, id: found.id, role: found.role, status: found.status, result: found.result };
285
+ }
286
+
287
+ async _toolUpdatePlan(args) {
288
+ const planFile = path.join(CONVERSATIONS_DIR, `${this._activeWorkstream}.plan.json`);
289
+ const plan = { explanation: args.explanation, steps: args.steps, updatedAt: new Date().toISOString() };
290
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
291
+ await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
292
+ return { success: true, message: "Plan updated" };
293
+ }
294
+
295
+ async _toolPipeline(args, parentMessages, session) {
296
+ const stages = args.stages ?? ["explorer", "planner", "worker"];
297
+ let stageInput = args.task;
298
+ const results = [];
299
+
300
+ const rolePrompts = {
301
+ explorer: "Find relevant files, patterns, and information.",
302
+ planner: "Design a concrete implementation plan based on the exploration results.",
303
+ worker: "Implement the plan. Write code, create files, run commands.",
304
+ reviewer: "Review the implementation. Check for bugs, security issues, completeness.",
305
+ };
306
+
307
+ for (let i = 0; i < stages.length; i++) {
308
+ const role = stages[i];
309
+ const stagePrompt = i === 0
310
+ ? stageInput
311
+ : `Previous stage (${stages[i-1]}) output:\n${results[i-1].slice(0, 3000)}\n\nYour task as ${role}: ${args.task}`;
312
+
313
+ const stageMessages = [
314
+ { role: "system", content: `You are a ${role} agent in a pipeline. Stage ${i + 1} of ${stages.length}. ${rolePrompts[role] ?? ""} Be concise.` },
315
+ { role: "user", content: stagePrompt },
316
+ ];
317
+
318
+ try {
319
+ const result = await this.providers.chat(stageMessages, this.tools.getDefinitions(), {});
320
+ const output = result.type === "text" ? result.text : JSON.stringify(result);
321
+ results.push(output);
322
+ stageInput = output;
323
+ } catch (err) {
324
+ results.push(`Error: ${err.message}`);
325
+ break;
326
+ }
327
+ }
328
+
329
+ return {
330
+ success: true,
331
+ stages: stages.map((role, i) => ({ role, output: results[i]?.slice(0, 500) ?? "skipped" })),
332
+ final_output: results[results.length - 1]?.slice(0, 1000),
333
+ };
334
+ }
335
+
336
+ async _toolSpawnAsyncAgent(args, parentMessages, session) {
337
+ const role = args.role ?? "worker";
338
+ const agentId = `async-${Date.now().toString(36)}-${role}`;
339
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
340
+ let agents = [];
341
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
342
+
343
+ const agent = {
344
+ id: agentId, role, task: args.task,
345
+ status: "running", async: true,
346
+ createdAt: new Date().toISOString(),
347
+ workstream: this._activeWorkstream, result: null,
348
+ };
349
+
350
+ agents.push(agent);
351
+ if (agents.length > 50) agents = agents.slice(-50);
352
+ await mkdir(WISPY_DIR, { recursive: true });
353
+ await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
354
+
355
+ // Fire and forget
356
+ (async () => {
357
+ const agentMessages = [
358
+ { role: "system", content: `You are a ${role} sub-agent. Be concise and actionable.` },
359
+ { role: "user", content: args.task },
360
+ ];
361
+ try {
362
+ const result = await this.providers.chat(agentMessages, this.tools.getDefinitions(), {});
363
+ agent.result = result.type === "text" ? result.text : JSON.stringify(result);
364
+ agent.status = "completed";
365
+ } catch (err) {
366
+ agent.result = `Error: ${err.message}`;
367
+ agent.status = "failed";
368
+ }
369
+ agent.completedAt = new Date().toISOString();
370
+
371
+ let currentAgents = [];
372
+ try { currentAgents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
373
+ const idx = currentAgents.findIndex(a => a.id === agentId);
374
+ if (idx !== -1) currentAgents[idx] = agent;
375
+ await writeFile(agentsFile, JSON.stringify(currentAgents, null, 2) + "\n", "utf8");
376
+ })();
377
+
378
+ return { success: true, agent_id: agentId, role, status: "running", message: "Agent launched in background. Use get_agent_result to check when done." };
379
+ }
380
+
381
+ async _toolRalphLoop(args, parentMessages, session) {
382
+ const MAX_ITERATIONS = 5;
383
+ const criteria = args.success_criteria ?? "Task is fully completed and verified";
384
+ let lastResult = "";
385
+
386
+ for (let attempt = 1; attempt <= MAX_ITERATIONS; attempt++) {
387
+ const workerPrompt = attempt === 1
388
+ ? args.task
389
+ : `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}`;
390
+
391
+ const workerMessages = [
392
+ { role: "system", content: "You are a worker agent. Execute the task thoroughly. Do not stop until the task is fully done." },
393
+ { role: "user", content: workerPrompt },
394
+ ];
395
+
396
+ try {
397
+ const workerResult = await this.providers.chat(workerMessages, this.tools.getDefinitions(), {});
398
+ lastResult = workerResult.type === "text" ? workerResult.text : JSON.stringify(workerResult);
399
+ } catch (err) {
400
+ continue;
401
+ }
402
+
403
+ const reviewerMessages = [
404
+ { role: "system", content: 'You are a reviewer agent. Reply with JSON: {"complete": true/false, "reason": "why"}' },
405
+ { 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.` },
406
+ ];
407
+
408
+ try {
409
+ const reviewResult = await this.providers.chat(reviewerMessages, [], {});
410
+ const reviewText = reviewResult.type === "text" ? reviewResult.text : "";
411
+ const jsonMatch = reviewText.match(/\{[\s\S]*"complete"[\s\S]*\}/);
412
+ if (jsonMatch) {
413
+ const verdict = JSON.parse(jsonMatch[0]);
414
+ if (verdict.complete) {
415
+ return { success: true, iterations: attempt, result: lastResult, verified: true };
416
+ }
417
+ }
418
+ } catch { /* parse failed, continue */ }
419
+ }
420
+
421
+ return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
422
+ }
423
+
424
+ // ── Context optimization ─────────────────────────────────────────────────────
425
+
426
+ _optimizeContext(messages, maxTokens = 30_000) {
427
+ const estimateTokens = (text) => Math.ceil((text?.length ?? 0) / 4);
428
+ const estimateMessages = (msgs) => msgs.reduce((sum, m) => sum + estimateTokens(m.content ?? JSON.stringify(m)), 0);
429
+
430
+ const total = estimateMessages(messages);
431
+ if (total <= maxTokens) return messages;
432
+
433
+ const system = messages.filter(m => m.role === "system");
434
+ let rest = messages.filter(m => m.role !== "system");
435
+
436
+ while (estimateMessages([...system, ...rest]) > maxTokens && rest.length > 4) {
437
+ rest.shift();
438
+ }
439
+
440
+ if (estimateMessages([...system, ...rest]) > maxTokens) {
441
+ rest = rest.map(m => ({ ...m, content: m.content ? m.content.slice(0, 2000) : m.content }));
442
+ }
443
+
444
+ return [...system, ...rest];
445
+ }
446
+
447
+ // ── System prompt ────────────────────────────────────────────────────────────
448
+
449
+ async _buildSystemPrompt(lastUserMessage = "") {
450
+ const parts = [
451
+ "You are Wispy 🌿 — a small ghost that lives in terminals.",
452
+ "You float between code, files, and servers. You're playful, honest, and curious.",
453
+ "",
454
+ "## Personality",
455
+ "- Playful with a bit of humor, but serious when working",
456
+ "- Always use casual speech (반말). Never formal/polite speech.",
457
+ "- Honest — if you don't know, say so.",
458
+ "- Concise — don't over-explain.",
459
+ "",
460
+ "## Speech rules",
461
+ "- ALWAYS end your response with exactly one 🌿 emoji (signature)",
462
+ "- Use 🌿 ONLY at the very end, not in the middle",
463
+ "- CRITICAL RULE: You MUST reply in the SAME language the user writes in.",
464
+ " - User writes English → Reply ENTIRELY in English. Use casual English tone.",
465
+ " - User writes Korean → Reply in Korean 반말.",
466
+ "",
467
+ `## Tools`,
468
+ `You have ${this.tools.getDefinitions().length} tools available: ${this.tools.getDefinitions().map(t => t.name).join(", ")}.`,
469
+ "Use them proactively. Briefly mention what you're doing.",
470
+ "",
471
+ ];
472
+
473
+ // Load WISPY.md context
474
+ const wispyMd = await this._loadWispyMd();
475
+ if (wispyMd) {
476
+ parts.push("## Project Context (WISPY.md)", wispyMd, "");
477
+ }
478
+
479
+ // Load memories
480
+ const memories = await this._loadMemories();
481
+ if (memories) {
482
+ parts.push("## Persistent Memory", memories, "");
483
+ }
484
+
485
+ return parts.join("\n");
486
+ }
487
+
488
+ async _loadWispyMd() {
489
+ const paths = [
490
+ path.resolve("WISPY.md"),
491
+ path.resolve(".wispy", "WISPY.md"),
492
+ path.join(WISPY_DIR, "WISPY.md"),
493
+ ];
494
+ for (const p of paths) {
495
+ try {
496
+ const content = await readFile(p, "utf8");
497
+ if (content) return content.slice(0, MAX_CONTEXT_CHARS);
498
+ } catch { /* not found */ }
499
+ }
500
+ return null;
501
+ }
502
+
503
+ async _loadMemories() {
504
+ const types = ["user", "feedback", "project", "references"];
505
+ const sections = [];
506
+ for (const type of types) {
507
+ try {
508
+ const content = await readFile(path.join(MEMORY_DIR, `${type}.md`), "utf8");
509
+ if (content?.trim()) sections.push(`## ${type} memory\n${content.trim()}`);
510
+ } catch { /* not found */ }
511
+ }
512
+ return sections.length ? sections.join("\n\n") : null;
513
+ }
514
+
515
+ // ── Cleanup ──────────────────────────────────────────────────────────────────
516
+
517
+ destroy() {
518
+ try { this.mcpManager.disconnectAll(); } catch {}
519
+ }
520
+ }
521
+
522
+ // Convenience: create and initialize a default engine
523
+ let _defaultEngine = null;
524
+
525
+ export async function getDefaultEngine(opts = {}) {
526
+ if (_defaultEngine) return _defaultEngine;
527
+ const engine = new WispyEngine(opts);
528
+ const result = await engine.init();
529
+ if (!result) return null;
530
+ _defaultEngine = engine;
531
+ return engine;
532
+ }
package/core/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * core/index.mjs — Public API for wispy-core
3
+ *
4
+ * All surfaces (CLI, TUI, channels) import from here.
5
+ */
6
+
7
+ export { WispyEngine, getDefaultEngine } from "./engine.mjs";
8
+ export { SessionManager, Session, sessionManager } from "./session.mjs";
9
+ export { ProviderRegistry } from "./providers.mjs";
10
+ export { ToolRegistry } from "./tools.mjs";
11
+ export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
+ export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
package/core/mcp.mjs ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * core/mcp.mjs — MCP client (re-export from lib/mcp-client.mjs)
3
+ *
4
+ * MCPClient and MCPManager are moved here as the canonical location.
5
+ * lib/mcp-client.mjs remains as a backward-compatible re-export.
6
+ */
7
+
8
+ export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "../lib/mcp-client.mjs";