chainlesschain 0.45.75 → 0.45.77
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/README.md +52 -15
- package/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{Analytics-sBrYoc3A.js → Analytics-Dd2DjBH5.js} +2 -2
- package/src/assets/web-panel/assets/AppLayout-CP9fATUN.js +1 -0
- package/src/assets/web-panel/assets/AppLayout-cxfKLu-m.css +1 -0
- package/src/assets/web-panel/assets/Backup-D6Tc7sf3.js +1 -0
- package/src/assets/web-panel/assets/Chat-DDUJZJ9I.js +1 -0
- package/src/assets/web-panel/assets/Chat-DfR76jyX.css +1 -0
- package/src/assets/web-panel/assets/Cowork-CPqYhoMI.css +1 -0
- package/src/assets/web-panel/assets/Cowork-XRFqGfqJ.js +48 -0
- package/src/assets/web-panel/assets/{Cron-CNs03iHJ.js → Cron-BnWzy_ZB.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-DanoHPSI.js → Dashboard-D2vCkoGu.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CCMVr3Y8.js → Git-DYlvK4sh.js} +2 -2
- package/src/assets/web-panel/assets/{Logs-BY6A0UNG.js → Logs-4VgUbfP0.js} +2 -2
- package/src/assets/web-panel/assets/{McpTools-CrBVYlg6.js → McpTools-ChaiHoWY.js} +2 -2
- package/src/assets/web-panel/assets/{Memory-CWx3SpUt.js → Memory-PFtpuOwf.js} +2 -2
- package/src/assets/web-panel/assets/{Notes-1LcGD49x.js → Notes-wc_n6Rh1.js} +2 -2
- package/src/assets/web-panel/assets/{Organization-Dx2DhbkM.js → Organization-D1qUa8NQ.js} +4 -4
- package/src/assets/web-panel/assets/{P2P-B16fjqfJ.js → P2P-DIG2gnR8.js} +2 -2
- package/src/assets/web-panel/assets/{Permissions-BQbC9FzG.js → Permissions-CpE-Ar1e.js} +3 -3
- package/src/assets/web-panel/assets/{Projects-CjhZbNYm.js → Projects-GjuS-C6U.js} +2 -2
- package/src/assets/web-panel/assets/{Providers-ivOAQtHM.js → Providers-CCfGeqh_.js} +2 -2
- package/src/assets/web-panel/assets/{RssFeed-BrsErdrU.js → RssFeed-5TkrXK7Z.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DnEvJU5h.js → Security-CcfBWT1D.js} +3 -3
- package/src/assets/web-panel/assets/{Services-7jQywNbl.js → Services-Cnm5Zs5h.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CLlblJcG.js → Skills-BHapMb9h.js} +1 -1
- package/src/assets/web-panel/assets/{Tasks-CmJBC1cf.js → Tasks-DPb9OMck.js} +1 -1
- package/src/assets/web-panel/assets/Templates-Dij5t-rf.js +1 -0
- package/src/assets/web-panel/assets/{Wallet-3iYASEx_.js → Wallet-BJV5KmWA.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-s3Hzd9db.js → WebAuthn-DLkvYwSc.js} +5 -5
- package/src/assets/web-panel/assets/{antd-gZyc63Qr.js → antd-BQNxIyr-.js} +82 -82
- package/src/assets/web-panel/assets/github-dark-Dfs9RUU9.css +1 -0
- package/src/assets/web-panel/assets/index-CB5YlndO.js +2 -0
- package/src/assets/web-panel/assets/{markdown-Bv7nG63L.js → markdown-BeVIhIzs.js} +1 -1
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/learning.js +273 -0
- package/src/commands/lowcode.js +23 -8
- package/src/gateways/discord/discord-formatter.js +89 -0
- package/src/gateways/gateway-base.js +189 -0
- package/src/gateways/telegram/telegram-formatter.js +93 -0
- package/src/gateways/ws/action-protocol.js +54 -1
- package/src/gateways/ws/message-dispatcher.js +1 -0
- package/src/gateways/ws/ws-server.js +10 -1
- package/src/index.js +2 -0
- package/src/lib/app-builder.js +136 -8
- package/src/lib/autonomous-agent.js +8 -1
- package/src/lib/cli-context-engineering.js +15 -0
- package/src/lib/cowork-task-runner.js +101 -0
- package/src/lib/cowork-task-templates.js +493 -0
- package/src/lib/execution-backend.js +239 -0
- package/src/lib/hook-manager.js +2 -0
- package/src/lib/iteration-budget.js +175 -0
- package/src/lib/learning/learning-hooks.js +117 -0
- package/src/lib/learning/learning-tables.js +66 -0
- package/src/lib/learning/outcome-feedback.js +243 -0
- package/src/lib/learning/reflection-engine.js +323 -0
- package/src/lib/learning/skill-improver.js +536 -0
- package/src/lib/learning/skill-synthesizer.js +315 -0
- package/src/lib/learning/trajectory-store.js +409 -0
- package/src/lib/plugin-autodiscovery.js +224 -0
- package/src/lib/session-search.js +193 -0
- package/src/lib/sub-agent-context.js +7 -2
- package/src/lib/user-profile.js +172 -0
- package/src/lib/web-ui-server.js +1 -1
- package/src/repl/agent-repl.js +109 -0
- package/src/runtime/agent-core.js +75 -4
- package/src/runtime/coding-agent-contract-shared.cjs +35 -0
- package/src/runtime/coding-agent-policy.cjs +10 -0
- package/src/assets/web-panel/assets/AppLayout-2RCrdXxl.js +0 -1
- package/src/assets/web-panel/assets/AppLayout-D9pBLPC3.css +0 -1
- package/src/assets/web-panel/assets/Backup-D68fenbD.js +0 -1
- package/src/assets/web-panel/assets/Chat-B2nB8o_F.js +0 -1
- package/src/assets/web-panel/assets/Chat-DB46afPg.css +0 -1
- package/src/assets/web-panel/assets/Templates-RXT8-DNk.js +0 -1
- package/src/assets/web-panel/assets/index-CyGtHm63.js +0 -2
|
@@ -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 };
|