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.
@@ -94,6 +94,12 @@ export class ChainlessChainWSServer extends EventEmitter {
94
94
  /** Running child processes: requestId → ChildProcess */
95
95
  this.processes = new Map();
96
96
 
97
+ /** Session manager for stateful agent/chat sessions */
98
+ this.sessionManager = options.sessionManager || null;
99
+
100
+ /** Session handlers: sessionId → WSAgentHandler | WSChatHandler */
101
+ this.sessionHandlers = new Map();
102
+
97
103
  this._heartbeatTimer = null;
98
104
  this._clientCounter = 0;
99
105
  }
@@ -128,6 +134,21 @@ export class ChainlessChainWSServer extends EventEmitter {
128
134
  this._heartbeatTimer = null;
129
135
  }
130
136
 
137
+ // Close all session handlers
138
+ for (const [sessionId, handler] of this.sessionHandlers) {
139
+ if (handler && handler.destroy) {
140
+ handler.destroy();
141
+ }
142
+ if (this.sessionManager) {
143
+ try {
144
+ this.sessionManager.closeSession(sessionId);
145
+ } catch (_err) {
146
+ // Non-critical
147
+ }
148
+ }
149
+ }
150
+ this.sessionHandlers.clear();
151
+
131
152
  // Kill all running child processes
132
153
  for (const [id, child] of this.processes) {
133
154
  try {
@@ -205,7 +226,7 @@ export class ChainlessChainWSServer extends EventEmitter {
205
226
  }
206
227
 
207
228
  /** @private */
208
- _handleMessage(clientId, ws, message) {
229
+ async _handleMessage(clientId, ws, message) {
209
230
  const { id, type } = message;
210
231
 
211
232
  if (!id) {
@@ -245,6 +266,27 @@ export class ChainlessChainWSServer extends EventEmitter {
245
266
  case "cancel":
246
267
  this._cancelRequest(id, ws);
247
268
  break;
269
+ case "session-create":
270
+ await this._handleSessionCreate(id, ws, message);
271
+ break;
272
+ case "session-resume":
273
+ await this._handleSessionResume(id, ws, message);
274
+ break;
275
+ case "session-message":
276
+ this._handleSessionMessage(id, ws, message);
277
+ break;
278
+ case "session-list":
279
+ this._handleSessionList(id, ws);
280
+ break;
281
+ case "session-close":
282
+ this._handleSessionClose(id, ws, message);
283
+ break;
284
+ case "slash-command":
285
+ this._handleSlashCommand(id, ws, message);
286
+ break;
287
+ case "session-answer":
288
+ this._handleSessionAnswer(id, ws, message);
289
+ break;
248
290
  default:
249
291
  this._send(ws, {
250
292
  id,
@@ -441,6 +483,271 @@ export class ChainlessChainWSServer extends EventEmitter {
441
483
  }
442
484
  }
443
485
 
486
+ // ─── Session handlers ─────────────────────────────────────────────
487
+
488
+ /** @private */
489
+ async _handleSessionCreate(id, ws, message) {
490
+ if (!this.sessionManager) {
491
+ this._send(ws, {
492
+ id,
493
+ type: "error",
494
+ code: "NO_SESSION_SUPPORT",
495
+ message: "Session support not configured on this server",
496
+ });
497
+ return;
498
+ }
499
+
500
+ const { sessionType, provider, model, apiKey, baseUrl, projectRoot } =
501
+ message;
502
+
503
+ try {
504
+ const { sessionId } = this.sessionManager.createSession({
505
+ type: sessionType || "agent",
506
+ provider,
507
+ model,
508
+ apiKey,
509
+ baseUrl,
510
+ projectRoot,
511
+ });
512
+
513
+ const session = this.sessionManager.getSession(sessionId);
514
+
515
+ // Lazy-load handler modules to avoid circular deps
516
+ try {
517
+ const { WebSocketInteractionAdapter } =
518
+ await import("./interaction-adapter.js");
519
+ session.interaction = new WebSocketInteractionAdapter(ws, sessionId);
520
+
521
+ let handler;
522
+ if ((sessionType || "agent") === "chat") {
523
+ const { WSChatHandler } = await import("./ws-chat-handler.js");
524
+ handler = new WSChatHandler({
525
+ session,
526
+ interaction: session.interaction,
527
+ });
528
+ } else {
529
+ const { WSAgentHandler } = await import("./ws-agent-handler.js");
530
+ handler = new WSAgentHandler({
531
+ session,
532
+ interaction: session.interaction,
533
+ db: this.sessionManager.db,
534
+ });
535
+ }
536
+ this.sessionHandlers.set(sessionId, handler);
537
+ } catch (_err) {
538
+ // Handler creation failed — session still created, handler can be set later
539
+ }
540
+
541
+ this.emit("session:create", { sessionId, type: sessionType || "agent" });
542
+
543
+ this._send(ws, {
544
+ id,
545
+ type: "session-created",
546
+ sessionId,
547
+ sessionType: sessionType || "agent",
548
+ });
549
+ } catch (err) {
550
+ this._send(ws, {
551
+ id,
552
+ type: "error",
553
+ code: "SESSION_CREATE_FAILED",
554
+ message: err.message,
555
+ });
556
+ }
557
+ }
558
+
559
+ /** @private */
560
+ async _handleSessionResume(id, ws, message) {
561
+ if (!this.sessionManager) {
562
+ this._send(ws, {
563
+ id,
564
+ type: "error",
565
+ code: "NO_SESSION_SUPPORT",
566
+ message: "Session support not configured",
567
+ });
568
+ return;
569
+ }
570
+
571
+ const { sessionId } = message;
572
+ const session = this.sessionManager.resumeSession(sessionId);
573
+
574
+ if (!session) {
575
+ this._send(ws, {
576
+ id,
577
+ type: "error",
578
+ code: "SESSION_NOT_FOUND",
579
+ message: `Session not found: ${sessionId}`,
580
+ });
581
+ return;
582
+ }
583
+
584
+ // Rebuild interaction adapter and handler for the resumed session
585
+ if (!this.sessionHandlers.has(sessionId)) {
586
+ try {
587
+ const { WebSocketInteractionAdapter } =
588
+ await import("./interaction-adapter.js");
589
+ session.interaction = new WebSocketInteractionAdapter(ws, sessionId);
590
+
591
+ let handler;
592
+ if (session.type === "chat") {
593
+ const { WSChatHandler } = await import("./ws-chat-handler.js");
594
+ handler = new WSChatHandler({
595
+ session,
596
+ interaction: session.interaction,
597
+ });
598
+ } else {
599
+ const { WSAgentHandler } = await import("./ws-agent-handler.js");
600
+ handler = new WSAgentHandler({
601
+ session,
602
+ interaction: session.interaction,
603
+ db: this.sessionManager.db,
604
+ });
605
+ }
606
+ this.sessionHandlers.set(sessionId, handler);
607
+ } catch (_err) {
608
+ // Handler creation failed — session resumed without handler
609
+ }
610
+ }
611
+
612
+ // Filter out system messages for history
613
+ const history = (session.messages || []).filter((m) => m.role !== "system");
614
+
615
+ this._send(ws, {
616
+ id,
617
+ type: "session-resumed",
618
+ sessionId: session.id,
619
+ history,
620
+ });
621
+ }
622
+
623
+ /** @private */
624
+ _handleSessionMessage(id, ws, message) {
625
+ const { sessionId, content } = message;
626
+ const handler = this.sessionHandlers.get(sessionId);
627
+
628
+ if (!handler) {
629
+ this._send(ws, {
630
+ id,
631
+ type: "error",
632
+ code: "SESSION_NOT_FOUND",
633
+ message: `No active session handler for: ${sessionId}`,
634
+ });
635
+ return;
636
+ }
637
+
638
+ // Fire and forget — handler emits events via interaction adapter
639
+ handler
640
+ .handleMessage(content, id)
641
+ .then(() => {
642
+ // Persist messages after each turn
643
+ if (this.sessionManager) {
644
+ try {
645
+ this.sessionManager.persistMessages(sessionId);
646
+ } catch (_err) {
647
+ // Non-critical
648
+ }
649
+ }
650
+ })
651
+ .catch((err) => {
652
+ this._send(ws, {
653
+ id,
654
+ type: "error",
655
+ code: "MESSAGE_FAILED",
656
+ message: err.message,
657
+ });
658
+ });
659
+ }
660
+
661
+ /** @private */
662
+ _handleSessionList(id, ws) {
663
+ if (!this.sessionManager) {
664
+ this._send(ws, {
665
+ id,
666
+ type: "error",
667
+ code: "NO_SESSION_SUPPORT",
668
+ message: "Session support not configured",
669
+ });
670
+ return;
671
+ }
672
+
673
+ const sessions = this.sessionManager.listSessions();
674
+ this._send(ws, {
675
+ id,
676
+ type: "session-list-result",
677
+ sessions,
678
+ });
679
+ }
680
+
681
+ /** @private */
682
+ _handleSessionClose(id, ws, message) {
683
+ const { sessionId } = message;
684
+
685
+ // Remove handler
686
+ const handler = this.sessionHandlers.get(sessionId);
687
+ if (handler && handler.destroy) {
688
+ handler.destroy();
689
+ }
690
+ this.sessionHandlers.delete(sessionId);
691
+
692
+ // Close session in manager
693
+ if (this.sessionManager) {
694
+ try {
695
+ this.sessionManager.closeSession(sessionId);
696
+ } catch (_err) {
697
+ // Non-critical
698
+ }
699
+ }
700
+
701
+ this.emit("session:close", { sessionId });
702
+
703
+ this._send(ws, {
704
+ id,
705
+ type: "result",
706
+ success: true,
707
+ sessionId,
708
+ });
709
+ }
710
+
711
+ /** @private */
712
+ _handleSlashCommand(id, ws, message) {
713
+ const { sessionId, command } = message;
714
+ const handler = this.sessionHandlers.get(sessionId);
715
+
716
+ if (!handler) {
717
+ this._send(ws, {
718
+ id,
719
+ type: "error",
720
+ code: "SESSION_NOT_FOUND",
721
+ message: `No active session handler for: ${sessionId}`,
722
+ });
723
+ return;
724
+ }
725
+
726
+ handler.handleSlashCommand(command, id);
727
+ }
728
+
729
+ /** @private */
730
+ _handleSessionAnswer(id, ws, message) {
731
+ const { sessionId, requestId, answer } = message;
732
+
733
+ if (!this.sessionManager) {
734
+ this._send(ws, {
735
+ id,
736
+ type: "error",
737
+ code: "NO_SESSION_SUPPORT",
738
+ message: "Session support not configured",
739
+ });
740
+ return;
741
+ }
742
+
743
+ const session = this.sessionManager.getSession(sessionId);
744
+ if (session && session.interaction && session.interaction.resolveAnswer) {
745
+ session.interaction.resolveAnswer(requestId, answer);
746
+ }
747
+
748
+ this._send(ws, { id, type: "result", success: true });
749
+ }
750
+
444
751
  /** @private — ping/pong heartbeat to detect dead connections */
445
752
  _startHeartbeat() {
446
753
  this._heartbeatTimer = setInterval(() => {
@@ -0,0 +1,363 @@
1
+ /**
2
+ * WebSocket Session Manager
3
+ *
4
+ * Registry and lifecycle management for stateful agent/chat sessions
5
+ * accessed over WebSocket. Each session maintains its own message history,
6
+ * context engine, permanent memory, plan manager, and LLM configuration.
7
+ */
8
+
9
+ import { createHash } from "crypto";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { PlanModeManager } from "./plan-mode.js";
13
+ import { CLIContextEngineering } from "./cli-context-engineering.js";
14
+ import { CLIPermanentMemory } from "./permanent-memory.js";
15
+ import {
16
+ createSession as dbCreateSession,
17
+ saveMessages as dbSaveMessages,
18
+ getSession as dbGetSession,
19
+ listSessions as dbListSessions,
20
+ } from "./session-manager.js";
21
+ import { getBaseSystemPrompt } from "./agent-core.js";
22
+
23
+ /**
24
+ * @typedef {object} Session
25
+ * @property {string} id
26
+ * @property {"agent"|"chat"} type
27
+ * @property {"active"|"closed"} status
28
+ * @property {Array} messages
29
+ * @property {string} provider
30
+ * @property {string} model
31
+ * @property {string|null} apiKey
32
+ * @property {string|null} baseUrl
33
+ * @property {string} projectRoot
34
+ * @property {string|null} rulesContent
35
+ * @property {PlanModeManager} planManager
36
+ * @property {CLIContextEngineering|null} contextEngine
37
+ * @property {CLIPermanentMemory|null} permanentMemory
38
+ * @property {import("./interaction-adapter.js").WebSocketInteractionAdapter|null} interaction
39
+ * @property {string} createdAt
40
+ * @property {string} lastActivity
41
+ */
42
+
43
+ export class WSSessionManager {
44
+ /**
45
+ * @param {object} options
46
+ * @param {object} [options.db] - Database instance
47
+ * @param {object} [options.config] - Config object
48
+ * @param {string} [options.defaultProjectRoot] - Default project root
49
+ */
50
+ constructor(options = {}) {
51
+ this.db = options.db || null;
52
+ this.config = options.config || {};
53
+ this.defaultProjectRoot = options.defaultProjectRoot || process.cwd();
54
+
55
+ /** @type {Map<string, Session>} */
56
+ this.sessions = new Map();
57
+ }
58
+
59
+ /**
60
+ * Generate a unique session ID
61
+ */
62
+ _generateId() {
63
+ const hash = createHash("sha256")
64
+ .update(Math.random().toString() + Date.now().toString())
65
+ .digest("hex")
66
+ .slice(0, 8);
67
+ return `ws-session-${Date.now()}-${hash}`;
68
+ }
69
+
70
+ /**
71
+ * Create a new session.
72
+ *
73
+ * @param {object} options
74
+ * @param {"agent"|"chat"} [options.type="agent"]
75
+ * @param {string} [options.projectRoot]
76
+ * @param {string} [options.provider="ollama"]
77
+ * @param {string} [options.model]
78
+ * @param {string} [options.apiKey]
79
+ * @param {string} [options.baseUrl]
80
+ * @returns {{ sessionId: string }}
81
+ */
82
+ createSession(options = {}) {
83
+ const sessionId = this._generateId();
84
+ const type = options.type || "agent";
85
+ const projectRoot = options.projectRoot || this.defaultProjectRoot;
86
+ const provider = options.provider || "ollama";
87
+ const model =
88
+ options.model || (provider === "ollama" ? "qwen2.5:7b" : null);
89
+ const baseUrl = options.baseUrl || "http://localhost:11434";
90
+
91
+ // Load project context
92
+ let rulesContent = null;
93
+ try {
94
+ const rulesPath = path.join(projectRoot, ".chainlesschain", "rules.md");
95
+ if (fs.existsSync(rulesPath)) {
96
+ rulesContent = fs.readFileSync(rulesPath, "utf8");
97
+ }
98
+ } catch (_err) {
99
+ // Non-critical
100
+ }
101
+
102
+ // Create plan manager (non-singleton, per-session)
103
+ const planManager = new PlanModeManager();
104
+
105
+ // Create context engine
106
+ let contextEngine = null;
107
+ let permanentMemory = null;
108
+ try {
109
+ const memoryDir = path.join(projectRoot, "memory");
110
+ permanentMemory = new CLIPermanentMemory({
111
+ db: this.db,
112
+ memoryDir,
113
+ });
114
+ permanentMemory.initialize();
115
+ } catch (_err) {
116
+ // Non-critical
117
+ }
118
+
119
+ try {
120
+ contextEngine = new CLIContextEngineering({
121
+ db: this.db,
122
+ permanentMemory,
123
+ });
124
+ } catch (_err) {
125
+ // Non-critical
126
+ }
127
+
128
+ // Build initial system prompt
129
+ let systemPrompt = getBaseSystemPrompt(projectRoot);
130
+ if (rulesContent) {
131
+ systemPrompt += `\n\n## Project Rules\n${rulesContent}`;
132
+ }
133
+
134
+ const messages = [{ role: "system", content: systemPrompt }];
135
+
136
+ // Persist to DB
137
+ if (this.db) {
138
+ try {
139
+ dbCreateSession(this.db, {
140
+ id: sessionId,
141
+ title: `WS ${type} ${new Date().toISOString().slice(0, 10)}`,
142
+ provider,
143
+ model: model || "",
144
+ messages,
145
+ });
146
+ } catch (_err) {
147
+ // Non-critical
148
+ }
149
+ }
150
+
151
+ const session = {
152
+ id: sessionId,
153
+ type,
154
+ status: "active",
155
+ messages,
156
+ provider,
157
+ model,
158
+ apiKey: options.apiKey || null,
159
+ baseUrl,
160
+ projectRoot,
161
+ rulesContent,
162
+ planManager,
163
+ contextEngine,
164
+ permanentMemory,
165
+ interaction: null, // Set by ws-server after creation
166
+ createdAt: new Date().toISOString(),
167
+ lastActivity: new Date().toISOString(),
168
+ };
169
+
170
+ this.sessions.set(sessionId, session);
171
+
172
+ return { sessionId };
173
+ }
174
+
175
+ /**
176
+ * Resume an existing session from DB.
177
+ *
178
+ * @param {string} sessionId
179
+ * @returns {Session|null}
180
+ */
181
+ resumeSession(sessionId) {
182
+ // Check in-memory first
183
+ if (this.sessions.has(sessionId)) {
184
+ const session = this.sessions.get(sessionId);
185
+ session.status = "active";
186
+ session.lastActivity = new Date().toISOString();
187
+ return session;
188
+ }
189
+
190
+ // Try loading from DB
191
+ if (!this.db) return null;
192
+
193
+ try {
194
+ const dbSession = dbGetSession(this.db, sessionId);
195
+ if (!dbSession) return null;
196
+
197
+ const messages =
198
+ typeof dbSession.messages === "string"
199
+ ? JSON.parse(dbSession.messages)
200
+ : dbSession.messages || [];
201
+
202
+ const planManager = new PlanModeManager();
203
+ let contextEngine = null;
204
+ let permanentMemory = null;
205
+
206
+ try {
207
+ const memoryDir = path.join(this.defaultProjectRoot, "memory");
208
+ permanentMemory = new CLIPermanentMemory({
209
+ db: this.db,
210
+ memoryDir,
211
+ });
212
+ permanentMemory.initialize();
213
+ } catch (_err) {
214
+ // Non-critical
215
+ }
216
+
217
+ try {
218
+ contextEngine = new CLIContextEngineering({
219
+ db: this.db,
220
+ permanentMemory,
221
+ });
222
+ } catch (_err) {
223
+ // Non-critical
224
+ }
225
+
226
+ const session = {
227
+ id: dbSession.id,
228
+ type: "agent", // Default, since DB doesn't store type
229
+ status: "active",
230
+ messages,
231
+ provider: dbSession.provider || "ollama",
232
+ model: dbSession.model || null,
233
+ apiKey: null,
234
+ baseUrl: "http://localhost:11434",
235
+ projectRoot: this.defaultProjectRoot,
236
+ rulesContent: null,
237
+ planManager,
238
+ contextEngine,
239
+ permanentMemory,
240
+ interaction: null,
241
+ createdAt: dbSession.created_at,
242
+ lastActivity: new Date().toISOString(),
243
+ };
244
+
245
+ this.sessions.set(session.id, session);
246
+ return session;
247
+ } catch (_err) {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Close a session and persist final state.
254
+ *
255
+ * @param {string} sessionId
256
+ */
257
+ closeSession(sessionId) {
258
+ const session = this.sessions.get(sessionId);
259
+ if (!session) return;
260
+
261
+ session.status = "closed";
262
+
263
+ // Persist messages to DB
264
+ if (this.db) {
265
+ try {
266
+ dbSaveMessages(this.db, sessionId, session.messages);
267
+ } catch (_err) {
268
+ // Non-critical
269
+ }
270
+ }
271
+
272
+ // Auto-summarize into permanent memory
273
+ if (session.permanentMemory && session.messages.length > 4) {
274
+ try {
275
+ session.permanentMemory.autoSummarize(session.messages);
276
+ } catch (_err) {
277
+ // Non-critical
278
+ }
279
+ }
280
+
281
+ // Clean up plan manager listeners
282
+ if (session.planManager) {
283
+ session.planManager.removeAllListeners();
284
+ }
285
+
286
+ this.sessions.delete(sessionId);
287
+ }
288
+
289
+ /**
290
+ * List all sessions (in-memory + DB).
291
+ *
292
+ * @returns {Array<{id, type, status, createdAt, lastActivity}>}
293
+ */
294
+ listSessions() {
295
+ const results = [];
296
+
297
+ // In-memory active sessions
298
+ for (const [, session] of this.sessions) {
299
+ results.push({
300
+ id: session.id,
301
+ type: session.type,
302
+ status: session.status,
303
+ provider: session.provider,
304
+ model: session.model,
305
+ messageCount: session.messages.length,
306
+ createdAt: session.createdAt,
307
+ lastActivity: session.lastActivity,
308
+ });
309
+ }
310
+
311
+ // DB sessions (exclude already-listed in-memory ones)
312
+ if (this.db) {
313
+ try {
314
+ const dbSessions = dbListSessions(this.db, { limit: 20 });
315
+ const inMemoryIds = new Set(this.sessions.keys());
316
+ for (const dbs of dbSessions) {
317
+ if (!inMemoryIds.has(dbs.id)) {
318
+ results.push({
319
+ id: dbs.id,
320
+ type: "unknown",
321
+ status: "persisted",
322
+ provider: dbs.provider,
323
+ model: dbs.model,
324
+ messageCount: dbs.message_count,
325
+ createdAt: dbs.created_at,
326
+ lastActivity: dbs.updated_at,
327
+ });
328
+ }
329
+ }
330
+ } catch (_err) {
331
+ // Non-critical
332
+ }
333
+ }
334
+
335
+ return results;
336
+ }
337
+
338
+ /**
339
+ * Get a session by ID.
340
+ *
341
+ * @param {string} sessionId
342
+ * @returns {Session|null}
343
+ */
344
+ getSession(sessionId) {
345
+ return this.sessions.get(sessionId) || null;
346
+ }
347
+
348
+ /**
349
+ * Persist current messages for a session.
350
+ */
351
+ persistMessages(sessionId) {
352
+ const session = this.sessions.get(sessionId);
353
+ if (!session || !this.db) return;
354
+
355
+ try {
356
+ dbSaveMessages(this.db, sessionId, session.messages);
357
+ } catch (_err) {
358
+ // Non-critical
359
+ }
360
+
361
+ session.lastActivity = new Date().toISOString();
362
+ }
363
+ }