chainlesschain 0.45.75 → 0.45.76

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,536 @@
1
+ /**
2
+ * SkillImprover — Iteratively improves auto-synthesized SKILL.md files
3
+ * based on execution feedback and better trajectories.
4
+ *
5
+ * Three improvement triggers:
6
+ * 1. repairFromError — skill execution failed, patch the procedure
7
+ * 2. updateFromCorrection — user corrected the agent, learn the delta
8
+ * 3. improveFromBetterTrajectory — a higher-scoring trajectory for
9
+ * the same tool pattern was found, merge improvements
10
+ *
11
+ * All changes are logged to skill_improvement_log for auditability.
12
+ */
13
+
14
+ import fs from "fs";
15
+ import path from "path";
16
+
17
+ // ── _deps for test injection ────────────────────────────
18
+ const _deps = { fs, path };
19
+
20
+ // ── Helpers ─────────────────────────────────────────────
21
+
22
+ /**
23
+ * Bump a semver-like version string (e.g. "1.0.0" → "1.1.0").
24
+ * Bumps the minor version.
25
+ * @param {string} version
26
+ * @returns {string}
27
+ */
28
+ export function bumpVersion(version) {
29
+ if (!version) return "1.1.0";
30
+ const parts = version.split(".");
31
+ if (parts.length < 3) return "1.1.0";
32
+ const major = parseInt(parts[0], 10) || 1;
33
+ const minor = parseInt(parts[1], 10) || 0;
34
+ return `${major}.${minor + 1}.0`;
35
+ }
36
+
37
+ /**
38
+ * Parse simple YAML frontmatter from SKILL.md content.
39
+ * Returns { meta: {}, body: string }.
40
+ * @param {string} content
41
+ * @returns {{ meta: Record<string, string>, body: string }}
42
+ */
43
+ export function parseSkillFrontmatter(content) {
44
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
45
+ if (!match) return { meta: {}, body: content };
46
+
47
+ const meta = {};
48
+ for (const line of match[1].split("\n")) {
49
+ const idx = line.indexOf(":");
50
+ if (idx > 0) {
51
+ const key = line.slice(0, idx).trim();
52
+ const val = line.slice(idx + 1).trim();
53
+ meta[key] = val;
54
+ }
55
+ }
56
+ return { meta, body: match[2] };
57
+ }
58
+
59
+ /**
60
+ * Rebuild SKILL.md from meta + body.
61
+ * @param {Record<string, string>} meta
62
+ * @param {string} body
63
+ * @returns {string}
64
+ */
65
+ export function rebuildSkillMd(meta, body) {
66
+ const lines = Object.entries(meta).map(([k, v]) => `${k}: ${v}`);
67
+ return `---\n${lines.join("\n")}\n---\n${body}`;
68
+ }
69
+
70
+ // ── LLM prompt builders ────────────────────────────────
71
+
72
+ /**
73
+ * Build LLM prompt for error-based repair.
74
+ * @param {string} skillContent — current SKILL.md
75
+ * @param {object} errorContext — { error, toolChain, userIntent }
76
+ * @returns {Array<{role:string, content:string}>}
77
+ */
78
+ export function buildRepairPrompt(skillContent, errorContext) {
79
+ return [
80
+ {
81
+ role: "system",
82
+ content: `You are a skill improvement expert. A skill failed during execution.
83
+ Analyze the error and suggest fixes to the skill's procedure.
84
+ Output ONLY valid JSON:
85
+ {
86
+ "diagnosis": "What went wrong",
87
+ "fixedProcedure": ["Step 1", "Step 2", ...],
88
+ "newPitfalls": ["Pitfall description", ...],
89
+ "confidence": 0.0-1.0
90
+ }
91
+ If the skill cannot be improved from this error, respond with: {"not_applicable": true}`,
92
+ },
93
+ {
94
+ role: "user",
95
+ content: `## Current Skill
96
+ ${skillContent.slice(0, 1500)}
97
+
98
+ ## Error Context
99
+ Error: ${errorContext.error || "unknown"}
100
+ User intent: ${errorContext.userIntent || "unknown"}
101
+ Tool chain: ${JSON.stringify(errorContext.toolChain || []).slice(0, 500)}`,
102
+ },
103
+ ];
104
+ }
105
+
106
+ /**
107
+ * Build LLM prompt for correction-based update.
108
+ * @param {string} skillContent
109
+ * @param {object} correctionContext — { userMessage, previousToolChain, correctedToolChain }
110
+ * @returns {Array<{role:string, content:string}>}
111
+ */
112
+ export function buildCorrectionPrompt(skillContent, correctionContext) {
113
+ return [
114
+ {
115
+ role: "system",
116
+ content: `You are a skill improvement expert. The user corrected the agent's behavior.
117
+ Compare the original and corrected execution to improve the skill.
118
+ Output ONLY valid JSON:
119
+ {
120
+ "whatChanged": "Description of the correction",
121
+ "updatedProcedure": ["Step 1", "Step 2", ...],
122
+ "newPitfalls": ["Pitfall description", ...],
123
+ "confidence": 0.0-1.0
124
+ }
125
+ If the correction is too specific to generalize, respond with: {"not_applicable": true}`,
126
+ },
127
+ {
128
+ role: "user",
129
+ content: `## Current Skill
130
+ ${skillContent.slice(0, 1500)}
131
+
132
+ ## Correction
133
+ User said: ${correctionContext.userMessage || ""}
134
+ Original tools: ${JSON.stringify(correctionContext.previousToolChain || []).slice(0, 300)}
135
+ Corrected tools: ${JSON.stringify(correctionContext.correctedToolChain || []).slice(0, 300)}`,
136
+ },
137
+ ];
138
+ }
139
+
140
+ /**
141
+ * Build LLM prompt for improvement from a better trajectory.
142
+ * @param {string} skillContent
143
+ * @param {object} betterTrajectory
144
+ * @returns {Array<{role:string, content:string}>}
145
+ */
146
+ export function buildImprovementPrompt(skillContent, betterTrajectory) {
147
+ const toolSteps = (betterTrajectory.toolChain || [])
148
+ .map(
149
+ (t, i) =>
150
+ ` ${i + 1}. ${t.tool}(${JSON.stringify(t.args || {}).slice(0, 150)}) → ${t.status}`,
151
+ )
152
+ .join("\n");
153
+
154
+ return [
155
+ {
156
+ role: "system",
157
+ content: `You are a skill improvement expert. A better execution trajectory was found for a similar task.
158
+ Merge improvements into the existing skill.
159
+ Output ONLY valid JSON:
160
+ {
161
+ "improvements": "Summary of what's better",
162
+ "mergedProcedure": ["Step 1", "Step 2", ...],
163
+ "mergedPitfalls": ["Pitfall description", ...],
164
+ "updatedVerification": "Updated verification step",
165
+ "confidence": 0.0-1.0
166
+ }
167
+ If no meaningful improvements can be extracted, respond with: {"not_applicable": true}`,
168
+ },
169
+ {
170
+ role: "user",
171
+ content: `## Current Skill
172
+ ${skillContent.slice(0, 1500)}
173
+
174
+ ## Better Trajectory (score: ${betterTrajectory.outcomeScore || "?"})
175
+ Intent: ${betterTrajectory.userIntent || "unknown"}
176
+ Tool chain:
177
+ ${toolSteps}`,
178
+ },
179
+ ];
180
+ }
181
+
182
+ // ── SkillImprover class ────────────────────────────────
183
+
184
+ export class SkillImprover {
185
+ /**
186
+ * @param {import("better-sqlite3").Database} db
187
+ * @param {function} llmChat — async (messages) => string
188
+ * @param {import("./trajectory-store.js").TrajectoryStore} trajectoryStore
189
+ * @param {{skillsDir?:string}} [config]
190
+ */
191
+ constructor(db, llmChat, trajectoryStore, config = {}) {
192
+ this.db = db;
193
+ this.llmChat = llmChat;
194
+ this.trajectoryStore = trajectoryStore;
195
+ this.skillsDir = config.skillsDir || null;
196
+ }
197
+
198
+ /**
199
+ * Repair a skill after an execution error.
200
+ * @param {string} skillName
201
+ * @param {object} errorContext — { error, toolChain, userIntent }
202
+ * @returns {Promise<{improved:boolean, reason:string}>}
203
+ */
204
+ async repairFromError(skillName, errorContext) {
205
+ const skillContent = await this._readSkill(skillName);
206
+ if (!skillContent) {
207
+ return { improved: false, reason: "skill not found" };
208
+ }
209
+
210
+ const suggestion = await this._callLLM(
211
+ buildRepairPrompt(skillContent, errorContext),
212
+ );
213
+ if (!suggestion || suggestion.not_applicable) {
214
+ return { improved: false, reason: "LLM deemed not applicable" };
215
+ }
216
+ if ((suggestion.confidence || 0) < 0.4) {
217
+ return { improved: false, reason: "low confidence" };
218
+ }
219
+
220
+ const { meta, body } = parseSkillFrontmatter(skillContent);
221
+ const newBody = this._applyProcedurePatch(body, suggestion);
222
+ meta.version = bumpVersion(meta.version);
223
+
224
+ const newContent = rebuildSkillMd(meta, newBody);
225
+ await this._writeSkill(skillName, newContent);
226
+ this._logImprovement(skillName, "error_repair", suggestion.diagnosis || "");
227
+
228
+ return { improved: true, reason: suggestion.diagnosis || "repaired" };
229
+ }
230
+
231
+ /**
232
+ * Update a skill based on user correction.
233
+ * @param {string} skillName
234
+ * @param {object} correctionContext — { userMessage, previousToolChain, correctedToolChain }
235
+ * @returns {Promise<{improved:boolean, reason:string}>}
236
+ */
237
+ async updateFromCorrection(skillName, correctionContext) {
238
+ const skillContent = await this._readSkill(skillName);
239
+ if (!skillContent) {
240
+ return { improved: false, reason: "skill not found" };
241
+ }
242
+
243
+ const suggestion = await this._callLLM(
244
+ buildCorrectionPrompt(skillContent, correctionContext),
245
+ );
246
+ if (!suggestion || suggestion.not_applicable) {
247
+ return { improved: false, reason: "LLM deemed not applicable" };
248
+ }
249
+ if ((suggestion.confidence || 0) < 0.4) {
250
+ return { improved: false, reason: "low confidence" };
251
+ }
252
+
253
+ const { meta, body } = parseSkillFrontmatter(skillContent);
254
+ const newBody = this._applyCorrectionPatch(body, suggestion);
255
+ meta.version = bumpVersion(meta.version);
256
+
257
+ const newContent = rebuildSkillMd(meta, newBody);
258
+ await this._writeSkill(skillName, newContent);
259
+ this._logImprovement(
260
+ skillName,
261
+ "user_correction",
262
+ suggestion.whatChanged || "",
263
+ );
264
+
265
+ return { improved: true, reason: suggestion.whatChanged || "corrected" };
266
+ }
267
+
268
+ /**
269
+ * Improve a skill from a higher-scoring trajectory.
270
+ * @param {string} skillName
271
+ * @param {object} betterTrajectory — hydrated trajectory object
272
+ * @returns {Promise<{improved:boolean, reason:string}>}
273
+ */
274
+ async improveFromBetterTrajectory(skillName, betterTrajectory) {
275
+ const skillContent = await this._readSkill(skillName);
276
+ if (!skillContent) {
277
+ return { improved: false, reason: "skill not found" };
278
+ }
279
+
280
+ const suggestion = await this._callLLM(
281
+ buildImprovementPrompt(skillContent, betterTrajectory),
282
+ );
283
+ if (!suggestion || suggestion.not_applicable) {
284
+ return { improved: false, reason: "LLM deemed not applicable" };
285
+ }
286
+ if ((suggestion.confidence || 0) < 0.4) {
287
+ return { improved: false, reason: "low confidence" };
288
+ }
289
+
290
+ const { meta, body } = parseSkillFrontmatter(skillContent);
291
+ const newBody = this._applyImprovementPatch(body, suggestion);
292
+ meta.version = bumpVersion(meta.version);
293
+
294
+ const newContent = rebuildSkillMd(meta, newBody);
295
+ await this._writeSkill(skillName, newContent);
296
+ this._logImprovement(
297
+ skillName,
298
+ "better_trajectory",
299
+ suggestion.improvements || "",
300
+ );
301
+
302
+ return { improved: true, reason: suggestion.improvements || "improved" };
303
+ }
304
+
305
+ /**
306
+ * Scan for skills that can be improved from recent high-score trajectories.
307
+ * @returns {Promise<{improved: string[], skipped: string[]}>}
308
+ */
309
+ async scanForImprovements() {
310
+ const improved = [];
311
+ const skipped = [];
312
+
313
+ // Find synthesized trajectories that have higher-scoring siblings
314
+ const synthesized = this.db
315
+ .prepare(
316
+ `SELECT DISTINCT synthesized_skill, tool_chain, outcome_score
317
+ FROM learning_trajectories
318
+ WHERE synthesized_skill IS NOT NULL
319
+ ORDER BY outcome_score ASC
320
+ LIMIT 20`,
321
+ )
322
+ .all();
323
+
324
+ for (const row of synthesized) {
325
+ try {
326
+ let chain;
327
+ try {
328
+ chain = JSON.parse(row.tool_chain);
329
+ } catch {
330
+ continue;
331
+ }
332
+
333
+ const toolNames = [...new Set(chain.map((t) => t.tool))];
334
+ const betterOnes = this.trajectoryStore.findSimilar(toolNames, {
335
+ minSimilarity: 0.6,
336
+ });
337
+
338
+ // Find a trajectory with significantly higher score
339
+ const better = betterOnes.find(
340
+ (t) =>
341
+ t.outcomeScore != null &&
342
+ t.outcomeScore > (row.outcome_score || 0) + 0.15,
343
+ );
344
+
345
+ if (better) {
346
+ const result = await this.improveFromBetterTrajectory(
347
+ row.synthesized_skill,
348
+ better,
349
+ );
350
+ if (result.improved) {
351
+ improved.push(row.synthesized_skill);
352
+ } else {
353
+ skipped.push(`${row.synthesized_skill}: ${result.reason}`);
354
+ }
355
+ }
356
+ } catch (err) {
357
+ skipped.push(
358
+ `${row.synthesized_skill || "unknown"}: error - ${err.message}`,
359
+ );
360
+ }
361
+ }
362
+
363
+ return { improved, skipped };
364
+ }
365
+
366
+ // ── Internal ────────────────────────────────────────
367
+
368
+ /**
369
+ * Call LLM and parse JSON response.
370
+ * @param {Array<{role:string, content:string}>} messages
371
+ * @returns {Promise<object|null>}
372
+ */
373
+ async _callLLM(messages) {
374
+ if (!this.llmChat) return null;
375
+ try {
376
+ const response = await this.llmChat(messages);
377
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
378
+ if (!jsonMatch) return null;
379
+ return JSON.parse(jsonMatch[0]);
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Read a skill file from disk.
387
+ * @param {string} skillName
388
+ * @returns {Promise<string|null>}
389
+ */
390
+ async _readSkill(skillName) {
391
+ if (!this.skillsDir) return null;
392
+ const skillFile = _deps.path.join(this.skillsDir, skillName, "SKILL.md");
393
+ try {
394
+ return await _deps.fs.promises.readFile(skillFile, "utf-8");
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Write updated skill content to disk.
402
+ * @param {string} skillName
403
+ * @param {string} content
404
+ */
405
+ async _writeSkill(skillName, content) {
406
+ if (!this.skillsDir) return;
407
+ const skillDir = _deps.path.join(this.skillsDir, skillName);
408
+ const skillFile = _deps.path.join(skillDir, "SKILL.md");
409
+ await _deps.fs.promises.mkdir(skillDir, { recursive: true });
410
+ await _deps.fs.promises.writeFile(skillFile, content, "utf-8");
411
+ }
412
+
413
+ /**
414
+ * Log an improvement to skill_improvement_log table.
415
+ * @param {string} skillName
416
+ * @param {string} triggerType
417
+ * @param {string} detail
418
+ */
419
+ _logImprovement(skillName, triggerType, detail) {
420
+ try {
421
+ this.db
422
+ .prepare(
423
+ `INSERT INTO skill_improvement_log (skill_name, trigger_type, detail)
424
+ VALUES (?, ?, ?)`,
425
+ )
426
+ .run(skillName, triggerType, (detail || "").slice(0, 500));
427
+ } catch {
428
+ // Non-critical, don't break the flow
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Apply repair patch to skill body (replace Procedure + append Pitfalls).
434
+ * @param {string} body
435
+ * @param {object} suggestion
436
+ * @returns {string}
437
+ */
438
+ _applyProcedurePatch(body, suggestion) {
439
+ let result = body;
440
+
441
+ if (suggestion.fixedProcedure && suggestion.fixedProcedure.length > 0) {
442
+ const newProcedure = suggestion.fixedProcedure
443
+ .map((step, i) => `${i + 1}. ${step}`)
444
+ .join("\n");
445
+ result = result.replace(
446
+ /## Procedure\n[\s\S]*?(?=\n## |\n$|$)/,
447
+ `## Procedure\n${newProcedure}`,
448
+ );
449
+ }
450
+
451
+ if (suggestion.newPitfalls && suggestion.newPitfalls.length > 0) {
452
+ const pitfallLines = suggestion.newPitfalls
453
+ .map((p) => `- ${p}`)
454
+ .join("\n");
455
+ result = result.replace(
456
+ /## Pitfalls\n[\s\S]*?(?=\n## |\n$|$)/,
457
+ `## Pitfalls\n${pitfallLines}`,
458
+ );
459
+ }
460
+
461
+ return result;
462
+ }
463
+
464
+ /**
465
+ * Apply correction patch to skill body.
466
+ * @param {string} body
467
+ * @param {object} suggestion
468
+ * @returns {string}
469
+ */
470
+ _applyCorrectionPatch(body, suggestion) {
471
+ let result = body;
472
+
473
+ if (suggestion.updatedProcedure && suggestion.updatedProcedure.length > 0) {
474
+ const newProcedure = suggestion.updatedProcedure
475
+ .map((step, i) => `${i + 1}. ${step}`)
476
+ .join("\n");
477
+ result = result.replace(
478
+ /## Procedure\n[\s\S]*?(?=\n## |\n$|$)/,
479
+ `## Procedure\n${newProcedure}`,
480
+ );
481
+ }
482
+
483
+ if (suggestion.newPitfalls && suggestion.newPitfalls.length > 0) {
484
+ const pitfallLines = suggestion.newPitfalls
485
+ .map((p) => `- ${p}`)
486
+ .join("\n");
487
+ result = result.replace(
488
+ /## Pitfalls\n[\s\S]*?(?=\n## |\n$|$)/,
489
+ `## Pitfalls\n${pitfallLines}`,
490
+ );
491
+ }
492
+
493
+ return result;
494
+ }
495
+
496
+ /**
497
+ * Apply improvement patch to skill body.
498
+ * @param {string} body
499
+ * @param {object} suggestion
500
+ * @returns {string}
501
+ */
502
+ _applyImprovementPatch(body, suggestion) {
503
+ let result = body;
504
+
505
+ if (suggestion.mergedProcedure && suggestion.mergedProcedure.length > 0) {
506
+ const newProcedure = suggestion.mergedProcedure
507
+ .map((step, i) => `${i + 1}. ${step}`)
508
+ .join("\n");
509
+ result = result.replace(
510
+ /## Procedure\n[\s\S]*?(?=\n## |\n$|$)/,
511
+ `## Procedure\n${newProcedure}`,
512
+ );
513
+ }
514
+
515
+ if (suggestion.mergedPitfalls && suggestion.mergedPitfalls.length > 0) {
516
+ const pitfallLines = suggestion.mergedPitfalls
517
+ .map((p) => `- ${p}`)
518
+ .join("\n");
519
+ result = result.replace(
520
+ /## Pitfalls\n[\s\S]*?(?=\n## |\n$|$)/,
521
+ `## Pitfalls\n${pitfallLines}`,
522
+ );
523
+ }
524
+
525
+ if (suggestion.updatedVerification) {
526
+ result = result.replace(
527
+ /## Verification\n[\s\S]*?(?=\n## |\n$|$)/,
528
+ `## Verification\n${suggestion.updatedVerification}`,
529
+ );
530
+ }
531
+
532
+ return result;
533
+ }
534
+ }
535
+
536
+ export { _deps };