@castlekit/castle 0.1.6 → 0.3.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 (60) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +20 -3
  4. package/src/app/api/avatars/[id]/route.ts +57 -7
  5. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  6. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  7. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  8. package/src/app/api/openclaw/chat/route.ts +272 -0
  9. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  10. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  11. package/src/app/api/openclaw/logs/route.ts +17 -3
  12. package/src/app/api/openclaw/restart/route.ts +6 -1
  13. package/src/app/api/openclaw/session/status/route.ts +42 -0
  14. package/src/app/api/settings/avatar/route.ts +190 -0
  15. package/src/app/api/settings/route.ts +88 -0
  16. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  17. package/src/app/chat/[channelId]/page.tsx +305 -0
  18. package/src/app/chat/layout.tsx +96 -0
  19. package/src/app/chat/page.tsx +52 -0
  20. package/src/app/globals.css +89 -2
  21. package/src/app/layout.tsx +7 -1
  22. package/src/app/page.tsx +49 -17
  23. package/src/app/settings/page.tsx +300 -0
  24. package/src/components/chat/agent-mention-popup.tsx +89 -0
  25. package/src/components/chat/archived-channels.tsx +190 -0
  26. package/src/components/chat/channel-list.tsx +140 -0
  27. package/src/components/chat/chat-input.tsx +310 -0
  28. package/src/components/chat/create-channel-dialog.tsx +171 -0
  29. package/src/components/chat/markdown-content.tsx +205 -0
  30. package/src/components/chat/message-bubble.tsx +152 -0
  31. package/src/components/chat/message-list.tsx +508 -0
  32. package/src/components/chat/message-queue.tsx +68 -0
  33. package/src/components/chat/session-divider.tsx +61 -0
  34. package/src/components/chat/session-stats-panel.tsx +139 -0
  35. package/src/components/chat/storage-indicator.tsx +76 -0
  36. package/src/components/layout/sidebar.tsx +126 -45
  37. package/src/components/layout/user-menu.tsx +29 -4
  38. package/src/components/providers/presence-provider.tsx +8 -0
  39. package/src/components/providers/search-provider.tsx +81 -0
  40. package/src/components/search/search-dialog.tsx +269 -0
  41. package/src/components/ui/avatar.tsx +11 -9
  42. package/src/components/ui/dialog.tsx +10 -4
  43. package/src/components/ui/tooltip.tsx +25 -8
  44. package/src/components/ui/twemoji-text.tsx +37 -0
  45. package/src/lib/api-security.ts +125 -0
  46. package/src/lib/date-utils.ts +79 -0
  47. package/src/lib/db/__tests__/queries.test.ts +318 -0
  48. package/src/lib/db/index.ts +642 -0
  49. package/src/lib/db/queries.ts +1017 -0
  50. package/src/lib/db/schema.ts +160 -0
  51. package/src/lib/hooks/use-agent-status.ts +251 -0
  52. package/src/lib/hooks/use-chat.ts +775 -0
  53. package/src/lib/hooks/use-openclaw.ts +105 -70
  54. package/src/lib/hooks/use-search.ts +113 -0
  55. package/src/lib/hooks/use-session-stats.ts +57 -0
  56. package/src/lib/hooks/use-user-settings.ts +46 -0
  57. package/src/lib/types/chat.ts +186 -0
  58. package/src/lib/types/search.ts +60 -0
  59. package/src/middleware.ts +52 -0
  60. package/vitest.config.ts +13 -0
@@ -0,0 +1,1017 @@
1
+ import { eq, desc, asc, and, lt, gt, sql, inArray } from "drizzle-orm";
2
+ import { v4 as uuid } from "uuid";
3
+ import { getDb } from "./index";
4
+ import {
5
+ channels,
6
+ channelAgents,
7
+ sessions,
8
+ messages,
9
+ messageAttachments,
10
+ messageReactions,
11
+ recentSearches,
12
+ settings,
13
+ agentStatuses,
14
+ } from "./schema";
15
+ import type {
16
+ Channel,
17
+ ChatMessage,
18
+ ChannelSession,
19
+ MessageAttachment,
20
+ MessageReaction,
21
+ } from "@/lib/types/chat";
22
+
23
+ // ============================================================================
24
+ // Channels
25
+ // ============================================================================
26
+
27
+ export function createChannel(
28
+ name: string,
29
+ defaultAgentId: string,
30
+ agentIds: string[] = []
31
+ ): Channel {
32
+ const db = getDb();
33
+ const id = uuid();
34
+ const now = Date.now();
35
+
36
+ db.insert(channels).values({
37
+ id,
38
+ name,
39
+ defaultAgentId,
40
+ createdAt: now,
41
+ updatedAt: now,
42
+ }).run();
43
+
44
+ // Add agents to junction table
45
+ const agents = agentIds.length > 0 ? agentIds : [defaultAgentId];
46
+ for (const agentId of agents) {
47
+ db.insert(channelAgents)
48
+ .values({ channelId: id, agentId })
49
+ .run();
50
+ }
51
+
52
+ return {
53
+ id,
54
+ name,
55
+ defaultAgentId,
56
+ agents,
57
+ createdAt: now,
58
+ };
59
+ }
60
+
61
+ export function getChannels(includeArchived = false): Channel[] {
62
+ const db = getDb();
63
+
64
+ const query = includeArchived
65
+ ? db.select().from(channels).where(sql`${channels.archivedAt} IS NOT NULL`).orderBy(desc(channels.archivedAt))
66
+ : db.select().from(channels).where(sql`${channels.archivedAt} IS NULL`).orderBy(desc(channels.createdAt));
67
+
68
+ const rows = query.all();
69
+ if (rows.length === 0) return [];
70
+
71
+ // Batch-load all channel agents in one query (avoids N+1)
72
+ const channelIds = rows.map((r) => r.id);
73
+ const allAgentRows = db
74
+ .select()
75
+ .from(channelAgents)
76
+ .where(inArray(channelAgents.channelId, channelIds))
77
+ .all();
78
+
79
+ const agentsByChannel = new Map<string, string[]>();
80
+ for (const a of allAgentRows) {
81
+ const list = agentsByChannel.get(a.channelId) || [];
82
+ list.push(a.agentId);
83
+ agentsByChannel.set(a.channelId, list);
84
+ }
85
+
86
+ return rows.map((row) => ({
87
+ id: row.id,
88
+ name: row.name,
89
+ defaultAgentId: row.defaultAgentId,
90
+ agents: agentsByChannel.get(row.id) || [],
91
+ createdAt: row.createdAt,
92
+ archivedAt: row.archivedAt ?? null,
93
+ }));
94
+ }
95
+
96
+ export function getChannel(id: string): Channel | null {
97
+ const db = getDb();
98
+ const row = db.select().from(channels).where(eq(channels.id, id)).get();
99
+ if (!row) return null;
100
+
101
+ const agentRows = db
102
+ .select()
103
+ .from(channelAgents)
104
+ .where(eq(channelAgents.channelId, id))
105
+ .all();
106
+
107
+ return {
108
+ id: row.id,
109
+ name: row.name,
110
+ defaultAgentId: row.defaultAgentId,
111
+ agents: agentRows.map((a) => a.agentId),
112
+ createdAt: row.createdAt,
113
+ archivedAt: row.archivedAt ?? null,
114
+ };
115
+ }
116
+
117
+ export function updateChannel(
118
+ id: string,
119
+ updates: { name?: string; defaultAgentId?: string }
120
+ ): boolean {
121
+ const db = getDb();
122
+ const result = db
123
+ .update(channels)
124
+ .set({ ...updates, updatedAt: Date.now() })
125
+ .where(eq(channels.id, id))
126
+ .run();
127
+ return result.changes > 0;
128
+ }
129
+
130
+ export function deleteChannel(id: string): boolean {
131
+ const db = getDb();
132
+
133
+ // Access underlying better-sqlite3 for raw SQL operations
134
+ type SqliteClient = {
135
+ prepare: (sql: string) => { run: (...params: unknown[]) => { changes: number }; all: (...params: unknown[]) => Record<string, unknown>[] };
136
+ exec: (sql: string) => void;
137
+ };
138
+ type DrizzleDb = { session: { client: SqliteClient } };
139
+ const sqlite = (db as unknown as DrizzleDb).session.client;
140
+
141
+ // The FTS5 delete trigger can fail with "SQL logic error" if the FTS5 index
142
+ // is out of sync with the messages table. To avoid this:
143
+ // 1. Drop the FTS5 delete trigger
144
+ // 2. Delete all dependent records and the channel
145
+ // 3. Recreate the trigger
146
+ // 4. Rebuild the FTS5 index to stay in sync
147
+ sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_delete");
148
+
149
+ try {
150
+ // Delete dependent records: attachments/reactions → messages → sessions → agents → channel
151
+ const msgIds = db
152
+ .select({ id: messages.id })
153
+ .from(messages)
154
+ .where(eq(messages.channelId, id))
155
+ .all()
156
+ .map((r) => r.id);
157
+
158
+ if (msgIds.length > 0) {
159
+ db.delete(messageAttachments)
160
+ .where(inArray(messageAttachments.messageId, msgIds))
161
+ .run();
162
+ db.delete(messageReactions)
163
+ .where(inArray(messageReactions.messageId, msgIds))
164
+ .run();
165
+ }
166
+
167
+ db.delete(messages).where(eq(messages.channelId, id)).run();
168
+ db.delete(sessions).where(eq(sessions.channelId, id)).run();
169
+ db.delete(channelAgents).where(eq(channelAgents.channelId, id)).run();
170
+
171
+ const result = db.delete(channels).where(eq(channels.id, id)).run();
172
+ return result.changes > 0;
173
+ } finally {
174
+ // Always recreate the trigger and rebuild FTS5 index
175
+ sqlite.exec(`
176
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
177
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
178
+ END;
179
+ `);
180
+ // Rebuild FTS5 to stay in sync after bulk deletion
181
+ sqlite.exec("INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')");
182
+ }
183
+ }
184
+
185
+ export function archiveChannel(id: string): boolean {
186
+ const db = getDb();
187
+ const result = db
188
+ .update(channels)
189
+ .set({ archivedAt: Date.now() })
190
+ .where(eq(channels.id, id))
191
+ .run();
192
+ return result.changes > 0;
193
+ }
194
+
195
+ export function restoreChannel(id: string): boolean {
196
+ const db = getDb();
197
+ const result = db
198
+ .update(channels)
199
+ .set({ archivedAt: null })
200
+ .where(eq(channels.id, id))
201
+ .run();
202
+ return result.changes > 0;
203
+ }
204
+
205
+ /**
206
+ * Mark a channel as accessed (updates last_accessed_at to now).
207
+ */
208
+ export function touchChannel(id: string): void {
209
+ const db = getDb();
210
+ db.update(channels)
211
+ .set({ lastAccessedAt: Date.now() })
212
+ .where(eq(channels.id, id))
213
+ .run();
214
+ }
215
+
216
+ /**
217
+ * Get the most recently accessed channel ID, or null if none.
218
+ */
219
+ export function getLastAccessedChannelId(): string | null {
220
+ const db = getDb();
221
+ const row = db
222
+ .select({ id: channels.id })
223
+ .from(channels)
224
+ .where(and(
225
+ sql`${channels.lastAccessedAt} IS NOT NULL`,
226
+ sql`${channels.archivedAt} IS NULL`
227
+ ))
228
+ .orderBy(desc(channels.lastAccessedAt))
229
+ .limit(1)
230
+ .get();
231
+ return row?.id ?? null;
232
+ }
233
+
234
+ // ============================================================================
235
+ // Messages
236
+ // ============================================================================
237
+
238
+ export function createMessage(params: {
239
+ channelId: string;
240
+ sessionId?: string;
241
+ senderType: "user" | "agent";
242
+ senderId: string;
243
+ senderName?: string;
244
+ content: string;
245
+ status?: "complete" | "interrupted" | "aborted";
246
+ mentionedAgentId?: string;
247
+ runId?: string;
248
+ sessionKey?: string;
249
+ inputTokens?: number;
250
+ outputTokens?: number;
251
+ }): ChatMessage {
252
+ const db = getDb();
253
+ const id = uuid();
254
+ const now = Date.now();
255
+
256
+ db.insert(messages)
257
+ .values({
258
+ id,
259
+ channelId: params.channelId,
260
+ sessionId: params.sessionId ?? null,
261
+ senderType: params.senderType,
262
+ senderId: params.senderId,
263
+ senderName: params.senderName ?? null,
264
+ content: params.content,
265
+ status: params.status ?? "complete",
266
+ mentionedAgentId: params.mentionedAgentId ?? null,
267
+ runId: params.runId ?? null,
268
+ sessionKey: params.sessionKey ?? null,
269
+ inputTokens: params.inputTokens ?? null,
270
+ outputTokens: params.outputTokens ?? null,
271
+ createdAt: now,
272
+ })
273
+ .run();
274
+
275
+ return {
276
+ id,
277
+ channelId: params.channelId,
278
+ sessionId: params.sessionId ?? null,
279
+ senderType: params.senderType,
280
+ senderId: params.senderId,
281
+ senderName: params.senderName ?? null,
282
+ content: params.content,
283
+ status: params.status ?? "complete",
284
+ mentionedAgentId: params.mentionedAgentId ?? null,
285
+ runId: params.runId ?? null,
286
+ sessionKey: params.sessionKey ?? null,
287
+ inputTokens: params.inputTokens ?? null,
288
+ outputTokens: params.outputTokens ?? null,
289
+ createdAt: now,
290
+ attachments: [],
291
+ reactions: [],
292
+ };
293
+ }
294
+
295
+ export function updateMessage(
296
+ id: string,
297
+ updates: {
298
+ content?: string;
299
+ status?: "complete" | "interrupted" | "aborted";
300
+ runId?: string;
301
+ sessionKey?: string;
302
+ inputTokens?: number;
303
+ outputTokens?: number;
304
+ }
305
+ ): boolean {
306
+ const db = getDb();
307
+ const result = db
308
+ .update(messages)
309
+ .set(updates)
310
+ .where(eq(messages.id, id))
311
+ .run();
312
+ return result.changes > 0;
313
+ }
314
+
315
+ export function deleteMessage(id: string): boolean {
316
+ const db = getDb();
317
+ const result = db.delete(messages).where(eq(messages.id, id)).run();
318
+ return result.changes > 0;
319
+ }
320
+
321
+ /**
322
+ * Hydrate raw message rows with attachments and reactions.
323
+ * Shared by getMessagesByChannel, getMessagesAfter, and getMessagesAround.
324
+ */
325
+ function hydrateMessages(rows: typeof messages.$inferSelect[]): ChatMessage[] {
326
+ const db = getDb();
327
+ const messageIds = rows.map((r) => r.id);
328
+
329
+ const attachmentRows = messageIds.length > 0
330
+ ? db.select().from(messageAttachments).where(inArray(messageAttachments.messageId, messageIds)).all()
331
+ : [];
332
+ const reactionRows = messageIds.length > 0
333
+ ? db.select().from(messageReactions).where(inArray(messageReactions.messageId, messageIds)).all()
334
+ : [];
335
+
336
+ const attachmentsByMsg = new Map<string, MessageAttachment[]>();
337
+ for (const a of attachmentRows) {
338
+ const list = attachmentsByMsg.get(a.messageId) || [];
339
+ list.push({
340
+ id: a.id,
341
+ messageId: a.messageId,
342
+ attachmentType: a.attachmentType as "image" | "audio",
343
+ filePath: a.filePath,
344
+ mimeType: a.mimeType,
345
+ fileSize: a.fileSize,
346
+ originalName: a.originalName,
347
+ createdAt: a.createdAt,
348
+ });
349
+ attachmentsByMsg.set(a.messageId, list);
350
+ }
351
+
352
+ const reactionsByMsg = new Map<string, MessageReaction[]>();
353
+ for (const r of reactionRows) {
354
+ const list = reactionsByMsg.get(r.messageId) || [];
355
+ list.push({
356
+ id: r.id,
357
+ messageId: r.messageId,
358
+ agentId: r.agentId,
359
+ emoji: r.emoji,
360
+ emojiChar: r.emojiChar,
361
+ createdAt: r.createdAt,
362
+ });
363
+ reactionsByMsg.set(r.messageId, list);
364
+ }
365
+
366
+ return rows.map((row) => ({
367
+ id: row.id,
368
+ channelId: row.channelId,
369
+ sessionId: row.sessionId,
370
+ senderType: row.senderType as "user" | "agent",
371
+ senderId: row.senderId,
372
+ senderName: row.senderName,
373
+ content: row.content,
374
+ status: row.status as "complete" | "interrupted" | "aborted",
375
+ mentionedAgentId: row.mentionedAgentId,
376
+ runId: row.runId,
377
+ sessionKey: row.sessionKey,
378
+ inputTokens: row.inputTokens,
379
+ outputTokens: row.outputTokens,
380
+ createdAt: row.createdAt,
381
+ attachments: attachmentsByMsg.get(row.id) || [],
382
+ reactions: reactionsByMsg.get(row.id) || [],
383
+ }));
384
+ }
385
+
386
+ /**
387
+ * Get messages for a channel with cursor-based pagination.
388
+ * @param channelId - Channel to load messages for
389
+ * @param limit - Max messages to return (default 50)
390
+ * @param before - Message ID cursor — returns messages older than this
391
+ */
392
+ export function getMessagesByChannel(
393
+ channelId: string,
394
+ limit = 50,
395
+ before?: string
396
+ ): ChatMessage[] {
397
+ const db = getDb();
398
+
399
+ let query;
400
+ if (before) {
401
+ const cursor = db
402
+ .select({ createdAt: messages.createdAt, id: messages.id })
403
+ .from(messages)
404
+ .where(eq(messages.id, before))
405
+ .get();
406
+
407
+ if (!cursor) return [];
408
+
409
+ // Composite cursor (createdAt, id) to avoid skipping messages with identical timestamps
410
+ query = db
411
+ .select()
412
+ .from(messages)
413
+ .where(
414
+ and(
415
+ eq(messages.channelId, channelId),
416
+ sql`(${messages.createdAt}, ${messages.id}) < (${cursor.createdAt}, ${cursor.id})`
417
+ )
418
+ )
419
+ .orderBy(desc(messages.createdAt))
420
+ .limit(limit)
421
+ .all();
422
+ } else {
423
+ query = db
424
+ .select()
425
+ .from(messages)
426
+ .where(eq(messages.channelId, channelId))
427
+ .orderBy(desc(messages.createdAt))
428
+ .limit(limit)
429
+ .all();
430
+ }
431
+
432
+ // Reverse to chronological order
433
+ return hydrateMessages(query.reverse());
434
+ }
435
+
436
+ /**
437
+ * Get messages for a channel AFTER a cursor (forward pagination).
438
+ * Returns messages newer than the cursor, in chronological order.
439
+ */
440
+ export function getMessagesAfter(
441
+ channelId: string,
442
+ afterId: string,
443
+ limit = 50
444
+ ): ChatMessage[] {
445
+ const db = getDb();
446
+
447
+ const cursor = db
448
+ .select({ createdAt: messages.createdAt, id: messages.id })
449
+ .from(messages)
450
+ .where(eq(messages.id, afterId))
451
+ .get();
452
+
453
+ if (!cursor) return [];
454
+
455
+ // Composite cursor (createdAt, id) to avoid skipping messages with identical timestamps
456
+ const rows = db
457
+ .select()
458
+ .from(messages)
459
+ .where(
460
+ and(
461
+ eq(messages.channelId, channelId),
462
+ sql`(${messages.createdAt}, ${messages.id}) > (${cursor.createdAt}, ${cursor.id})`
463
+ )
464
+ )
465
+ .orderBy(asc(messages.createdAt))
466
+ .limit(limit)
467
+ .all();
468
+
469
+ return hydrateMessages(rows);
470
+ }
471
+
472
+ /**
473
+ * Get a window of messages around an anchor message.
474
+ * Returns ~limit messages centered on the anchor, plus hasMoreBefore/hasMoreAfter flags.
475
+ */
476
+ export function getMessagesAround(
477
+ channelId: string,
478
+ anchorMessageId: string,
479
+ limit = 50
480
+ ): { messages: ChatMessage[]; hasMoreBefore: boolean; hasMoreAfter: boolean } | null {
481
+ const db = getDb();
482
+
483
+ // Look up the anchor message
484
+ const anchor = db
485
+ .select()
486
+ .from(messages)
487
+ .where(and(eq(messages.id, anchorMessageId), eq(messages.channelId, channelId)))
488
+ .get();
489
+
490
+ if (!anchor) return null;
491
+
492
+ const half = Math.floor(limit / 2);
493
+
494
+ // Messages before the anchor (composite cursor, DESC then reverse)
495
+ const beforeRows = db
496
+ .select()
497
+ .from(messages)
498
+ .where(
499
+ and(
500
+ eq(messages.channelId, channelId),
501
+ sql`(${messages.createdAt}, ${messages.id}) < (${anchor.createdAt}, ${anchor.id})`
502
+ )
503
+ )
504
+ .orderBy(desc(messages.createdAt))
505
+ .limit(half)
506
+ .all()
507
+ .reverse();
508
+
509
+ // Messages after the anchor (composite cursor, ASC)
510
+ const afterRows = db
511
+ .select()
512
+ .from(messages)
513
+ .where(
514
+ and(
515
+ eq(messages.channelId, channelId),
516
+ sql`(${messages.createdAt}, ${messages.id}) > (${anchor.createdAt}, ${anchor.id})`
517
+ )
518
+ )
519
+ .orderBy(asc(messages.createdAt))
520
+ .limit(half)
521
+ .all();
522
+
523
+ // Combine: before + anchor + after (chronological order)
524
+ const allRows = [...beforeRows, anchor, ...afterRows];
525
+
526
+ return {
527
+ messages: hydrateMessages(allRows),
528
+ hasMoreBefore: beforeRows.length === half,
529
+ hasMoreAfter: afterRows.length === half,
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Find an agent message by runId (for updating partial saves).
535
+ * Only matches agent messages — user messages also carry the runId as a reference
536
+ * to the run they triggered, but should never be overwritten with agent content.
537
+ */
538
+ export function getMessageByRunId(runId: string) {
539
+ const db = getDb();
540
+ return db
541
+ .select()
542
+ .from(messages)
543
+ .where(and(eq(messages.runId, runId), eq(messages.senderType, "agent")))
544
+ .get() ?? null;
545
+ }
546
+
547
+ // ============================================================================
548
+ // Sessions
549
+ // ============================================================================
550
+
551
+ export function createSession(params: {
552
+ channelId: string;
553
+ sessionKey?: string;
554
+ }): ChannelSession {
555
+ const db = getDb();
556
+ const id = uuid();
557
+ const now = Date.now();
558
+
559
+ db.insert(sessions)
560
+ .values({
561
+ id,
562
+ channelId: params.channelId,
563
+ sessionKey: params.sessionKey ?? null,
564
+ startedAt: now,
565
+ })
566
+ .run();
567
+
568
+ return {
569
+ id,
570
+ channelId: params.channelId,
571
+ sessionKey: params.sessionKey ?? null,
572
+ startedAt: now,
573
+ endedAt: null,
574
+ summary: null,
575
+ totalInputTokens: 0,
576
+ totalOutputTokens: 0,
577
+ };
578
+ }
579
+
580
+ export function updateSession(
581
+ id: string,
582
+ updates: {
583
+ sessionKey?: string;
584
+ endedAt?: number;
585
+ summary?: string;
586
+ totalInputTokens?: number;
587
+ totalOutputTokens?: number;
588
+ }
589
+ ): boolean {
590
+ const db = getDb();
591
+ const result = db
592
+ .update(sessions)
593
+ .set(updates)
594
+ .where(eq(sessions.id, id))
595
+ .run();
596
+ return result.changes > 0;
597
+ }
598
+
599
+ export function getSessionsByChannel(channelId: string): ChannelSession[] {
600
+ const db = getDb();
601
+ return db
602
+ .select()
603
+ .from(sessions)
604
+ .where(eq(sessions.channelId, channelId))
605
+ .orderBy(desc(sessions.startedAt))
606
+ .all()
607
+ .map((row) => ({
608
+ id: row.id,
609
+ channelId: row.channelId,
610
+ sessionKey: row.sessionKey,
611
+ startedAt: row.startedAt,
612
+ endedAt: row.endedAt,
613
+ summary: row.summary,
614
+ totalInputTokens: row.totalInputTokens ?? 0,
615
+ totalOutputTokens: row.totalOutputTokens ?? 0,
616
+ }));
617
+ }
618
+
619
+ export function getLatestSessionKey(channelId: string): string | null {
620
+ const db = getDb();
621
+ const row = db
622
+ .select({ sessionKey: sessions.sessionKey })
623
+ .from(sessions)
624
+ .where(eq(sessions.channelId, channelId))
625
+ .orderBy(desc(sessions.startedAt))
626
+ .limit(1)
627
+ .get();
628
+ return row?.sessionKey ?? null;
629
+ }
630
+
631
+ // ============================================================================
632
+ // Attachments
633
+ // ============================================================================
634
+
635
+ export function createAttachment(params: {
636
+ messageId: string;
637
+ attachmentType: "image" | "audio";
638
+ filePath: string;
639
+ mimeType?: string;
640
+ fileSize?: number;
641
+ originalName?: string;
642
+ }): MessageAttachment {
643
+ const db = getDb();
644
+ const id = uuid();
645
+ const now = Date.now();
646
+
647
+ db.insert(messageAttachments)
648
+ .values({
649
+ id,
650
+ messageId: params.messageId,
651
+ attachmentType: params.attachmentType,
652
+ filePath: params.filePath,
653
+ mimeType: params.mimeType ?? null,
654
+ fileSize: params.fileSize ?? null,
655
+ originalName: params.originalName ?? null,
656
+ createdAt: now,
657
+ })
658
+ .run();
659
+
660
+ return {
661
+ id,
662
+ messageId: params.messageId,
663
+ attachmentType: params.attachmentType,
664
+ filePath: params.filePath,
665
+ mimeType: params.mimeType ?? null,
666
+ fileSize: params.fileSize ?? null,
667
+ originalName: params.originalName ?? null,
668
+ createdAt: now,
669
+ };
670
+ }
671
+
672
+ export function getAttachmentsByMessage(messageId: string): MessageAttachment[] {
673
+ const db = getDb();
674
+ return db
675
+ .select()
676
+ .from(messageAttachments)
677
+ .where(eq(messageAttachments.messageId, messageId))
678
+ .all()
679
+ .map((row) => ({
680
+ id: row.id,
681
+ messageId: row.messageId,
682
+ attachmentType: row.attachmentType as "image" | "audio",
683
+ filePath: row.filePath,
684
+ mimeType: row.mimeType,
685
+ fileSize: row.fileSize,
686
+ originalName: row.originalName,
687
+ createdAt: row.createdAt,
688
+ }));
689
+ }
690
+
691
+ // ============================================================================
692
+ // Reactions
693
+ // ============================================================================
694
+
695
+ export function createReaction(params: {
696
+ messageId: string;
697
+ agentId?: string;
698
+ emoji: string;
699
+ emojiChar: string;
700
+ }): MessageReaction {
701
+ const db = getDb();
702
+ const id = uuid();
703
+ const now = Date.now();
704
+
705
+ db.insert(messageReactions)
706
+ .values({
707
+ id,
708
+ messageId: params.messageId,
709
+ agentId: params.agentId ?? null,
710
+ emoji: params.emoji,
711
+ emojiChar: params.emojiChar,
712
+ createdAt: now,
713
+ })
714
+ .run();
715
+
716
+ return {
717
+ id,
718
+ messageId: params.messageId,
719
+ agentId: params.agentId ?? null,
720
+ emoji: params.emoji,
721
+ emojiChar: params.emojiChar,
722
+ createdAt: now,
723
+ };
724
+ }
725
+
726
+ export function getReactionsByMessage(messageId: string): MessageReaction[] {
727
+ const db = getDb();
728
+ return db
729
+ .select()
730
+ .from(messageReactions)
731
+ .where(eq(messageReactions.messageId, messageId))
732
+ .all()
733
+ .map((row) => ({
734
+ id: row.id,
735
+ messageId: row.messageId,
736
+ agentId: row.agentId,
737
+ emoji: row.emoji,
738
+ emojiChar: row.emojiChar,
739
+ createdAt: row.createdAt,
740
+ }));
741
+ }
742
+
743
+ // ============================================================================
744
+ // Search (FTS5)
745
+ // ============================================================================
746
+
747
+ /**
748
+ * Full-text search across messages using FTS5.
749
+ * Uses parameterized MATCH queries for safety.
750
+ */
751
+ export function searchMessages(
752
+ query: string,
753
+ channelId?: string,
754
+ limit = 30
755
+ ): ChatMessage[] {
756
+ const db = getDb();
757
+
758
+ // Safety: reject overly long queries
759
+ if (!query || query.length > 500) return [];
760
+
761
+ // Sanitize FTS5 query: wrap each word in double quotes to prevent FTS5 operator injection.
762
+ // FTS5 supports operators like AND, OR, NOT, NEAR, and column filters — quoting each
763
+ // term treats them as literal strings instead of operators.
764
+ const sanitizedQuery = query
765
+ .replace(/"/g, "") // Remove existing quotes to prevent breaking out
766
+ .split(/\s+/)
767
+ .filter(Boolean)
768
+ .map((term) => `"${term}"`)
769
+ .join(" ");
770
+
771
+ if (!sanitizedQuery) return [];
772
+
773
+ // Use raw SQL for FTS5 MATCH — Drizzle doesn't support virtual tables
774
+ const dbInstance = db as unknown as { all: (query: unknown) => unknown[] };
775
+
776
+ // Access the underlying better-sqlite3 instance
777
+ type DrizzleInternals = { session: { client: { prepare: (sql: string) => { all: (...params: unknown[]) => Record<string, unknown>[] } } } };
778
+ const sqlite = (db as unknown as DrizzleInternals).session.client;
779
+
780
+ let rows: Record<string, unknown>[];
781
+
782
+ if (channelId) {
783
+ const stmt = sqlite.prepare(`
784
+ SELECT m.*
785
+ FROM messages m
786
+ JOIN messages_fts fts ON m.rowid = fts.rowid
787
+ WHERE messages_fts MATCH ?
788
+ AND m.channel_id = ?
789
+ ORDER BY m.created_at DESC
790
+ LIMIT ?
791
+ `);
792
+ rows = stmt.all(sanitizedQuery, channelId, limit);
793
+ } else {
794
+ const stmt = sqlite.prepare(`
795
+ SELECT m.*
796
+ FROM messages m
797
+ JOIN messages_fts fts ON m.rowid = fts.rowid
798
+ WHERE messages_fts MATCH ?
799
+ ORDER BY m.created_at DESC
800
+ LIMIT ?
801
+ `);
802
+ rows = stmt.all(sanitizedQuery, limit);
803
+ }
804
+
805
+ return rows.map((row) => ({
806
+ id: row.id as string,
807
+ channelId: row.channel_id as string,
808
+ sessionId: (row.session_id as string) ?? null,
809
+ senderType: row.sender_type as "user" | "agent",
810
+ senderId: row.sender_id as string,
811
+ senderName: (row.sender_name as string) ?? null,
812
+ content: row.content as string,
813
+ status: (row.status as "complete" | "interrupted" | "aborted") ?? "complete",
814
+ mentionedAgentId: (row.mentioned_agent_id as string) ?? null,
815
+ runId: (row.run_id as string) ?? null,
816
+ sessionKey: (row.session_key as string) ?? null,
817
+ inputTokens: (row.input_tokens as number) ?? null,
818
+ outputTokens: (row.output_tokens as number) ?? null,
819
+ createdAt: row.created_at as number,
820
+ attachments: [],
821
+ reactions: [],
822
+ }));
823
+ }
824
+
825
+ // ============================================================================
826
+ // Storage Stats
827
+ // ============================================================================
828
+
829
+ export function getStorageStats() {
830
+ const db = getDb();
831
+
832
+ const messageCount = db
833
+ .select({ count: sql<number>`count(*)` })
834
+ .from(messages)
835
+ .get();
836
+
837
+ const channelCount = db
838
+ .select({ count: sql<number>`count(*)` })
839
+ .from(channels)
840
+ .get();
841
+
842
+ const attachmentCount = db
843
+ .select({ count: sql<number>`count(*)` })
844
+ .from(messageAttachments)
845
+ .get();
846
+
847
+ // Total attachment size from DB metadata
848
+ const totalAttachmentSize = db
849
+ .select({ total: sql<number>`COALESCE(SUM(file_size), 0)` })
850
+ .from(messageAttachments)
851
+ .get();
852
+
853
+ return {
854
+ messages: messageCount?.count ?? 0,
855
+ channels: channelCount?.count ?? 0,
856
+ attachments: attachmentCount?.count ?? 0,
857
+ totalAttachmentBytes: totalAttachmentSize?.total ?? 0,
858
+ };
859
+ }
860
+
861
+ // ============================================================================
862
+ // Settings
863
+ // ============================================================================
864
+
865
+ /**
866
+ * Get a setting value by key. Returns null if not set.
867
+ */
868
+ export function getSetting(key: string): string | null {
869
+ const db = getDb();
870
+ const row = db
871
+ .select({ value: settings.value })
872
+ .from(settings)
873
+ .where(eq(settings.key, key))
874
+ .get();
875
+ return row?.value ?? null;
876
+ }
877
+
878
+ /**
879
+ * Get all settings as a key-value record.
880
+ */
881
+ export function getAllSettings(): Record<string, string> {
882
+ const db = getDb();
883
+ const rows = db.select().from(settings).all();
884
+ const result: Record<string, string> = {};
885
+ for (const row of rows) {
886
+ result[row.key] = row.value;
887
+ }
888
+ return result;
889
+ }
890
+
891
+ /**
892
+ * Set a setting value (upsert).
893
+ */
894
+ export function setSetting(key: string, value: string): void {
895
+ const db = getDb();
896
+ const existing = db
897
+ .select({ key: settings.key })
898
+ .from(settings)
899
+ .where(eq(settings.key, key))
900
+ .get();
901
+
902
+ if (existing) {
903
+ db.update(settings)
904
+ .set({ value, updatedAt: Date.now() })
905
+ .where(eq(settings.key, key))
906
+ .run();
907
+ } else {
908
+ db.insert(settings)
909
+ .values({ key, value, updatedAt: Date.now() })
910
+ .run();
911
+ }
912
+ }
913
+
914
+ // ============================================================================
915
+ // Agent Statuses
916
+ // ============================================================================
917
+
918
+ const ACTIVE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
919
+
920
+ export type AgentStatusValue = "idle" | "thinking" | "active";
921
+
922
+ export interface AgentStatusRow {
923
+ agentId: string;
924
+ status: AgentStatusValue;
925
+ updatedAt: number;
926
+ }
927
+
928
+ /**
929
+ * Get all agent statuses. If an agent's status is "active" but updated_at
930
+ * is more than 2 minutes ago, it's returned as "idle" instead.
931
+ */
932
+ export function getAgentStatuses(): AgentStatusRow[] {
933
+ const db = getDb();
934
+ const rows = db.select().from(agentStatuses).all();
935
+ const now = Date.now();
936
+
937
+ return rows.map((row) => {
938
+ const status = row.status as AgentStatusValue;
939
+ // Auto-expire "active" status after 2 minutes
940
+ if (status === "active" && now - row.updatedAt > ACTIVE_DURATION_MS) {
941
+ return { agentId: row.agentId, status: "idle" as AgentStatusValue, updatedAt: row.updatedAt };
942
+ }
943
+ return { agentId: row.agentId, status, updatedAt: row.updatedAt };
944
+ });
945
+ }
946
+
947
+ /**
948
+ * Set an agent's status. Upserts the row.
949
+ */
950
+ export function setAgentStatus(agentId: string, status: AgentStatusValue): void {
951
+ const db = getDb();
952
+ const now = Date.now();
953
+
954
+ const existing = db.select().from(agentStatuses).where(eq(agentStatuses.agentId, agentId)).get();
955
+ if (existing) {
956
+ db.update(agentStatuses)
957
+ .set({ status, updatedAt: now })
958
+ .where(eq(agentStatuses.agentId, agentId))
959
+ .run();
960
+ } else {
961
+ db.insert(agentStatuses)
962
+ .values({ agentId, status, updatedAt: now })
963
+ .run();
964
+ }
965
+ }
966
+
967
+ // ============================================================================
968
+ // Recent Searches
969
+ // ============================================================================
970
+
971
+ const MAX_RECENT_SEARCHES = 15;
972
+
973
+ export function getRecentSearches(): string[] {
974
+ const db = getDb();
975
+ const rows = db
976
+ .select({ query: recentSearches.query })
977
+ .from(recentSearches)
978
+ .orderBy(desc(recentSearches.createdAt))
979
+ .limit(MAX_RECENT_SEARCHES)
980
+ .all();
981
+ return rows.map((r) => r.query);
982
+ }
983
+
984
+ export function addRecentSearch(query: string): void {
985
+ const db = getDb();
986
+ const trimmed = query.trim();
987
+ if (!trimmed) return;
988
+
989
+ // Remove duplicate if it already exists
990
+ db.delete(recentSearches)
991
+ .where(eq(recentSearches.query, trimmed))
992
+ .run();
993
+
994
+ // Insert as most recent
995
+ db.insert(recentSearches)
996
+ .values({ query: trimmed, createdAt: Date.now() })
997
+ .run();
998
+
999
+ // Prune old entries beyond the limit
1000
+ const all = db
1001
+ .select({ id: recentSearches.id })
1002
+ .from(recentSearches)
1003
+ .orderBy(desc(recentSearches.createdAt))
1004
+ .all();
1005
+
1006
+ if (all.length > MAX_RECENT_SEARCHES) {
1007
+ const toDelete = all.slice(MAX_RECENT_SEARCHES).map((r) => r.id);
1008
+ db.delete(recentSearches)
1009
+ .where(inArray(recentSearches.id, toDelete))
1010
+ .run();
1011
+ }
1012
+ }
1013
+
1014
+ export function clearRecentSearches(): void {
1015
+ const db = getDb();
1016
+ db.delete(recentSearches).run();
1017
+ }