wispy-cli 0.7.0 → 0.9.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/core/engine.mjs CHANGED
@@ -19,6 +19,8 @@ import { ProviderRegistry } from "./providers.mjs";
19
19
  import { ToolRegistry } from "./tools.mjs";
20
20
  import { SessionManager } from "./session.mjs";
21
21
  import { MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
22
+ import { MemoryManager } from "./memory.mjs";
23
+ import { SubAgentManager } from "./subagents.mjs";
22
24
 
23
25
  const MAX_TOOL_ROUNDS = 10;
24
26
  const MAX_CONTEXT_CHARS = 40_000;
@@ -30,6 +32,8 @@ export class WispyEngine {
30
32
  this.tools = new ToolRegistry();
31
33
  this.sessions = new SessionManager();
32
34
  this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
35
+ this.memory = new MemoryManager(WISPY_DIR);
36
+ this.subagents = new SubAgentManager(this, this.sessions);
33
37
  this._initialized = false;
34
38
  this._activeWorkstream = config.workstream
35
39
  ?? process.env.WISPY_WORKSTREAM
@@ -56,6 +60,9 @@ export class WispyEngine {
56
60
  // Register built-in tools
57
61
  this.tools.registerBuiltin();
58
62
 
63
+ // Register memory tools
64
+ this._registerMemoryTools();
65
+
59
66
  // Initialize MCP
60
67
  if (!opts.skipMcp) {
61
68
  await ensureDefaultMcpConfig(this.mcpManager.configPath);
@@ -209,6 +216,29 @@ export class WispyEngine {
209
216
  return this._toolSpawnAsyncAgent(args, messages, session);
210
217
  case "ralph_loop":
211
218
  return this._toolRalphLoop(args, messages, session);
219
+ case "memory_save":
220
+ return this._toolMemorySave(args);
221
+ case "memory_search":
222
+ return this._toolMemorySearch(args);
223
+ case "memory_list":
224
+ return this._toolMemoryList();
225
+ case "memory_get":
226
+ return this._toolMemoryGet(args);
227
+ case "memory_append":
228
+ return this._toolMemoryAppend(args);
229
+ case "memory_delete":
230
+ return this._toolMemoryDelete(args);
231
+ // Sub-agent tools (v0.9)
232
+ case "spawn_subagent":
233
+ return this._toolSpawnSubagent(args, opts);
234
+ case "list_subagents":
235
+ return this._toolListSubagents();
236
+ case "get_subagent_result":
237
+ return this._toolGetSubagentResult(args);
238
+ case "kill_subagent":
239
+ return this._toolKillSubagent(args);
240
+ case "steer_subagent":
241
+ return this._toolSteerSubagent(args);
212
242
  default:
213
243
  return this.tools.execute(name, args);
214
244
  }
@@ -421,6 +451,88 @@ export class WispyEngine {
421
451
  return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
422
452
  }
423
453
 
454
+ // ── Sub-agent tools (v0.9) ──────────────────────────────────────────────────
455
+
456
+ async _toolSpawnSubagent(args, parentOpts = {}) {
457
+ const agent = await this.subagents.spawn({
458
+ task: args.task,
459
+ label: args.label,
460
+ model: args.model ?? null,
461
+ timeout: args.timeout_seconds ?? 300,
462
+ workstream: this._activeWorkstream,
463
+ onNotify: parentOpts?.onSubagentNotify ?? null,
464
+ });
465
+
466
+ return {
467
+ success: true,
468
+ id: agent.id,
469
+ label: agent.label,
470
+ status: agent.status,
471
+ message: `Sub-agent '${agent.label}' spawned (id: ${agent.id}). Use get_subagent_result to check when done.`,
472
+ };
473
+ }
474
+
475
+ async _toolListSubagents() {
476
+ const inMemory = this.subagents.list();
477
+ const history = await this.subagents.listHistory(20);
478
+
479
+ // Merge: in-memory takes precedence
480
+ const inMemoryIds = new Set(inMemory.map(a => a.id));
481
+ const combined = [
482
+ ...inMemory.map(a => a.toJSON()),
483
+ ...history.filter(h => !inMemoryIds.has(h.id)),
484
+ ].slice(0, 30);
485
+
486
+ return {
487
+ success: true,
488
+ agents: combined.map(a => ({
489
+ id: a.id,
490
+ label: a.label,
491
+ status: a.status,
492
+ task: a.task?.slice(0, 80),
493
+ model: a.model,
494
+ createdAt: a.createdAt,
495
+ completedAt: a.completedAt,
496
+ })),
497
+ total: combined.length,
498
+ };
499
+ }
500
+
501
+ async _toolGetSubagentResult(args) {
502
+ const inMemory = this.subagents.get(args.id);
503
+ if (inMemory) {
504
+ return {
505
+ success: true,
506
+ ...inMemory.toJSON(),
507
+ result_preview: inMemory.result?.slice(0, 500),
508
+ };
509
+ }
510
+
511
+ // Try disk
512
+ const disk = await this.subagents.loadFromDisk(args.id);
513
+ if (disk) {
514
+ return { success: true, ...disk, result_preview: disk.result?.slice(0, 500) };
515
+ }
516
+
517
+ return { success: false, error: `Sub-agent not found: ${args.id}` };
518
+ }
519
+
520
+ _toolKillSubagent(args) {
521
+ const agent = this.subagents.get(args.id);
522
+ if (!agent) return { success: false, error: `Sub-agent not found: ${args.id}` };
523
+ this.subagents.kill(args.id);
524
+ return { success: true, id: args.id, message: `Sub-agent '${agent.label}' killed.` };
525
+ }
526
+
527
+ _toolSteerSubagent(args) {
528
+ try {
529
+ this.subagents.steer(args.id, args.message);
530
+ return { success: true, id: args.id, message: `Guidance sent to sub-agent.` };
531
+ } catch (err) {
532
+ return { success: false, error: err.message };
533
+ }
534
+ }
535
+
424
536
  // ── Context optimization ─────────────────────────────────────────────────────
425
537
 
426
538
  _optimizeContext(messages, maxTokens = 30_000) {
@@ -476,10 +588,18 @@ export class WispyEngine {
476
588
  parts.push("## Project Context (WISPY.md)", wispyMd, "");
477
589
  }
478
590
 
479
- // Load memories
480
- const memories = await this._loadMemories();
481
- if (memories) {
482
- parts.push("## Persistent Memory", memories, "");
591
+ // Load memories via MemoryManager
592
+ try {
593
+ const memories = await this.memory.getContextForPrompt(lastUserMessage);
594
+ if (memories) {
595
+ parts.push("## Persistent Memory", memories, "");
596
+ }
597
+ } catch {
598
+ // Fallback to old method
599
+ const memories = await this._loadMemories();
600
+ if (memories) {
601
+ parts.push("## Persistent Memory", memories, "");
602
+ }
483
603
  }
484
604
 
485
605
  return parts.join("\n");
@@ -512,6 +632,154 @@ export class WispyEngine {
512
632
  return sections.length ? sections.join("\n\n") : null;
513
633
  }
514
634
 
635
+ // ── Memory tools ─────────────────────────────────────────────────────────────
636
+
637
+ _registerMemoryTools() {
638
+ const memoryTools = [
639
+ {
640
+ name: "memory_save",
641
+ description: "Save important information to persistent memory. Use this to remember facts, preferences, or information for future conversations.",
642
+ parameters: {
643
+ type: "object",
644
+ properties: {
645
+ key: { type: "string", description: "Memory key/filename (e.g., 'user', 'MEMORY', 'projects/myapp', 'daily/2025-01-01')" },
646
+ content: { type: "string", description: "Content to save" },
647
+ title: { type: "string", description: "Optional title for the memory file" },
648
+ },
649
+ required: ["key", "content"],
650
+ },
651
+ },
652
+ {
653
+ name: "memory_append",
654
+ description: "Append a new entry to an existing memory file without overwriting it.",
655
+ parameters: {
656
+ type: "object",
657
+ properties: {
658
+ key: { type: "string", description: "Memory key/filename" },
659
+ content: { type: "string", description: "Content to append" },
660
+ },
661
+ required: ["key", "content"],
662
+ },
663
+ },
664
+ {
665
+ name: "memory_search",
666
+ description: "Search across all memory files for information. Returns matching snippets.",
667
+ parameters: {
668
+ type: "object",
669
+ properties: {
670
+ query: { type: "string", description: "Search query" },
671
+ },
672
+ required: ["query"],
673
+ },
674
+ },
675
+ {
676
+ name: "memory_list",
677
+ description: "List all memory files with their keys and previews.",
678
+ parameters: { type: "object", properties: {}, required: [] },
679
+ },
680
+ {
681
+ name: "memory_get",
682
+ description: "Get the full content of a specific memory file.",
683
+ parameters: {
684
+ type: "object",
685
+ properties: {
686
+ key: { type: "string", description: "Memory key to retrieve" },
687
+ },
688
+ required: ["key"],
689
+ },
690
+ },
691
+ {
692
+ name: "memory_delete",
693
+ description: "Delete a memory file.",
694
+ parameters: {
695
+ type: "object",
696
+ properties: {
697
+ key: { type: "string", description: "Memory key to delete" },
698
+ },
699
+ required: ["key"],
700
+ },
701
+ },
702
+ ];
703
+
704
+ for (const tool of memoryTools) {
705
+ this.tools._definitions.set(tool.name, tool);
706
+ }
707
+ }
708
+
709
+ async _toolMemorySave(args) {
710
+ try {
711
+ const result = await this.memory.save(args.key, args.content, { title: args.title });
712
+ return { success: true, key: args.key, message: `Saved to memory: ${args.key}` };
713
+ } catch (err) {
714
+ return { success: false, error: err.message };
715
+ }
716
+ }
717
+
718
+ async _toolMemoryAppend(args) {
719
+ try {
720
+ const result = await this.memory.append(args.key, args.content);
721
+ return { success: true, key: args.key, message: `Appended to memory: ${args.key}` };
722
+ } catch (err) {
723
+ return { success: false, error: err.message };
724
+ }
725
+ }
726
+
727
+ async _toolMemorySearch(args) {
728
+ try {
729
+ const results = await this.memory.search(args.query, { limit: 10 });
730
+ if (results.length === 0) {
731
+ return { success: true, results: [], message: "No memories found matching your query." };
732
+ }
733
+ return {
734
+ success: true,
735
+ results: results.map(r => ({
736
+ key: r.key,
737
+ matchCount: r.matchCount,
738
+ snippets: r.snippets.map(s => `[line ${s.lineNumber}] ${s.text}`),
739
+ })),
740
+ };
741
+ } catch (err) {
742
+ return { success: false, error: err.message };
743
+ }
744
+ }
745
+
746
+ async _toolMemoryList() {
747
+ try {
748
+ const keys = await this.memory.list();
749
+ return {
750
+ success: true,
751
+ memories: keys.map(k => ({
752
+ key: k.key,
753
+ preview: k.preview,
754
+ size: k.size,
755
+ updatedAt: k.updatedAt,
756
+ })),
757
+ total: keys.length,
758
+ };
759
+ } catch (err) {
760
+ return { success: false, error: err.message };
761
+ }
762
+ }
763
+
764
+ async _toolMemoryGet(args) {
765
+ try {
766
+ const mem = await this.memory.get(args.key);
767
+ if (!mem) return { success: false, error: `Memory "${args.key}" not found` };
768
+ return { success: true, key: args.key, content: mem.content };
769
+ } catch (err) {
770
+ return { success: false, error: err.message };
771
+ }
772
+ }
773
+
774
+ async _toolMemoryDelete(args) {
775
+ try {
776
+ const result = await this.memory.delete(args.key);
777
+ return result;
778
+ } catch (err) {
779
+ return { success: false, error: err.message };
780
+ }
781
+ }
782
+
515
783
  // ── Cleanup ──────────────────────────────────────────────────────────────────
516
784
 
517
785
  destroy() {
package/core/index.mjs CHANGED
@@ -10,3 +10,6 @@ export { ProviderRegistry } from "./providers.mjs";
10
10
  export { ToolRegistry } from "./tools.mjs";
11
11
  export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
12
  export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
13
+ export { MemoryManager } from "./memory.mjs";
14
+ export { CronManager } from "./cron.mjs";
15
+ export { SubAgentManager, SubAgent } from "./subagents.mjs";
@@ -0,0 +1,275 @@
1
+ /**
2
+ * core/memory.mjs — Long-term memory system for Wispy
3
+ *
4
+ * File-based memory stored in ~/.wispy/memory/
5
+ * - MEMORY.md — main persistent memory
6
+ * - daily/YYYY-MM-DD.md — daily logs
7
+ * - projects/<name>.md — project-specific memory
8
+ * - user.md — user preferences/info
9
+ */
10
+
11
+ import path from "node:path";
12
+ import { readFile, writeFile, appendFile, mkdir, readdir, unlink, stat } from "node:fs/promises";
13
+
14
+ const MAX_SEARCH_RESULTS = 20;
15
+ const MAX_SNIPPET_CHARS = 200;
16
+
17
+ export class MemoryManager {
18
+ constructor(wispyDir) {
19
+ this.memoryDir = path.join(wispyDir, "memory");
20
+ }
21
+
22
+ /**
23
+ * Ensure memory directory exists
24
+ */
25
+ async _ensureDir(subDir = "") {
26
+ const dir = subDir ? path.join(this.memoryDir, subDir) : this.memoryDir;
27
+ await mkdir(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ /**
32
+ * Resolve key to file path
33
+ * Keys can be: "user", "MEMORY", "daily/2025-01-01", "projects/myproject"
34
+ */
35
+ _keyToPath(key) {
36
+ // Normalize: strip .md extension if present
37
+ const clean = key.replace(/\.md$/, "");
38
+ return path.join(this.memoryDir, `${clean}.md`);
39
+ }
40
+
41
+ /**
42
+ * Save (overwrite) a memory file
43
+ */
44
+ async save(key, content, metadata = {}) {
45
+ await this._ensureDir();
46
+
47
+ // Ensure subdirectory exists
48
+ const filePath = this._keyToPath(key);
49
+ const dir = path.dirname(filePath);
50
+ await mkdir(dir, { recursive: true });
51
+
52
+ const ts = new Date().toISOString();
53
+ const header = metadata.title
54
+ ? `# ${metadata.title}\n\n_Last updated: ${ts}_\n\n`
55
+ : `_Last updated: ${ts}_\n\n`;
56
+
57
+ await writeFile(filePath, header + content, "utf8");
58
+ return { key, path: filePath };
59
+ }
60
+
61
+ /**
62
+ * Append to an existing memory file (creates if doesn't exist)
63
+ */
64
+ async append(key, content) {
65
+ await this._ensureDir();
66
+ const filePath = this._keyToPath(key);
67
+ const dir = path.dirname(filePath);
68
+ await mkdir(dir, { recursive: true });
69
+
70
+ const ts = new Date().toISOString().slice(0, 16);
71
+ await appendFile(filePath, `\n- [${ts}] ${content}\n`, "utf8");
72
+ return { key, path: filePath };
73
+ }
74
+
75
+ /**
76
+ * Get a specific memory file
77
+ */
78
+ async get(key) {
79
+ const filePath = this._keyToPath(key);
80
+ try {
81
+ const content = await readFile(filePath, "utf8");
82
+ return { key, content, path: filePath };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * List all memory keys (relative paths without .md)
90
+ */
91
+ async list() {
92
+ await this._ensureDir();
93
+ const keys = [];
94
+ await this._collectKeys(this.memoryDir, this.memoryDir, keys);
95
+ return keys;
96
+ }
97
+
98
+ async _collectKeys(baseDir, dir, keys) {
99
+ let entries;
100
+ try {
101
+ entries = await readdir(dir, { withFileTypes: true });
102
+ } catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ const fullPath = path.join(dir, entry.name);
107
+ if (entry.isDirectory()) {
108
+ await this._collectKeys(baseDir, fullPath, keys);
109
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
110
+ const rel = path.relative(baseDir, fullPath).replace(/\.md$/, "");
111
+ const fileStat = await stat(fullPath).catch(() => null);
112
+ const content = await readFile(fullPath, "utf8").catch(() => "");
113
+ keys.push({
114
+ key: rel,
115
+ path: fullPath,
116
+ size: content.trim().length,
117
+ preview: content.trim().slice(0, 80).replace(/\n/g, " "),
118
+ updatedAt: fileStat?.mtime?.toISOString() ?? null,
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Full-text search across all memory files
126
+ * Returns matching snippets with file path and line numbers
127
+ */
128
+ async search(query, opts = {}) {
129
+ if (!query?.trim()) return [];
130
+ await this._ensureDir();
131
+
132
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
133
+ const results = [];
134
+ await this._searchDir(this.memoryDir, this.memoryDir, terms, results);
135
+
136
+ // Sort by relevance (number of term matches)
137
+ results.sort((a, b) => b.matchCount - a.matchCount);
138
+
139
+ return results.slice(0, opts.limit ?? MAX_SEARCH_RESULTS);
140
+ }
141
+
142
+ async _searchDir(baseDir, dir, terms, results) {
143
+ let entries;
144
+ try {
145
+ entries = await readdir(dir, { withFileTypes: true });
146
+ } catch {
147
+ return;
148
+ }
149
+ for (const entry of entries) {
150
+ const fullPath = path.join(dir, entry.name);
151
+ if (entry.isDirectory()) {
152
+ await this._searchDir(baseDir, fullPath, terms, results);
153
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
154
+ const content = await readFile(fullPath, "utf8").catch(() => "");
155
+ const lines = content.split("\n");
156
+ const key = path.relative(baseDir, fullPath).replace(/\.md$/, "");
157
+
158
+ let matchCount = 0;
159
+ const matchingLines = [];
160
+
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i];
163
+ const lower = line.toLowerCase();
164
+ const lineMatches = terms.filter(t => lower.includes(t)).length;
165
+ if (lineMatches > 0) {
166
+ matchCount += lineMatches;
167
+ matchingLines.push({
168
+ lineNumber: i + 1,
169
+ text: line.slice(0, MAX_SNIPPET_CHARS),
170
+ });
171
+ }
172
+ }
173
+
174
+ if (matchCount > 0) {
175
+ results.push({
176
+ key,
177
+ path: fullPath,
178
+ matchCount,
179
+ snippets: matchingLines.slice(0, 5),
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Delete a memory file
188
+ */
189
+ async delete(key) {
190
+ const filePath = this._keyToPath(key);
191
+ try {
192
+ await unlink(filePath);
193
+ return { success: true, key };
194
+ } catch {
195
+ return { success: false, key, error: "Not found" };
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Auto-capture important info from conversation messages
201
+ * Uses AI to extract key facts (called from engine)
202
+ */
203
+ async autoCapture(messages, aiCall) {
204
+ // Extract user messages for analysis
205
+ const recentUserMsgs = messages
206
+ .filter(m => m.role === "user")
207
+ .slice(-5)
208
+ .map(m => m.content)
209
+ .join("\n\n");
210
+
211
+ if (!recentUserMsgs.trim()) return null;
212
+
213
+ try {
214
+ const prompt = `Extract any important facts, preferences, or information from this conversation that should be remembered for future sessions. Be concise. If nothing important, reply "nothing".
215
+
216
+ Conversation:
217
+ ${recentUserMsgs.slice(0, 2000)}`;
218
+
219
+ const result = await aiCall(prompt);
220
+ if (result && result.toLowerCase() !== "nothing") {
221
+ const today = new Date().toISOString().slice(0, 10);
222
+ await this.append(`daily/${today}`, result.slice(0, 500));
223
+ return result;
224
+ }
225
+ } catch {
226
+ // Silently fail — auto-capture is optional
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Get today's daily log key
233
+ */
234
+ getDailyKey() {
235
+ return `daily/${new Date().toISOString().slice(0, 10)}`;
236
+ }
237
+
238
+ /**
239
+ * Get formatted memory context for injection into system prompt
240
+ */
241
+ async getContextForPrompt(query = "", maxChars = 3000) {
242
+ try {
243
+ const results = [];
244
+
245
+ // Always include MEMORY.md if it exists
246
+ const mainMemory = await this.get("MEMORY");
247
+ if (mainMemory?.content?.trim()) {
248
+ results.push(`### MEMORY.md\n${mainMemory.content.trim()}`);
249
+ }
250
+
251
+ // Include user.md if it exists
252
+ const userMemory = await this.get("user");
253
+ if (userMemory?.content?.trim()) {
254
+ results.push(`### user.md\n${userMemory.content.trim()}`);
255
+ }
256
+
257
+ // Search for relevant memories if query provided
258
+ if (query.trim()) {
259
+ const searchResults = await this.search(query, { limit: 5 });
260
+ for (const r of searchResults) {
261
+ if (r.key === "MEMORY" || r.key === "user") continue; // already included
262
+ const mem = await this.get(r.key);
263
+ if (mem?.content?.trim()) {
264
+ results.push(`### ${r.key}.md\n${mem.content.trim()}`);
265
+ }
266
+ }
267
+ }
268
+
269
+ const joined = results.join("\n\n");
270
+ return joined.slice(0, maxChars) || null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ }