@agentuity/opencode 1.0.11 → 1.0.13

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 (48) hide show
  1. package/dist/agents/lead.d.ts +1 -1
  2. package/dist/agents/lead.d.ts.map +1 -1
  3. package/dist/agents/lead.js +9 -0
  4. package/dist/agents/lead.js.map +1 -1
  5. package/dist/agents/monitor.d.ts +1 -1
  6. package/dist/agents/monitor.d.ts.map +1 -1
  7. package/dist/agents/monitor.js +13 -0
  8. package/dist/agents/monitor.js.map +1 -1
  9. package/dist/background/manager.d.ts +4 -1
  10. package/dist/background/manager.d.ts.map +1 -1
  11. package/dist/background/manager.js +161 -3
  12. package/dist/background/manager.js.map +1 -1
  13. package/dist/background/types.d.ts +21 -0
  14. package/dist/background/types.d.ts.map +1 -1
  15. package/dist/plugin/hooks/cadence.d.ts +2 -1
  16. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  17. package/dist/plugin/hooks/cadence.js +57 -1
  18. package/dist/plugin/hooks/cadence.js.map +1 -1
  19. package/dist/plugin/plugin.d.ts.map +1 -1
  20. package/dist/plugin/plugin.js +196 -7
  21. package/dist/plugin/plugin.js.map +1 -1
  22. package/dist/sqlite/index.d.ts +3 -0
  23. package/dist/sqlite/index.d.ts.map +1 -0
  24. package/dist/sqlite/index.js +2 -0
  25. package/dist/sqlite/index.js.map +1 -0
  26. package/dist/sqlite/queries.d.ts +18 -0
  27. package/dist/sqlite/queries.d.ts.map +1 -0
  28. package/dist/sqlite/queries.js +41 -0
  29. package/dist/sqlite/queries.js.map +1 -0
  30. package/dist/sqlite/reader.d.ts +44 -0
  31. package/dist/sqlite/reader.d.ts.map +1 -0
  32. package/dist/sqlite/reader.js +526 -0
  33. package/dist/sqlite/reader.js.map +1 -0
  34. package/dist/sqlite/types.d.ts +110 -0
  35. package/dist/sqlite/types.d.ts.map +1 -0
  36. package/dist/sqlite/types.js +2 -0
  37. package/dist/sqlite/types.js.map +1 -0
  38. package/package.json +3 -3
  39. package/src/agents/lead.ts +9 -0
  40. package/src/agents/monitor.ts +13 -0
  41. package/src/background/manager.ts +174 -3
  42. package/src/background/types.ts +10 -0
  43. package/src/plugin/hooks/cadence.ts +72 -1
  44. package/src/plugin/plugin.ts +271 -23
  45. package/src/sqlite/index.ts +16 -0
  46. package/src/sqlite/queries.ts +50 -0
  47. package/src/sqlite/reader.ts +677 -0
  48. package/src/sqlite/types.ts +121 -0
@@ -0,0 +1,677 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { QUERIES } from './queries';
6
+ import type {
7
+ DBMessage,
8
+ DBSession,
9
+ DBTextPart,
10
+ DBTodo,
11
+ DBToolCall,
12
+ MessageTokens,
13
+ OpenCodeDBConfig,
14
+ SessionCostSummary,
15
+ SessionStatus,
16
+ SessionSummary,
17
+ SessionTreeNode,
18
+ TodoSummary,
19
+ } from './types';
20
+
21
+ type Statement = ReturnType<Database['prepare']>;
22
+
23
+ type SessionRow = {
24
+ id: string;
25
+ project_id: string;
26
+ parent_id: string | null;
27
+ slug: string;
28
+ directory: string;
29
+ title: string;
30
+ version: string;
31
+ share_url: string | null;
32
+ summary_additions: number | null;
33
+ summary_deletions: number | null;
34
+ summary_files: number | null;
35
+ summary_diffs: string | null;
36
+ time_created: number;
37
+ time_updated: number;
38
+ time_compacting: number | null;
39
+ time_archived: number | null;
40
+ };
41
+
42
+ type MessageRow = {
43
+ id: string;
44
+ session_id: string;
45
+ time_created: number;
46
+ time_updated: number;
47
+ data: string;
48
+ };
49
+
50
+ type PartRow = {
51
+ id: string;
52
+ message_id: string;
53
+ session_id: string;
54
+ time_created: number;
55
+ time_updated: number;
56
+ data: string;
57
+ };
58
+
59
+ type TodoRow = {
60
+ session_id: string;
61
+ content: string;
62
+ status: string;
63
+ priority: string;
64
+ position: number;
65
+ };
66
+
67
+ type ToolState = {
68
+ status?: string;
69
+ input?: unknown;
70
+ output?: unknown;
71
+ timeStarted?: number;
72
+ timeEnded?: number;
73
+ time_started?: number;
74
+ time_ended?: number;
75
+ };
76
+
77
+ type PartData = {
78
+ type?: string;
79
+ text?: string;
80
+ tool?: string;
81
+ callID?: string;
82
+ callId?: string;
83
+ state?: ToolState;
84
+ };
85
+
86
+ const REQUIRED_TABLES = new Set(['session', 'message', 'part', 'todo']);
87
+ const DEFAULT_LIMIT = 100;
88
+ const DEFAULT_TOOL_LIMIT = 50;
89
+
90
+ function safeParseJSON<T>(value: string | null | undefined): T | null {
91
+ if (!value) return null;
92
+ try {
93
+ return JSON.parse(value) as T;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function isMemoryPath(path: string): boolean {
100
+ return path === ':memory:' || path.includes('mode=memory');
101
+ }
102
+
103
+ function resolveDBPath(config?: OpenCodeDBConfig): string | null {
104
+ if (config?.dbPath) {
105
+ return config.dbPath;
106
+ }
107
+
108
+ const home = homedir();
109
+ const candidates: string[] = [];
110
+ const currentPlatform = platform();
111
+
112
+ if (currentPlatform === 'darwin') {
113
+ candidates.push(join(home, 'Library', 'Application Support', 'opencode', 'opencode.db'));
114
+ }
115
+
116
+ if (currentPlatform === 'win32') {
117
+ const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
118
+ const localAppData = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local');
119
+ candidates.push(join(appData, 'opencode', 'opencode.db'));
120
+ candidates.push(join(localAppData, 'opencode', 'opencode.db'));
121
+ }
122
+
123
+ // Linux default
124
+ candidates.push(join(home, '.local', 'share', 'opencode', 'opencode.db'));
125
+
126
+ for (const candidate of candidates) {
127
+ if (existsSync(candidate)) {
128
+ return candidate;
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ function buildSessionSummary(row: SessionRow): SessionSummary | undefined {
136
+ const diffs = safeParseJSON<unknown>(row.summary_diffs);
137
+ const hasSummary =
138
+ row.summary_additions !== null ||
139
+ row.summary_deletions !== null ||
140
+ row.summary_files !== null ||
141
+ diffs !== null;
142
+
143
+ if (!hasSummary) {
144
+ return undefined;
145
+ }
146
+
147
+ return {
148
+ additions: row.summary_additions ?? undefined,
149
+ deletions: row.summary_deletions ?? undefined,
150
+ files: row.summary_files ?? undefined,
151
+ diffs: diffs ?? undefined,
152
+ };
153
+ }
154
+
155
+ function mapSession(row: SessionRow): DBSession {
156
+ return {
157
+ id: row.id,
158
+ projectId: row.project_id,
159
+ parentId: row.parent_id ?? undefined,
160
+ slug: row.slug,
161
+ directory: row.directory,
162
+ title: row.title,
163
+ version: row.version,
164
+ shareUrl: row.share_url ?? undefined,
165
+ summary: buildSessionSummary(row),
166
+ timeCreated: row.time_created,
167
+ timeUpdated: row.time_updated,
168
+ timeCompacting: row.time_compacting ?? undefined,
169
+ timeArchived: row.time_archived ?? undefined,
170
+ };
171
+ }
172
+
173
+ function mapMessage(row: MessageRow): DBMessage {
174
+ const payload = safeParseJSON<Record<string, unknown>>(row.data) ?? {};
175
+ const tokens = payload.tokens as MessageTokens | undefined;
176
+
177
+ return {
178
+ id: row.id,
179
+ sessionId: row.session_id,
180
+ role: typeof payload.role === 'string' ? payload.role : 'unknown',
181
+ agent: typeof payload.agent === 'string' ? payload.agent : undefined,
182
+ model: typeof payload.model === 'string' ? payload.model : undefined,
183
+ cost: typeof payload.cost === 'number' ? payload.cost : undefined,
184
+ tokens: tokens,
185
+ error: typeof payload.error === 'string' ? payload.error : undefined,
186
+ timeCreated: row.time_created,
187
+ timeUpdated: row.time_updated,
188
+ };
189
+ }
190
+
191
+ function mapToolCall(row: PartRow): DBToolCall | null {
192
+ const payload = safeParseJSON<PartData>(row.data);
193
+ if (!payload || (payload.type !== 'tool' && payload.type !== 'tool-invocation')) {
194
+ return null;
195
+ }
196
+
197
+ const state = payload.state ?? {};
198
+ const callId = payload.callID ?? payload.callId ?? '';
199
+
200
+ return {
201
+ id: row.id,
202
+ messageId: row.message_id,
203
+ sessionId: row.session_id,
204
+ callId,
205
+ tool: payload.tool ?? 'unknown',
206
+ status: state.status ?? 'unknown',
207
+ input: state.input,
208
+ output: state.output,
209
+ timeStarted: state.timeStarted ?? state.time_started,
210
+ timeEnded: state.timeEnded ?? state.time_ended,
211
+ };
212
+ }
213
+
214
+ function mapTextPart(row: PartRow): DBTextPart | null {
215
+ const payload = safeParseJSON<PartData>(row.data);
216
+ if (!payload || payload.type !== 'text' || typeof payload.text !== 'string') {
217
+ return null;
218
+ }
219
+
220
+ return {
221
+ id: row.id,
222
+ messageId: row.message_id,
223
+ sessionId: row.session_id,
224
+ text: payload.text,
225
+ timeCreated: row.time_created,
226
+ };
227
+ }
228
+
229
+ function isNotNull<T>(value: T | null): value is T {
230
+ return value !== null;
231
+ }
232
+
233
+ function summarizeTodos(todos: DBTodo[]): TodoSummary {
234
+ const total = todos.length;
235
+ const completed = todos.filter((todo) => todo.status === 'completed').length;
236
+ const pending = total - completed;
237
+
238
+ return { total, pending, completed };
239
+ }
240
+
241
+ function sumTreeCost(node: SessionTreeNode): number {
242
+ return (
243
+ (node.costSummary?.totalCost ?? 0) +
244
+ node.children.reduce((sum, child) => sum + sumTreeCost(child), 0)
245
+ );
246
+ }
247
+
248
+ function createEmptySession(sessionId: string): DBSession {
249
+ return {
250
+ id: sessionId,
251
+ projectId: 'unknown',
252
+ parentId: undefined,
253
+ slug: 'unknown',
254
+ directory: '',
255
+ title: 'Unknown Session',
256
+ version: 'unknown',
257
+ timeCreated: 0,
258
+ timeUpdated: 0,
259
+ };
260
+ }
261
+
262
+ export class OpenCodeDBReader {
263
+ private db: Database | null = null;
264
+ private available = false;
265
+ private readonly config: OpenCodeDBConfig;
266
+ private dbPath: string | null = null;
267
+ private statements = new Map<keyof typeof QUERIES, Statement>();
268
+
269
+ constructor(config?: OpenCodeDBConfig) {
270
+ this.config = {
271
+ enableSchemaValidation: true,
272
+ ...config,
273
+ };
274
+ }
275
+
276
+ isAvailable(): boolean {
277
+ if (this.available && this.db) {
278
+ return true;
279
+ }
280
+
281
+ const resolved = resolveDBPath(this.config);
282
+ if (!resolved) return false;
283
+
284
+ if (isMemoryPath(resolved)) {
285
+ return true;
286
+ }
287
+
288
+ return existsSync(resolved);
289
+ }
290
+
291
+ open(): boolean {
292
+ if (this.db) {
293
+ return this.available;
294
+ }
295
+
296
+ this.dbPath = resolveDBPath(this.config);
297
+ if (!this.dbPath) {
298
+ this.available = false;
299
+ return false;
300
+ }
301
+
302
+ const isMemory = isMemoryPath(this.dbPath);
303
+
304
+ if (this.config.dbPath && !isMemory && !existsSync(this.dbPath)) {
305
+ this.available = false;
306
+ return false;
307
+ }
308
+
309
+ try {
310
+ if (isMemory) {
311
+ // In-memory shared DBs (used in tests): open in readwrite mode
312
+ // so pragmas can be set and the shared cache is accessible.
313
+ this.db = new Database(this.dbPath);
314
+ this.db.run('PRAGMA journal_mode = WAL');
315
+ } else {
316
+ // Real file-based DBs: open read-only for safety.
317
+ // WAL is already configured by OpenCode; readers inherit it.
318
+ this.db = new Database(this.dbPath, { readonly: true });
319
+ }
320
+ // busy_timeout is safe on both readonly and readwrite connections.
321
+ this.db.run('PRAGMA busy_timeout = 3000');
322
+ } catch (error) {
323
+ console.warn('[OpenCodeDBReader] Failed to open database', error);
324
+ this.available = false;
325
+ this.db = null;
326
+ return false;
327
+ }
328
+
329
+ if (this.config.enableSchemaValidation && !this.validateSchema()) {
330
+ console.warn('[OpenCodeDBReader] Required tables missing in database');
331
+ this.close();
332
+ this.available = false;
333
+ return false;
334
+ }
335
+
336
+ this.available = true;
337
+ return true;
338
+ }
339
+
340
+ close(): void {
341
+ if (this.db) {
342
+ this.db.close();
343
+ }
344
+ this.db = null;
345
+ this.available = false;
346
+ this.statements.clear();
347
+ }
348
+
349
+ getSession(id: string): DBSession | null {
350
+ if (!this.ensureOpen()) return null;
351
+
352
+ try {
353
+ const statement = this.getStatement('GET_SESSION');
354
+ const row = statement?.get(id) as SessionRow | null;
355
+ return row ? mapSession(row) : null;
356
+ } catch (error) {
357
+ console.warn('[OpenCodeDBReader] Failed to get session', error);
358
+ return null;
359
+ }
360
+ }
361
+
362
+ getChildSessions(parentId: string): DBSession[] {
363
+ if (!this.ensureOpen()) return [];
364
+
365
+ try {
366
+ const statement = this.getStatement('GET_CHILD_SESSIONS');
367
+ const rows = statement?.all(parentId) as SessionRow[] | null;
368
+ return rows ? rows.map(mapSession) : [];
369
+ } catch (error) {
370
+ console.warn('[OpenCodeDBReader] Failed to get child sessions', error);
371
+ return [];
372
+ }
373
+ }
374
+
375
+ getSessionsByProject(projectId: string): DBSession[] {
376
+ if (!this.ensureOpen()) return [];
377
+
378
+ try {
379
+ const statement = this.getStatement('GET_SESSIONS_BY_PROJECT');
380
+ const rows = statement?.all(projectId) as SessionRow[] | null;
381
+ return rows ? rows.map(mapSession) : [];
382
+ } catch (error) {
383
+ console.warn('[OpenCodeDBReader] Failed to get project sessions', error);
384
+ return [];
385
+ }
386
+ }
387
+
388
+ getSessionTree(rootId: string): SessionTreeNode {
389
+ const visited = new Set<string>();
390
+ return this.buildSessionTree(rootId, visited);
391
+ }
392
+
393
+ getMessages(sessionId: string, opts?: { limit?: number; offset?: number }): DBMessage[] {
394
+ if (!this.ensureOpen()) return [];
395
+
396
+ const limit = opts?.limit ?? DEFAULT_LIMIT;
397
+ const offset = opts?.offset ?? 0;
398
+
399
+ try {
400
+ const statement = this.getStatement('GET_MESSAGES');
401
+ const rows = statement?.all(sessionId, limit, offset) as MessageRow[] | null;
402
+ return rows ? rows.map(mapMessage) : [];
403
+ } catch (error) {
404
+ console.warn('[OpenCodeDBReader] Failed to get messages', error);
405
+ return [];
406
+ }
407
+ }
408
+
409
+ getLatestMessage(sessionId: string): DBMessage | null {
410
+ if (!this.ensureOpen()) return null;
411
+
412
+ try {
413
+ const statement = this.getStatement('GET_LATEST_MESSAGE');
414
+ const row = statement?.get(sessionId) as MessageRow | null;
415
+ return row ? mapMessage(row) : null;
416
+ } catch (error) {
417
+ console.warn('[OpenCodeDBReader] Failed to get latest message', error);
418
+ return null;
419
+ }
420
+ }
421
+
422
+ getMessageCount(sessionId: string): number {
423
+ if (!this.ensureOpen()) return 0;
424
+
425
+ try {
426
+ const statement = this.getStatement('GET_MESSAGE_COUNT');
427
+ const row = statement?.get(sessionId) as { count: number } | null;
428
+ return row?.count ?? 0;
429
+ } catch (error) {
430
+ console.warn('[OpenCodeDBReader] Failed to get message count', error);
431
+ return 0;
432
+ }
433
+ }
434
+
435
+ getActiveToolCalls(sessionId: string): DBToolCall[] {
436
+ if (!this.ensureOpen()) return [];
437
+
438
+ try {
439
+ const statement = this.getStatement('GET_ACTIVE_TOOLS');
440
+ const rows = statement?.all(sessionId) as PartRow[] | null;
441
+ return rows ? rows.map(mapToolCall).filter(isNotNull) : [];
442
+ } catch (error) {
443
+ console.warn('[OpenCodeDBReader] Failed to get active tools', error);
444
+ return [];
445
+ }
446
+ }
447
+
448
+ getToolCallHistory(sessionId: string, opts?: { limit?: number }): DBToolCall[] {
449
+ if (!this.ensureOpen()) return [];
450
+
451
+ const limit = opts?.limit ?? DEFAULT_TOOL_LIMIT;
452
+
453
+ try {
454
+ const statement = this.getStatement('GET_TOOL_HISTORY');
455
+ const rows = statement?.all(sessionId, limit) as PartRow[] | null;
456
+ return rows ? rows.map(mapToolCall).filter(isNotNull) : [];
457
+ } catch (error) {
458
+ console.warn('[OpenCodeDBReader] Failed to get tool history', error);
459
+ return [];
460
+ }
461
+ }
462
+
463
+ getTextParts(sessionId: string, opts?: { limit?: number }): DBTextPart[] {
464
+ if (!this.ensureOpen()) return [];
465
+
466
+ const limit = opts?.limit ?? DEFAULT_LIMIT;
467
+
468
+ try {
469
+ const statement = this.getStatement('GET_TEXT_PARTS');
470
+ const rows = statement?.all(sessionId, limit) as PartRow[] | null;
471
+ return rows ? rows.map(mapTextPart).filter(isNotNull) : [];
472
+ } catch (error) {
473
+ console.warn('[OpenCodeDBReader] Failed to get text parts', error);
474
+ return [];
475
+ }
476
+ }
477
+
478
+ getTodos(sessionId: string): DBTodo[] {
479
+ if (!this.ensureOpen()) return [];
480
+
481
+ try {
482
+ const statement = this.getStatement('GET_TODOS');
483
+ const rows = statement?.all(sessionId) as TodoRow[] | null;
484
+ return rows
485
+ ? rows.map((row) => ({
486
+ sessionId: row.session_id,
487
+ content: row.content,
488
+ status: row.status,
489
+ priority: row.priority,
490
+ position: row.position,
491
+ }))
492
+ : [];
493
+ } catch (error) {
494
+ console.warn('[OpenCodeDBReader] Failed to get todos', error);
495
+ return [];
496
+ }
497
+ }
498
+
499
+ getSessionCost(sessionId: string): SessionCostSummary {
500
+ if (!this.ensureOpen()) {
501
+ return {
502
+ totalCost: 0,
503
+ totalTokens: 0,
504
+ inputTokens: 0,
505
+ outputTokens: 0,
506
+ reasoningTokens: 0,
507
+ cacheRead: 0,
508
+ cacheWrite: 0,
509
+ messageCount: 0,
510
+ };
511
+ }
512
+
513
+ try {
514
+ const statement = this.getStatement('GET_SESSION_COST');
515
+ const row = statement?.get(sessionId) as {
516
+ total_cost: number;
517
+ total_tokens: number;
518
+ input_tokens: number;
519
+ output_tokens: number;
520
+ reasoning_tokens: number;
521
+ cache_read: number;
522
+ cache_write: number;
523
+ message_count: number;
524
+ } | null;
525
+
526
+ return {
527
+ totalCost: row?.total_cost ?? 0,
528
+ totalTokens: row?.total_tokens ?? 0,
529
+ inputTokens: row?.input_tokens ?? 0,
530
+ outputTokens: row?.output_tokens ?? 0,
531
+ reasoningTokens: row?.reasoning_tokens ?? 0,
532
+ cacheRead: row?.cache_read ?? 0,
533
+ cacheWrite: row?.cache_write ?? 0,
534
+ messageCount: row?.message_count ?? 0,
535
+ };
536
+ } catch (error) {
537
+ console.warn('[OpenCodeDBReader] Failed to get session cost', error);
538
+ return {
539
+ totalCost: 0,
540
+ totalTokens: 0,
541
+ inputTokens: 0,
542
+ outputTokens: 0,
543
+ reasoningTokens: 0,
544
+ cacheRead: 0,
545
+ cacheWrite: 0,
546
+ messageCount: 0,
547
+ };
548
+ }
549
+ }
550
+
551
+ getSessionStatus(sessionId: string): SessionStatus {
552
+ const session = this.getSession(sessionId);
553
+ if (!session) {
554
+ return { status: 'idle', lastActivity: 0 };
555
+ }
556
+
557
+ if (session.timeArchived) {
558
+ return { status: 'archived', lastActivity: session.timeUpdated };
559
+ }
560
+
561
+ if (session.timeCompacting) {
562
+ return { status: 'compacting', lastActivity: session.timeUpdated };
563
+ }
564
+
565
+ const latest = this.getLatestMessage(sessionId);
566
+ if (latest?.error) {
567
+ return { status: 'error', lastActivity: latest.timeUpdated };
568
+ }
569
+
570
+ const activeTools = this.getActiveToolCalls(sessionId);
571
+ const lastActivity = Math.max(session.timeUpdated, latest?.timeUpdated ?? 0);
572
+
573
+ if (activeTools.length > 0) {
574
+ return { status: 'active', lastActivity };
575
+ }
576
+
577
+ return { status: 'idle', lastActivity };
578
+ }
579
+
580
+ searchSessions(query: string, opts?: { limit?: number }): DBSession[] {
581
+ if (!this.ensureOpen()) return [];
582
+
583
+ try {
584
+ const pattern = `%${query}%`;
585
+ if (opts?.limit !== undefined) {
586
+ const statement = this.getStatement('SEARCH_SESSIONS_LIMITED');
587
+ const rows = statement?.all(pattern, opts.limit) as SessionRow[] | null;
588
+ return rows ? rows.map(mapSession) : [];
589
+ }
590
+ const statement = this.getStatement('SEARCH_SESSIONS');
591
+ const rows = statement?.all(pattern) as SessionRow[] | null;
592
+ return rows ? rows.map(mapSession) : [];
593
+ } catch (error) {
594
+ console.warn('[OpenCodeDBReader] Failed to search sessions', error);
595
+ return [];
596
+ }
597
+ }
598
+
599
+ getSessionDashboard(parentSessionId: string): {
600
+ sessions: SessionTreeNode[];
601
+ totalCost: number;
602
+ } {
603
+ const sessions = this.getChildSessions(parentSessionId).map((child) =>
604
+ this.getSessionTree(child.id)
605
+ );
606
+ const totalCost = sessions.reduce((sum, node) => sum + sumTreeCost(node), 0);
607
+ return { sessions, totalCost };
608
+ }
609
+
610
+ private ensureOpen(): boolean {
611
+ if (this.db && this.available) {
612
+ return true;
613
+ }
614
+ return this.open();
615
+ }
616
+
617
+ private getStatement(key: keyof typeof QUERIES): Statement | null {
618
+ if (!this.db) return null;
619
+
620
+ const existing = this.statements.get(key);
621
+ if (existing) return existing;
622
+
623
+ const statement = this.db.prepare(QUERIES[key]);
624
+ this.statements.set(key, statement);
625
+ return statement;
626
+ }
627
+
628
+ private validateSchema(): boolean {
629
+ if (!this.db) return false;
630
+
631
+ try {
632
+ const statement = this.db.prepare(QUERIES.CHECK_TABLES);
633
+ const rows = statement.all() as Array<{ name: string }>;
634
+ const found = new Set(rows.map((row) => row.name));
635
+ for (const table of REQUIRED_TABLES) {
636
+ if (!found.has(table)) {
637
+ return false;
638
+ }
639
+ }
640
+ return true;
641
+ } catch (error) {
642
+ console.warn('[OpenCodeDBReader] Failed to validate schema', error);
643
+ return false;
644
+ }
645
+ }
646
+
647
+ private buildSessionTree(rootId: string, visited: Set<string>): SessionTreeNode {
648
+ if (visited.has(rootId)) {
649
+ console.warn('[OpenCodeDBReader] Detected session cycle', rootId);
650
+ return {
651
+ session: createEmptySession(rootId),
652
+ children: [],
653
+ messageCount: 0,
654
+ activeToolCount: 0,
655
+ };
656
+ }
657
+
658
+ visited.add(rootId);
659
+ const session = this.getSession(rootId) ?? createEmptySession(rootId);
660
+ const children = this.getChildSessions(rootId).map((child) =>
661
+ this.buildSessionTree(child.id, visited)
662
+ );
663
+ const messageCount = this.getMessageCount(rootId);
664
+ const activeToolCount = this.getActiveToolCalls(rootId).length;
665
+ const todos = this.getTodos(rootId);
666
+ const costSummary = this.getSessionCost(rootId);
667
+
668
+ return {
669
+ session,
670
+ children,
671
+ messageCount,
672
+ activeToolCount,
673
+ todoSummary: todos.length > 0 ? summarizeTodos(todos) : undefined,
674
+ costSummary,
675
+ };
676
+ }
677
+ }