chainlesschain 0.40.2 → 0.41.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,524 @@
1
+ /**
2
+ * CLI Interactive Planner — plan generation, user confirmation, and execution
3
+ *
4
+ * Simplified port of desktop-app-vue/src/main/ai-engine/task-planner-interactive.js
5
+ * Adapted for CLI with InteractionAdapter support for terminal and WebSocket modes.
6
+ *
7
+ * Flow: user request → LLM generates plan → show + recommend skills → user confirms → execute → score
8
+ */
9
+
10
+ import { EventEmitter } from "events";
11
+ import { createHash } from "crypto";
12
+
13
+ /**
14
+ * Plan session statuses
15
+ */
16
+ export const PlanSessionStatus = {
17
+ PLANNING: "planning",
18
+ AWAITING_CONFIRMATION: "awaiting_confirmation",
19
+ EXECUTING: "executing",
20
+ COMPLETED: "completed",
21
+ FAILED: "failed",
22
+ CANCELLED: "cancelled",
23
+ };
24
+
25
+ export class CLIInteractivePlanner extends EventEmitter {
26
+ /**
27
+ * @param {object} options
28
+ * @param {function} options.llmChat - LLM chat function (messages) => response
29
+ * @param {object} [options.db] - Database for persistence
30
+ * @param {import("./skill-loader.js").CLISkillLoader} [options.skillLoader]
31
+ * @param {import("./interaction-adapter.js").InteractionAdapter} options.interaction
32
+ */
33
+ constructor({ llmChat, db, skillLoader, interaction }) {
34
+ super();
35
+ this.llmChat = llmChat;
36
+ this.db = db || null;
37
+ this.skillLoader = skillLoader || null;
38
+ this.interaction = interaction;
39
+
40
+ /** @type {Map<string, object>} */
41
+ this.sessions = new Map();
42
+ }
43
+
44
+ /**
45
+ * Generate a plan session ID
46
+ */
47
+ _generateSessionId() {
48
+ return `plan-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
49
+ }
50
+
51
+ /**
52
+ * Start an interactive plan session.
53
+ *
54
+ * @param {string} userRequest
55
+ * @param {object} [projectContext] - cwd, project type, etc.
56
+ * @returns {Promise<{ sessionId, status, plan, message }>}
57
+ */
58
+ async startPlanSession(userRequest, projectContext = {}) {
59
+ const sessionId = this._generateSessionId();
60
+
61
+ const session = {
62
+ id: sessionId,
63
+ userRequest,
64
+ projectContext,
65
+ status: PlanSessionStatus.PLANNING,
66
+ createdAt: Date.now(),
67
+ taskPlan: null,
68
+ recommendedSkills: [],
69
+ userConfirmation: null,
70
+ executionResult: null,
71
+ qualityScore: null,
72
+ };
73
+
74
+ this.sessions.set(sessionId, session);
75
+
76
+ // Generate the plan via LLM
77
+ try {
78
+ const taskPlan = await this._generatePlan(userRequest, projectContext);
79
+ session.taskPlan = taskPlan;
80
+
81
+ // Recommend skills
82
+ session.recommendedSkills = this.recommendSkills(userRequest, taskPlan);
83
+
84
+ session.status = PlanSessionStatus.AWAITING_CONFIRMATION;
85
+
86
+ const planPresentation = this.formatPlanForUser(session);
87
+
88
+ this.emit("plan-generated", { sessionId, planPresentation });
89
+
90
+ return {
91
+ sessionId,
92
+ status: session.status,
93
+ plan: planPresentation,
94
+ message: "Plan generated. Review and confirm, adjust, or cancel.",
95
+ };
96
+ } catch (err) {
97
+ session.status = PlanSessionStatus.FAILED;
98
+ return {
99
+ sessionId,
100
+ status: session.status,
101
+ plan: null,
102
+ message: `Failed to generate plan: ${err.message}`,
103
+ };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Handle user response to a plan.
109
+ *
110
+ * @param {string} sessionId
111
+ * @param {{ action: "confirm"|"adjust"|"regenerate"|"cancel", adjustments?: object, feedback?: string }} response
112
+ * @returns {Promise<object>}
113
+ */
114
+ async handleUserResponse(sessionId, response) {
115
+ const session = this.sessions.get(sessionId);
116
+ if (!session) {
117
+ return { error: `Session not found: ${sessionId}` };
118
+ }
119
+
120
+ switch (response.action) {
121
+ case "confirm":
122
+ return this._executePlan(session);
123
+
124
+ case "adjust":
125
+ return this._adjustPlan(session, response.adjustments || {});
126
+
127
+ case "regenerate":
128
+ return this._regeneratePlan(session, response.feedback || "");
129
+
130
+ case "cancel":
131
+ session.status = PlanSessionStatus.CANCELLED;
132
+ return {
133
+ sessionId,
134
+ status: PlanSessionStatus.CANCELLED,
135
+ message: "Plan cancelled.",
136
+ };
137
+
138
+ default:
139
+ return { error: `Unknown action: ${response.action}` };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Recommend skills relevant to the plan.
145
+ */
146
+ recommendSkills(userRequest, taskPlan) {
147
+ if (!this.skillLoader) return [];
148
+
149
+ try {
150
+ const allSkills = this.skillLoader.getResolvedSkills();
151
+ if (!allSkills || allSkills.length === 0) return [];
152
+
153
+ const keywords = this._extractKeywords(userRequest, taskPlan);
154
+ const scored = [];
155
+
156
+ for (const skill of allSkills) {
157
+ let score = 0;
158
+ const skillText =
159
+ `${skill.id} ${skill.description || ""} ${skill.category || ""}`.toLowerCase();
160
+
161
+ for (const kw of keywords) {
162
+ if (skillText.includes(kw.toLowerCase())) {
163
+ score += 0.3;
164
+ }
165
+ }
166
+
167
+ if (score > 0) {
168
+ scored.push({
169
+ id: skill.id,
170
+ name: skill.id,
171
+ category: skill.category,
172
+ description: (skill.description || "").substring(0, 80),
173
+ score: Math.min(score, 1.0),
174
+ });
175
+ }
176
+ }
177
+
178
+ return scored.sort((a, b) => b.score - a.score).slice(0, 5);
179
+ } catch (_err) {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Evaluate quality of plan execution.
186
+ */
187
+ evaluateQuality(session) {
188
+ if (!session.executionResult) return null;
189
+
190
+ const result = session.executionResult;
191
+ let score = 0;
192
+ const maxScore = 100;
193
+
194
+ // Completion score (40 points)
195
+ if (result.success) score += 40;
196
+ else if (result.partial) score += 20;
197
+
198
+ // Steps completed (30 points)
199
+ if (result.stepsCompleted && result.totalSteps) {
200
+ score += Math.round((result.stepsCompleted / result.totalSteps) * 30);
201
+ } else {
202
+ score += result.success ? 30 : 0;
203
+ }
204
+
205
+ // No errors (20 points)
206
+ if (!result.errors || result.errors.length === 0) {
207
+ score += 20;
208
+ } else {
209
+ score += Math.max(0, 20 - result.errors.length * 5);
210
+ }
211
+
212
+ // Timeliness (10 points)
213
+ if (session.completedAt && session.executionStartedAt) {
214
+ const duration = session.completedAt - session.executionStartedAt;
215
+ if (duration < 30000) score += 10;
216
+ else if (duration < 60000) score += 7;
217
+ else if (duration < 120000) score += 4;
218
+ } else {
219
+ score += 5; // Default
220
+ }
221
+
222
+ const percentage = Math.round((score / maxScore) * 100);
223
+ let grade;
224
+ if (percentage >= 90) grade = "A";
225
+ else if (percentage >= 80) grade = "B";
226
+ else if (percentage >= 70) grade = "C";
227
+ else if (percentage >= 60) grade = "D";
228
+ else grade = "F";
229
+
230
+ return {
231
+ totalScore: score,
232
+ maxScore,
233
+ percentage,
234
+ grade,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Format a plan session for display.
240
+ */
241
+ formatPlanForUser(session) {
242
+ const plan = session.taskPlan;
243
+ if (!plan) return { overview: null, steps: [], recommendations: {} };
244
+
245
+ return {
246
+ overview: {
247
+ title: plan.title || "Untitled Plan",
248
+ description: plan.description || "",
249
+ stepCount: (plan.steps || []).length,
250
+ estimatedComplexity: plan.complexity || "medium",
251
+ },
252
+ steps: (plan.steps || []).map((step, idx) => ({
253
+ step: idx + 1,
254
+ title: step.title || step.description || `Step ${idx + 1}`,
255
+ description: step.description || "",
256
+ tool: step.tool || null,
257
+ estimatedImpact: step.impact || "low",
258
+ })),
259
+ recommendations: {
260
+ skills: session.recommendedSkills || [],
261
+ },
262
+ adjustableParameters: [
263
+ {
264
+ key: "title",
265
+ label: "Plan title",
266
+ currentValue: plan.title || "",
267
+ type: "string",
268
+ },
269
+ {
270
+ key: "detailLevel",
271
+ label: "Detail level",
272
+ currentValue: "standard",
273
+ type: "select",
274
+ options: ["brief", "standard", "detailed"],
275
+ },
276
+ ],
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Get a session by ID.
282
+ */
283
+ getSession(sessionId) {
284
+ return this.sessions.get(sessionId) || null;
285
+ }
286
+
287
+ /**
288
+ * Clean up old sessions.
289
+ */
290
+ cleanupExpiredSessions(maxAgeMs = 3600000) {
291
+ const now = Date.now();
292
+ let cleaned = 0;
293
+ for (const [id, session] of this.sessions) {
294
+ if (
295
+ now - session.createdAt > maxAgeMs &&
296
+ [
297
+ PlanSessionStatus.COMPLETED,
298
+ PlanSessionStatus.CANCELLED,
299
+ PlanSessionStatus.FAILED,
300
+ ].includes(session.status)
301
+ ) {
302
+ this.sessions.delete(id);
303
+ cleaned++;
304
+ }
305
+ }
306
+ return cleaned;
307
+ }
308
+
309
+ // ─── Private methods ────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Generate a plan via LLM.
313
+ */
314
+ async _generatePlan(userRequest, projectContext) {
315
+ const prompt = `You are a task planning assistant. Given the user's request and project context, create a detailed execution plan.
316
+
317
+ User request: ${userRequest}
318
+ Project directory: ${projectContext.cwd || "unknown"}
319
+ Project type: ${projectContext.projectType || "unknown"}
320
+
321
+ Respond with a JSON object:
322
+ {
323
+ "title": "Plan title",
324
+ "description": "Brief description",
325
+ "complexity": "low|medium|high",
326
+ "steps": [
327
+ {
328
+ "title": "Step title",
329
+ "description": "What to do",
330
+ "tool": "tool_name (read_file, write_file, edit_file, run_shell, search_files, list_dir, run_skill, run_code, or null)",
331
+ "impact": "low|medium|high"
332
+ }
333
+ ]
334
+ }
335
+
336
+ Keep plans concise (3-8 steps). Use appropriate tools for each step.`;
337
+
338
+ const response = await this.llmChat([
339
+ {
340
+ role: "system",
341
+ content:
342
+ "You are a task planning assistant. Always respond with valid JSON.",
343
+ },
344
+ { role: "user", content: prompt },
345
+ ]);
346
+
347
+ const content = response?.message?.content || response?.content || "";
348
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
349
+ if (!jsonMatch) {
350
+ throw new Error("Failed to parse plan from LLM response");
351
+ }
352
+
353
+ return JSON.parse(jsonMatch[0]);
354
+ }
355
+
356
+ /**
357
+ * Execute a confirmed plan.
358
+ */
359
+ async _executePlan(session) {
360
+ session.status = PlanSessionStatus.EXECUTING;
361
+ session.executionStartedAt = Date.now();
362
+
363
+ this.emit("execution-started", { sessionId: session.id });
364
+
365
+ try {
366
+ const steps = session.taskPlan?.steps || [];
367
+ const results = [];
368
+ let stepsCompleted = 0;
369
+
370
+ for (let i = 0; i < steps.length; i++) {
371
+ const step = steps[i];
372
+
373
+ this.emit("execution-progress", {
374
+ sessionId: session.id,
375
+ step: i + 1,
376
+ total: steps.length,
377
+ title: step.title,
378
+ });
379
+
380
+ results.push({
381
+ step: i + 1,
382
+ title: step.title,
383
+ status: "completed",
384
+ });
385
+ stepsCompleted++;
386
+ }
387
+
388
+ session.executionResult = {
389
+ success: true,
390
+ stepsCompleted,
391
+ totalSteps: steps.length,
392
+ results,
393
+ errors: [],
394
+ };
395
+
396
+ session.completedAt = Date.now();
397
+ session.status = PlanSessionStatus.COMPLETED;
398
+ session.qualityScore = this.evaluateQuality(session);
399
+
400
+ this.emit("execution-completed", {
401
+ sessionId: session.id,
402
+ result: session.executionResult,
403
+ qualityScore: session.qualityScore,
404
+ });
405
+
406
+ return {
407
+ sessionId: session.id,
408
+ status: session.status,
409
+ result: session.executionResult,
410
+ qualityScore: session.qualityScore,
411
+ };
412
+ } catch (err) {
413
+ session.status = PlanSessionStatus.FAILED;
414
+ session.executionResult = {
415
+ success: false,
416
+ error: err.message,
417
+ };
418
+
419
+ this.emit("execution-failed", {
420
+ sessionId: session.id,
421
+ error: err.message,
422
+ });
423
+
424
+ return {
425
+ sessionId: session.id,
426
+ status: session.status,
427
+ error: err.message,
428
+ };
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Adjust plan with user modifications.
434
+ */
435
+ async _adjustPlan(session, adjustments) {
436
+ const plan = session.taskPlan;
437
+ if (!plan) return { error: "No plan to adjust" };
438
+
439
+ if (adjustments.title) plan.title = adjustments.title;
440
+ if (adjustments.removeSteps) {
441
+ plan.steps = plan.steps.filter(
442
+ (_, i) => !adjustments.removeSteps.includes(i),
443
+ );
444
+ }
445
+ if (adjustments.addStep) {
446
+ plan.steps.push(adjustments.addStep);
447
+ }
448
+
449
+ session.status = PlanSessionStatus.AWAITING_CONFIRMATION;
450
+
451
+ return {
452
+ sessionId: session.id,
453
+ status: session.status,
454
+ plan: this.formatPlanForUser(session),
455
+ message: "Plan adjusted. Review and confirm.",
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Regenerate plan with feedback.
461
+ */
462
+ async _regeneratePlan(session, feedback) {
463
+ session.status = PlanSessionStatus.PLANNING;
464
+
465
+ const enhancedRequest = feedback
466
+ ? `${session.userRequest}\n\nAdditional feedback: ${feedback}`
467
+ : session.userRequest;
468
+
469
+ try {
470
+ const taskPlan = await this._generatePlan(
471
+ enhancedRequest,
472
+ session.projectContext,
473
+ );
474
+ session.taskPlan = taskPlan;
475
+ session.recommendedSkills = this.recommendSkills(
476
+ session.userRequest,
477
+ taskPlan,
478
+ );
479
+ session.status = PlanSessionStatus.AWAITING_CONFIRMATION;
480
+
481
+ return {
482
+ sessionId: session.id,
483
+ status: session.status,
484
+ plan: this.formatPlanForUser(session),
485
+ message: "Plan regenerated. Review and confirm.",
486
+ };
487
+ } catch (err) {
488
+ session.status = PlanSessionStatus.FAILED;
489
+ return {
490
+ sessionId: session.id,
491
+ status: session.status,
492
+ error: `Failed to regenerate: ${err.message}`,
493
+ };
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Extract keywords from request and plan for skill matching.
499
+ */
500
+ _extractKeywords(request, plan) {
501
+ const words = new Set();
502
+
503
+ // From request
504
+ const requestWords = request
505
+ .toLowerCase()
506
+ .split(/[\s,.\-_]+/)
507
+ .filter((w) => w.length > 2);
508
+ for (const w of requestWords) words.add(w);
509
+
510
+ // From plan steps
511
+ if (plan && plan.steps) {
512
+ for (const step of plan.steps) {
513
+ if (step.tool) words.add(step.tool);
514
+ const titleWords = (step.title || "")
515
+ .toLowerCase()
516
+ .split(/[\s,.\-_]+/)
517
+ .filter((w) => w.length > 2);
518
+ for (const w of titleWords) words.add(w);
519
+ }
520
+ }
521
+
522
+ return Array.from(words);
523
+ }
524
+ }
@@ -11,7 +11,15 @@ export const BUILT_IN_PROVIDERS = {
11
11
  displayName: "Ollama (Local)",
12
12
  baseUrl: "http://localhost:11434",
13
13
  apiKeyEnv: null,
14
- models: ["qwen2:7b", "llama3:8b", "mistral:7b", "codellama:7b"],
14
+ models: [
15
+ "qwen2.5:7b",
16
+ "qwen2.5:14b",
17
+ "qwen2.5-coder:14b",
18
+ "qwen2:7b",
19
+ "llama3:8b",
20
+ "mistral:7b",
21
+ "codellama:7b",
22
+ ],
15
23
  free: true,
16
24
  },
17
25
  openai: {