chainlesschain 0.45.74 → 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.
Files changed (41) hide show
  1. package/README.md +52 -15
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/.build-hash +1 -1
  4. package/src/assets/web-panel/assets/{AppLayout-BhJ3YFWt.js → AppLayout-2RCrdXxl.js} +1 -1
  5. package/src/assets/web-panel/assets/AppLayout-D9pBLPC3.css +1 -0
  6. package/src/assets/web-panel/assets/{Chat-DaxTP3x8.js → Chat-B2nB8o_F.js} +1 -1
  7. package/src/assets/web-panel/assets/{Dashboard-CjlX4CrX.js → Dashboard-DanoHPSI.js} +1 -1
  8. package/src/assets/web-panel/assets/{Skills-BCvgBkD3.js → Skills-CLlblJcG.js} +1 -1
  9. package/src/assets/web-panel/assets/chat-DWBA4-cl.js +1 -0
  10. package/src/assets/web-panel/assets/{index-DrmEk9S3.js → index-CyGtHm63.js} +2 -2
  11. package/src/assets/web-panel/index.html +1 -1
  12. package/src/commands/learning.js +273 -0
  13. package/src/commands/lowcode.js +23 -8
  14. package/src/gateways/discord/discord-formatter.js +89 -0
  15. package/src/gateways/gateway-base.js +189 -0
  16. package/src/gateways/telegram/telegram-formatter.js +93 -0
  17. package/src/index.js +2 -0
  18. package/src/lib/app-builder.js +136 -8
  19. package/src/lib/autonomous-agent.js +8 -1
  20. package/src/lib/cli-context-engineering.js +15 -0
  21. package/src/lib/execution-backend.js +239 -0
  22. package/src/lib/hook-manager.js +2 -0
  23. package/src/lib/iteration-budget.js +175 -0
  24. package/src/lib/learning/learning-hooks.js +117 -0
  25. package/src/lib/learning/learning-tables.js +66 -0
  26. package/src/lib/learning/outcome-feedback.js +243 -0
  27. package/src/lib/learning/reflection-engine.js +323 -0
  28. package/src/lib/learning/skill-improver.js +536 -0
  29. package/src/lib/learning/skill-synthesizer.js +315 -0
  30. package/src/lib/learning/trajectory-store.js +409 -0
  31. package/src/lib/plugin-autodiscovery.js +224 -0
  32. package/src/lib/session-search.js +193 -0
  33. package/src/lib/sub-agent-context.js +7 -2
  34. package/src/lib/user-profile.js +172 -0
  35. package/src/lib/web-ui-server.js +1 -1
  36. package/src/repl/agent-repl.js +109 -0
  37. package/src/runtime/agent-core.js +75 -4
  38. package/src/runtime/coding-agent-contract-shared.cjs +35 -0
  39. package/src/runtime/coding-agent-policy.cjs +10 -0
  40. package/src/assets/web-panel/assets/AppLayout-Cr2lWhF-.css +0 -1
  41. package/src/assets/web-panel/assets/chat-BmwHBi9M.js +0 -1
@@ -0,0 +1,315 @@
1
+ /**
2
+ * SkillSynthesizer — Automatically creates SKILL.md files from
3
+ * successful complex execution trajectories.
4
+ *
5
+ * Trigger conditions (all must be met):
6
+ * 1. tool_count >= minToolCount (default 5)
7
+ * 2. outcome_score >= minScore (default 0.7)
8
+ * 3. synthesized_skill IS NULL
9
+ * 4. At least minSimilar similar trajectories exist
10
+ *
11
+ * Process:
12
+ * 1. Find eligible trajectories
13
+ * 2. Check for duplicates (tool chain fingerprint)
14
+ * 3. Send to LLM for pattern extraction
15
+ * 4. Generate SKILL.md
16
+ * 5. Write to workspace skill layer
17
+ */
18
+
19
+ import fs from "fs";
20
+ import path from "path";
21
+
22
+ // ── _deps for test injection ────────────────────────────
23
+
24
+ const _deps = { fs, path };
25
+
26
+ // ── Helpers ─────────────────────────────────────────────
27
+
28
+ /**
29
+ * Extract unique tool names from a tool chain.
30
+ * @param {Array<{tool:string}>} toolChain
31
+ * @returns {string[]}
32
+ */
33
+ export function extractToolNames(toolChain) {
34
+ return [...new Set((toolChain || []).map((t) => t.tool))];
35
+ }
36
+
37
+ /**
38
+ * Compute tool chain fingerprint (sorted tool name set).
39
+ * Used for deduplication.
40
+ * @param {Array<{tool:string}>} toolChain
41
+ * @returns {string}
42
+ */
43
+ export function toolChainFingerprint(toolChain) {
44
+ return extractToolNames(toolChain).sort().join(",");
45
+ }
46
+
47
+ /**
48
+ * Check if two fingerprints overlap by at least threshold.
49
+ * Uses Jaccard index on the tool sets.
50
+ * @param {string} fp1
51
+ * @param {string} fp2
52
+ * @param {number} [threshold=0.7]
53
+ * @returns {boolean}
54
+ */
55
+ export function fingerprintsOverlap(fp1, fp2, threshold = 0.7) {
56
+ const set1 = new Set(fp1.split(",").filter(Boolean));
57
+ const set2 = new Set(fp2.split(",").filter(Boolean));
58
+ const intersection = [...set1].filter((t) => set2.has(t)).length;
59
+ const union = new Set([...set1, ...set2]).size;
60
+ return union > 0 ? intersection / union >= threshold : false;
61
+ }
62
+
63
+ /**
64
+ * Generate a kebab-case skill name from user intent.
65
+ * @param {string} userIntent
66
+ * @returns {string}
67
+ */
68
+ export function generateSkillName(userIntent) {
69
+ if (!userIntent) return "auto-learned-skill";
70
+ return (
71
+ userIntent
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9\u4e00-\u9fff\s-]/g, "")
74
+ .trim()
75
+ .split(/\s+/)
76
+ .slice(0, 4)
77
+ .join("-") || "auto-learned-skill"
78
+ );
79
+ }
80
+
81
+ // ── LLM prompt template ─────────────────────────────────
82
+
83
+ /**
84
+ * Build the LLM prompt for pattern extraction.
85
+ * @param {object} trajectory
86
+ * @returns {Array<{role:string, content:string}>}
87
+ */
88
+ export function buildExtractionPrompt(trajectory) {
89
+ const toolSteps = (trajectory.toolChain || [])
90
+ .map(
91
+ (t, i) =>
92
+ ` ${i + 1}. ${t.tool}(${JSON.stringify(t.args || {}).slice(0, 200)}) → ${t.status} (${t.durationMs || 0}ms)`,
93
+ )
94
+ .join("\n");
95
+
96
+ return [
97
+ {
98
+ role: "system",
99
+ content: `You are a skill extraction expert. Analyze execution trajectories and extract reusable workflow patterns.
100
+ Output ONLY valid JSON with these fields:
101
+ {
102
+ "name": "kebab-case-name",
103
+ "description": "One-line description",
104
+ "procedure": ["Step 1", "Step 2", ...],
105
+ "pitfalls": ["Pitfall 1: description", ...],
106
+ "verification": "How to confirm success",
107
+ "tools": ["tool_name_1", "tool_name_2"]
108
+ }
109
+ If the trajectory is too specific or not reusable, respond with: {"not_applicable": true}`,
110
+ },
111
+ {
112
+ role: "user",
113
+ content: `## Execution Trajectory
114
+ User intent: ${trajectory.userIntent || "unknown"}
115
+ Tool chain:
116
+ ${toolSteps}
117
+ Final response: ${(trajectory.finalResponse || "").slice(0, 500)}`,
118
+ },
119
+ ];
120
+ }
121
+
122
+ /**
123
+ * Generate SKILL.md content from extracted pattern.
124
+ * @param {object} pattern — { name, description, procedure, pitfalls, verification, tools }
125
+ * @param {string} trajectoryId
126
+ * @param {number} [confidence=0.7]
127
+ * @returns {string}
128
+ */
129
+ export function generateSkillMd(pattern, trajectoryId, confidence = 0.7) {
130
+ const tools = (pattern.tools || []).join(", ");
131
+ const procedure = (pattern.procedure || [])
132
+ .map((step, i) => `${i + 1}. ${step}`)
133
+ .join("\n");
134
+ const pitfalls = (pattern.pitfalls || []).map((p) => `- ${p}`).join("\n");
135
+
136
+ return `---
137
+ name: ${pattern.name}
138
+ description: ${pattern.description || "Auto-learned skill"}
139
+ version: 1.0.0
140
+ category: auto-learned
141
+ tags: [auto-synthesized]
142
+ tools: [${tools}]
143
+ ---
144
+
145
+ ## Procedure
146
+ ${procedure || "1. Follow the extracted workflow"}
147
+
148
+ ## Pitfalls
149
+ ${pitfalls || "- None identified yet"}
150
+
151
+ ## Verification
152
+ ${pattern.verification || "Verify the task completed successfully"}
153
+
154
+ ## Metadata
155
+ - Source: trajectory
156
+ - Trajectory ID: ${trajectoryId}
157
+ - Confidence: ${confidence}
158
+ - Created by: learning-loop
159
+ `;
160
+ }
161
+
162
+ // ── SkillSynthesizer class ──────────────────────────────
163
+
164
+ export class SkillSynthesizer {
165
+ /**
166
+ * @param {import("better-sqlite3").Database} db
167
+ * @param {function} llmChat — async (messages) => string (LLM response)
168
+ * @param {import("./trajectory-store.js").TrajectoryStore} trajectoryStore
169
+ * @param {{minToolCount?:number, minScore?:number, minSimilar?:number, outputDir?:string}} [config]
170
+ */
171
+ constructor(db, llmChat, trajectoryStore, config = {}) {
172
+ this.db = db;
173
+ this.llmChat = llmChat;
174
+ this.trajectoryStore = trajectoryStore;
175
+ this.minToolCount = config.minToolCount ?? 5;
176
+ this.minScore = config.minScore ?? 0.7;
177
+ this.minSimilar = config.minSimilar ?? 2;
178
+ this.outputDir = config.outputDir || null;
179
+ }
180
+
181
+ /**
182
+ * Scan for eligible trajectories and synthesize skills.
183
+ * @returns {Promise<{created: string[], skipped: string[]}>}
184
+ */
185
+ async synthesize() {
186
+ const candidates = this.trajectoryStore.findComplexUnprocessed({
187
+ minToolCount: this.minToolCount,
188
+ minScore: this.minScore,
189
+ limit: 10,
190
+ });
191
+
192
+ const created = [];
193
+ const skipped = [];
194
+
195
+ for (const traj of candidates) {
196
+ try {
197
+ // Check similarity count
198
+ const toolNames = extractToolNames(traj.toolChain);
199
+ const similar = this.trajectoryStore.findSimilar(toolNames, {
200
+ minSimilarity: 0.5,
201
+ excludeId: traj.id,
202
+ });
203
+
204
+ if (similar.length < this.minSimilar) {
205
+ skipped.push(
206
+ `${traj.id}: insufficient similar trajectories (${similar.length}/${this.minSimilar})`,
207
+ );
208
+ continue;
209
+ }
210
+
211
+ // Check dedup against existing synthesized skills
212
+ const fp = toolChainFingerprint(traj.toolChain);
213
+ if (this._isDuplicate(fp)) {
214
+ skipped.push(`${traj.id}: duplicate fingerprint`);
215
+ continue;
216
+ }
217
+
218
+ // Extract pattern via LLM
219
+ const pattern = await this._extractPattern(traj);
220
+ if (!pattern || pattern.not_applicable) {
221
+ skipped.push(`${traj.id}: LLM deemed not applicable`);
222
+ continue;
223
+ }
224
+
225
+ // Generate and persist
226
+ const skillName = pattern.name || generateSkillName(traj.userIntent);
227
+ const content = generateSkillMd(
228
+ pattern,
229
+ traj.id,
230
+ traj.outcomeScore || 0.7,
231
+ );
232
+
233
+ if (this.outputDir) {
234
+ await this._persistSkill(skillName, content);
235
+ }
236
+
237
+ // Mark trajectory as synthesized
238
+ this.trajectoryStore.markSynthesized(traj.id, skillName);
239
+ created.push(skillName);
240
+ } catch (err) {
241
+ skipped.push(`${traj.id}: error - ${err.message}`);
242
+ }
243
+ }
244
+
245
+ return { created, skipped };
246
+ }
247
+
248
+ /**
249
+ * Extract pattern from a single trajectory via LLM.
250
+ * @param {object} trajectory
251
+ * @returns {Promise<object|null>}
252
+ */
253
+ async _extractPattern(trajectory) {
254
+ if (!this.llmChat) return null;
255
+
256
+ const messages = buildExtractionPrompt(trajectory);
257
+ const response = await this.llmChat(messages);
258
+
259
+ try {
260
+ // Try to parse JSON from response
261
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
262
+ if (!jsonMatch) return null;
263
+ return JSON.parse(jsonMatch[0]);
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Check if a fingerprint matches any already-synthesized trajectory.
271
+ * @param {string} fingerprint
272
+ * @returns {boolean}
273
+ */
274
+ _isDuplicate(fingerprint) {
275
+ // Check against already-synthesized trajectories
276
+ const synthesized = this.db
277
+ .prepare(
278
+ "SELECT tool_chain FROM learning_trajectories WHERE synthesized_skill IS NOT NULL",
279
+ )
280
+ .all();
281
+
282
+ for (const row of synthesized) {
283
+ let chain;
284
+ try {
285
+ chain = JSON.parse(row.tool_chain);
286
+ } catch {
287
+ continue;
288
+ }
289
+ const existingFp = toolChainFingerprint(chain);
290
+ if (fingerprintsOverlap(fingerprint, existingFp)) {
291
+ return true;
292
+ }
293
+ }
294
+
295
+ return false;
296
+ }
297
+
298
+ /**
299
+ * Write SKILL.md to the output directory.
300
+ * @param {string} skillName
301
+ * @param {string} content
302
+ * @returns {Promise<{skillDir:string, skillFile:string}>}
303
+ */
304
+ async _persistSkill(skillName, content) {
305
+ const skillDir = _deps.path.join(this.outputDir, skillName);
306
+ const skillFile = _deps.path.join(skillDir, "SKILL.md");
307
+
308
+ await _deps.fs.promises.mkdir(skillDir, { recursive: true });
309
+ await _deps.fs.promises.writeFile(skillFile, content, "utf-8");
310
+
311
+ return { skillDir, skillFile };
312
+ }
313
+ }
314
+
315
+ export { _deps };
@@ -0,0 +1,409 @@
1
+ /**
2
+ * TrajectoryStore — Records complete execution trajectories for the
3
+ * autonomous learning loop.
4
+ *
5
+ * A trajectory captures the full chain:
6
+ * user intent → tool calls (with args/results) → final response
7
+ *
8
+ * Consumed by:
9
+ * - OutcomeFeedback (P1) — backfills outcome_score
10
+ * - SkillSynthesizer (P2) — finds complex high-quality patterns
11
+ * - SkillImprover (P2) — finds similar trajectories for comparison
12
+ * - ReflectionEngine (P3) — periodic review
13
+ */
14
+
15
+ import { ensureLearningTables } from "./learning-tables.js";
16
+
17
+ // ── Helpers ──────────────────────────────────────────────
18
+
19
+ function generateId() {
20
+ const hex = () =>
21
+ Math.floor(Math.random() * 0x10000)
22
+ .toString(16)
23
+ .padStart(4, "0");
24
+ return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`;
25
+ }
26
+
27
+ /**
28
+ * Classify complexity based on tool call count.
29
+ * @param {number} toolCount
30
+ * @returns {"simple"|"moderate"|"complex"}
31
+ */
32
+ export function classifyComplexity(toolCount) {
33
+ if (toolCount <= 2) return "simple";
34
+ if (toolCount <= 5) return "moderate";
35
+ return "complex";
36
+ }
37
+
38
+ // ── _deps (for test injection, per CLI convention) ──────
39
+
40
+ const _deps = { generateId };
41
+
42
+ // ── TrajectoryStore ─────────────────────────────────────
43
+
44
+ export class TrajectoryStore {
45
+ /**
46
+ * @param {import("better-sqlite3").Database} db
47
+ */
48
+ constructor(db) {
49
+ this.db = db;
50
+ ensureLearningTables(db);
51
+ }
52
+
53
+ // ── Write path ──────────────────────────────────────
54
+
55
+ /**
56
+ * Start recording a new trajectory (called on UserPromptSubmit).
57
+ * @param {string} sessionId
58
+ * @param {string} userIntent — the user's raw input
59
+ * @returns {string} trajectoryId
60
+ */
61
+ startTrajectory(sessionId, userIntent) {
62
+ const id = _deps.generateId();
63
+ this.db
64
+ .prepare(
65
+ `INSERT INTO learning_trajectories (id, session_id, user_intent, tool_chain, tool_count, complexity_level)
66
+ VALUES (?, ?, ?, '[]', 0, 'simple')`,
67
+ )
68
+ .run(id, sessionId, userIntent || "");
69
+ return id;
70
+ }
71
+
72
+ /**
73
+ * Append a tool call record to the trajectory (called on PostToolUse).
74
+ * @param {string} trajectoryId
75
+ * @param {{tool:string, args:any, result:any, durationMs:number, status:string}} record
76
+ */
77
+ appendToolCall(trajectoryId, record) {
78
+ if (!trajectoryId) return;
79
+
80
+ const row = this.db
81
+ .prepare(
82
+ "SELECT tool_chain, tool_count FROM learning_trajectories WHERE id = ?",
83
+ )
84
+ .get(trajectoryId);
85
+ if (!row) return;
86
+
87
+ let chain;
88
+ try {
89
+ chain = JSON.parse(row.tool_chain);
90
+ } catch {
91
+ chain = [];
92
+ }
93
+
94
+ // Truncate large results to keep DB lean (max 500 chars)
95
+ const truncatedResult =
96
+ typeof record.result === "string" && record.result.length > 500
97
+ ? record.result.slice(0, 500) + "...[truncated]"
98
+ : typeof record.result === "object"
99
+ ? JSON.stringify(record.result).slice(0, 500)
100
+ : String(record.result ?? "");
101
+
102
+ chain.push({
103
+ tool: record.tool,
104
+ args: record.args,
105
+ result: truncatedResult,
106
+ durationMs: record.durationMs || 0,
107
+ status: record.status || "completed",
108
+ });
109
+
110
+ const newCount = chain.length;
111
+ this.db
112
+ .prepare(
113
+ `UPDATE learning_trajectories
114
+ SET tool_chain = ?, tool_count = ?, complexity_level = ?
115
+ WHERE id = ?`,
116
+ )
117
+ .run(
118
+ JSON.stringify(chain),
119
+ newCount,
120
+ classifyComplexity(newCount),
121
+ trajectoryId,
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Mark trajectory as complete (called on response-complete event).
127
+ * @param {string} trajectoryId
128
+ * @param {{finalResponse?:string, tags?:string[]}} data
129
+ * @returns {object|null} the completed trajectory row
130
+ */
131
+ completeTrajectory(trajectoryId, data = {}) {
132
+ if (!trajectoryId) return null;
133
+
134
+ this.db
135
+ .prepare(
136
+ `UPDATE learning_trajectories
137
+ SET final_response = ?, completed_at = datetime('now')
138
+ WHERE id = ?`,
139
+ )
140
+ .run(data.finalResponse || "", trajectoryId);
141
+
142
+ // Insert tags
143
+ if (Array.isArray(data.tags) && data.tags.length > 0) {
144
+ const insert = this.db.prepare(
145
+ "INSERT OR IGNORE INTO learning_trajectory_tags (trajectory_id, tag) VALUES (?, ?)",
146
+ );
147
+ for (const tag of data.tags) {
148
+ insert.run(trajectoryId, tag);
149
+ }
150
+ }
151
+
152
+ return this.getTrajectory(trajectoryId);
153
+ }
154
+
155
+ /**
156
+ * Backfill outcome score (called by OutcomeFeedback).
157
+ * @param {string} trajectoryId
158
+ * @param {number} score 0-1
159
+ * @param {"auto"|"user"|"reflection"} source
160
+ */
161
+ setOutcomeScore(trajectoryId, score, source) {
162
+ if (!trajectoryId) return;
163
+ const clamped = Math.max(0, Math.min(1, score));
164
+ this.db
165
+ .prepare(
166
+ `UPDATE learning_trajectories
167
+ SET outcome_score = ?, outcome_source = ?
168
+ WHERE id = ?`,
169
+ )
170
+ .run(clamped, source || "auto", trajectoryId);
171
+ }
172
+
173
+ /**
174
+ * Mark that a skill was synthesized from this trajectory.
175
+ * @param {string} trajectoryId
176
+ * @param {string} skillName
177
+ */
178
+ markSynthesized(trajectoryId, skillName) {
179
+ if (!trajectoryId) return;
180
+ this.db
181
+ .prepare(
182
+ "UPDATE learning_trajectories SET synthesized_skill = ? WHERE id = ?",
183
+ )
184
+ .run(skillName, trajectoryId);
185
+ }
186
+
187
+ // ── Read path ───────────────────────────────────────
188
+
189
+ /**
190
+ * Get a single trajectory by ID.
191
+ * @param {string} id
192
+ * @returns {object|null}
193
+ */
194
+ getTrajectory(id) {
195
+ const row = this.db
196
+ .prepare("SELECT * FROM learning_trajectories WHERE id = ?")
197
+ .get(id);
198
+ return row ? this._hydrate(row) : null;
199
+ }
200
+
201
+ /**
202
+ * List trajectories for a session.
203
+ * @param {string} sessionId
204
+ * @param {{limit?:number}} options
205
+ * @returns {object[]}
206
+ */
207
+ listBySession(sessionId, options = {}) {
208
+ const limit = options.limit || 50;
209
+ const rows = this.db
210
+ .prepare(
211
+ `SELECT * FROM learning_trajectories
212
+ WHERE session_id = ?
213
+ ORDER BY created_at DESC
214
+ LIMIT ?`,
215
+ )
216
+ .all(sessionId, limit);
217
+ return rows.map((r) => this._hydrate(r));
218
+ }
219
+
220
+ /**
221
+ * Find complex, high-score, un-synthesized trajectories.
222
+ * Used by SkillSynthesizer to find candidates.
223
+ * @param {{minToolCount?:number, minScore?:number, limit?:number}} options
224
+ * @returns {object[]}
225
+ */
226
+ findComplexUnprocessed(options = {}) {
227
+ const minToolCount = options.minToolCount ?? 5;
228
+ const minScore = options.minScore ?? 0.7;
229
+ const limit = options.limit ?? 10;
230
+
231
+ const rows = this.db
232
+ .prepare(
233
+ `SELECT * FROM learning_trajectories
234
+ WHERE tool_count >= ?
235
+ AND outcome_score >= ?
236
+ AND synthesized_skill IS NULL
237
+ AND completed_at IS NOT NULL
238
+ ORDER BY outcome_score DESC, tool_count DESC
239
+ LIMIT ?`,
240
+ )
241
+ .all(minToolCount, minScore, limit);
242
+ return rows.map((r) => this._hydrate(r));
243
+ }
244
+
245
+ /**
246
+ * Find trajectories with similar tool usage patterns.
247
+ * Similarity = Jaccard index of tool name sets.
248
+ * @param {string[]} toolNames — tool names to match against
249
+ * @param {{minSimilarity?:number, limit?:number, excludeId?:string}} options
250
+ * @returns {object[]}
251
+ */
252
+ findSimilar(toolNames, options = {}) {
253
+ const minSimilarity = options.minSimilarity ?? 0.5;
254
+ const limit = options.limit ?? 20;
255
+ const excludeId = options.excludeId || null;
256
+
257
+ // Fetch all completed trajectories (bounded)
258
+ const rows = this.db
259
+ .prepare(
260
+ `SELECT * FROM learning_trajectories
261
+ WHERE completed_at IS NOT NULL
262
+ ORDER BY created_at DESC
263
+ LIMIT 200`,
264
+ )
265
+ .all();
266
+
267
+ const inputSet = new Set(toolNames);
268
+ const results = [];
269
+
270
+ for (const row of rows) {
271
+ if (excludeId && row.id === excludeId) continue;
272
+
273
+ let chain;
274
+ try {
275
+ chain = JSON.parse(row.tool_chain);
276
+ } catch {
277
+ continue;
278
+ }
279
+
280
+ const rowTools = new Set(chain.map((t) => t.tool));
281
+ const intersection = [...inputSet].filter((t) => rowTools.has(t)).length;
282
+ const union = new Set([...inputSet, ...rowTools]).size;
283
+ const similarity = union > 0 ? intersection / union : 0;
284
+
285
+ if (similarity >= minSimilarity) {
286
+ results.push({ ...this._hydrate(row), similarity });
287
+ }
288
+ }
289
+
290
+ results.sort((a, b) => b.similarity - a.similarity);
291
+ return results.slice(0, limit);
292
+ }
293
+
294
+ /**
295
+ * Get recent trajectories (for CLI display).
296
+ * @param {{limit?:number, sessionId?:string}} options
297
+ * @returns {object[]}
298
+ */
299
+ getRecent(options = {}) {
300
+ const limit = options.limit || 20;
301
+ if (options.sessionId) {
302
+ return this.listBySession(options.sessionId, { limit });
303
+ }
304
+ const rows = this.db
305
+ .prepare(
306
+ `SELECT * FROM learning_trajectories
307
+ ORDER BY created_at DESC
308
+ LIMIT ?`,
309
+ )
310
+ .all(limit);
311
+ return rows.map((r) => this._hydrate(r));
312
+ }
313
+
314
+ /**
315
+ * Get basic stats.
316
+ * @returns {{total:number, complex:number, scored:number, synthesized:number}}
317
+ */
318
+ getStats() {
319
+ const total = this.db
320
+ .prepare("SELECT COUNT(*) as c FROM learning_trajectories")
321
+ .get().c;
322
+ const complex = this.db
323
+ .prepare(
324
+ "SELECT COUNT(*) as c FROM learning_trajectories WHERE complexity_level = 'complex'",
325
+ )
326
+ .get().c;
327
+ const scored = this.db
328
+ .prepare(
329
+ "SELECT COUNT(*) as c FROM learning_trajectories WHERE outcome_score IS NOT NULL",
330
+ )
331
+ .get().c;
332
+ const synthesized = this.db
333
+ .prepare(
334
+ "SELECT COUNT(*) as c FROM learning_trajectories WHERE synthesized_skill IS NOT NULL",
335
+ )
336
+ .get().c;
337
+ return { total, complex, scored, synthesized };
338
+ }
339
+
340
+ // ── Maintenance ─────────────────────────────────────
341
+
342
+ /**
343
+ * Delete trajectories older than retentionDays.
344
+ * @param {number} [retentionDays=90]
345
+ * @returns {number} deleted count
346
+ */
347
+ cleanup(retentionDays = 90) {
348
+ // Delete tags first (FK-like cleanup)
349
+ this.db
350
+ .prepare(
351
+ `DELETE FROM learning_trajectory_tags
352
+ WHERE trajectory_id IN (
353
+ SELECT id FROM learning_trajectories
354
+ WHERE created_at < datetime('now', ?)
355
+ )`,
356
+ )
357
+ .run(`-${retentionDays} days`);
358
+
359
+ const result = this.db
360
+ .prepare(
361
+ `DELETE FROM learning_trajectories
362
+ WHERE created_at < datetime('now', ?)`,
363
+ )
364
+ .run(`-${retentionDays} days`);
365
+
366
+ return result.changes;
367
+ }
368
+
369
+ // ── Internal ────────────────────────────────────────
370
+
371
+ /**
372
+ * Hydrate a DB row: parse JSON fields, attach tags.
373
+ * @param {object} row
374
+ * @returns {object}
375
+ */
376
+ _hydrate(row) {
377
+ let toolChain;
378
+ try {
379
+ toolChain = JSON.parse(row.tool_chain);
380
+ } catch {
381
+ toolChain = [];
382
+ }
383
+
384
+ const tags = this.db
385
+ .prepare(
386
+ "SELECT tag FROM learning_trajectory_tags WHERE trajectory_id = ?",
387
+ )
388
+ .all(row.id)
389
+ .map((r) => r.tag);
390
+
391
+ return {
392
+ id: row.id,
393
+ sessionId: row.session_id,
394
+ userIntent: row.user_intent,
395
+ toolChain,
396
+ toolCount: row.tool_count,
397
+ finalResponse: row.final_response,
398
+ outcomeScore: row.outcome_score,
399
+ outcomeSource: row.outcome_source,
400
+ complexityLevel: row.complexity_level,
401
+ synthesizedSkill: row.synthesized_skill,
402
+ createdAt: row.created_at,
403
+ completedAt: row.completed_at,
404
+ tags,
405
+ };
406
+ }
407
+ }
408
+
409
+ export { _deps };