wispy-cli 1.3.0 → 1.4.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 +89 -0
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +121 -0
- package/core/index.mjs +3 -0
- package/core/migrate.mjs +357 -0
- package/core/skills.mjs +339 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
package/core/migrate.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/migrate.mjs — Migration tool for importing from OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* Helps users switch from OpenClaw to Wispy by importing:
|
|
5
|
+
* - Memories (MEMORY.md → ~/.wispy/memory/)
|
|
6
|
+
* - Workspace files (SOUL.md, USER.md, IDENTITY.md → user-model)
|
|
7
|
+
* - Cron jobs (openclaw cron format → wispy cron format)
|
|
8
|
+
* - Channel configs (telegram token, etc.)
|
|
9
|
+
*
|
|
10
|
+
* SAFE: Read-only from OpenClaw. Copies to Wispy, never modifies OpenClaw.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, mkdir, readdir, access, stat } from "node:fs/promises";
|
|
14
|
+
import { join, basename } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
const OPENCLAW_DIR = join(homedir(), ".openclaw");
|
|
18
|
+
const OPENCLAW_WORKSPACE = join(OPENCLAW_DIR, "workspace");
|
|
19
|
+
|
|
20
|
+
export class OpenClawMigrator {
|
|
21
|
+
constructor(wispyDir) {
|
|
22
|
+
this.wispyDir = wispyDir;
|
|
23
|
+
this.report = {
|
|
24
|
+
detected: false,
|
|
25
|
+
memories: [],
|
|
26
|
+
userModel: [],
|
|
27
|
+
cronJobs: [],
|
|
28
|
+
channels: [],
|
|
29
|
+
skipped: [],
|
|
30
|
+
errors: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async detect() {
|
|
37
|
+
try {
|
|
38
|
+
await access(OPENCLAW_DIR);
|
|
39
|
+
this.report.detected = true;
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
this.report.detected = false;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Main migration entry ──────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run full migration.
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {boolean} opts.dryRun — if true, only report what would be migrated
|
|
53
|
+
* @param {boolean} opts.memoryOnly — if true, only migrate memories
|
|
54
|
+
*/
|
|
55
|
+
async migrate(opts = {}) {
|
|
56
|
+
const { dryRun = false, memoryOnly = false } = opts;
|
|
57
|
+
|
|
58
|
+
const detected = await this.detect();
|
|
59
|
+
if (!detected) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: `OpenClaw not found at ${OPENCLAW_DIR}`,
|
|
63
|
+
report: this.report,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Migrate memories
|
|
68
|
+
await this._migrateMemories(dryRun);
|
|
69
|
+
|
|
70
|
+
if (!memoryOnly) {
|
|
71
|
+
await this._migrateWorkspaceFiles(dryRun);
|
|
72
|
+
await this._migrateCronJobs(dryRun);
|
|
73
|
+
await this._migrateChannels(dryRun);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
dryRun,
|
|
79
|
+
report: this.report,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Memory migration ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async _migrateMemories(dryRun) {
|
|
86
|
+
const memoryDir = join(this.wispyDir, "memory");
|
|
87
|
+
|
|
88
|
+
// Check for MEMORY.md in workspace root
|
|
89
|
+
const memoryMdPath = join(OPENCLAW_WORKSPACE, "MEMORY.md");
|
|
90
|
+
try {
|
|
91
|
+
const content = await readFile(memoryMdPath, "utf8");
|
|
92
|
+
if (content.trim()) {
|
|
93
|
+
const dest = join(memoryDir, "openclaw-import.md");
|
|
94
|
+
if (!dryRun) {
|
|
95
|
+
await mkdir(memoryDir, { recursive: true });
|
|
96
|
+
await writeFile(dest, `# Imported from OpenClaw MEMORY.md\n\n${content}`, "utf8");
|
|
97
|
+
}
|
|
98
|
+
this.report.memories.push({ source: memoryMdPath, dest, action: "copy" });
|
|
99
|
+
}
|
|
100
|
+
} catch { /* No MEMORY.md — skip */ }
|
|
101
|
+
|
|
102
|
+
// Check for memory/ subdirectory
|
|
103
|
+
const openclawMemoryDir = join(OPENCLAW_WORKSPACE, "memory");
|
|
104
|
+
try {
|
|
105
|
+
const files = await readdir(openclawMemoryDir);
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (!file.endsWith(".md") && !file.endsWith(".json")) continue;
|
|
108
|
+
const src = join(openclawMemoryDir, file);
|
|
109
|
+
const dest = join(memoryDir, `openclaw-${file}`);
|
|
110
|
+
try {
|
|
111
|
+
const content = await readFile(src, "utf8");
|
|
112
|
+
if (content.trim()) {
|
|
113
|
+
if (!dryRun) {
|
|
114
|
+
await mkdir(memoryDir, { recursive: true });
|
|
115
|
+
await writeFile(dest, content, "utf8");
|
|
116
|
+
}
|
|
117
|
+
this.report.memories.push({ source: src, dest, action: "copy" });
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.report.errors.push(`Memory file ${file}: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch { /* No memory directory */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Workspace files → user model ──────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async _migrateWorkspaceFiles(dryRun) {
|
|
129
|
+
const filesToProcess = [
|
|
130
|
+
{ name: "USER.md", type: "user" },
|
|
131
|
+
{ name: "IDENTITY.md", type: "identity" },
|
|
132
|
+
{ name: "SOUL.md", type: "soul" },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
for (const { name, type } of filesToProcess) {
|
|
136
|
+
const src = join(OPENCLAW_WORKSPACE, name);
|
|
137
|
+
try {
|
|
138
|
+
const content = await readFile(src, "utf8");
|
|
139
|
+
if (!content.trim()) continue;
|
|
140
|
+
|
|
141
|
+
// Extract useful info and save to wispy memory
|
|
142
|
+
const dest = join(this.wispyDir, "memory", `openclaw-${name.toLowerCase()}`);
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
await mkdir(join(this.wispyDir, "memory"), { recursive: true });
|
|
145
|
+
await writeFile(dest, `# Imported from OpenClaw ${name}\n\n${content}`, "utf8");
|
|
146
|
+
}
|
|
147
|
+
this.report.userModel.push({ source: src, dest, type, action: "copy" });
|
|
148
|
+
|
|
149
|
+
// Parse USER.md for structured data
|
|
150
|
+
if (type === "user" && !dryRun) {
|
|
151
|
+
await this._importUserMd(content);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* File doesn't exist, skip */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _importUserMd(content) {
|
|
158
|
+
// Extract structured fields from USER.md
|
|
159
|
+
const userModelPath = join(this.wispyDir, "user-model.json");
|
|
160
|
+
|
|
161
|
+
let model = {};
|
|
162
|
+
try {
|
|
163
|
+
model = JSON.parse(await readFile(userModelPath, "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
model = { basics: {}, preferences: {}, patterns: { peakHours: [], avgSessionLength: 0, commonTasks: [], totalSessions: 0, totalMessages: 0 } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Simple regex extraction
|
|
169
|
+
const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/);
|
|
170
|
+
if (nameMatch && !model.basics?.name) {
|
|
171
|
+
const name = nameMatch[1].replace(/\(.+\)/, "").trim();
|
|
172
|
+
model.basics = model.basics ?? {};
|
|
173
|
+
model.basics.name = name;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const tzMatch = content.match(/\*\*Timezone:\*\*\s*(.+)/);
|
|
177
|
+
if (tzMatch) {
|
|
178
|
+
model.basics = model.basics ?? {};
|
|
179
|
+
model.basics.timezone = tzMatch[1].trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Korean name usually indicates Korean language preference
|
|
183
|
+
if (content.includes("민서") || content.includes("Korean") || content.toLowerCase().includes("asia/seoul")) {
|
|
184
|
+
model.preferences = model.preferences ?? {};
|
|
185
|
+
if (!model.preferences.communicationLanguage) {
|
|
186
|
+
model.preferences.communicationLanguage = "Korean";
|
|
187
|
+
model.basics.language = "ko";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// OS hints
|
|
192
|
+
if (content.includes("MacBook") || content.includes("macOS")) {
|
|
193
|
+
model.basics = model.basics ?? {};
|
|
194
|
+
model.basics.os = "macOS";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
model.updatedAt = new Date().toISOString();
|
|
198
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
199
|
+
await writeFile(userModelPath, JSON.stringify(model, null, 2) + "\n", "utf8");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Cron job migration ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async _migrateCronJobs(dryRun) {
|
|
205
|
+
// OpenClaw cron config locations
|
|
206
|
+
const candidates = [
|
|
207
|
+
join(OPENCLAW_DIR, "cron.json"),
|
|
208
|
+
join(OPENCLAW_DIR, "config", "cron.json"),
|
|
209
|
+
join(OPENCLAW_WORKSPACE, "cron.json"),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const src of candidates) {
|
|
213
|
+
try {
|
|
214
|
+
const raw = await readFile(src, "utf8");
|
|
215
|
+
const openclawCron = JSON.parse(raw);
|
|
216
|
+
|
|
217
|
+
// Convert OpenClaw cron format to Wispy cron format
|
|
218
|
+
const wispyCronPath = join(this.wispyDir, "cron.json");
|
|
219
|
+
let wispyCron = [];
|
|
220
|
+
try {
|
|
221
|
+
wispyCron = JSON.parse(await readFile(wispyCronPath, "utf8"));
|
|
222
|
+
} catch { /* empty */ }
|
|
223
|
+
|
|
224
|
+
const converted = this._convertCronFormat(openclawCron);
|
|
225
|
+
const toAdd = converted.filter(j =>
|
|
226
|
+
!wispyCron.some(existing => existing.name === j.name)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (toAdd.length > 0) {
|
|
230
|
+
if (!dryRun) {
|
|
231
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
232
|
+
await writeFile(wispyCronPath, JSON.stringify([...wispyCron, ...toAdd], null, 2) + "\n", "utf8");
|
|
233
|
+
}
|
|
234
|
+
for (const job of toAdd) {
|
|
235
|
+
this.report.cronJobs.push({ source: src, job: job.name, action: "import" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
break; // Only process first found
|
|
239
|
+
} catch { /* Not found or parse error */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_convertCronFormat(openclawCron) {
|
|
244
|
+
// OpenClaw cron is an array of job objects
|
|
245
|
+
const jobs = Array.isArray(openclawCron) ? openclawCron : (openclawCron.jobs ?? []);
|
|
246
|
+
return jobs.map(job => ({
|
|
247
|
+
name: job.name ?? job.id ?? `imported-${Date.now()}`,
|
|
248
|
+
schedule: job.schedule ?? job.cron ?? "0 9 * * *",
|
|
249
|
+
prompt: job.prompt ?? job.task ?? job.message ?? "Run imported cron job",
|
|
250
|
+
enabled: job.enabled !== false,
|
|
251
|
+
importedFrom: "openclaw",
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Channel config migration ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async _migrateChannels(dryRun) {
|
|
258
|
+
// Look for OpenClaw channel configs
|
|
259
|
+
const configCandidates = [
|
|
260
|
+
join(OPENCLAW_DIR, "openclaw.json"),
|
|
261
|
+
join(OPENCLAW_DIR, "config.json"),
|
|
262
|
+
join(OPENCLAW_DIR, "config", "openclaw.json"),
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const src of configCandidates) {
|
|
266
|
+
try {
|
|
267
|
+
const raw = await readFile(src, "utf8");
|
|
268
|
+
const config = JSON.parse(raw);
|
|
269
|
+
|
|
270
|
+
const channelsPath = join(this.wispyDir, "channels.json");
|
|
271
|
+
let wispyChannels = {};
|
|
272
|
+
try {
|
|
273
|
+
wispyChannels = JSON.parse(await readFile(channelsPath, "utf8"));
|
|
274
|
+
} catch { /* empty */ }
|
|
275
|
+
|
|
276
|
+
// Extract Telegram config
|
|
277
|
+
const telegramToken = config.telegram?.token
|
|
278
|
+
?? config.channels?.telegram?.token
|
|
279
|
+
?? config.plugins?.telegram?.token;
|
|
280
|
+
|
|
281
|
+
if (telegramToken && !wispyChannels.telegram) {
|
|
282
|
+
wispyChannels.telegram = { token: telegramToken };
|
|
283
|
+
this.report.channels.push({ channel: "telegram", action: "import" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Extract Discord config
|
|
287
|
+
const discordToken = config.discord?.token
|
|
288
|
+
?? config.channels?.discord?.token;
|
|
289
|
+
|
|
290
|
+
if (discordToken && !wispyChannels.discord) {
|
|
291
|
+
wispyChannels.discord = { token: discordToken };
|
|
292
|
+
this.report.channels.push({ channel: "discord", action: "import" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.report.channels.length > 0 && !dryRun) {
|
|
296
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
297
|
+
await writeFile(channelsPath, JSON.stringify(wispyChannels, null, 2) + "\n", "utf8");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
break; // Only process first found
|
|
301
|
+
} catch { /* Not found */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Report formatting ─────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
formatReport() {
|
|
308
|
+
const r = this.report;
|
|
309
|
+
const lines = [];
|
|
310
|
+
|
|
311
|
+
if (!r.detected) {
|
|
312
|
+
return `❌ OpenClaw not found at ${OPENCLAW_DIR}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
lines.push(`✅ OpenClaw detected at ${OPENCLAW_DIR}`);
|
|
316
|
+
lines.push("");
|
|
317
|
+
|
|
318
|
+
if (r.memories.length > 0) {
|
|
319
|
+
lines.push(`📝 Memories (${r.memories.length}):`);
|
|
320
|
+
for (const m of r.memories) {
|
|
321
|
+
lines.push(` ${m.action}: ${basename(m.source)} → ${basename(m.dest)}`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
lines.push("📝 Memories: none found");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (r.userModel.length > 0) {
|
|
328
|
+
lines.push(`👤 User profile files (${r.userModel.length}):`);
|
|
329
|
+
for (const u of r.userModel) {
|
|
330
|
+
lines.push(` ${u.action}: ${u.source.replace(homedir(), "~")}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (r.cronJobs.length > 0) {
|
|
335
|
+
lines.push(`⏰ Cron jobs (${r.cronJobs.length}):`);
|
|
336
|
+
for (const j of r.cronJobs) {
|
|
337
|
+
lines.push(` import: ${j.job}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (r.channels.length > 0) {
|
|
342
|
+
lines.push(`📡 Channels (${r.channels.length}):`);
|
|
343
|
+
for (const c of r.channels) {
|
|
344
|
+
lines.push(` import: ${c.channel}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (r.errors.length > 0) {
|
|
349
|
+
lines.push(`⚠️ Errors (${r.errors.length}):`);
|
|
350
|
+
for (const e of r.errors) {
|
|
351
|
+
lines.push(` ${e}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
}
|
package/core/skills.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/skills.mjs — Self-improving learning loop (Hermes-inspired)
|
|
3
|
+
*
|
|
4
|
+
* After completing a complex multi-tool task, Wispy automatically creates
|
|
5
|
+
* a reusable "skill" — a saved prompt+tool pattern invokable with /skill-name.
|
|
6
|
+
*
|
|
7
|
+
* Skills are stored in ~/.wispy/skills/<name>.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, mkdir, unlink, readdir, access } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const SKILLS_DIR_NAME = "skills";
|
|
14
|
+
|
|
15
|
+
export class SkillManager {
|
|
16
|
+
constructor(wispyDir, engine = null) {
|
|
17
|
+
this.wispyDir = wispyDir;
|
|
18
|
+
this.skillsDir = join(wispyDir, SKILLS_DIR_NAME);
|
|
19
|
+
this.engine = engine; // WispyEngine reference for AI calls
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Storage helpers ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
async _ensureDir() {
|
|
25
|
+
await mkdir(this.skillsDir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_skillPath(name) {
|
|
29
|
+
// Sanitize: lowercase, hyphens only
|
|
30
|
+
const safe = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
31
|
+
return join(this.skillsDir, `${safe}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async _read(name) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await readFile(this._skillPath(name), "utf8");
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async _write(skill) {
|
|
44
|
+
await this._ensureDir();
|
|
45
|
+
await writeFile(this._skillPath(skill.name), JSON.stringify(skill, null, 2) + "\n", "utf8");
|
|
46
|
+
return skill;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a skill manually.
|
|
53
|
+
* @param {{ name, description, prompt, tools?, tags? }} opts
|
|
54
|
+
*/
|
|
55
|
+
async create(opts) {
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const skill = {
|
|
58
|
+
name: opts.name,
|
|
59
|
+
description: opts.description ?? "",
|
|
60
|
+
version: 1,
|
|
61
|
+
prompt: opts.prompt,
|
|
62
|
+
tools: opts.tools ?? [],
|
|
63
|
+
tags: opts.tags ?? [],
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
createdFrom: opts.createdFrom ?? null,
|
|
67
|
+
timesUsed: 0,
|
|
68
|
+
lastUsed: null,
|
|
69
|
+
improvements: [],
|
|
70
|
+
};
|
|
71
|
+
return this._write(skill);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(name) {
|
|
75
|
+
return this._read(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async list() {
|
|
79
|
+
await this._ensureDir();
|
|
80
|
+
let files;
|
|
81
|
+
try {
|
|
82
|
+
files = await readdir(this.skillsDir);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const skills = [];
|
|
87
|
+
for (const f of files.filter(f => f.endsWith(".json"))) {
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(join(this.skillsDir, f), "utf8");
|
|
90
|
+
skills.push(JSON.parse(raw));
|
|
91
|
+
} catch { /* skip corrupted */ }
|
|
92
|
+
}
|
|
93
|
+
return skills.sort((a, b) => (b.timesUsed ?? 0) - (a.timesUsed ?? 0));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async search(query) {
|
|
97
|
+
const all = await this.list();
|
|
98
|
+
if (!query) return all;
|
|
99
|
+
const q = query.toLowerCase();
|
|
100
|
+
return all.filter(s =>
|
|
101
|
+
s.name.includes(q) ||
|
|
102
|
+
s.description?.toLowerCase().includes(q) ||
|
|
103
|
+
s.tags?.some(t => t.toLowerCase().includes(q)) ||
|
|
104
|
+
s.prompt?.toLowerCase().includes(q)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async delete(name) {
|
|
109
|
+
try {
|
|
110
|
+
await unlink(this._skillPath(name));
|
|
111
|
+
return { success: true, name };
|
|
112
|
+
} catch {
|
|
113
|
+
return { success: false, error: `Skill '${name}' not found` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getStats(name) {
|
|
118
|
+
const skill = await this._read(name);
|
|
119
|
+
if (!skill) return null;
|
|
120
|
+
return {
|
|
121
|
+
name: skill.name,
|
|
122
|
+
timesUsed: skill.timesUsed ?? 0,
|
|
123
|
+
lastUsed: skill.lastUsed ?? null,
|
|
124
|
+
version: skill.version ?? 1,
|
|
125
|
+
improvements: skill.improvements?.length ?? 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Auto-capture ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* After a conversation, check if it's worth learning a skill.
|
|
133
|
+
* Non-blocking — caller should fire-and-forget.
|
|
134
|
+
*
|
|
135
|
+
* Criteria:
|
|
136
|
+
* 1. 3+ distinct tools used in sequence
|
|
137
|
+
* 2. No error in final assistant message
|
|
138
|
+
* 3. Pattern not already saved
|
|
139
|
+
*
|
|
140
|
+
* @param {Array} messages Full conversation messages
|
|
141
|
+
* @param {string} sessionId Session ID for attribution
|
|
142
|
+
* @returns {object|null} Created skill, or null
|
|
143
|
+
*/
|
|
144
|
+
async autoCapture(messages, sessionId = null) {
|
|
145
|
+
try {
|
|
146
|
+
if (!this.engine) return null;
|
|
147
|
+
|
|
148
|
+
// Count distinct tool calls
|
|
149
|
+
const toolCalls = messages.filter(m => m.toolCalls?.length > 0);
|
|
150
|
+
const toolNames = new Set(toolCalls.flatMap(m => m.toolCalls?.map(tc => tc.name) ?? []));
|
|
151
|
+
|
|
152
|
+
if (toolNames.size < 3) return null;
|
|
153
|
+
|
|
154
|
+
// Check final assistant message for errors
|
|
155
|
+
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
|
|
156
|
+
if (!lastAssistant?.content) return null;
|
|
157
|
+
|
|
158
|
+
const errorSignals = ["⚠️", "error:", "failed:", "could not", "unable to"];
|
|
159
|
+
const hasError = errorSignals.some(s => lastAssistant.content.toLowerCase().includes(s.toLowerCase()));
|
|
160
|
+
if (hasError) return null;
|
|
161
|
+
|
|
162
|
+
// Extract summary of what was done
|
|
163
|
+
const userMessages = messages.filter(m => m.role === "user");
|
|
164
|
+
if (userMessages.length === 0) return null;
|
|
165
|
+
|
|
166
|
+
const taskSummary = userMessages.map(m => m.content).join(" / ").slice(0, 500);
|
|
167
|
+
|
|
168
|
+
// Use AI to extract skill
|
|
169
|
+
const extractionPrompt = `Analyze this completed task and decide if it's worth saving as a reusable skill.
|
|
170
|
+
|
|
171
|
+
Task summary: "${taskSummary}"
|
|
172
|
+
Tools used: ${[...toolNames].join(", ")}
|
|
173
|
+
|
|
174
|
+
A skill is worth saving if:
|
|
175
|
+
- The task is a common, repeatable workflow (not a one-off question)
|
|
176
|
+
- It involves a sequence of meaningful steps
|
|
177
|
+
- Someone would plausibly repeat it
|
|
178
|
+
|
|
179
|
+
If worth saving, respond with JSON:
|
|
180
|
+
{
|
|
181
|
+
"worthy": true,
|
|
182
|
+
"name": "kebab-case-name-max-30-chars",
|
|
183
|
+
"description": "One sentence description",
|
|
184
|
+
"prompt": "Reusable instruction prompt that generalizes this task",
|
|
185
|
+
"tags": ["tag1", "tag2"]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
If NOT worth saving, respond with: {"worthy": false}
|
|
189
|
+
|
|
190
|
+
Respond with JSON only.`;
|
|
191
|
+
|
|
192
|
+
let extractionResult;
|
|
193
|
+
try {
|
|
194
|
+
const res = await this.engine.providers.chat(
|
|
195
|
+
[
|
|
196
|
+
{ role: "system", content: "You are a skill extraction system. Respond with JSON only." },
|
|
197
|
+
{ role: "user", content: extractionPrompt },
|
|
198
|
+
],
|
|
199
|
+
[],
|
|
200
|
+
{}
|
|
201
|
+
);
|
|
202
|
+
extractionResult = res.type === "text" ? res.text : "";
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse JSON response
|
|
208
|
+
const jsonMatch = extractionResult.match(/\{[\s\S]*\}/);
|
|
209
|
+
if (!jsonMatch) return null;
|
|
210
|
+
|
|
211
|
+
let extracted;
|
|
212
|
+
try {
|
|
213
|
+
extracted = JSON.parse(jsonMatch[0]);
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!extracted.worthy) return null;
|
|
219
|
+
if (!extracted.name || !extracted.prompt) return null;
|
|
220
|
+
|
|
221
|
+
// Check if already exists
|
|
222
|
+
const existing = await this._read(extracted.name);
|
|
223
|
+
if (existing) return null; // Don't overwrite existing skills
|
|
224
|
+
|
|
225
|
+
// Save
|
|
226
|
+
const skill = await this.create({
|
|
227
|
+
name: extracted.name,
|
|
228
|
+
description: extracted.description ?? "",
|
|
229
|
+
prompt: extracted.prompt,
|
|
230
|
+
tools: [...toolNames],
|
|
231
|
+
tags: extracted.tags ?? [],
|
|
232
|
+
createdFrom: sessionId,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return skill;
|
|
236
|
+
} catch {
|
|
237
|
+
return null; // Never fail silently
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Skill improvement ────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Improve an existing skill using feedback.
|
|
245
|
+
* @param {string} name Skill name
|
|
246
|
+
* @param {string} feedback User feedback
|
|
247
|
+
* @returns {object|null} Updated skill
|
|
248
|
+
*/
|
|
249
|
+
async improve(name, feedback) {
|
|
250
|
+
if (!this.engine) throw new Error("Engine required for AI-powered improvement");
|
|
251
|
+
|
|
252
|
+
const skill = await this._read(name);
|
|
253
|
+
if (!skill) throw new Error(`Skill '${name}' not found`);
|
|
254
|
+
|
|
255
|
+
const improvementPrompt = `Improve this skill's prompt based on user feedback.
|
|
256
|
+
|
|
257
|
+
Current prompt:
|
|
258
|
+
"${skill.prompt}"
|
|
259
|
+
|
|
260
|
+
User feedback: "${feedback}"
|
|
261
|
+
|
|
262
|
+
Create an improved version of the prompt that incorporates the feedback while keeping it general and reusable.
|
|
263
|
+
Respond with JSON only: {"improved_prompt": "..."}`;
|
|
264
|
+
|
|
265
|
+
let result;
|
|
266
|
+
try {
|
|
267
|
+
const res = await this.engine.providers.chat(
|
|
268
|
+
[
|
|
269
|
+
{ role: "system", content: "You are a skill improvement system. Respond with JSON only." },
|
|
270
|
+
{ role: "user", content: improvementPrompt },
|
|
271
|
+
],
|
|
272
|
+
[],
|
|
273
|
+
{}
|
|
274
|
+
);
|
|
275
|
+
result = res.type === "text" ? res.text : "";
|
|
276
|
+
} catch (err) {
|
|
277
|
+
throw new Error(`AI improvement failed: ${err.message}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
|
281
|
+
if (!jsonMatch) throw new Error("Failed to parse improvement response");
|
|
282
|
+
|
|
283
|
+
let parsed;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
286
|
+
} catch {
|
|
287
|
+
throw new Error("Invalid JSON in improvement response");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!parsed.improved_prompt) throw new Error("No improved prompt in response");
|
|
291
|
+
|
|
292
|
+
// Update skill
|
|
293
|
+
skill.prompt = parsed.improved_prompt;
|
|
294
|
+
skill.version = (skill.version ?? 1) + 1;
|
|
295
|
+
skill.updatedAt = new Date().toISOString();
|
|
296
|
+
skill.improvements = skill.improvements ?? [];
|
|
297
|
+
skill.improvements.push({
|
|
298
|
+
date: new Date().toISOString(),
|
|
299
|
+
feedback,
|
|
300
|
+
version: skill.version,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return this._write(skill);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Execution ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Execute a skill by name.
|
|
310
|
+
* @param {string} name Skill name
|
|
311
|
+
* @param {object} args Optional args to template into prompt
|
|
312
|
+
* @param {string} sessionId Session to execute in
|
|
313
|
+
* @returns {string} Result text
|
|
314
|
+
*/
|
|
315
|
+
async execute(name, args = {}, sessionId = null) {
|
|
316
|
+
if (!this.engine) throw new Error("Engine required for skill execution");
|
|
317
|
+
|
|
318
|
+
const skill = await this._read(name);
|
|
319
|
+
if (!skill) throw new Error(`Skill '${name}' not found`);
|
|
320
|
+
|
|
321
|
+
// Simple template: replace {{key}} with args[key]
|
|
322
|
+
let prompt = skill.prompt;
|
|
323
|
+
for (const [key, val] of Object.entries(args)) {
|
|
324
|
+
prompt = prompt.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), String(val));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const start = Date.now();
|
|
328
|
+
const result = await this.engine.processMessage(sessionId, prompt, {});
|
|
329
|
+
const duration = Date.now() - start;
|
|
330
|
+
|
|
331
|
+
// Update stats
|
|
332
|
+
skill.timesUsed = (skill.timesUsed ?? 0) + 1;
|
|
333
|
+
skill.lastUsed = new Date().toISOString();
|
|
334
|
+
skill._lastDuration = duration;
|
|
335
|
+
await this._write(skill);
|
|
336
|
+
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
}
|