wispy-cli 1.2.3 → 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.
@@ -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
+ }