@charming_groot/agent 0.1.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.
Files changed (57) hide show
  1. package/dist/agent-loop.d.ts +46 -0
  2. package/dist/agent-loop.d.ts.map +1 -0
  3. package/dist/agent-loop.js +139 -0
  4. package/dist/agent-loop.js.map +1 -0
  5. package/dist/index.d.ts +15 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +9 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/message-manager.d.ts +46 -0
  10. package/dist/message-manager.d.ts.map +1 -0
  11. package/dist/message-manager.js +152 -0
  12. package/dist/message-manager.js.map +1 -0
  13. package/dist/permission.d.ts +37 -0
  14. package/dist/permission.d.ts.map +1 -0
  15. package/dist/permission.js +62 -0
  16. package/dist/permission.js.map +1 -0
  17. package/dist/session-manager.d.ts +31 -0
  18. package/dist/session-manager.d.ts.map +1 -0
  19. package/dist/session-manager.js +101 -0
  20. package/dist/session-manager.js.map +1 -0
  21. package/dist/skill-tool.d.ts +38 -0
  22. package/dist/skill-tool.d.ts.map +1 -0
  23. package/dist/skill-tool.js +112 -0
  24. package/dist/skill-tool.js.map +1 -0
  25. package/dist/sub-agent-tool.d.ts +39 -0
  26. package/dist/sub-agent-tool.d.ts.map +1 -0
  27. package/dist/sub-agent-tool.js +80 -0
  28. package/dist/sub-agent-tool.js.map +1 -0
  29. package/dist/token-counter.d.ts +16 -0
  30. package/dist/token-counter.d.ts.map +1 -0
  31. package/dist/token-counter.js +69 -0
  32. package/dist/token-counter.js.map +1 -0
  33. package/dist/tool-dispatcher.d.ts +15 -0
  34. package/dist/tool-dispatcher.d.ts.map +1 -0
  35. package/dist/tool-dispatcher.js +94 -0
  36. package/dist/tool-dispatcher.js.map +1 -0
  37. package/package.json +34 -0
  38. package/src/agent-loop.ts +210 -0
  39. package/src/index.ts +19 -0
  40. package/src/message-manager.ts +184 -0
  41. package/src/permission.ts +104 -0
  42. package/src/session-manager.ts +121 -0
  43. package/src/skill-tool.ts +155 -0
  44. package/src/sub-agent-tool.ts +122 -0
  45. package/src/token-counter.ts +79 -0
  46. package/src/tool-dispatcher.ts +124 -0
  47. package/tests/agent-loop.test.ts +372 -0
  48. package/tests/message-manager-new.test.ts +204 -0
  49. package/tests/message-manager.test.ts +195 -0
  50. package/tests/permission.test.ts +148 -0
  51. package/tests/session-manager.test.ts +106 -0
  52. package/tests/skill-tool.test.ts +119 -0
  53. package/tests/sub-agent-tool.test.ts +198 -0
  54. package/tests/token-counter.test.ts +77 -0
  55. package/tests/tool-dispatcher.test.ts +181 -0
  56. package/tsconfig.json +9 -0
  57. package/vitest.config.ts +17 -0
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@charming_groot/agent",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "ReAct agent loop with sub-agent support, token counting, and message compression for CLI Agent",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "test": "vitest run",
20
+ "clean": "rm -rf dist"
21
+ },
22
+ "dependencies": {
23
+ "@charming_groot/core": "workspace:*",
24
+ "@charming_groot/providers": "workspace:*",
25
+ "js-tiktoken": "^1.0.21"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20.11.0",
29
+ "typescript": "^5.4.0",
30
+ "vitest": "^1.6.0"
31
+ },
32
+ "keywords": ["agent", "cli", "llm", "react", "sub-agent", "parallel"],
33
+ "license": "MIT"
34
+ }
@@ -0,0 +1,210 @@
1
+ import type {
2
+ ILlmProvider,
3
+ ITool,
4
+ AgentConfig,
5
+ ToolDescription,
6
+ LlmResponse,
7
+ Message,
8
+ AgentLogger,
9
+ } from '@charming_groot/core';
10
+ import {
11
+ Registry,
12
+ RunContext,
13
+ EventBus,
14
+ AbortError,
15
+ createChildLogger,
16
+ } from '@charming_groot/core';
17
+ import { MessageManager } from './message-manager.js';
18
+ import { ToolDispatcher } from './tool-dispatcher.js';
19
+ import { PermissionManager, type PermissionHandler } from './permission.js';
20
+
21
+ /**
22
+ * Callback that builds/rebuilds the system prompt dynamically.
23
+ * Called before each LLM iteration so the prompt can reflect
24
+ * current state (open files, cwd, etc.) without a hard dependency
25
+ * on @core/context-engine.
26
+ */
27
+ export type SystemPromptBuilder = (context: RunContext) => string | Promise<string>;
28
+
29
+ export interface AgentLoopOptions {
30
+ provider: ILlmProvider;
31
+ toolRegistry: Registry<ITool>;
32
+ config: AgentConfig;
33
+ permissionHandler?: PermissionHandler;
34
+ eventBus?: EventBus;
35
+ streaming?: boolean;
36
+ /** Dynamic system prompt builder — takes precedence over config.systemPrompt */
37
+ systemPromptBuilder?: SystemPromptBuilder;
38
+ }
39
+
40
+ export interface AgentResult {
41
+ readonly content: string;
42
+ readonly runId: string;
43
+ readonly iterations: number;
44
+ readonly aborted: boolean;
45
+ }
46
+
47
+ export class AgentLoop {
48
+ private readonly provider: ILlmProvider;
49
+ private readonly toolDispatcher: ToolDispatcher;
50
+ private readonly messageManager: MessageManager;
51
+ private readonly context: RunContext;
52
+ private readonly maxIterations: number;
53
+ private readonly logger: AgentLogger;
54
+ private readonly streaming: boolean;
55
+ private readonly systemPromptBuilder?: SystemPromptBuilder;
56
+ private iterations = 0;
57
+
58
+ constructor(options: AgentLoopOptions) {
59
+ const eventBus = options.eventBus ?? new EventBus();
60
+ this.context = new RunContext(options.config, eventBus);
61
+ this.provider = options.provider;
62
+ this.messageManager = new MessageManager();
63
+ this.maxIterations = options.config.maxIterations;
64
+ this.logger = createChildLogger('agent-loop');
65
+ this.streaming = options.streaming ?? false;
66
+ this.systemPromptBuilder = options.systemPromptBuilder;
67
+
68
+ const permissionManager = new PermissionManager(options.permissionHandler);
69
+ this.toolDispatcher = new ToolDispatcher(options.toolRegistry, permissionManager);
70
+
71
+ // Static system prompt used only when no dynamic builder is provided
72
+ if (!this.systemPromptBuilder && options.config.systemPrompt) {
73
+ this.messageManager.addSystemMessage(options.config.systemPrompt);
74
+ }
75
+ }
76
+
77
+ get runId(): string {
78
+ return this.context.runId;
79
+ }
80
+
81
+ get eventBus(): EventBus {
82
+ return this.context.eventBus;
83
+ }
84
+
85
+ async run(userMessage: string): Promise<AgentResult> {
86
+ this.messageManager.addUserMessage(userMessage);
87
+ const agentStartedAt = Date.now();
88
+ this.context.eventBus.emit('agent:start', {
89
+ runId: this.context.runId,
90
+ model: this.context.config.provider.model,
91
+ startedAt: agentStartedAt,
92
+ });
93
+ this.iterations = 0;
94
+
95
+ try {
96
+ let lastContent = '';
97
+
98
+ while (this.iterations < this.maxIterations) {
99
+ if (this.context.isAborted) {
100
+ throw new AbortError('Agent loop aborted');
101
+ }
102
+
103
+ this.iterations++;
104
+ this.logger.debug({ iteration: this.iterations }, 'Starting iteration');
105
+
106
+ // Rebuild system prompt dynamically if builder is provided
107
+ if (this.systemPromptBuilder) {
108
+ const prompt = await this.systemPromptBuilder(this.context);
109
+ this.messageManager.setSystemMessage(prompt);
110
+ }
111
+
112
+ const compressed = this.messageManager.compressIfNeeded();
113
+ if (compressed > 0) {
114
+ this.logger.info({ compressed }, 'History compressed');
115
+ }
116
+
117
+ const toolDescriptions = this.getToolDescriptions();
118
+ const messages = this.messageManager.getMessages();
119
+
120
+ this.context.eventBus.emit('llm:request', {
121
+ runId: this.context.runId,
122
+ messages,
123
+ });
124
+
125
+ const llmStartedAt = Date.now();
126
+ const response = this.streaming
127
+ ? await this.streamResponse(messages, toolDescriptions)
128
+ : await this.provider.chat(messages, toolDescriptions);
129
+
130
+ this.context.eventBus.emit('llm:response', {
131
+ runId: this.context.runId,
132
+ response,
133
+ durationMs: Date.now() - llmStartedAt,
134
+ model: this.context.config.provider.model,
135
+ });
136
+
137
+ lastContent = response.content;
138
+
139
+ if (response.stopReason === 'end_turn' || response.toolCalls.length === 0) {
140
+ this.messageManager.addAssistantMessage(response.content);
141
+ break;
142
+ }
143
+
144
+ // Tool use flow
145
+ this.messageManager.addAssistantMessage(response.content, response.toolCalls);
146
+ const results = await this.toolDispatcher.dispatchAll(
147
+ response.toolCalls,
148
+ this.context
149
+ );
150
+ this.messageManager.addToolResults(results);
151
+ }
152
+
153
+ const aborted = this.context.isAborted;
154
+ this.context.eventBus.emit('agent:end', {
155
+ runId: this.context.runId,
156
+ reason: aborted ? 'aborted' : 'complete',
157
+ durationMs: Date.now() - agentStartedAt,
158
+ iterations: this.iterations,
159
+ });
160
+
161
+ return {
162
+ content: lastContent,
163
+ runId: this.context.runId,
164
+ iterations: this.iterations,
165
+ aborted,
166
+ };
167
+ } catch (error) {
168
+ this.context.eventBus.emit('agent:error', {
169
+ runId: this.context.runId,
170
+ error: error instanceof Error ? error : new Error(String(error)),
171
+ });
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ abort(reason?: string): void {
177
+ this.context.abort(reason);
178
+ }
179
+
180
+ getContext(): RunContext {
181
+ return this.context;
182
+ }
183
+
184
+ private async streamResponse(
185
+ messages: readonly Message[],
186
+ tools: ToolDescription[]
187
+ ): Promise<LlmResponse> {
188
+ let finalResponse: LlmResponse | undefined;
189
+
190
+ for await (const event of this.provider.stream(messages, tools)) {
191
+ if (event.type === 'text_delta' && event.content) {
192
+ this.context.eventBus.emit('llm:stream', {
193
+ runId: this.context.runId,
194
+ chunk: event.content,
195
+ });
196
+ } else if (event.type === 'done' && event.response) {
197
+ finalResponse = event.response;
198
+ }
199
+ }
200
+
201
+ if (!finalResponse) {
202
+ throw new Error('Stream ended without a final response');
203
+ }
204
+ return finalResponse;
205
+ }
206
+
207
+ private getToolDescriptions(): ToolDescription[] {
208
+ return this.toolDispatcher.getToolDescriptions();
209
+ }
210
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { AgentLoop } from './agent-loop.js';
2
+ export type { AgentLoopOptions, AgentResult, SystemPromptBuilder } from './agent-loop.js';
3
+ export { MessageManager } from './message-manager.js';
4
+ export type { CompressionConfig } from './message-manager.js';
5
+ export { countTextTokens, countMessageTokens, countHistoryTokens } from './token-counter.js';
6
+ export { ToolDispatcher } from './tool-dispatcher.js';
7
+ export { PermissionManager } from './permission.js';
8
+ export type {
9
+ PermissionHandler,
10
+ PermissionDecision,
11
+ ApprovalLevel,
12
+ PersistApprovalCallback,
13
+ } from './permission.js';
14
+ export { SubAgentTool } from './sub-agent-tool.js';
15
+ export type { SubAgentToolConfig } from './sub-agent-tool.js';
16
+ export { SkillTool } from './skill-tool.js';
17
+ export type { SkillEntry, SkillProvider } from './skill-tool.js';
18
+ export { SessionManager } from './session-manager.js';
19
+ export type { SessionMeta } from './session-manager.js';
@@ -0,0 +1,184 @@
1
+ import type { Message, ToolCall, ToolResult } from '@charming_groot/core';
2
+ import { countHistoryTokens } from './token-counter.js';
3
+
4
+ export interface CompressionConfig {
5
+ /** Maximum tokens in history before compression triggers (default: 100_000) */
6
+ maxHistoryTokens?: number;
7
+ /** Number of recent non-system messages to keep intact (default: 10) */
8
+ keepRecentMessages?: number;
9
+ /** Truncate individual message content to this length in summary (default: 300) */
10
+ summaryContentLength?: number;
11
+ }
12
+
13
+ export class MessageManager {
14
+ private readonly messages: Message[] = [];
15
+ private readonly maxHistoryTokens: number;
16
+ private readonly keepRecentMessages: number;
17
+ private readonly summaryContentLength: number;
18
+
19
+ constructor(config: CompressionConfig = {}) {
20
+ this.maxHistoryTokens = config.maxHistoryTokens ?? 100_000;
21
+ this.keepRecentMessages = config.keepRecentMessages ?? 10;
22
+ this.summaryContentLength = config.summaryContentLength ?? 300;
23
+ }
24
+
25
+ addSystemMessage(content: string): void {
26
+ this.messages.push({ role: 'system', content });
27
+ }
28
+
29
+ setSystemMessage(content: string): void {
30
+ const idx = this.messages.findIndex(m => m.role === 'system');
31
+ if (idx >= 0) {
32
+ this.messages[idx] = { role: 'system', content };
33
+ } else {
34
+ this.messages.unshift({ role: 'system', content });
35
+ }
36
+ }
37
+
38
+ addUserMessage(content: string): void {
39
+ this.messages.push({ role: 'user', content });
40
+ }
41
+
42
+ addAssistantMessage(content: string, toolCalls?: readonly ToolCall[]): void {
43
+ this.messages.push({
44
+ role: 'assistant',
45
+ content,
46
+ toolCalls: toolCalls ? [...toolCalls] : undefined,
47
+ });
48
+ }
49
+
50
+ addToolResults(results: ReadonlyMap<string, ToolResult>): void {
51
+ const toolResults = [...results.entries()].map(([toolCallId, result]) => ({
52
+ toolCallId,
53
+ content: result.success
54
+ ? result.output
55
+ : `Error: ${result.error ?? 'Unknown error'}`,
56
+ }));
57
+ this.messages.push({ role: 'user', content: '', toolResults });
58
+ }
59
+
60
+ getMessages(): readonly Message[] {
61
+ return [...this.messages];
62
+ }
63
+
64
+ getLastMessage(): Message | undefined {
65
+ return this.messages[this.messages.length - 1];
66
+ }
67
+
68
+ get messageCount(): number {
69
+ return this.messages.length;
70
+ }
71
+
72
+ /** Exact token count using js-tiktoken. */
73
+ get totalTokens(): number {
74
+ return countHistoryTokens(this.messages);
75
+ }
76
+
77
+ clear(): void {
78
+ this.messages.length = 0;
79
+ }
80
+
81
+ serialize(): string {
82
+ return JSON.stringify(this.messages);
83
+ }
84
+
85
+ restore(json: string): void {
86
+ const parsed: unknown = JSON.parse(json);
87
+ if (!Array.isArray(parsed)) {
88
+ throw new Error('Invalid serialized messages: expected an array');
89
+ }
90
+ this.messages.length = 0;
91
+ for (const item of parsed) {
92
+ if (
93
+ typeof item === 'object' && item !== null &&
94
+ 'role' in item && 'content' in item &&
95
+ typeof (item as Message).role === 'string' &&
96
+ typeof (item as Message).content === 'string'
97
+ ) {
98
+ this.messages.push(item as Message);
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Compress history if it exceeds the token budget.
105
+ *
106
+ * Strategy:
107
+ * 1. Always keep all system messages.
108
+ * 2. Keep the last `keepRecentMessages` non-system messages intact.
109
+ * 3. Summarize older messages into a structured digest that preserves:
110
+ * - what the user asked
111
+ * - what tools were called and whether they succeeded
112
+ * - key assistant conclusions
113
+ *
114
+ * Returns the number of messages compressed (0 = no compression needed).
115
+ */
116
+ compressIfNeeded(): number {
117
+ if (this.totalTokens <= this.maxHistoryTokens) return 0;
118
+
119
+ const system: Message[] = [];
120
+ const rest: Message[] = [];
121
+
122
+ for (const msg of this.messages) {
123
+ if (msg.role === 'system') system.push(msg);
124
+ else rest.push(msg);
125
+ }
126
+
127
+ const keepCount = Math.min(this.keepRecentMessages, rest.length);
128
+ const toSummarize = rest.slice(0, rest.length - keepCount);
129
+ const toKeep = rest.slice(rest.length - keepCount);
130
+
131
+ if (toSummarize.length === 0) return 0;
132
+
133
+ const summaryMessage: Message = {
134
+ role: 'user',
135
+ content: this.buildDigest(toSummarize),
136
+ };
137
+
138
+ this.messages.length = 0;
139
+ this.messages.push(...system, summaryMessage, ...toKeep);
140
+ return toSummarize.length;
141
+ }
142
+
143
+ private buildDigest(messages: Message[]): string {
144
+ const maxLen = this.summaryContentLength;
145
+ const lines: string[] = [
146
+ `[Context summary — ${messages.length} earlier messages compressed]`,
147
+ '',
148
+ ];
149
+
150
+ for (const msg of messages) {
151
+ if (msg.role === 'user' && !msg.toolResults) {
152
+ const text = truncate(msg.content, maxLen);
153
+ if (text) lines.push(`User: ${text}`);
154
+ }
155
+
156
+ if (msg.role === 'assistant') {
157
+ if (msg.content) {
158
+ lines.push(`Assistant: ${truncate(msg.content, maxLen)}`);
159
+ }
160
+ if (msg.toolCalls?.length) {
161
+ for (const tc of msg.toolCalls) {
162
+ lines.push(` → ${tc.name}(${truncate(tc.arguments, 120)})`);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (msg.toolResults?.length) {
168
+ for (const tr of msg.toolResults) {
169
+ lines.push(` ← result: ${truncate(tr.content, 120)}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ lines.push('', '[End of summary — conversation continues below]');
175
+ return lines.join('\n');
176
+ }
177
+ }
178
+
179
+ function truncate(text: string, maxLen: number): string {
180
+ if (!text) return '';
181
+ return text.length <= maxLen ? text : text.slice(0, maxLen) + '…';
182
+ }
183
+
184
+ export { countMessageTokens, countHistoryTokens } from './token-counter.js';
@@ -0,0 +1,104 @@
1
+ import type { ITool, JsonObject } from '@charming_groot/core';
2
+
3
+ /**
4
+ * Approval level returned by PermissionHandler.
5
+ *
6
+ * - 'once' — allow this single invocation only
7
+ * - 'session' — allow for the rest of this session (cached in allowedTools)
8
+ * - 'always' — allow permanently (caller is responsible for persistence)
9
+ * - 'deny' — block this invocation
10
+ */
11
+ export type ApprovalLevel = 'once' | 'session' | 'always' | 'deny';
12
+
13
+ /**
14
+ * Decision returned by PermissionHandler.
15
+ * Can be a simple boolean (backward-compatible) or an ApprovalLevel.
16
+ */
17
+ export type PermissionDecision = boolean | ApprovalLevel;
18
+
19
+ /**
20
+ * Callback that decides whether a tool invocation is allowed.
21
+ * Receives the tool name and the parsed parameters so the handler
22
+ * can make context-aware decisions (e.g., block `rm -rf /`).
23
+ */
24
+ export type PermissionHandler = (
25
+ toolName: string,
26
+ params: JsonObject,
27
+ ) => Promise<PermissionDecision>;
28
+
29
+ /** Callback invoked when a tool is approved with 'always' level */
30
+ export type PersistApprovalCallback = (toolName: string) => void;
31
+
32
+ const AUTO_APPROVE: PermissionHandler = async () => true;
33
+
34
+ function normalizeDecision(decision: PermissionDecision): ApprovalLevel {
35
+ if (decision === true) return 'session';
36
+ if (decision === false) return 'deny';
37
+ return decision;
38
+ }
39
+
40
+ export class PermissionManager {
41
+ private readonly handler: PermissionHandler;
42
+ private readonly sessionAllowed = new Set<string>();
43
+ private readonly alwaysAllowed = new Set<string>();
44
+ private readonly onPersist: PersistApprovalCallback | undefined;
45
+
46
+ constructor(
47
+ handler?: PermissionHandler,
48
+ onPersist?: PersistApprovalCallback,
49
+ ) {
50
+ this.handler = handler ?? AUTO_APPROVE;
51
+ this.onPersist = onPersist;
52
+ }
53
+
54
+ async checkPermission(tool: ITool, params: JsonObject = {}): Promise<boolean> {
55
+ if (!tool.requiresPermission) {
56
+ return true;
57
+ }
58
+
59
+ if (this.alwaysAllowed.has(tool.name) || this.sessionAllowed.has(tool.name)) {
60
+ return true;
61
+ }
62
+
63
+ const raw = await this.handler(tool.name, params);
64
+ const level = normalizeDecision(raw);
65
+
66
+ if (level === 'deny') return false;
67
+
68
+ if (level === 'session') {
69
+ this.sessionAllowed.add(tool.name);
70
+ } else if (level === 'always') {
71
+ this.alwaysAllowed.add(tool.name);
72
+ this.onPersist?.(tool.name);
73
+ }
74
+ // 'once' — no caching, just allow this time
75
+
76
+ return true;
77
+ }
78
+
79
+ allowTool(toolName: string, level: 'session' | 'always' = 'session'): void {
80
+ if (level === 'always') {
81
+ this.alwaysAllowed.add(toolName);
82
+ } else {
83
+ this.sessionAllowed.add(toolName);
84
+ }
85
+ }
86
+
87
+ revokeTool(toolName: string): void {
88
+ this.sessionAllowed.delete(toolName);
89
+ this.alwaysAllowed.delete(toolName);
90
+ }
91
+
92
+ isAllowed(toolName: string): boolean {
93
+ return this.sessionAllowed.has(toolName) || this.alwaysAllowed.has(toolName);
94
+ }
95
+
96
+ clearSession(): void {
97
+ this.sessionAllowed.clear();
98
+ }
99
+
100
+ clearAll(): void {
101
+ this.sessionAllowed.clear();
102
+ this.alwaysAllowed.clear();
103
+ }
104
+ }
@@ -0,0 +1,121 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import type { AgentLogger } from '@charming_groot/core';
4
+ import { createChildLogger } from '@charming_groot/core';
5
+ import { MessageManager } from './message-manager.js';
6
+
7
+ /** Metadata stored alongside the conversation messages */
8
+ export interface SessionMeta {
9
+ readonly sessionId: string;
10
+ readonly createdAt: string;
11
+ readonly updatedAt: string;
12
+ }
13
+
14
+ /** On-disk JSON shape */
15
+ interface SessionFile {
16
+ meta: SessionMeta;
17
+ messages: string; // MessageManager.serialize() output
18
+ }
19
+
20
+ /**
21
+ * Persists and restores agent conversations to/from disk.
22
+ *
23
+ * Usage:
24
+ * const sm = new SessionManager('/path/to/sessions');
25
+ * await sm.save(sessionId, messageManager);
26
+ * await sm.load(sessionId, messageManager);
27
+ */
28
+ export class SessionManager {
29
+ private readonly sessionsDir: string;
30
+ private readonly logger: AgentLogger;
31
+
32
+ constructor(sessionsDir: string) {
33
+ this.sessionsDir = sessionsDir;
34
+ this.logger = createChildLogger('session-manager');
35
+ }
36
+
37
+ /** Save the current conversation to disk. */
38
+ async save(sessionId: string, manager: MessageManager): Promise<void> {
39
+ const filePath = this.sessionPath(sessionId);
40
+ await mkdir(dirname(filePath), { recursive: true });
41
+
42
+ let meta: SessionMeta;
43
+ try {
44
+ const existing = await this.readSessionFile(filePath);
45
+ meta = {
46
+ sessionId,
47
+ createdAt: existing.meta.createdAt,
48
+ updatedAt: new Date().toISOString(),
49
+ };
50
+ } catch {
51
+ meta = {
52
+ sessionId,
53
+ createdAt: new Date().toISOString(),
54
+ updatedAt: new Date().toISOString(),
55
+ };
56
+ }
57
+
58
+ const data: SessionFile = {
59
+ meta,
60
+ messages: manager.serialize(),
61
+ };
62
+
63
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
64
+ this.logger.info({ sessionId, messages: manager.messageCount }, 'Session saved');
65
+ }
66
+
67
+ /** Restore a conversation from disk into the given MessageManager. */
68
+ async load(sessionId: string, manager: MessageManager): Promise<SessionMeta> {
69
+ const filePath = this.sessionPath(sessionId);
70
+ const data = await this.readSessionFile(filePath);
71
+ manager.restore(data.messages);
72
+ this.logger.info({ sessionId, messages: manager.messageCount }, 'Session loaded');
73
+ return data.meta;
74
+ }
75
+
76
+ /** Check if a session file exists. */
77
+ async exists(sessionId: string): Promise<boolean> {
78
+ try {
79
+ await this.readSessionFile(this.sessionPath(sessionId));
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** List all session IDs in the sessions directory. */
87
+ async list(): Promise<SessionMeta[]> {
88
+ const { readdir } = await import('node:fs/promises');
89
+ try {
90
+ const entries = await readdir(this.sessionsDir);
91
+ const metas: SessionMeta[] = [];
92
+ for (const entry of entries) {
93
+ if (!entry.endsWith('.session.json')) continue;
94
+ try {
95
+ const data = await this.readSessionFile(join(this.sessionsDir, entry));
96
+ metas.push(data.meta);
97
+ } catch {
98
+ // skip corrupt files
99
+ }
100
+ }
101
+ return metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ private sessionPath(sessionId: string): string {
108
+ // Sanitize sessionId to prevent path traversal
109
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
110
+ return join(this.sessionsDir, `${safe}.session.json`);
111
+ }
112
+
113
+ private async readSessionFile(filePath: string): Promise<SessionFile> {
114
+ const content = await readFile(filePath, 'utf-8');
115
+ const parsed = JSON.parse(content) as SessionFile;
116
+ if (!parsed.meta || !parsed.messages) {
117
+ throw new Error('Invalid session file format');
118
+ }
119
+ return parsed;
120
+ }
121
+ }