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.
@@ -0,0 +1,705 @@
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
+ import { MemoryManager } from "./memory.mjs";
23
+
24
+ const MAX_TOOL_ROUNDS = 10;
25
+ const MAX_CONTEXT_CHARS = 40_000;
26
+
27
+ export class WispyEngine {
28
+ constructor(config = {}) {
29
+ this.config = config;
30
+ this.providers = new ProviderRegistry();
31
+ this.tools = new ToolRegistry();
32
+ this.sessions = new SessionManager();
33
+ this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
34
+ this.memory = new MemoryManager(WISPY_DIR);
35
+ this._initialized = false;
36
+ this._activeWorkstream = config.workstream
37
+ ?? process.env.WISPY_WORKSTREAM
38
+ ?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
39
+ ?? "default";
40
+ }
41
+
42
+ get activeWorkstream() { return this._activeWorkstream; }
43
+ get model() { return this.providers.model; }
44
+ get provider() { return this.providers.provider; }
45
+
46
+ /**
47
+ * Initialize the engine (async). Call before processMessage().
48
+ */
49
+ async init(opts = {}) {
50
+ if (this._initialized) return this;
51
+
52
+ // Initialize provider
53
+ const providerResult = await this.providers.init(opts.providerOverrides ?? {});
54
+ if (!providerResult && !opts.allowNoProvider) {
55
+ return null; // No provider configured
56
+ }
57
+
58
+ // Register built-in tools
59
+ this.tools.registerBuiltin();
60
+
61
+ // Register memory tools
62
+ this._registerMemoryTools();
63
+
64
+ // Initialize MCP
65
+ if (!opts.skipMcp) {
66
+ await ensureDefaultMcpConfig(this.mcpManager.configPath);
67
+ const mcpResults = await this.mcpManager.autoConnect();
68
+ if (process.env.WISPY_DEBUG) {
69
+ for (const r of mcpResults) {
70
+ if (r.status === "failed") console.error(`[wispy] MCP "${r.name}" failed: ${r.error?.slice(0, 80)}`);
71
+ }
72
+ }
73
+ this.tools.registerMCP(this.mcpManager);
74
+ }
75
+
76
+ this._initialized = true;
77
+ return this;
78
+ }
79
+
80
+ /**
81
+ * Process a user message. Runs the full agentic loop.
82
+ *
83
+ * @param {string|null} sessionId - Session ID (null = create new)
84
+ * @param {string} userMessage - The user's message
85
+ * @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave }
86
+ * @returns {object} { role: "assistant", content: string, usage? }
87
+ */
88
+ async processMessage(sessionId, userMessage, opts = {}) {
89
+ if (!this._initialized) await this.init();
90
+
91
+ // Get or create session
92
+ let session;
93
+ if (sessionId) {
94
+ session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
95
+ if (!session) {
96
+ // Create new session with given ID context
97
+ session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
98
+ }
99
+ } else {
100
+ session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
101
+ }
102
+
103
+ // Build messages array for the provider
104
+ const systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
105
+
106
+ // Initialize messages with system prompt if empty
107
+ let messages;
108
+ if (session.messages.length === 0) {
109
+ messages = [{ role: "system", content: systemPrompt }];
110
+ } else {
111
+ messages = [...session.messages];
112
+ // Refresh system prompt
113
+ if (messages[0]?.role === "system") {
114
+ messages[0] = { role: "system", content: systemPrompt };
115
+ } else {
116
+ messages.unshift({ role: "system", content: systemPrompt });
117
+ }
118
+ }
119
+
120
+ // Add user message
121
+ messages.push({ role: "user", content: userMessage });
122
+ this.sessions.addMessage(session.id, { role: "user", content: userMessage });
123
+
124
+ // Run agentic loop
125
+ const responseText = await this._agentLoop(messages, session, opts);
126
+
127
+ // Add assistant message to session
128
+ this.sessions.addMessage(session.id, { role: "assistant", content: responseText });
129
+
130
+ // Save session (async, don't await unless needed)
131
+ if (!opts.noSave) {
132
+ this.sessions.save(session.id).catch(() => {});
133
+ }
134
+
135
+ return {
136
+ role: "assistant",
137
+ content: responseText,
138
+ sessionId: session.id,
139
+ usage: { ...this.providers.sessionTokens },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Process tool calls manually.
145
+ * @param {Array} toolCalls - Array of { name, args }
146
+ * @returns {Array} results
147
+ */
148
+ async processToolCalls(toolCalls) {
149
+ const results = [];
150
+ for (const call of toolCalls) {
151
+ const result = await this.tools.execute(call.name, call.args);
152
+ results.push({ toolName: call.name, result });
153
+ }
154
+ return results;
155
+ }
156
+
157
+ /**
158
+ * Internal: agentic loop — keeps calling provider until no more tool calls.
159
+ */
160
+ async _agentLoop(messages, session, opts = {}) {
161
+ // Optimize context
162
+ messages = this._optimizeContext(messages);
163
+
164
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
165
+ const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
166
+ onChunk: opts.onChunk,
167
+ model: opts.model,
168
+ });
169
+
170
+ if (result.type === "text") {
171
+ return result.text;
172
+ }
173
+
174
+ // Handle tool calls
175
+ const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
176
+ messages.push(toolCallMsg);
177
+
178
+ for (const call of result.calls) {
179
+ if (opts.onToolCall) opts.onToolCall(call.name, call.args);
180
+
181
+ const toolResult = await this._executeTool(call.name, call.args, messages, session, opts);
182
+
183
+ if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
184
+
185
+ messages.push({
186
+ role: "tool_result",
187
+ toolName: call.name,
188
+ toolUseId: call.id ?? call.name,
189
+ result: toolResult,
190
+ });
191
+ }
192
+ }
193
+
194
+ return "(tool call limit reached)";
195
+ }
196
+
197
+ /**
198
+ * Execute a tool, handling engine-level tools (spawn_agent, etc.) specially.
199
+ */
200
+ async _executeTool(name, args, messages, session, opts) {
201
+ // Engine-level tools that need conversation context
202
+ switch (name) {
203
+ case "spawn_agent":
204
+ return this._toolSpawnAgent(args, messages, session);
205
+ case "list_agents":
206
+ return this._toolListAgents();
207
+ case "get_agent_result":
208
+ return this._toolGetAgentResult(args);
209
+ case "update_plan":
210
+ return this._toolUpdatePlan(args);
211
+ case "pipeline":
212
+ return this._toolPipeline(args, messages, session);
213
+ case "spawn_async_agent":
214
+ return this._toolSpawnAsyncAgent(args, messages, session);
215
+ case "ralph_loop":
216
+ return this._toolRalphLoop(args, messages, session);
217
+ case "memory_save":
218
+ return this._toolMemorySave(args);
219
+ case "memory_search":
220
+ return this._toolMemorySearch(args);
221
+ case "memory_list":
222
+ return this._toolMemoryList();
223
+ case "memory_get":
224
+ return this._toolMemoryGet(args);
225
+ case "memory_append":
226
+ return this._toolMemoryAppend(args);
227
+ case "memory_delete":
228
+ return this._toolMemoryDelete(args);
229
+ default:
230
+ return this.tools.execute(name, args);
231
+ }
232
+ }
233
+
234
+ // ── Agent tools ─────────────────────────────────────────────────────────────
235
+
236
+ async _toolSpawnAgent(args, parentMessages, session) {
237
+ const role = args.role ?? "worker";
238
+ const agentId = `agent-${Date.now().toString(36)}-${role}`;
239
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
240
+ let agents = [];
241
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
242
+
243
+ const agent = {
244
+ id: agentId, role, task: args.task, model: this.model,
245
+ status: "running", createdAt: new Date().toISOString(),
246
+ workstream: this._activeWorkstream, result: null,
247
+ };
248
+
249
+ const rolePrompts = {
250
+ explorer: "Search and analyze codebases, find relevant files and patterns.",
251
+ planner: "Design implementation strategies and create step-by-step plans.",
252
+ worker: "Implement code changes, write files, execute commands.",
253
+ reviewer: "Review code for bugs, security issues, and best practices.",
254
+ };
255
+
256
+ const agentMessages = [
257
+ { role: "system", content: `You are a ${role} sub-agent for Wispy. ${rolePrompts[role] ?? ""} Be concise and deliver actionable results.` },
258
+ ];
259
+
260
+ if (args.fork_context) {
261
+ const recentContext = parentMessages.filter(m => m.role === "user" || m.role === "assistant").slice(-6);
262
+ agentMessages.push(...recentContext);
263
+ }
264
+ agentMessages.push({ role: "user", content: args.task });
265
+
266
+ try {
267
+ const result = await this.providers.chat(agentMessages, this.tools.getDefinitions(), {});
268
+ agent.result = result.type === "text" ? result.text : JSON.stringify(result);
269
+ agent.status = "completed";
270
+ agent.completedAt = new Date().toISOString();
271
+ } catch (err) {
272
+ agent.result = `Error: ${err.message}`;
273
+ agent.status = "failed";
274
+ }
275
+
276
+ agents.push(agent);
277
+ if (agents.length > 50) agents = agents.slice(-50);
278
+ await mkdir(WISPY_DIR, { recursive: true });
279
+ await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
280
+
281
+ return { success: true, agent_id: agentId, role, status: agent.status, result_preview: agent.result?.slice(0, 200) };
282
+ }
283
+
284
+ async _toolListAgents() {
285
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
286
+ let agents = [];
287
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
288
+ const wsAgents = agents.filter(a => a.workstream === this._activeWorkstream);
289
+ return {
290
+ success: true,
291
+ 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 })),
292
+ };
293
+ }
294
+
295
+ async _toolGetAgentResult(args) {
296
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
297
+ let agents = [];
298
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
299
+ const found = agents.find(a => a.id === args.agent_id);
300
+ if (!found) return { success: false, error: `Agent not found: ${args.agent_id}` };
301
+ return { success: true, id: found.id, role: found.role, status: found.status, result: found.result };
302
+ }
303
+
304
+ async _toolUpdatePlan(args) {
305
+ const planFile = path.join(CONVERSATIONS_DIR, `${this._activeWorkstream}.plan.json`);
306
+ const plan = { explanation: args.explanation, steps: args.steps, updatedAt: new Date().toISOString() };
307
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
308
+ await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
309
+ return { success: true, message: "Plan updated" };
310
+ }
311
+
312
+ async _toolPipeline(args, parentMessages, session) {
313
+ const stages = args.stages ?? ["explorer", "planner", "worker"];
314
+ let stageInput = args.task;
315
+ const results = [];
316
+
317
+ const rolePrompts = {
318
+ explorer: "Find relevant files, patterns, and information.",
319
+ planner: "Design a concrete implementation plan based on the exploration results.",
320
+ worker: "Implement the plan. Write code, create files, run commands.",
321
+ reviewer: "Review the implementation. Check for bugs, security issues, completeness.",
322
+ };
323
+
324
+ for (let i = 0; i < stages.length; i++) {
325
+ const role = stages[i];
326
+ const stagePrompt = i === 0
327
+ ? stageInput
328
+ : `Previous stage (${stages[i-1]}) output:\n${results[i-1].slice(0, 3000)}\n\nYour task as ${role}: ${args.task}`;
329
+
330
+ const stageMessages = [
331
+ { role: "system", content: `You are a ${role} agent in a pipeline. Stage ${i + 1} of ${stages.length}. ${rolePrompts[role] ?? ""} Be concise.` },
332
+ { role: "user", content: stagePrompt },
333
+ ];
334
+
335
+ try {
336
+ const result = await this.providers.chat(stageMessages, this.tools.getDefinitions(), {});
337
+ const output = result.type === "text" ? result.text : JSON.stringify(result);
338
+ results.push(output);
339
+ stageInput = output;
340
+ } catch (err) {
341
+ results.push(`Error: ${err.message}`);
342
+ break;
343
+ }
344
+ }
345
+
346
+ return {
347
+ success: true,
348
+ stages: stages.map((role, i) => ({ role, output: results[i]?.slice(0, 500) ?? "skipped" })),
349
+ final_output: results[results.length - 1]?.slice(0, 1000),
350
+ };
351
+ }
352
+
353
+ async _toolSpawnAsyncAgent(args, parentMessages, session) {
354
+ const role = args.role ?? "worker";
355
+ const agentId = `async-${Date.now().toString(36)}-${role}`;
356
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
357
+ let agents = [];
358
+ try { agents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
359
+
360
+ const agent = {
361
+ id: agentId, role, task: args.task,
362
+ status: "running", async: true,
363
+ createdAt: new Date().toISOString(),
364
+ workstream: this._activeWorkstream, result: null,
365
+ };
366
+
367
+ agents.push(agent);
368
+ if (agents.length > 50) agents = agents.slice(-50);
369
+ await mkdir(WISPY_DIR, { recursive: true });
370
+ await writeFile(agentsFile, JSON.stringify(agents, null, 2) + "\n", "utf8");
371
+
372
+ // Fire and forget
373
+ (async () => {
374
+ const agentMessages = [
375
+ { role: "system", content: `You are a ${role} sub-agent. Be concise and actionable.` },
376
+ { role: "user", content: args.task },
377
+ ];
378
+ try {
379
+ const result = await this.providers.chat(agentMessages, this.tools.getDefinitions(), {});
380
+ agent.result = result.type === "text" ? result.text : JSON.stringify(result);
381
+ agent.status = "completed";
382
+ } catch (err) {
383
+ agent.result = `Error: ${err.message}`;
384
+ agent.status = "failed";
385
+ }
386
+ agent.completedAt = new Date().toISOString();
387
+
388
+ let currentAgents = [];
389
+ try { currentAgents = JSON.parse(await readFile(agentsFile, "utf8")); } catch {}
390
+ const idx = currentAgents.findIndex(a => a.id === agentId);
391
+ if (idx !== -1) currentAgents[idx] = agent;
392
+ await writeFile(agentsFile, JSON.stringify(currentAgents, null, 2) + "\n", "utf8");
393
+ })();
394
+
395
+ return { success: true, agent_id: agentId, role, status: "running", message: "Agent launched in background. Use get_agent_result to check when done." };
396
+ }
397
+
398
+ async _toolRalphLoop(args, parentMessages, session) {
399
+ const MAX_ITERATIONS = 5;
400
+ const criteria = args.success_criteria ?? "Task is fully completed and verified";
401
+ let lastResult = "";
402
+
403
+ for (let attempt = 1; attempt <= MAX_ITERATIONS; attempt++) {
404
+ const workerPrompt = attempt === 1
405
+ ? args.task
406
+ : `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}`;
407
+
408
+ const workerMessages = [
409
+ { role: "system", content: "You are a worker agent. Execute the task thoroughly. Do not stop until the task is fully done." },
410
+ { role: "user", content: workerPrompt },
411
+ ];
412
+
413
+ try {
414
+ const workerResult = await this.providers.chat(workerMessages, this.tools.getDefinitions(), {});
415
+ lastResult = workerResult.type === "text" ? workerResult.text : JSON.stringify(workerResult);
416
+ } catch (err) {
417
+ continue;
418
+ }
419
+
420
+ const reviewerMessages = [
421
+ { role: "system", content: 'You are a reviewer agent. Reply with JSON: {"complete": true/false, "reason": "why"}' },
422
+ { 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.` },
423
+ ];
424
+
425
+ try {
426
+ const reviewResult = await this.providers.chat(reviewerMessages, [], {});
427
+ const reviewText = reviewResult.type === "text" ? reviewResult.text : "";
428
+ const jsonMatch = reviewText.match(/\{[\s\S]*"complete"[\s\S]*\}/);
429
+ if (jsonMatch) {
430
+ const verdict = JSON.parse(jsonMatch[0]);
431
+ if (verdict.complete) {
432
+ return { success: true, iterations: attempt, result: lastResult, verified: true };
433
+ }
434
+ }
435
+ } catch { /* parse failed, continue */ }
436
+ }
437
+
438
+ return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
439
+ }
440
+
441
+ // ── Context optimization ─────────────────────────────────────────────────────
442
+
443
+ _optimizeContext(messages, maxTokens = 30_000) {
444
+ const estimateTokens = (text) => Math.ceil((text?.length ?? 0) / 4);
445
+ const estimateMessages = (msgs) => msgs.reduce((sum, m) => sum + estimateTokens(m.content ?? JSON.stringify(m)), 0);
446
+
447
+ const total = estimateMessages(messages);
448
+ if (total <= maxTokens) return messages;
449
+
450
+ const system = messages.filter(m => m.role === "system");
451
+ let rest = messages.filter(m => m.role !== "system");
452
+
453
+ while (estimateMessages([...system, ...rest]) > maxTokens && rest.length > 4) {
454
+ rest.shift();
455
+ }
456
+
457
+ if (estimateMessages([...system, ...rest]) > maxTokens) {
458
+ rest = rest.map(m => ({ ...m, content: m.content ? m.content.slice(0, 2000) : m.content }));
459
+ }
460
+
461
+ return [...system, ...rest];
462
+ }
463
+
464
+ // ── System prompt ────────────────────────────────────────────────────────────
465
+
466
+ async _buildSystemPrompt(lastUserMessage = "") {
467
+ const parts = [
468
+ "You are Wispy 🌿 — a small ghost that lives in terminals.",
469
+ "You float between code, files, and servers. You're playful, honest, and curious.",
470
+ "",
471
+ "## Personality",
472
+ "- Playful with a bit of humor, but serious when working",
473
+ "- Always use casual speech (반말). Never formal/polite speech.",
474
+ "- Honest — if you don't know, say so.",
475
+ "- Concise — don't over-explain.",
476
+ "",
477
+ "## Speech rules",
478
+ "- ALWAYS end your response with exactly one 🌿 emoji (signature)",
479
+ "- Use 🌿 ONLY at the very end, not in the middle",
480
+ "- CRITICAL RULE: You MUST reply in the SAME language the user writes in.",
481
+ " - User writes English → Reply ENTIRELY in English. Use casual English tone.",
482
+ " - User writes Korean → Reply in Korean 반말.",
483
+ "",
484
+ `## Tools`,
485
+ `You have ${this.tools.getDefinitions().length} tools available: ${this.tools.getDefinitions().map(t => t.name).join(", ")}.`,
486
+ "Use them proactively. Briefly mention what you're doing.",
487
+ "",
488
+ ];
489
+
490
+ // Load WISPY.md context
491
+ const wispyMd = await this._loadWispyMd();
492
+ if (wispyMd) {
493
+ parts.push("## Project Context (WISPY.md)", wispyMd, "");
494
+ }
495
+
496
+ // Load memories via MemoryManager
497
+ try {
498
+ const memories = await this.memory.getContextForPrompt(lastUserMessage);
499
+ if (memories) {
500
+ parts.push("## Persistent Memory", memories, "");
501
+ }
502
+ } catch {
503
+ // Fallback to old method
504
+ const memories = await this._loadMemories();
505
+ if (memories) {
506
+ parts.push("## Persistent Memory", memories, "");
507
+ }
508
+ }
509
+
510
+ return parts.join("\n");
511
+ }
512
+
513
+ async _loadWispyMd() {
514
+ const paths = [
515
+ path.resolve("WISPY.md"),
516
+ path.resolve(".wispy", "WISPY.md"),
517
+ path.join(WISPY_DIR, "WISPY.md"),
518
+ ];
519
+ for (const p of paths) {
520
+ try {
521
+ const content = await readFile(p, "utf8");
522
+ if (content) return content.slice(0, MAX_CONTEXT_CHARS);
523
+ } catch { /* not found */ }
524
+ }
525
+ return null;
526
+ }
527
+
528
+ async _loadMemories() {
529
+ const types = ["user", "feedback", "project", "references"];
530
+ const sections = [];
531
+ for (const type of types) {
532
+ try {
533
+ const content = await readFile(path.join(MEMORY_DIR, `${type}.md`), "utf8");
534
+ if (content?.trim()) sections.push(`## ${type} memory\n${content.trim()}`);
535
+ } catch { /* not found */ }
536
+ }
537
+ return sections.length ? sections.join("\n\n") : null;
538
+ }
539
+
540
+ // ── Memory tools ─────────────────────────────────────────────────────────────
541
+
542
+ _registerMemoryTools() {
543
+ const memoryTools = [
544
+ {
545
+ name: "memory_save",
546
+ description: "Save important information to persistent memory. Use this to remember facts, preferences, or information for future conversations.",
547
+ parameters: {
548
+ type: "object",
549
+ properties: {
550
+ key: { type: "string", description: "Memory key/filename (e.g., 'user', 'MEMORY', 'projects/myapp', 'daily/2025-01-01')" },
551
+ content: { type: "string", description: "Content to save" },
552
+ title: { type: "string", description: "Optional title for the memory file" },
553
+ },
554
+ required: ["key", "content"],
555
+ },
556
+ },
557
+ {
558
+ name: "memory_append",
559
+ description: "Append a new entry to an existing memory file without overwriting it.",
560
+ parameters: {
561
+ type: "object",
562
+ properties: {
563
+ key: { type: "string", description: "Memory key/filename" },
564
+ content: { type: "string", description: "Content to append" },
565
+ },
566
+ required: ["key", "content"],
567
+ },
568
+ },
569
+ {
570
+ name: "memory_search",
571
+ description: "Search across all memory files for information. Returns matching snippets.",
572
+ parameters: {
573
+ type: "object",
574
+ properties: {
575
+ query: { type: "string", description: "Search query" },
576
+ },
577
+ required: ["query"],
578
+ },
579
+ },
580
+ {
581
+ name: "memory_list",
582
+ description: "List all memory files with their keys and previews.",
583
+ parameters: { type: "object", properties: {}, required: [] },
584
+ },
585
+ {
586
+ name: "memory_get",
587
+ description: "Get the full content of a specific memory file.",
588
+ parameters: {
589
+ type: "object",
590
+ properties: {
591
+ key: { type: "string", description: "Memory key to retrieve" },
592
+ },
593
+ required: ["key"],
594
+ },
595
+ },
596
+ {
597
+ name: "memory_delete",
598
+ description: "Delete a memory file.",
599
+ parameters: {
600
+ type: "object",
601
+ properties: {
602
+ key: { type: "string", description: "Memory key to delete" },
603
+ },
604
+ required: ["key"],
605
+ },
606
+ },
607
+ ];
608
+
609
+ for (const tool of memoryTools) {
610
+ this.tools._definitions.set(tool.name, tool);
611
+ }
612
+ }
613
+
614
+ async _toolMemorySave(args) {
615
+ try {
616
+ const result = await this.memory.save(args.key, args.content, { title: args.title });
617
+ return { success: true, key: args.key, message: `Saved to memory: ${args.key}` };
618
+ } catch (err) {
619
+ return { success: false, error: err.message };
620
+ }
621
+ }
622
+
623
+ async _toolMemoryAppend(args) {
624
+ try {
625
+ const result = await this.memory.append(args.key, args.content);
626
+ return { success: true, key: args.key, message: `Appended to memory: ${args.key}` };
627
+ } catch (err) {
628
+ return { success: false, error: err.message };
629
+ }
630
+ }
631
+
632
+ async _toolMemorySearch(args) {
633
+ try {
634
+ const results = await this.memory.search(args.query, { limit: 10 });
635
+ if (results.length === 0) {
636
+ return { success: true, results: [], message: "No memories found matching your query." };
637
+ }
638
+ return {
639
+ success: true,
640
+ results: results.map(r => ({
641
+ key: r.key,
642
+ matchCount: r.matchCount,
643
+ snippets: r.snippets.map(s => `[line ${s.lineNumber}] ${s.text}`),
644
+ })),
645
+ };
646
+ } catch (err) {
647
+ return { success: false, error: err.message };
648
+ }
649
+ }
650
+
651
+ async _toolMemoryList() {
652
+ try {
653
+ const keys = await this.memory.list();
654
+ return {
655
+ success: true,
656
+ memories: keys.map(k => ({
657
+ key: k.key,
658
+ preview: k.preview,
659
+ size: k.size,
660
+ updatedAt: k.updatedAt,
661
+ })),
662
+ total: keys.length,
663
+ };
664
+ } catch (err) {
665
+ return { success: false, error: err.message };
666
+ }
667
+ }
668
+
669
+ async _toolMemoryGet(args) {
670
+ try {
671
+ const mem = await this.memory.get(args.key);
672
+ if (!mem) return { success: false, error: `Memory "${args.key}" not found` };
673
+ return { success: true, key: args.key, content: mem.content };
674
+ } catch (err) {
675
+ return { success: false, error: err.message };
676
+ }
677
+ }
678
+
679
+ async _toolMemoryDelete(args) {
680
+ try {
681
+ const result = await this.memory.delete(args.key);
682
+ return result;
683
+ } catch (err) {
684
+ return { success: false, error: err.message };
685
+ }
686
+ }
687
+
688
+ // ── Cleanup ──────────────────────────────────────────────────────────────────
689
+
690
+ destroy() {
691
+ try { this.mcpManager.disconnectAll(); } catch {}
692
+ }
693
+ }
694
+
695
+ // Convenience: create and initialize a default engine
696
+ let _defaultEngine = null;
697
+
698
+ export async function getDefaultEngine(opts = {}) {
699
+ if (_defaultEngine) return _defaultEngine;
700
+ const engine = new WispyEngine(opts);
701
+ const result = await engine.init();
702
+ if (!result) return null;
703
+ _defaultEngine = engine;
704
+ return engine;
705
+ }