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 +172 -2
- package/core/cron.mjs +346 -0
- package/core/engine.mjs +177 -4
- package/core/index.mjs +2 -0
- package/core/memory.mjs +275 -0
- package/lib/wispy-repl.mjs +59 -0
- package/package.json +6 -4
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 () => {
|
|
82
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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";
|
package/core/memory.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/wispy-repl.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
55
|
+
"@slack/bolt": ">=3.0.0",
|
|
54
56
|
"discord.js": ">=14.0.0",
|
|
55
|
-
"
|
|
57
|
+
"grammy": ">=1.0.0"
|
|
56
58
|
},
|
|
57
59
|
"peerDependenciesMeta": {
|
|
58
60
|
"grammy": {
|