@hybridaione/hybridclaw 0.1.5

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 (196) hide show
  1. package/.env.example +14 -0
  2. package/.github/workflows/pages.yml +41 -0
  3. package/AGENTS.md +24 -0
  4. package/CHANGELOG.md +113 -0
  5. package/LICENSE +21 -0
  6. package/README.md +226 -0
  7. package/SECURITY.md +67 -0
  8. package/config.example.json +62 -0
  9. package/container/Dockerfile +23 -0
  10. package/container/package-lock.json +246 -0
  11. package/container/package.json +17 -0
  12. package/container/src/hybridai-client.ts +38 -0
  13. package/container/src/index.ts +198 -0
  14. package/container/src/ipc.ts +37 -0
  15. package/container/src/tools.ts +1008 -0
  16. package/container/src/types.ts +74 -0
  17. package/container/src/web-fetch.ts +389 -0
  18. package/container/tsconfig.json +16 -0
  19. package/dist/agent.d.ts +3 -0
  20. package/dist/agent.d.ts.map +1 -0
  21. package/dist/agent.js +21 -0
  22. package/dist/agent.js.map +1 -0
  23. package/dist/cli.d.ts +3 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +32 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +38 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +107 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/container-runner.d.ts +11 -0
  32. package/dist/container-runner.d.ts.map +1 -0
  33. package/dist/container-runner.js +246 -0
  34. package/dist/container-runner.js.map +1 -0
  35. package/dist/conversation.d.ts +18 -0
  36. package/dist/conversation.d.ts.map +1 -0
  37. package/dist/conversation.js +29 -0
  38. package/dist/conversation.js.map +1 -0
  39. package/dist/db.d.ts +29 -0
  40. package/dist/db.d.ts.map +1 -0
  41. package/dist/db.js +205 -0
  42. package/dist/db.js.map +1 -0
  43. package/dist/discord.d.ts +17 -0
  44. package/dist/discord.d.ts.map +1 -0
  45. package/dist/discord.js +115 -0
  46. package/dist/discord.js.map +1 -0
  47. package/dist/env.d.ts +6 -0
  48. package/dist/env.d.ts.map +1 -0
  49. package/dist/env.js +36 -0
  50. package/dist/env.js.map +1 -0
  51. package/dist/gateway-client.d.ts +8 -0
  52. package/dist/gateway-client.d.ts.map +1 -0
  53. package/dist/gateway-client.js +57 -0
  54. package/dist/gateway-client.js.map +1 -0
  55. package/dist/gateway-service.d.ts +23 -0
  56. package/dist/gateway-service.d.ts.map +1 -0
  57. package/dist/gateway-service.js +360 -0
  58. package/dist/gateway-service.js.map +1 -0
  59. package/dist/gateway-types.d.ts +40 -0
  60. package/dist/gateway-types.d.ts.map +1 -0
  61. package/dist/gateway-types.js +6 -0
  62. package/dist/gateway-types.js.map +1 -0
  63. package/dist/gateway.d.ts +2 -0
  64. package/dist/gateway.d.ts.map +1 -0
  65. package/dist/gateway.js +138 -0
  66. package/dist/gateway.js.map +1 -0
  67. package/dist/hatch.d.ts +7 -0
  68. package/dist/hatch.d.ts.map +1 -0
  69. package/dist/hatch.js +99 -0
  70. package/dist/hatch.js.map +1 -0
  71. package/dist/health.d.ts +2 -0
  72. package/dist/health.d.ts.map +1 -0
  73. package/dist/health.js +169 -0
  74. package/dist/health.js.map +1 -0
  75. package/dist/heartbeat.d.ts +3 -0
  76. package/dist/heartbeat.d.ts.map +1 -0
  77. package/dist/heartbeat.js +103 -0
  78. package/dist/heartbeat.js.map +1 -0
  79. package/dist/hybridai-bots.d.ts +5 -0
  80. package/dist/hybridai-bots.d.ts.map +1 -0
  81. package/dist/hybridai-bots.js +34 -0
  82. package/dist/hybridai-bots.js.map +1 -0
  83. package/dist/index.d.ts +2 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/ipc.d.ts +33 -0
  88. package/dist/ipc.d.ts.map +1 -0
  89. package/dist/ipc.js +142 -0
  90. package/dist/ipc.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +21 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/mount-security.d.ts +28 -0
  96. package/dist/mount-security.d.ts.map +1 -0
  97. package/dist/mount-security.js +187 -0
  98. package/dist/mount-security.js.map +1 -0
  99. package/dist/onboarding.d.ts +7 -0
  100. package/dist/onboarding.d.ts.map +1 -0
  101. package/dist/onboarding.js +445 -0
  102. package/dist/onboarding.js.map +1 -0
  103. package/dist/prompt-hooks.d.ts +17 -0
  104. package/dist/prompt-hooks.d.ts.map +1 -0
  105. package/dist/prompt-hooks.js +83 -0
  106. package/dist/prompt-hooks.js.map +1 -0
  107. package/dist/runtime-config.d.ts +78 -0
  108. package/dist/runtime-config.d.ts.map +1 -0
  109. package/dist/runtime-config.js +471 -0
  110. package/dist/runtime-config.js.map +1 -0
  111. package/dist/scheduled-task-runner.d.ts +11 -0
  112. package/dist/scheduled-task-runner.d.ts.map +1 -0
  113. package/dist/scheduled-task-runner.js +16 -0
  114. package/dist/scheduled-task-runner.js.map +1 -0
  115. package/dist/scheduler.d.ts +11 -0
  116. package/dist/scheduler.d.ts.map +1 -0
  117. package/dist/scheduler.js +165 -0
  118. package/dist/scheduler.js.map +1 -0
  119. package/dist/session-maintenance.d.ts +9 -0
  120. package/dist/session-maintenance.d.ts.map +1 -0
  121. package/dist/session-maintenance.js +168 -0
  122. package/dist/session-maintenance.js.map +1 -0
  123. package/dist/session-transcripts.d.ts +11 -0
  124. package/dist/session-transcripts.d.ts.map +1 -0
  125. package/dist/session-transcripts.js +32 -0
  126. package/dist/session-transcripts.js.map +1 -0
  127. package/dist/side-effects.d.ts +3 -0
  128. package/dist/side-effects.d.ts.map +1 -0
  129. package/dist/side-effects.js +30 -0
  130. package/dist/side-effects.js.map +1 -0
  131. package/dist/skills.d.ts +32 -0
  132. package/dist/skills.d.ts.map +1 -0
  133. package/dist/skills.js +376 -0
  134. package/dist/skills.js.map +1 -0
  135. package/dist/tui.d.ts +2 -0
  136. package/dist/tui.d.ts.map +1 -0
  137. package/dist/tui.js +305 -0
  138. package/dist/tui.js.map +1 -0
  139. package/dist/types.d.ts +132 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +3 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/workspace.d.ts +25 -0
  144. package/dist/workspace.d.ts.map +1 -0
  145. package/dist/workspace.js +154 -0
  146. package/dist/workspace.js.map +1 -0
  147. package/docs/chat.html +929 -0
  148. package/docs/hai_logo_free.png +0 -0
  149. package/docs/hero.png +0 -0
  150. package/docs/index.html +1213 -0
  151. package/package.json +34 -0
  152. package/skills/current-time/SKILL.md +26 -0
  153. package/skills/iss-position/SKILL.md +46 -0
  154. package/skills/iss-position/agents/openai.yaml +3 -0
  155. package/skills/iss-position/scripts/get_iss_position.py +107 -0
  156. package/skills/repo-orientation/SKILL.md +74 -0
  157. package/src/agent.ts +35 -0
  158. package/src/cli.ts +35 -0
  159. package/src/config.ts +137 -0
  160. package/src/container-runner.ts +305 -0
  161. package/src/conversation.ts +49 -0
  162. package/src/db.ts +290 -0
  163. package/src/discord.ts +156 -0
  164. package/src/env.ts +36 -0
  165. package/src/gateway-client.ts +73 -0
  166. package/src/gateway-service.ts +456 -0
  167. package/src/gateway-types.ts +47 -0
  168. package/src/gateway.ts +199 -0
  169. package/src/health.ts +189 -0
  170. package/src/heartbeat.ts +121 -0
  171. package/src/hybridai-bots.ts +48 -0
  172. package/src/ipc.ts +163 -0
  173. package/src/logger.ts +26 -0
  174. package/src/mount-security.ts +216 -0
  175. package/src/onboarding.ts +569 -0
  176. package/src/prompt-hooks.ts +113 -0
  177. package/src/runtime-config.ts +588 -0
  178. package/src/scheduled-task-runner.ts +26 -0
  179. package/src/scheduler.ts +196 -0
  180. package/src/session-maintenance.ts +263 -0
  181. package/src/session-transcripts.ts +45 -0
  182. package/src/side-effects.ts +41 -0
  183. package/src/skills.ts +429 -0
  184. package/src/tui.ts +327 -0
  185. package/src/types.ts +135 -0
  186. package/src/workspace.ts +171 -0
  187. package/templates/AGENTS.md +104 -0
  188. package/templates/BOOT.md +3 -0
  189. package/templates/BOOTSTRAP.md +54 -0
  190. package/templates/HEARTBEAT.md +5 -0
  191. package/templates/IDENTITY.md +18 -0
  192. package/templates/MEMORY.md +19 -0
  193. package/templates/SOUL.md +36 -0
  194. package/templates/TOOLS.md +22 -0
  195. package/templates/USER.md +17 -0
  196. package/tsconfig.json +18 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Container Runner — manages a pool of persistent containers.
3
+ * Containers stay alive between requests and exit after an idle timeout.
4
+ */
5
+ import { ChildProcess, spawn } from 'child_process';
6
+
7
+ import {
8
+ ADDITIONAL_MOUNTS,
9
+ CONTAINER_CPUS,
10
+ CONTAINER_IMAGE,
11
+ CONTAINER_MEMORY,
12
+ CONTAINER_TIMEOUT,
13
+ HYBRIDAI_API_KEY,
14
+ HYBRIDAI_BASE_URL,
15
+ HYBRIDAI_MODEL,
16
+ MAX_CONCURRENT_CONTAINERS,
17
+ } from './config.js';
18
+ import { cleanupIpc, ensureAgentDirs, ensureSessionDirs, getSessionPaths, readOutput, writeInput } from './ipc.js';
19
+ import { logger } from './logger.js';
20
+ import { validateAdditionalMounts } from './mount-security.js';
21
+ import type { AdditionalMount, ChatMessage, ContainerInput, ContainerOutput, ScheduledTask, ToolProgressEvent } from './types.js';
22
+
23
+ const IDLE_TIMEOUT_MS = 300_000; // 5 minutes — matches container-side default
24
+
25
+ interface PoolEntry {
26
+ process: ChildProcess;
27
+ containerName: string;
28
+ sessionId: string;
29
+ startedAt: number;
30
+ stderrBuffer: string;
31
+ onToolProgress?: (event: ToolProgressEvent) => void;
32
+ }
33
+
34
+ const pool = new Map<string, PoolEntry>();
35
+ const TOOL_RESULT_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+)\s+result\s+\((\d+)ms\):\s*(.*)$/;
36
+ const TOOL_START_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+):\s*(.*)$/;
37
+
38
+ function emitToolProgress(entry: PoolEntry, line: string): void {
39
+ const callback = entry.onToolProgress;
40
+ if (!callback) return;
41
+
42
+ const resultMatch = line.match(TOOL_RESULT_RE);
43
+ if (resultMatch) {
44
+ try {
45
+ callback({
46
+ sessionId: entry.sessionId,
47
+ toolName: resultMatch[1],
48
+ phase: 'finish',
49
+ durationMs: parseInt(resultMatch[2], 10),
50
+ preview: resultMatch[3],
51
+ });
52
+ } catch (err) {
53
+ logger.debug({ sessionId: entry.sessionId, err }, 'Tool progress callback failed');
54
+ }
55
+ return;
56
+ }
57
+
58
+ const startMatch = line.match(TOOL_START_RE);
59
+ if (startMatch) {
60
+ try {
61
+ callback({
62
+ sessionId: entry.sessionId,
63
+ toolName: startMatch[1],
64
+ phase: 'start',
65
+ preview: startMatch[2],
66
+ });
67
+ } catch (err) {
68
+ logger.debug({ sessionId: entry.sessionId, err }, 'Tool progress callback failed');
69
+ }
70
+ }
71
+ }
72
+
73
+ export function getActiveContainerCount(): number {
74
+ return pool.size;
75
+ }
76
+
77
+ function stopContainer(containerName: string): void {
78
+ const proc = spawn('docker', ['stop', containerName], { stdio: 'ignore' });
79
+ proc.on('error', (err) => {
80
+ logger.debug({ containerName, err }, 'Failed to stop container');
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Get or spawn a persistent container for a session.
86
+ */
87
+ function getOrSpawnContainer(sessionId: string, agentId: string): PoolEntry {
88
+ const existing = pool.get(sessionId);
89
+ if (existing && !existing.process.killed && existing.process.exitCode === null) {
90
+ logger.debug({ sessionId, containerName: existing.containerName }, 'Reusing container');
91
+ return existing;
92
+ }
93
+
94
+ // Clean up stale entry
95
+ if (existing) {
96
+ pool.delete(sessionId);
97
+ }
98
+
99
+ ensureSessionDirs(sessionId);
100
+ ensureAgentDirs(agentId);
101
+ const { ipcPath, workspacePath } = getSessionPaths(sessionId, agentId);
102
+ const containerName = `hybridclaw-${sessionId.replace(/[^a-zA-Z0-9-]/g, '-')}-${Date.now()}`;
103
+
104
+ const args = [
105
+ 'run',
106
+ '--rm',
107
+ '-i',
108
+ '--name', containerName,
109
+ '--memory', CONTAINER_MEMORY,
110
+ `--cpus=${CONTAINER_CPUS}`,
111
+ '--read-only',
112
+ '--tmpfs', '/tmp',
113
+ '-v', `${workspacePath}:/workspace:rw`,
114
+ '-v', `${ipcPath}:/ipc:rw`,
115
+ '-e', `HYBRIDAI_BASE_URL=${HYBRIDAI_BASE_URL}`,
116
+ '-e', `HYBRIDAI_MODEL=${HYBRIDAI_MODEL}`,
117
+ '-e', `CONTAINER_IDLE_TIMEOUT=${IDLE_TIMEOUT_MS}`,
118
+ ];
119
+
120
+ // Run as host user so bind-mount file ownership matches
121
+ const hostUid = process.getuid?.();
122
+ const hostGid = process.getgid?.();
123
+ if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
124
+ args.push('--user', `${hostUid}:${hostGid}`);
125
+ args.push('-e', 'HOME=/home/node');
126
+ }
127
+
128
+ // Validate and append additional mounts
129
+ if (ADDITIONAL_MOUNTS) {
130
+ try {
131
+ const requested = JSON.parse(ADDITIONAL_MOUNTS) as AdditionalMount[];
132
+ const validated = validateAdditionalMounts(requested);
133
+ for (const m of validated) {
134
+ args.push('-v', `${m.hostPath}:${m.containerPath}:${m.readonly ? 'ro' : 'rw'}`);
135
+ }
136
+ } catch (err) {
137
+ logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Failed to parse ADDITIONAL_MOUNTS');
138
+ }
139
+ }
140
+
141
+ args.push(CONTAINER_IMAGE);
142
+
143
+ logger.info({ sessionId, containerName }, 'Spawning persistent container');
144
+
145
+ const proc = spawn('docker', args, {
146
+ stdio: ['pipe', 'pipe', 'pipe'],
147
+ });
148
+
149
+ const entry: PoolEntry = {
150
+ process: proc,
151
+ containerName,
152
+ sessionId,
153
+ startedAt: Date.now(),
154
+ stderrBuffer: '',
155
+ };
156
+
157
+ proc.stderr.on('data', (data) => {
158
+ entry.stderrBuffer += data.toString('utf-8');
159
+ const lines = entry.stderrBuffer.split('\n');
160
+ entry.stderrBuffer = lines.pop() || '';
161
+ for (const rawLine of lines) {
162
+ const line = rawLine.trim();
163
+ if (!line) continue;
164
+ logger.debug({ container: containerName }, line);
165
+ emitToolProgress(entry, line);
166
+ }
167
+ });
168
+
169
+ proc.on('close', (code) => {
170
+ const tail = entry.stderrBuffer.trim();
171
+ if (tail) {
172
+ logger.debug({ container: containerName }, tail);
173
+ emitToolProgress(entry, tail);
174
+ entry.stderrBuffer = '';
175
+ }
176
+ pool.delete(sessionId);
177
+ logger.info({ sessionId, containerName, code }, 'Container exited');
178
+ });
179
+
180
+ proc.on('error', (err) => {
181
+ pool.delete(sessionId);
182
+ logger.error({ sessionId, containerName, error: err }, 'Container error');
183
+ });
184
+
185
+ pool.set(sessionId, entry);
186
+ return entry;
187
+ }
188
+
189
+ /**
190
+ * Send a request to a persistent container and wait for the response.
191
+ */
192
+ export async function runContainer(
193
+ sessionId: string,
194
+ messages: ChatMessage[],
195
+ chatbotId: string,
196
+ enableRag: boolean,
197
+ model: string = HYBRIDAI_MODEL,
198
+ agentId: string = chatbotId,
199
+ channelId: string = '',
200
+ scheduledTasks?: ScheduledTask[],
201
+ allowedTools?: string[],
202
+ onToolProgress?: (event: ToolProgressEvent) => void,
203
+ abortSignal?: AbortSignal,
204
+ ): Promise<ContainerOutput> {
205
+ // Enforce concurrent container limit
206
+ if (pool.size >= MAX_CONCURRENT_CONTAINERS && !pool.has(sessionId)) {
207
+ return {
208
+ status: 'error',
209
+ result: null,
210
+ toolsUsed: [],
211
+ error: `Too many active containers (${pool.size}/${MAX_CONCURRENT_CONTAINERS}). Try again later.`,
212
+ };
213
+ }
214
+
215
+ const startTime = Date.now();
216
+
217
+ // Clean any stale output from previous request
218
+ cleanupIpc(sessionId);
219
+ ensureSessionDirs(sessionId);
220
+
221
+ const isNewContainer = !pool.has(sessionId) || pool.get(sessionId)!.process.killed || pool.get(sessionId)!.process.exitCode !== null;
222
+
223
+ let entry: PoolEntry;
224
+ try {
225
+ entry = getOrSpawnContainer(sessionId, agentId);
226
+ } catch (err) {
227
+ return {
228
+ status: 'error',
229
+ result: null,
230
+ toolsUsed: [],
231
+ error: `Container spawn error: ${err instanceof Error ? err.message : String(err)}`,
232
+ };
233
+ }
234
+
235
+ const input: ContainerInput = {
236
+ sessionId,
237
+ messages,
238
+ chatbotId,
239
+ enableRag,
240
+ apiKey: HYBRIDAI_API_KEY,
241
+ baseUrl: HYBRIDAI_BASE_URL.replace(/\/\/(localhost|127\.0\.0\.1)([:\/])/, '//host.docker.internal$2'),
242
+ model,
243
+ channelId,
244
+ scheduledTasks: scheduledTasks?.map((t) => ({
245
+ id: t.id,
246
+ cronExpr: t.cron_expr,
247
+ runAt: t.run_at,
248
+ everyMs: t.every_ms,
249
+ prompt: t.prompt,
250
+ enabled: t.enabled,
251
+ lastRun: t.last_run,
252
+ createdAt: t.created_at,
253
+ })),
254
+ allowedTools,
255
+ };
256
+
257
+ entry.onToolProgress = onToolProgress;
258
+ const onAbort = () => {
259
+ logger.info({ sessionId, containerName: entry.containerName }, 'Interrupt requested, stopping container');
260
+ stopContainer(entry.containerName);
261
+ };
262
+ if (abortSignal) {
263
+ abortSignal.addEventListener('abort', onAbort, { once: true });
264
+ if (abortSignal.aborted) onAbort();
265
+ }
266
+
267
+ try {
268
+ if (isNewContainer) {
269
+ // First request: send full input (including apiKey) via stdin — no file on disk.
270
+ // Write JSON on a single line followed by newline as delimiter.
271
+ // Do NOT end stdin — closing stdin can cause docker -i to terminate the container.
272
+ entry.process.stdin?.write(JSON.stringify(input) + '\n');
273
+ } else {
274
+ // Follow-up requests: write to IPC file, omitting apiKey
275
+ writeInput(sessionId, input, { omitApiKey: true });
276
+ }
277
+
278
+ // Wait for the container to produce output
279
+ const output = await readOutput(sessionId, CONTAINER_TIMEOUT, { signal: abortSignal });
280
+ const duration = Date.now() - startTime;
281
+
282
+ logger.info(
283
+ { sessionId, containerName: entry.containerName, duration, status: output.status, toolsUsed: output.toolsUsed },
284
+ 'Request completed',
285
+ );
286
+
287
+ return output;
288
+ } finally {
289
+ abortSignal?.removeEventListener('abort', onAbort);
290
+ if (entry.onToolProgress === onToolProgress) {
291
+ entry.onToolProgress = undefined;
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Stop all containers (for graceful shutdown).
298
+ */
299
+ export function stopAllContainers(): void {
300
+ for (const [sessionId, entry] of pool) {
301
+ logger.info({ sessionId, containerName: entry.containerName }, 'Stopping container (shutdown)');
302
+ stopContainer(entry.containerName);
303
+ }
304
+ pool.clear();
305
+ }
@@ -0,0 +1,49 @@
1
+ import { expandSkillInvocation, loadSkills, type Skill } from './skills.js';
2
+ import type { ChatMessage } from './types.js';
3
+ import { buildSystemPromptFromHooks } from './prompt-hooks.js';
4
+
5
+ interface HistoryMessage {
6
+ role: string;
7
+ content: string;
8
+ }
9
+
10
+ export interface ConversationContext {
11
+ messages: ChatMessage[];
12
+ skills: Skill[];
13
+ }
14
+
15
+ export function buildConversationContext(params: {
16
+ agentId: string;
17
+ sessionSummary?: string | null;
18
+ history: HistoryMessage[];
19
+ expandLatestHistoryUser?: boolean;
20
+ }): ConversationContext {
21
+ const { agentId, sessionSummary, history, expandLatestHistoryUser = false } = params;
22
+ const skills = loadSkills(agentId);
23
+ const systemPrompt = buildSystemPromptFromHooks({
24
+ agentId,
25
+ sessionSummary,
26
+ skills,
27
+ purpose: 'conversation',
28
+ });
29
+
30
+ const messages: ChatMessage[] = [];
31
+ if (systemPrompt) {
32
+ messages.push({ role: 'system', content: systemPrompt });
33
+ }
34
+
35
+ const historyMessages = [...history].reverse().map((msg): ChatMessage => ({
36
+ role: msg.role as ChatMessage['role'],
37
+ content: msg.content,
38
+ }));
39
+
40
+ if (expandLatestHistoryUser && historyMessages.length > 0) {
41
+ const latest = historyMessages[historyMessages.length - 1];
42
+ if (latest.role === 'user' && typeof latest.content === 'string') {
43
+ latest.content = expandSkillInvocation(latest.content, skills);
44
+ }
45
+ }
46
+
47
+ messages.push(...historyMessages);
48
+ return { messages, skills };
49
+ }
package/src/db.ts ADDED
@@ -0,0 +1,290 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import { DB_PATH } from './config.js';
6
+ import { logger } from './logger.js';
7
+ import type { AuditEntry, ScheduledTask, Session, StoredMessage } from './types.js';
8
+
9
+ let db: Database.Database;
10
+
11
+ function createSchema(database: Database.Database): void {
12
+ database.exec(`
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ id TEXT PRIMARY KEY,
15
+ guild_id TEXT,
16
+ channel_id TEXT NOT NULL,
17
+ chatbot_id TEXT,
18
+ model TEXT,
19
+ enable_rag INTEGER DEFAULT 1,
20
+ message_count INTEGER DEFAULT 0,
21
+ session_summary TEXT,
22
+ summary_updated_at TEXT,
23
+ compaction_count INTEGER DEFAULT 0,
24
+ memory_flush_at TEXT,
25
+ created_at TEXT DEFAULT (datetime('now')),
26
+ last_active TEXT DEFAULT (datetime('now'))
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS messages (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ session_id TEXT NOT NULL,
32
+ user_id TEXT NOT NULL,
33
+ username TEXT,
34
+ role TEXT NOT NULL,
35
+ content TEXT NOT NULL,
36
+ created_at TEXT DEFAULT (datetime('now'))
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
39
+
40
+ CREATE TABLE IF NOT EXISTS tasks (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ session_id TEXT NOT NULL,
43
+ channel_id TEXT NOT NULL,
44
+ cron_expr TEXT NOT NULL,
45
+ prompt TEXT NOT NULL,
46
+ enabled INTEGER DEFAULT 1,
47
+ last_run TEXT,
48
+ created_at TEXT DEFAULT (datetime('now'))
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS audit_log (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ session_id TEXT,
54
+ event TEXT NOT NULL,
55
+ detail TEXT,
56
+ duration_ms INTEGER,
57
+ created_at TEXT DEFAULT (datetime('now'))
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
60
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
61
+ `);
62
+ }
63
+
64
+ function migrateSchema(database: Database.Database): void {
65
+ const addColumnIfMissing = (table: string, column: string, ddl: string): void => {
66
+ const cols = database.pragma(`table_info(${table})`) as Array<{ name: string }>;
67
+ if (!cols.some((c) => c.name === column)) {
68
+ database.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
69
+ logger.info({ table, column }, 'Migrated table: added column');
70
+ }
71
+ };
72
+
73
+ // Add session columns if they don't exist
74
+ const sessionCols = database.pragma('table_info(sessions)') as Array<{ name: string }>;
75
+ if (!sessionCols.some((c) => c.name === 'model')) {
76
+ database.exec('ALTER TABLE sessions ADD COLUMN model TEXT');
77
+ logger.info('Migrated sessions table: added model column');
78
+ }
79
+ addColumnIfMissing('sessions', 'session_summary', 'session_summary TEXT');
80
+ addColumnIfMissing('sessions', 'summary_updated_at', 'summary_updated_at TEXT');
81
+ addColumnIfMissing('sessions', 'compaction_count', 'compaction_count INTEGER DEFAULT 0');
82
+ addColumnIfMissing('sessions', 'memory_flush_at', 'memory_flush_at TEXT');
83
+
84
+ // Add run_at and every_ms columns to tasks if they don't exist
85
+ const taskCols = database.pragma('table_info(tasks)') as Array<{ name: string }>;
86
+ if (!taskCols.some((c) => c.name === 'run_at')) {
87
+ database.exec('ALTER TABLE tasks ADD COLUMN run_at TEXT');
88
+ logger.info('Migrated tasks table: added run_at column');
89
+ }
90
+ if (!taskCols.some((c) => c.name === 'every_ms')) {
91
+ database.exec('ALTER TABLE tasks ADD COLUMN every_ms INTEGER');
92
+ logger.info('Migrated tasks table: added every_ms column');
93
+ }
94
+ }
95
+
96
+ export function initDatabase(): void {
97
+ const dbPath = path.resolve(DB_PATH);
98
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
99
+ db = new Database(dbPath);
100
+ db.pragma('journal_mode = WAL');
101
+ createSchema(db);
102
+ migrateSchema(db);
103
+ logger.info({ path: dbPath }, 'Database initialized');
104
+ }
105
+
106
+ // --- Sessions ---
107
+
108
+ export function getOrCreateSession(
109
+ sessionId: string,
110
+ guildId: string | null,
111
+ channelId: string,
112
+ ): Session {
113
+ const existing = getSessionById(sessionId);
114
+
115
+ if (existing) {
116
+ db.prepare('UPDATE sessions SET last_active = datetime(\'now\') WHERE id = ?').run(sessionId);
117
+ return existing;
118
+ }
119
+
120
+ db.prepare(
121
+ 'INSERT INTO sessions (id, guild_id, channel_id) VALUES (?, ?, ?)',
122
+ ).run(sessionId, guildId, channelId);
123
+
124
+ return getSessionById(sessionId) as Session;
125
+ }
126
+
127
+ export function getSessionById(sessionId: string): Session | undefined {
128
+ return db
129
+ .prepare('SELECT * FROM sessions WHERE id = ?')
130
+ .get(sessionId) as Session | undefined;
131
+ }
132
+
133
+ export function updateSessionChatbot(sessionId: string, chatbotId: string | null): void {
134
+ db.prepare('UPDATE sessions SET chatbot_id = ? WHERE id = ?').run(chatbotId, sessionId);
135
+ }
136
+
137
+ export function updateSessionModel(sessionId: string, model: string | null): void {
138
+ db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run(model, sessionId);
139
+ }
140
+
141
+ export function updateSessionRag(sessionId: string, enableRag: boolean): void {
142
+ db.prepare('UPDATE sessions SET enable_rag = ? WHERE id = ?').run(enableRag ? 1 : 0, sessionId);
143
+ }
144
+
145
+ export function getAllSessions(): Session[] {
146
+ return db.prepare('SELECT * FROM sessions ORDER BY last_active DESC').all() as Session[];
147
+ }
148
+
149
+ export function getSessionCount(): number {
150
+ const row = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
151
+ return row.count;
152
+ }
153
+
154
+ export function clearSessionHistory(sessionId: string): number {
155
+ const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
156
+ db.prepare(
157
+ 'UPDATE sessions SET message_count = 0, session_summary = NULL, summary_updated_at = NULL, compaction_count = 0, memory_flush_at = NULL WHERE id = ?',
158
+ ).run(sessionId);
159
+ return result.changes;
160
+ }
161
+
162
+ // --- Messages ---
163
+
164
+ export function storeMessage(
165
+ sessionId: string,
166
+ userId: string,
167
+ username: string | null,
168
+ role: string,
169
+ content: string,
170
+ ): void {
171
+ db.prepare(
172
+ 'INSERT INTO messages (session_id, user_id, username, role, content) VALUES (?, ?, ?, ?, ?)',
173
+ ).run(sessionId, userId, username, role, content);
174
+
175
+ db.prepare(
176
+ 'UPDATE sessions SET message_count = message_count + 1, last_active = datetime(\'now\') WHERE id = ?',
177
+ ).run(sessionId);
178
+ }
179
+
180
+ export function getConversationHistory(sessionId: string, limit = 50): StoredMessage[] {
181
+ return db
182
+ .prepare(
183
+ 'SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?',
184
+ )
185
+ .all(sessionId, limit) as StoredMessage[];
186
+ }
187
+
188
+ export interface CompactionCandidate {
189
+ cutoffId: number;
190
+ olderMessages: StoredMessage[];
191
+ }
192
+
193
+ export function getCompactionCandidateMessages(
194
+ sessionId: string,
195
+ keepRecent: number,
196
+ ): CompactionCandidate | null {
197
+ const keep = Math.max(1, Math.floor(keepRecent));
198
+ const cutoffRow = db
199
+ .prepare('SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?')
200
+ .get(sessionId, keep - 1) as { id: number } | undefined;
201
+ if (!cutoffRow) return null;
202
+
203
+ const older = db
204
+ .prepare('SELECT * FROM messages WHERE session_id = ? AND id < ? ORDER BY id ASC')
205
+ .all(sessionId, cutoffRow.id) as StoredMessage[];
206
+ if (older.length === 0) return null;
207
+
208
+ return {
209
+ cutoffId: cutoffRow.id,
210
+ olderMessages: older,
211
+ };
212
+ }
213
+
214
+ export function deleteMessagesBeforeId(sessionId: string, cutoffId: number): number {
215
+ const result = db
216
+ .prepare('DELETE FROM messages WHERE session_id = ? AND id < ?')
217
+ .run(sessionId, cutoffId);
218
+ db.prepare(
219
+ 'UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), last_active = datetime(\'now\') WHERE id = ?',
220
+ ).run(sessionId, sessionId);
221
+ return result.changes;
222
+ }
223
+
224
+ export function updateSessionSummary(sessionId: string, summary: string): void {
225
+ const normalized = summary.trim();
226
+ db.prepare(
227
+ 'UPDATE sessions SET session_summary = ?, summary_updated_at = datetime(\'now\'), compaction_count = compaction_count + 1 WHERE id = ?',
228
+ ).run(normalized || null, sessionId);
229
+ }
230
+
231
+ export function markSessionMemoryFlush(sessionId: string): void {
232
+ db.prepare('UPDATE sessions SET memory_flush_at = datetime(\'now\') WHERE id = ?').run(sessionId);
233
+ }
234
+
235
+ // --- Tasks ---
236
+
237
+ export function createTask(
238
+ sessionId: string,
239
+ channelId: string,
240
+ cronExpr: string,
241
+ prompt: string,
242
+ runAt?: string,
243
+ everyMs?: number,
244
+ ): number {
245
+ const result = db.prepare(
246
+ 'INSERT INTO tasks (session_id, channel_id, cron_expr, prompt, run_at, every_ms) VALUES (?, ?, ?, ?, ?, ?)',
247
+ ).run(sessionId, channelId, cronExpr, prompt, runAt || null, everyMs || null);
248
+ return result.lastInsertRowid as number;
249
+ }
250
+
251
+ export function getTasksForSession(sessionId: string): ScheduledTask[] {
252
+ return db
253
+ .prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at DESC')
254
+ .all(sessionId) as ScheduledTask[];
255
+ }
256
+
257
+ export function getAllEnabledTasks(): ScheduledTask[] {
258
+ return db.prepare('SELECT * FROM tasks WHERE enabled = 1').all() as ScheduledTask[];
259
+ }
260
+
261
+ export function updateTaskLastRun(taskId: number): void {
262
+ db.prepare('UPDATE tasks SET last_run = datetime(\'now\') WHERE id = ?').run(taskId);
263
+ }
264
+
265
+ export function toggleTask(taskId: number, enabled: boolean): void {
266
+ db.prepare('UPDATE tasks SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, taskId);
267
+ }
268
+
269
+ export function deleteTask(taskId: number): void {
270
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
271
+ }
272
+
273
+ // --- Audit ---
274
+
275
+ export function logAudit(
276
+ event: string,
277
+ sessionId?: string,
278
+ detail?: Record<string, unknown>,
279
+ durationMs?: number,
280
+ ): void {
281
+ db.prepare(
282
+ 'INSERT INTO audit_log (session_id, event, detail, duration_ms) VALUES (?, ?, ?, ?)',
283
+ ).run(sessionId || null, event, detail ? JSON.stringify(detail) : null, durationMs || null);
284
+ }
285
+
286
+ export function getRecentAudit(limit = 20): AuditEntry[] {
287
+ return db
288
+ .prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?')
289
+ .all(limit) as AuditEntry[];
290
+ }