chainlesschain 0.40.2 → 0.40.3

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,403 @@
1
+ /**
2
+ * WebSocket Agent Handler
3
+ *
4
+ * Handles agent session messages over WebSocket. Consumes agent-core's
5
+ * agentLoop generator and routes events to the client via the interaction
6
+ * adapter.
7
+ */
8
+
9
+ import { agentLoop, formatToolArgs } from "./agent-core.js";
10
+ import { detectTaskType, selectModelForTask } from "./task-model-selector.js";
11
+ import { PlanState } from "./plan-mode.js";
12
+
13
+ export class WSAgentHandler {
14
+ /**
15
+ * @param {object} options
16
+ * @param {import("./ws-session-manager.js").Session} options.session
17
+ * @param {import("./interaction-adapter.js").WebSocketInteractionAdapter} options.interaction
18
+ * @param {object} [options.db]
19
+ */
20
+ constructor({ session, interaction, db }) {
21
+ this.session = session;
22
+ this.interaction = interaction;
23
+ this.db = db || null;
24
+ this._processing = false;
25
+ }
26
+
27
+ /**
28
+ * Handle a user message — one turn of the agentic loop.
29
+ *
30
+ * @param {string} userMessage
31
+ * @param {string} [requestId] - id from ws message for response correlation
32
+ */
33
+ async handleMessage(userMessage, requestId) {
34
+ if (this._processing) {
35
+ this.interaction.emit("error", {
36
+ requestId,
37
+ code: "BUSY",
38
+ message: "Session is currently processing a message",
39
+ });
40
+ return;
41
+ }
42
+
43
+ this._processing = true;
44
+
45
+ try {
46
+ const { session } = this;
47
+
48
+ // Add user message
49
+ session.messages.push({ role: "user", content: userMessage });
50
+
51
+ // Auto-select model based on task type
52
+ let activeModel = session.model;
53
+ const taskDetection = detectTaskType(userMessage);
54
+ if (taskDetection.confidence > 0.3) {
55
+ const recommended = selectModelForTask(
56
+ session.provider,
57
+ taskDetection.taskType,
58
+ );
59
+ if (recommended && recommended !== activeModel) {
60
+ activeModel = recommended;
61
+ this.interaction.emit("model-switch", {
62
+ requestId,
63
+ from: session.model,
64
+ to: activeModel,
65
+ reason: taskDetection.name,
66
+ });
67
+ }
68
+ }
69
+
70
+ // Run agent loop
71
+ const loopOptions = {
72
+ provider: session.provider,
73
+ model: activeModel,
74
+ baseUrl: session.baseUrl || "http://localhost:11434",
75
+ apiKey: session.apiKey,
76
+ contextEngine: session.contextEngine,
77
+ hookDb: this.db,
78
+ cwd: session.projectRoot,
79
+ };
80
+
81
+ for await (const event of agentLoop(session.messages, loopOptions)) {
82
+ switch (event.type) {
83
+ case "tool-executing":
84
+ this.interaction.emit("tool-executing", {
85
+ requestId,
86
+ tool: event.tool,
87
+ args: event.args,
88
+ display: formatToolArgs(event.tool, event.args),
89
+ });
90
+ break;
91
+
92
+ case "tool-result":
93
+ this.interaction.emit("tool-result", {
94
+ requestId,
95
+ tool: event.tool,
96
+ result: event.result,
97
+ error: event.error,
98
+ });
99
+ break;
100
+
101
+ case "response-complete":
102
+ if (event.content) {
103
+ session.messages.push({
104
+ role: "assistant",
105
+ content: event.content,
106
+ });
107
+ }
108
+ this.interaction.emit("response-complete", {
109
+ requestId,
110
+ content: event.content,
111
+ });
112
+ break;
113
+ }
114
+ }
115
+
116
+ // Update last activity
117
+ session.lastActivity = new Date().toISOString();
118
+ } catch (err) {
119
+ this.interaction.emit("error", {
120
+ requestId,
121
+ code: "AGENT_ERROR",
122
+ message: err.message,
123
+ });
124
+
125
+ // Record error in context engine
126
+ if (this.session.contextEngine) {
127
+ this.session.contextEngine.recordError({
128
+ step: "ws-agent-loop",
129
+ message: err.message,
130
+ });
131
+ }
132
+ } finally {
133
+ this._processing = false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Handle slash commands within the session.
139
+ *
140
+ * @param {string} command - e.g. "/plan enter", "/model qwen2:7b"
141
+ * @param {string} [requestId]
142
+ */
143
+ async handleSlashCommand(command, requestId) {
144
+ const [cmd, ...args] = command.trim().split(/\s+/);
145
+ const arg = args.join(" ").trim();
146
+ const { session } = this;
147
+
148
+ switch (cmd) {
149
+ case "/model":
150
+ if (arg) {
151
+ session.model = arg;
152
+ this.interaction.emit("command-response", {
153
+ requestId,
154
+ command: cmd,
155
+ result: { model: arg },
156
+ });
157
+ } else {
158
+ this.interaction.emit("command-response", {
159
+ requestId,
160
+ command: cmd,
161
+ result: { model: session.model },
162
+ });
163
+ }
164
+ break;
165
+
166
+ case "/provider": {
167
+ const supported = [
168
+ "ollama",
169
+ "anthropic",
170
+ "openai",
171
+ "deepseek",
172
+ "dashscope",
173
+ "mistral",
174
+ "gemini",
175
+ "volcengine",
176
+ ];
177
+ if (arg && supported.includes(arg)) {
178
+ session.provider = arg;
179
+ this.interaction.emit("command-response", {
180
+ requestId,
181
+ command: cmd,
182
+ result: { provider: arg },
183
+ });
184
+ } else {
185
+ this.interaction.emit("command-response", {
186
+ requestId,
187
+ command: cmd,
188
+ result: { provider: session.provider, available: supported },
189
+ });
190
+ }
191
+ break;
192
+ }
193
+
194
+ case "/clear":
195
+ session.messages.length = 1; // Keep system prompt
196
+ this.interaction.emit("command-response", {
197
+ requestId,
198
+ command: cmd,
199
+ result: { cleared: true },
200
+ });
201
+ break;
202
+
203
+ case "/compact":
204
+ if (session.contextEngine && session.messages.length > 5) {
205
+ const compacted = session.contextEngine.smartCompact(
206
+ session.messages,
207
+ );
208
+ session.messages.length = 0;
209
+ session.messages.push(...compacted);
210
+ } else if (session.messages.length > 5) {
211
+ const systemMsg = session.messages[0];
212
+ const recent = session.messages.slice(-4);
213
+ session.messages.length = 0;
214
+ session.messages.push(systemMsg, ...recent);
215
+ }
216
+ this.interaction.emit("command-response", {
217
+ requestId,
218
+ command: cmd,
219
+ result: { messageCount: session.messages.length },
220
+ });
221
+ break;
222
+
223
+ case "/task":
224
+ if (arg === "clear") {
225
+ if (session.contextEngine) session.contextEngine.clearTask();
226
+ this.interaction.emit("command-response", {
227
+ requestId,
228
+ command: cmd,
229
+ result: { cleared: true },
230
+ });
231
+ } else if (arg) {
232
+ if (session.contextEngine) session.contextEngine.setTask(arg);
233
+ this.interaction.emit("command-response", {
234
+ requestId,
235
+ command: cmd,
236
+ result: { task: arg },
237
+ });
238
+ } else {
239
+ this.interaction.emit("command-response", {
240
+ requestId,
241
+ command: cmd,
242
+ result: {
243
+ task: session.contextEngine?.taskContext?.objective || null,
244
+ },
245
+ });
246
+ }
247
+ break;
248
+
249
+ case "/stats":
250
+ if (session.contextEngine) {
251
+ const stats = session.contextEngine.getStats();
252
+ this.interaction.emit("command-response", {
253
+ requestId,
254
+ command: cmd,
255
+ result: stats,
256
+ });
257
+ } else {
258
+ this.interaction.emit("command-response", {
259
+ requestId,
260
+ command: cmd,
261
+ result: { error: "Context engine not available" },
262
+ });
263
+ }
264
+ break;
265
+
266
+ case "/session":
267
+ this.interaction.emit("command-response", {
268
+ requestId,
269
+ command: cmd,
270
+ result: {
271
+ id: session.id,
272
+ type: session.type,
273
+ provider: session.provider,
274
+ model: session.model,
275
+ messageCount: session.messages.length,
276
+ projectRoot: session.projectRoot,
277
+ createdAt: session.createdAt,
278
+ lastActivity: session.lastActivity,
279
+ },
280
+ });
281
+ break;
282
+
283
+ case "/plan":
284
+ this._handlePlanCommand(arg, requestId);
285
+ break;
286
+
287
+ default:
288
+ this.interaction.emit("command-response", {
289
+ requestId,
290
+ command: cmd,
291
+ result: { error: `Unknown command: ${cmd}` },
292
+ });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Handle /plan sub-commands.
298
+ */
299
+ _handlePlanCommand(subCmd, requestId) {
300
+ const planManager = this.session.planManager;
301
+
302
+ if (!subCmd || subCmd === "enter") {
303
+ if (planManager.isActive()) {
304
+ this.interaction.emit("command-response", {
305
+ requestId,
306
+ command: "/plan",
307
+ result: { error: "Already in plan mode" },
308
+ });
309
+ } else {
310
+ planManager.enterPlanMode({ title: "Agent Plan" });
311
+ this.session.messages.push({
312
+ role: "system",
313
+ content:
314
+ "[PLAN MODE ACTIVE] You are now in plan mode. You can read files, search, and analyze — but write/execute tools are blocked. Any blocked tool calls will be recorded as plan items. Analyze the task thoroughly, then the user will approve your plan.",
315
+ });
316
+ this.interaction.emit("command-response", {
317
+ requestId,
318
+ command: "/plan",
319
+ result: { state: "analyzing", message: "Entered plan mode" },
320
+ });
321
+ }
322
+ } else if (subCmd === "show") {
323
+ if (!planManager.isActive()) {
324
+ this.interaction.emit("command-response", {
325
+ requestId,
326
+ command: "/plan show",
327
+ result: { error: "Not in plan mode" },
328
+ });
329
+ } else {
330
+ this.interaction.emit("plan-ready", {
331
+ requestId,
332
+ summary: planManager.generatePlanSummary(),
333
+ risk: planManager.getRiskAssessment(),
334
+ items: planManager.currentPlan?.items || [],
335
+ });
336
+ }
337
+ } else if (subCmd === "approve" || subCmd === "yes") {
338
+ if (!planManager.isActive()) {
339
+ this.interaction.emit("command-response", {
340
+ requestId,
341
+ command: "/plan approve",
342
+ result: { error: "No plan to approve" },
343
+ });
344
+ } else if (
345
+ !planManager.currentPlan ||
346
+ planManager.currentPlan.items.length === 0
347
+ ) {
348
+ this.interaction.emit("command-response", {
349
+ requestId,
350
+ command: "/plan approve",
351
+ result: { error: "Plan has no items" },
352
+ });
353
+ } else {
354
+ planManager.approvePlan();
355
+ this.session.messages.push({
356
+ role: "system",
357
+ content: `[PLAN APPROVED] The user has approved your plan with ${planManager.currentPlan.items.length} items. You can now use all tools including write_file, edit_file, run_shell, and run_skill. Execute the plan items in order.`,
358
+ });
359
+ this.interaction.emit("command-response", {
360
+ requestId,
361
+ command: "/plan approve",
362
+ result: {
363
+ state: PlanState.APPROVED,
364
+ itemCount: planManager.currentPlan.items.length,
365
+ },
366
+ });
367
+ }
368
+ } else if (subCmd === "reject" || subCmd === "no") {
369
+ if (planManager.isActive()) {
370
+ planManager.rejectPlan("User rejected");
371
+ this.interaction.emit("command-response", {
372
+ requestId,
373
+ command: "/plan reject",
374
+ result: { state: PlanState.REJECTED },
375
+ });
376
+ } else {
377
+ this.interaction.emit("command-response", {
378
+ requestId,
379
+ command: "/plan reject",
380
+ result: { error: "No plan to reject" },
381
+ });
382
+ }
383
+ } else if (subCmd === "exit") {
384
+ if (planManager.isActive()) {
385
+ planManager.exitPlanMode({ savePlan: true });
386
+ }
387
+ this.interaction.emit("command-response", {
388
+ requestId,
389
+ command: "/plan exit",
390
+ result: { state: PlanState.INACTIVE },
391
+ });
392
+ } else {
393
+ this.interaction.emit("command-response", {
394
+ requestId,
395
+ command: "/plan",
396
+ result: {
397
+ error: `Unknown /plan subcommand: ${subCmd}`,
398
+ available: ["enter", "show", "approve", "reject", "exit"],
399
+ },
400
+ });
401
+ }
402
+ }
403
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * WebSocket Chat Handler
3
+ *
4
+ * Handles simple streaming chat sessions over WebSocket.
5
+ * Consumes chat-core's chatStream generator.
6
+ */
7
+
8
+ import { chatWithStreaming } from "./chat-core.js";
9
+
10
+ export class WSChatHandler {
11
+ /**
12
+ * @param {object} options
13
+ * @param {import("./ws-session-manager.js").Session} options.session
14
+ * @param {import("./interaction-adapter.js").WebSocketInteractionAdapter} options.interaction
15
+ */
16
+ constructor({ session, interaction }) {
17
+ this.session = session;
18
+ this.interaction = interaction;
19
+ this._processing = false;
20
+ }
21
+
22
+ /**
23
+ * Handle a user message — stream the response.
24
+ *
25
+ * @param {string} userMessage
26
+ * @param {string} [requestId]
27
+ */
28
+ async handleMessage(userMessage, requestId) {
29
+ if (this._processing) {
30
+ this.interaction.emit("error", {
31
+ requestId,
32
+ code: "BUSY",
33
+ message: "Session is currently processing a message",
34
+ });
35
+ return;
36
+ }
37
+
38
+ this._processing = true;
39
+
40
+ try {
41
+ const { session } = this;
42
+ session.messages.push({ role: "user", content: userMessage });
43
+
44
+ const options = {
45
+ provider: session.provider,
46
+ model: session.model,
47
+ baseUrl: session.baseUrl || "http://localhost:11434",
48
+ apiKey: session.apiKey,
49
+ };
50
+
51
+ const fullContent = await chatWithStreaming(
52
+ session.messages,
53
+ options,
54
+ (event) => {
55
+ if (event.type === "response-token") {
56
+ this.interaction.emit("response-token", {
57
+ requestId,
58
+ token: event.token,
59
+ });
60
+ }
61
+ },
62
+ );
63
+
64
+ session.messages.push({ role: "assistant", content: fullContent });
65
+ this.interaction.emit("response-complete", {
66
+ requestId,
67
+ content: fullContent,
68
+ });
69
+
70
+ session.lastActivity = new Date().toISOString();
71
+ } catch (err) {
72
+ this.interaction.emit("error", {
73
+ requestId,
74
+ code: "CHAT_ERROR",
75
+ message: err.message,
76
+ });
77
+ } finally {
78
+ this._processing = false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Handle slash commands.
84
+ *
85
+ * @param {string} command
86
+ * @param {string} [requestId]
87
+ */
88
+ handleSlashCommand(command, requestId) {
89
+ const [cmd, ...args] = command.trim().split(/\s+/);
90
+ const arg = args.join(" ").trim();
91
+ const { session } = this;
92
+
93
+ switch (cmd) {
94
+ case "/model":
95
+ if (arg) session.model = arg;
96
+ this.interaction.emit("command-response", {
97
+ requestId,
98
+ command: cmd,
99
+ result: { model: session.model },
100
+ });
101
+ break;
102
+
103
+ case "/provider":
104
+ if (arg) session.provider = arg;
105
+ this.interaction.emit("command-response", {
106
+ requestId,
107
+ command: cmd,
108
+ result: { provider: session.provider },
109
+ });
110
+ break;
111
+
112
+ case "/clear":
113
+ session.messages.length = 0;
114
+ this.interaction.emit("command-response", {
115
+ requestId,
116
+ command: cmd,
117
+ result: { cleared: true },
118
+ });
119
+ break;
120
+
121
+ case "/history":
122
+ this.interaction.emit("command-response", {
123
+ requestId,
124
+ command: cmd,
125
+ result: {
126
+ messages: session.messages.map((m) => ({
127
+ role: m.role,
128
+ content:
129
+ m.content.length > 200
130
+ ? m.content.substring(0, 200) + "..."
131
+ : m.content,
132
+ })),
133
+ },
134
+ });
135
+ break;
136
+
137
+ default:
138
+ this.interaction.emit("command-response", {
139
+ requestId,
140
+ command: cmd,
141
+ result: { error: `Unknown command: ${cmd}` },
142
+ });
143
+ }
144
+ }
145
+ }