wispy-cli 0.7.0 → 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.
package/bin/wispy.mjs CHANGED
@@ -22,6 +22,150 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
 
23
23
  const args = process.argv.slice(2);
24
24
 
25
+ // ── cron sub-command ──────────────────────────────────────────────────────────
26
+ if (args[0] === "cron") {
27
+ const { WispyEngine, CronManager, WISPY_DIR } = await import(
28
+ path.join(__dirname, "..", "core", "index.mjs")
29
+ );
30
+ const { createInterface } = await import("node:readline");
31
+
32
+ const sub = args[1];
33
+
34
+ // Init engine for cron commands that need it
35
+ const engine = new WispyEngine();
36
+ await engine.init({ skipMcp: true });
37
+ const cron = new CronManager(WISPY_DIR, engine);
38
+ await cron.init();
39
+
40
+ if (!sub || sub === "list") {
41
+ const jobs = cron.list();
42
+ if (jobs.length === 0) {
43
+ console.log("No cron jobs configured. Use: wispy cron add");
44
+ } else {
45
+ console.log(`\n🕐 Cron Jobs (${jobs.length}):\n`);
46
+ for (const j of jobs) {
47
+ const status = j.enabled ? "✅" : "⏸️ ";
48
+ const schedStr = j.schedule.kind === "cron" ? j.schedule.expr
49
+ : j.schedule.kind === "every" ? `every ${j.schedule.ms / 60000}min`
50
+ : `at ${j.schedule.time}`;
51
+ console.log(` ${status} ${j.id.slice(0, 8)} ${j.name.padEnd(20)} ${schedStr}`);
52
+ console.log(` Task: ${j.task.slice(0, 60)}${j.task.length > 60 ? "..." : ""}`);
53
+ if (j.channel) console.log(` Channel: ${j.channel}`);
54
+ if (j.nextRun) console.log(` Next run: ${new Date(j.nextRun).toLocaleString()}`);
55
+ console.log("");
56
+ }
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ if (sub === "add") {
62
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
63
+ const ask = (q) => new Promise(r => rl.question(q, r));
64
+
65
+ console.log("\n🕐 Add Cron Job\n");
66
+ const name = await ask(" Job name: ");
67
+ const task = await ask(" Task (what to do): ");
68
+ const schedKind = await ask(" Schedule type (cron/every/at) [cron]: ") || "cron";
69
+ let schedule = { kind: schedKind };
70
+
71
+ if (schedKind === "cron") {
72
+ const expr = await ask(" Cron expression (e.g. '0 9 * * *'): ");
73
+ const tz = await ask(" Timezone [Asia/Seoul]: ") || "Asia/Seoul";
74
+ schedule = { kind: "cron", expr: expr.trim(), tz: tz.trim() };
75
+ } else if (schedKind === "every") {
76
+ const mins = await ask(" Interval in minutes: ");
77
+ schedule = { kind: "every", ms: parseFloat(mins) * 60_000 };
78
+ } else if (schedKind === "at") {
79
+ const time = await ask(" Run at (ISO datetime, e.g. 2025-01-01T09:00:00): ");
80
+ schedule = { kind: "at", time: time.trim() };
81
+ }
82
+
83
+ const channel = await ask(" Channel (e.g. telegram:12345, or leave empty): ");
84
+ rl.close();
85
+
86
+ const job = await cron.add({
87
+ name: name.trim(),
88
+ task: task.trim(),
89
+ schedule,
90
+ channel: channel.trim() || null,
91
+ enabled: true,
92
+ });
93
+
94
+ console.log(`\n✅ Job created: ${job.id}`);
95
+ console.log(` Next run: ${job.nextRun ? new Date(job.nextRun).toLocaleString() : "N/A"}`);
96
+ process.exit(0);
97
+ }
98
+
99
+ if (sub === "remove" && args[2]) {
100
+ const id = args[2];
101
+ // Support partial ID match
102
+ const all = cron.list();
103
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
104
+ if (!match) {
105
+ console.error(`Job not found: ${id}`);
106
+ process.exit(1);
107
+ }
108
+ await cron.remove(match.id);
109
+ console.log(`✅ Removed job: ${match.name} (${match.id})`);
110
+ process.exit(0);
111
+ }
112
+
113
+ if (sub === "run" && args[2]) {
114
+ const id = args[2];
115
+ const all = cron.list();
116
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
117
+ if (!match) {
118
+ console.error(`Job not found: ${id}`);
119
+ process.exit(1);
120
+ }
121
+ console.log(`🌿 Running job: ${match.name}...`);
122
+ const result = await cron.runNow(match.id);
123
+ console.log(result.output ?? result.error);
124
+ process.exit(0);
125
+ }
126
+
127
+ if (sub === "history" && args[2]) {
128
+ const id = args[2];
129
+ const all = cron.list();
130
+ const match = all.find(j => j.id === id || j.id.startsWith(id));
131
+ if (!match) {
132
+ console.error(`Job not found: ${id}`);
133
+ process.exit(1);
134
+ }
135
+ const history = await cron.getHistory(match.id);
136
+ console.log(`\n📋 History for "${match.name}" (last ${history.length} runs):\n`);
137
+ for (const h of history) {
138
+ const icon = h.status === "success" ? "✅" : "❌";
139
+ console.log(` ${icon} ${new Date(h.startedAt).toLocaleString()}`);
140
+ console.log(` ${h.output?.slice(0, 100) ?? h.error ?? ""}`);
141
+ console.log("");
142
+ }
143
+ if (history.length === 0) console.log(" No runs yet.");
144
+ process.exit(0);
145
+ }
146
+
147
+ if (sub === "start") {
148
+ console.log("🌿 Starting cron scheduler... (Ctrl+C to stop)\n");
149
+ cron.start();
150
+ process.on("SIGINT", () => { cron.stop(); process.exit(0); });
151
+ process.on("SIGTERM", () => { cron.stop(); process.exit(0); });
152
+ setInterval(() => {}, 60_000);
153
+ await new Promise(() => {});
154
+ }
155
+
156
+ console.log(`
157
+ 🕐 Wispy Cron Commands:
158
+
159
+ wispy cron list — list all jobs
160
+ wispy cron add — interactive job creation
161
+ wispy cron remove <id> — delete a job
162
+ wispy cron run <id> — trigger immediately
163
+ wispy cron history <id> — show past runs
164
+ wispy cron start — start scheduler (foreground)
165
+ `);
166
+ process.exit(0);
167
+ }
168
+
25
169
  // ── channel sub-command ───────────────────────────────────────────────────────
26
170
  if (args[0] === "channel") {
27
171
  const { channelSetup, channelList, channelTest } = await import(
@@ -77,9 +221,35 @@ if (serveMode || telegramMode || discordMode || slackMode) {
77
221
 
78
222
  await manager.startAll(only);
79
223
 
224
+ // Start cron scheduler if in serve mode
225
+ let cronManager = null;
226
+ if (serveMode) {
227
+ try {
228
+ const { WispyEngine, CronManager, WISPY_DIR } = await import(
229
+ path.join(__dirname, "..", "core", "index.mjs")
230
+ );
231
+ const engine = new WispyEngine();
232
+ await engine.init({ skipMcp: true });
233
+ cronManager = new CronManager(WISPY_DIR, engine);
234
+ await cronManager.init();
235
+ cronManager.start();
236
+ console.error("[wispy] Cron scheduler started");
237
+ } catch (err) {
238
+ console.error("[wispy] Failed to start cron scheduler:", err.message);
239
+ }
240
+ }
241
+
80
242
  // Keep process alive and stop cleanly on Ctrl+C
81
- process.on("SIGINT", async () => { await manager.stopAll(); process.exit(0); });
82
- process.on("SIGTERM", async () => { await manager.stopAll(); process.exit(0); });
243
+ process.on("SIGINT", async () => {
244
+ if (cronManager) cronManager.stop();
245
+ await manager.stopAll();
246
+ process.exit(0);
247
+ });
248
+ process.on("SIGTERM", async () => {
249
+ if (cronManager) cronManager.stop();
250
+ await manager.stopAll();
251
+ process.exit(0);
252
+ });
83
253
 
84
254
  // Prevent Node from exiting
85
255
  setInterval(() => {}, 60_000);
package/core/cron.mjs ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * core/cron.mjs — Task scheduler for Wispy
3
+ *
4
+ * Manages scheduled jobs stored in ~/.wispy/cron/jobs.json
5
+ * History stored in ~/.wispy/cron/history/<jobId>.json
6
+ *
7
+ * Schedule types:
8
+ * - cron: standard 5-field cron expression (uses cron-parser)
9
+ * - every: interval in ms (e.g., 1800000 for 30 minutes)
10
+ * - at: one-shot at specific ISO datetime
11
+ */
12
+
13
+ import path from "node:path";
14
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
15
+
16
+ const MAX_HISTORY_PER_JOB = 50;
17
+ const TICK_INTERVAL_MS = 30_000; // Check every 30 seconds
18
+
19
+ export class CronManager {
20
+ constructor(wispyDir, engine) {
21
+ this.cronDir = path.join(wispyDir, "cron");
22
+ this.jobsFile = path.join(this.cronDir, "jobs.json");
23
+ this.historyDir = path.join(this.cronDir, "history");
24
+ this.engine = engine;
25
+ this._jobs = [];
26
+ this._timer = null;
27
+ this._running = false;
28
+ this._cronParser = null; // lazy-loaded
29
+ }
30
+
31
+ /**
32
+ * Initialize: load jobs from disk
33
+ */
34
+ async init() {
35
+ await mkdir(this.cronDir, { recursive: true });
36
+ await mkdir(this.historyDir, { recursive: true });
37
+ await this._loadJobs();
38
+ return this;
39
+ }
40
+
41
+ /**
42
+ * Lazy-load cron-parser
43
+ */
44
+ async _getCronParser() {
45
+ if (!this._cronParser) {
46
+ try {
47
+ const mod = await import("cron-parser");
48
+ // v5 API: CronExpressionParser.parse(expr, { tz })
49
+ // v4 API: parseExpression(expr, { tz })
50
+ this._cronParser = mod.CronExpressionParser ?? mod.default ?? mod;
51
+ } catch {
52
+ this._cronParser = null;
53
+ }
54
+ }
55
+ return this._cronParser;
56
+ }
57
+
58
+ // ── Job Management ────────────────────────────────────────────────────────
59
+
60
+ async add(jobDef) {
61
+ const { randomUUID } = await import("node:crypto");
62
+ const job = {
63
+ id: jobDef.id ?? randomUUID(),
64
+ name: jobDef.name ?? "unnamed",
65
+ schedule: jobDef.schedule,
66
+ task: jobDef.task,
67
+ channel: jobDef.channel ?? null,
68
+ enabled: jobDef.enabled !== false,
69
+ createdAt: new Date().toISOString(),
70
+ lastRun: null,
71
+ nextRun: null,
72
+ };
73
+
74
+ // Compute next run time
75
+ job.nextRun = await this._computeNextRun(job);
76
+
77
+ this._jobs.push(job);
78
+ await this._saveJobs();
79
+ return job;
80
+ }
81
+
82
+ async remove(id) {
83
+ const before = this._jobs.length;
84
+ this._jobs = this._jobs.filter(j => j.id !== id);
85
+ if (this._jobs.length < before) {
86
+ await this._saveJobs();
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ async update(id, patch) {
93
+ const idx = this._jobs.findIndex(j => j.id === id);
94
+ if (idx === -1) return null;
95
+ this._jobs[idx] = { ...this._jobs[idx], ...patch };
96
+ // Recompute next run if schedule changed
97
+ if (patch.schedule) {
98
+ this._jobs[idx].nextRun = await this._computeNextRun(this._jobs[idx]);
99
+ }
100
+ await this._saveJobs();
101
+ return this._jobs[idx];
102
+ }
103
+
104
+ list() {
105
+ return [...this._jobs];
106
+ }
107
+
108
+ get(id) {
109
+ return this._jobs.find(j => j.id === id) ?? null;
110
+ }
111
+
112
+ // ── Scheduler ─────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Start the scheduler loop
116
+ */
117
+ start() {
118
+ if (this._running) return;
119
+ this._running = true;
120
+ this._tick();
121
+ this._timer = setInterval(() => this._tick(), TICK_INTERVAL_MS);
122
+ console.error(`[wispy-cron] Scheduler started (checking every ${TICK_INTERVAL_MS / 1000}s)`);
123
+ }
124
+
125
+ /**
126
+ * Stop the scheduler
127
+ */
128
+ stop() {
129
+ this._running = false;
130
+ if (this._timer) {
131
+ clearInterval(this._timer);
132
+ this._timer = null;
133
+ }
134
+ console.error("[wispy-cron] Scheduler stopped");
135
+ }
136
+
137
+ /**
138
+ * Manually trigger a job right now
139
+ */
140
+ async runNow(id) {
141
+ const job = this.get(id);
142
+ if (!job) return { success: false, error: "Job not found" };
143
+ return this._executeJob(job);
144
+ }
145
+
146
+ // ── History ───────────────────────────────────────────────────────────────
147
+
148
+ async getHistory(id, limit = 20) {
149
+ const histFile = path.join(this.historyDir, `${id}.json`);
150
+ try {
151
+ const raw = await readFile(histFile, "utf8");
152
+ const history = JSON.parse(raw);
153
+ return history.slice(-limit);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ // ── Internal ──────────────────────────────────────────────────────────────
160
+
161
+ async _tick() {
162
+ const now = Date.now();
163
+ for (const job of this._jobs) {
164
+ if (!job.enabled) continue;
165
+ if (!job.nextRun) continue;
166
+
167
+ const nextRunMs = new Date(job.nextRun).getTime();
168
+ if (now >= nextRunMs) {
169
+ // Execute and update
170
+ this._executeJob(job).catch(err => {
171
+ console.error(`[wispy-cron] Job "${job.name}" error:`, err.message);
172
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ async _executeJob(job) {
178
+ const startedAt = new Date().toISOString();
179
+ let output = "";
180
+ let status = "success";
181
+ let error = null;
182
+
183
+ try {
184
+ // Run via engine
185
+ const response = await this.engine.processMessage(null, job.task, {
186
+ noSave: true,
187
+ systemPrompt: `You are Wispy 🌿, running a scheduled task. Task name: "${job.name}". Execute the task and provide a concise summary. Always end with 🌿.`,
188
+ });
189
+ output = response.content;
190
+
191
+ // Deliver to channel if configured
192
+ if (job.channel) {
193
+ await this._deliverToChannel(job.channel, job.name, output);
194
+ }
195
+ } catch (err) {
196
+ status = "failed";
197
+ error = err.message;
198
+ output = `Error: ${err.message}`;
199
+ }
200
+
201
+ const runRecord = {
202
+ jobId: job.id,
203
+ startedAt,
204
+ completedAt: new Date().toISOString(),
205
+ status,
206
+ output: output.slice(0, 2000),
207
+ error,
208
+ };
209
+
210
+ // Save to history
211
+ await this._appendHistory(job.id, runRecord);
212
+
213
+ // Update job metadata
214
+ const idx = this._jobs.findIndex(j => j.id === job.id);
215
+ if (idx !== -1) {
216
+ this._jobs[idx].lastRun = startedAt;
217
+ this._jobs[idx].nextRun = await this._computeNextRun(this._jobs[idx]);
218
+ await this._saveJobs();
219
+ }
220
+
221
+ return { success: status === "success", output, error };
222
+ }
223
+
224
+ async _deliverToChannel(channel, jobName, output) {
225
+ const [type, id] = channel.split(":");
226
+ const message = `🌿 **${jobName}**\n\n${output}`;
227
+
228
+ try {
229
+ switch (type) {
230
+ case "telegram": {
231
+ const cfg = await this._loadChannelConfig("telegram");
232
+ if (!cfg?.token) return;
233
+ const url = `https://api.telegram.org/bot${cfg.token}/sendMessage`;
234
+ await fetch(url, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ chat_id: id, text: message.slice(0, 4096) }),
238
+ });
239
+ break;
240
+ }
241
+ case "discord": {
242
+ // Discord webhook format
243
+ const cfg = await this._loadChannelConfig("discord");
244
+ if (!cfg?.webhookUrl) return;
245
+ await fetch(cfg.webhookUrl, {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({ content: message.slice(0, 2000) }),
249
+ });
250
+ break;
251
+ }
252
+ default:
253
+ console.error(`[wispy-cron] Unknown channel type: ${type}`);
254
+ }
255
+ } catch (err) {
256
+ console.error(`[wispy-cron] Channel delivery failed: ${err.message}`);
257
+ }
258
+ }
259
+
260
+ async _loadChannelConfig(name) {
261
+ try {
262
+ const channelsPath = path.join(path.dirname(this.cronDir), "channels.json");
263
+ const raw = await readFile(channelsPath, "utf8");
264
+ const cfg = JSON.parse(raw);
265
+ return cfg[name] ?? null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ async _computeNextRun(job) {
272
+ const schedule = job.schedule;
273
+ if (!schedule) return null;
274
+
275
+ const now = new Date();
276
+
277
+ try {
278
+ if (schedule.kind === "at") {
279
+ const t = new Date(schedule.time);
280
+ // One-shot: if in the future, return it; if past, return null (disable)
281
+ return t > now ? t.toISOString() : null;
282
+ }
283
+
284
+ if (schedule.kind === "every") {
285
+ const intervalMs = schedule.ms ?? schedule.interval ?? 60_000;
286
+ // If lastRun exists, add interval to it; otherwise start from now
287
+ const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now();
288
+ return new Date(base + intervalMs).toISOString();
289
+ }
290
+
291
+ if (schedule.kind === "cron") {
292
+ const parser = await this._getCronParser();
293
+ if (!parser) {
294
+ console.error("[wispy-cron] cron-parser not available, skipping cron schedule");
295
+ return null;
296
+ }
297
+
298
+ const tz = schedule.tz ?? "UTC";
299
+ let interval;
300
+ // Support both v4 (parseExpression) and v5 (CronExpressionParser.parse) APIs
301
+ if (typeof parser.parse === "function") {
302
+ // v5: CronExpressionParser.parse(expr, options)
303
+ interval = parser.parse(schedule.expr, { currentDate: now, tz });
304
+ } else if (typeof parser.parseExpression === "function") {
305
+ // v4: module.parseExpression(expr, options)
306
+ interval = parser.parseExpression(schedule.expr, { currentDate: now, tz });
307
+ } else {
308
+ console.error("[wispy-cron] cron-parser API not recognized");
309
+ return null;
310
+ }
311
+ return interval.next().toISOString();
312
+ }
313
+ } catch (err) {
314
+ console.error(`[wispy-cron] Failed to compute next run: ${err.message}`);
315
+ }
316
+
317
+ return null;
318
+ }
319
+
320
+ async _appendHistory(jobId, record) {
321
+ const histFile = path.join(this.historyDir, `${jobId}.json`);
322
+ let history = [];
323
+ try {
324
+ history = JSON.parse(await readFile(histFile, "utf8"));
325
+ } catch {}
326
+ history.push(record);
327
+ if (history.length > MAX_HISTORY_PER_JOB) {
328
+ history = history.slice(-MAX_HISTORY_PER_JOB);
329
+ }
330
+ await writeFile(histFile, JSON.stringify(history, null, 2) + "\n", "utf8");
331
+ }
332
+
333
+ async _loadJobs() {
334
+ try {
335
+ const raw = await readFile(this.jobsFile, "utf8");
336
+ this._jobs = JSON.parse(raw);
337
+ } catch {
338
+ this._jobs = [];
339
+ }
340
+ }
341
+
342
+ async _saveJobs() {
343
+ await mkdir(this.cronDir, { recursive: true });
344
+ await writeFile(this.jobsFile, JSON.stringify(this._jobs, null, 2) + "\n", "utf8");
345
+ }
346
+ }
package/core/engine.mjs CHANGED
@@ -19,6 +19,7 @@ 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";
22
23
 
23
24
  const MAX_TOOL_ROUNDS = 10;
24
25
  const MAX_CONTEXT_CHARS = 40_000;
@@ -30,6 +31,7 @@ export class WispyEngine {
30
31
  this.tools = new ToolRegistry();
31
32
  this.sessions = new SessionManager();
32
33
  this.mcpManager = new MCPManager(config.mcpConfigPath ?? MCP_CONFIG_PATH);
34
+ this.memory = new MemoryManager(WISPY_DIR);
33
35
  this._initialized = false;
34
36
  this._activeWorkstream = config.workstream
35
37
  ?? process.env.WISPY_WORKSTREAM
@@ -56,6 +58,9 @@ export class WispyEngine {
56
58
  // Register built-in tools
57
59
  this.tools.registerBuiltin();
58
60
 
61
+ // Register memory tools
62
+ this._registerMemoryTools();
63
+
59
64
  // Initialize MCP
60
65
  if (!opts.skipMcp) {
61
66
  await ensureDefaultMcpConfig(this.mcpManager.configPath);
@@ -209,6 +214,18 @@ export class WispyEngine {
209
214
  return this._toolSpawnAsyncAgent(args, messages, session);
210
215
  case "ralph_loop":
211
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);
212
229
  default:
213
230
  return this.tools.execute(name, args);
214
231
  }
@@ -476,10 +493,18 @@ export class WispyEngine {
476
493
  parts.push("## Project Context (WISPY.md)", wispyMd, "");
477
494
  }
478
495
 
479
- // Load memories
480
- const memories = await this._loadMemories();
481
- if (memories) {
482
- parts.push("## Persistent Memory", memories, "");
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
+ }
483
508
  }
484
509
 
485
510
  return parts.join("\n");
@@ -512,6 +537,154 @@ export class WispyEngine {
512
537
  return sections.length ? sections.join("\n\n") : null;
513
538
  }
514
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
+
515
688
  // ── Cleanup ──────────────────────────────────────────────────────────────────
516
689
 
517
690
  destroy() {
package/core/index.mjs CHANGED
@@ -10,3 +10,5 @@ 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";
@@ -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
+ }
@@ -25,6 +25,7 @@ import {
25
25
  WISPY_DIR,
26
26
  CONVERSATIONS_DIR,
27
27
  MEMORY_DIR,
28
+ MemoryManager,
28
29
  } from "../core/index.mjs";
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -325,6 +326,10 @@ ${bold("Wispy Commands:")}
325
326
  ${cyan("/skills")} List installed skills
326
327
  ${cyan("/sessions")} List sessions
327
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
328
333
  ${cyan("/quit")} or ${cyan("/exit")} Exit
329
334
  `);
330
335
  return true;
@@ -555,6 +560,60 @@ ${bold("Wispy Commands:")}
555
560
  return true;
556
561
  }
557
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"));
570
+ return true;
571
+ }
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
+
558
617
  if (cmd === "/quit" || cmd === "/exit") {
559
618
  console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
560
619
  engine.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration and multi-channel bot support",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",
@@ -44,15 +44,17 @@
44
44
  "test": "node --test test/basic.test.mjs"
45
45
  },
46
46
  "dependencies": {
47
+ "cron-parser": "^5.5.0",
47
48
  "ink": "^5.2.1",
48
49
  "ink-spinner": "^5.0.0",
49
50
  "ink-text-input": "^6.0.0",
50
- "react": "^18.3.1"
51
+ "react": "^18.3.1",
52
+ "uuid": "^13.0.0"
51
53
  },
52
54
  "peerDependencies": {
53
- "grammy": ">=1.0.0",
55
+ "@slack/bolt": ">=3.0.0",
54
56
  "discord.js": ">=14.0.0",
55
- "@slack/bolt": ">=3.0.0"
57
+ "grammy": ">=1.0.0"
56
58
  },
57
59
  "peerDependenciesMeta": {
58
60
  "grammy": {