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.
- package/README.md +23 -16
- package/package.json +1 -1
- package/src/commands/serve.js +34 -1
- package/src/lib/agent-core.js +1160 -0
- package/src/lib/chat-core.js +177 -0
- package/src/lib/interaction-adapter.js +177 -0
- package/src/lib/interactive-planner.js +524 -0
- package/src/lib/llm-providers.js +9 -1
- package/src/lib/slot-filler.js +598 -0
- package/src/lib/task-model-selector.js +5 -5
- package/src/lib/ws-agent-handler.js +420 -0
- package/src/lib/ws-chat-handler.js +145 -0
- package/src/lib/ws-server.js +308 -1
- package/src/lib/ws-session-manager.js +363 -0
- package/src/repl/agent-repl.js +112 -715
package/src/lib/ws-server.js
CHANGED
|
@@ -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
|
+
}
|