agentboss 0.1.2 → 0.1.4

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.
@@ -1,1051 +1,1108 @@
1
- /**
2
- * Database query helpers for Agent Boss (boss.db)
3
- * Wraps common operations for the sql.js-backed SQLite database.
4
- *
5
- * sql.js API notes:
6
- * - db.run(sql, params) – execute with optional params array
7
- * - db.exec(sql) – execute, returns [{columns, values}]
8
- * - db.prepare(sql) – returns statement; use .bind().step() / .getAsObject()
9
- * - Positional ? placeholders; params passed as arrays
10
- *
11
- * @author Felix
12
- */
13
-
14
- // ---------------------------------------------------------------------------
15
- // Internal helpers
16
- // ---------------------------------------------------------------------------
17
-
18
- /**
19
- * Execute a SELECT and return all matching rows as plain objects.
20
- * @param {Object} db sql.js Database instance
21
- * @param {string} sql SQL query
22
- * @param {Array} params Positional parameters
23
- * @returns {Object[]}
24
- */
25
- function queryAll(db, sql, params = []) {
26
- const stmt = db.prepare(sql);
27
- if (params.length) stmt.bind(params);
28
- const results = [];
29
- while (stmt.step()) {
30
- results.push(stmt.getAsObject());
31
- }
32
- stmt.free();
33
- return results;
34
- }
35
-
36
- /**
37
- * Execute a SELECT and return the first matching row, or null.
38
- * @param {Object} db sql.js Database instance
39
- * @param {string} sql SQL query
40
- * @param {Array} params Positional parameters
41
- * @returns {Object|null}
42
- */
43
- function queryOne(db, sql, params = []) {
44
- const results = queryAll(db, sql, params);
45
- return results[0] || null;
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // Session queries
50
- // ---------------------------------------------------------------------------
51
-
52
- /**
53
- * Insert or replace a unified session row.
54
- * @param {Object} db
55
- * @param {Object} session
56
- */
57
- function upsertSession(db, session) {
58
- const sql = `INSERT OR REPLACE INTO unified_session (
59
- id, source, date, started_at, ended_at, duration_minutes, active_minutes,
60
- message_count, tokens_input, tokens_output, tokens_reasoning,
61
- tokens_cache_read, tokens_cache_write, cost_usd, project, title, model,
62
- error_count, tool_call_count, summary_additions, summary_deletions,
63
- summary_files, reverted, time_compacting,
64
- parent_session_id, agent_type
65
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
66
-
67
- db.run(sql, [
68
- session.id,
69
- session.source,
70
- session.date,
71
- session.started_at,
72
- session.ended_at || null,
73
- session.duration_minutes || 0,
74
- session.active_minutes ?? null,
75
- session.message_count || 0,
76
- session.tokens_input || 0,
77
- session.tokens_output || 0,
78
- session.tokens_reasoning || 0,
79
- session.tokens_cache_read || 0,
80
- session.tokens_cache_write || 0,
81
- session.cost_usd || 0,
82
- session.project || null,
83
- session.title || null,
84
- session.model || null,
85
- session.error_count || 0,
86
- session.tool_call_count || 0,
87
- session.summary_additions || 0,
88
- session.summary_deletions || 0,
89
- session.summary_files || 0,
90
- session.reverted || 0,
91
- session.time_compacting || 0,
92
- session.parent_session_id || null,
93
- session.agent_type || null,
94
- ]);
95
- }
96
-
97
- /**
98
- * Get sessions for a specific date, optionally filtered by source.
99
- * @param {Object} db
100
- * @param {string} date YYYY-MM-DD
101
- * @param {string} [source]
102
- * @returns {Object[]}
103
- */
104
- function getSessionsByDate(db, date, source) {
105
- if (source) {
106
- return queryAll(
107
- db,
108
- 'SELECT * FROM unified_session WHERE date = ? AND source = ? ORDER BY started_at',
109
- [date, source]
110
- );
111
- }
112
- return queryAll(
113
- db,
114
- 'SELECT * FROM unified_session WHERE date = ? ORDER BY started_at',
115
- [date]
116
- );
117
- }
118
-
119
- /**
120
- * Get a single session by ID.
121
- * @param {Object} db
122
- * @param {string} id
123
- * @returns {Object|null}
124
- */
125
- function getSessionById(db, id) {
126
- return queryOne(db, 'SELECT * FROM unified_session WHERE id = ?', [id]);
127
- }
128
-
129
- /**
130
- * Get sessions in a date range with optional source filter and pagination.
131
- * @param {Object} db
132
- * @param {string} fromDate YYYY-MM-DD inclusive
133
- * @param {string} toDate YYYY-MM-DD inclusive
134
- * @param {string} [source]
135
- * @param {number} [limit=100]
136
- * @param {number} [offset=0]
137
- * @returns {Object[]}
138
- */
139
- function getSessionsByDateRange(db, fromDate, toDate, source, limit = 100, offset = 0) {
140
- if (source) {
141
- return queryAll(
142
- db,
143
- `SELECT * FROM unified_session
144
- WHERE date >= ? AND date <= ? AND source = ?
145
- ORDER BY started_at DESC
146
- LIMIT ? OFFSET ?`,
147
- [fromDate, toDate, source, limit, offset]
148
- );
149
- }
150
- return queryAll(
151
- db,
152
- `SELECT * FROM unified_session
153
- WHERE date >= ? AND date <= ?
154
- ORDER BY started_at DESC
155
- LIMIT ? OFFSET ?`,
156
- [fromDate, toDate, limit, offset]
157
- );
158
- }
159
-
160
- /**
161
- * Count sessions in a date range (for pagination).
162
- * @param {Object} db
163
- * @param {string} fromDate
164
- * @param {string} toDate
165
- * @param {string} [source]
166
- * @returns {number}
167
- */
168
- function countSessionsByDateRange(db, fromDate, toDate, source) {
169
- const row = source
170
- ? queryOne(
171
- db,
172
- 'SELECT COUNT(*) AS cnt FROM unified_session WHERE date >= ? AND date <= ? AND source = ?',
173
- [fromDate, toDate, source]
174
- )
175
- : queryOne(
176
- db,
177
- 'SELECT COUNT(*) AS cnt FROM unified_session WHERE date >= ? AND date <= ?',
178
- [fromDate, toDate]
179
- );
180
- return row ? row.cnt : 0;
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Message queries
185
- // ---------------------------------------------------------------------------
186
-
187
- /**
188
- * Get all messages for a session ordered by timestamp.
189
- * @param {Object} db
190
- * @param {string} sessionId
191
- * @returns {Object[]}
192
- */
193
- function getMessagesBySession(db, sessionId) {
194
- return queryAll(
195
- db,
196
- 'SELECT * FROM unified_message WHERE session_id = ? ORDER BY timestamp',
197
- [sessionId]
198
- );
199
- }
200
-
201
- /**
202
- * Insert multiple messages efficiently inside a transaction.
203
- * @param {Object} db
204
- * @param {Object[]} messages
205
- */
206
- function bulkInsertMessages(db, messages) {
207
- if (!messages.length) return;
208
-
209
- const sql = `INSERT INTO unified_message (
210
- id, session_id, source, role, timestamp,
211
- tokens_input, tokens_output, tokens_reasoning,
212
- cost_usd, content_length, is_error, model_id, text
213
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`;
214
-
215
- db.run('BEGIN TRANSACTION');
216
- try {
217
- for (const m of messages) {
218
- // content_length: prefer explicit value, otherwise derive from text
219
- const len = m.content_length || (m.text ? m.text.length : 0);
220
- db.run(sql, [
221
- m.id,
222
- m.session_id,
223
- m.source,
224
- m.role,
225
- m.timestamp,
226
- m.tokens_input || 0,
227
- m.tokens_output || 0,
228
- m.tokens_reasoning || 0,
229
- m.cost_usd || 0,
230
- len,
231
- m.is_error || 0,
232
- m.model_id || null,
233
- m.text || null,
234
- ]);
235
- }
236
- db.run('COMMIT');
237
- } catch (err) {
238
- db.run('ROLLBACK');
239
- throw err;
240
- }
241
- }
242
-
243
- // ---------------------------------------------------------------------------
244
- // Part queries
245
- // ---------------------------------------------------------------------------
246
-
247
- /**
248
- * Get all parts for a session.
249
- * @param {Object} db
250
- * @param {string} sessionId
251
- * @returns {Object[]}
252
- */
253
- function getPartsBySession(db, sessionId) {
254
- return queryAll(
255
- db,
256
- 'SELECT * FROM unified_part WHERE session_id = ? ORDER BY timestamp',
257
- [sessionId]
258
- );
259
- }
260
-
261
- /**
262
- * Insert multiple parts efficiently inside a transaction.
263
- * @param {Object} db
264
- * @param {Object[]} parts
265
- */
266
- function bulkInsertParts(db, parts) {
267
- if (!parts.length) return;
268
-
269
- const sql = `INSERT INTO unified_part (
270
- id, message_id, session_id, source, type, timestamp
271
- ) VALUES (?,?,?,?,?,?)`;
272
-
273
- db.run('BEGIN TRANSACTION');
274
- try {
275
- for (const p of parts) {
276
- db.run(sql, [
277
- p.id,
278
- p.message_id,
279
- p.session_id,
280
- p.source,
281
- p.type,
282
- p.timestamp,
283
- ]);
284
- }
285
- db.run('COMMIT');
286
- } catch (err) {
287
- db.run('ROLLBACK');
288
- throw err;
289
- }
290
- }
291
-
292
- // ---------------------------------------------------------------------------
293
- // Tool call queries
294
- // ---------------------------------------------------------------------------
295
-
296
- /**
297
- * Get all tool calls for a session ordered by timestamp.
298
- * @param {Object} db
299
- * @param {string} sessionId
300
- * @returns {Object[]}
301
- */
302
- function getToolCallsBySession(db, sessionId) {
303
- return queryAll(
304
- db,
305
- 'SELECT * FROM unified_tool_call WHERE session_id = ? ORDER BY timestamp',
306
- [sessionId]
307
- );
308
- }
309
-
310
- /**
311
- * Insert multiple tool calls efficiently inside a transaction.
312
- * @param {Object} db
313
- * @param {Object[]} toolCalls
314
- */
315
- function bulkInsertToolCalls(db, toolCalls) {
316
- if (!toolCalls.length) return;
317
-
318
- const sql = `INSERT INTO unified_tool_call (
319
- id, part_id, session_id, source, tool_name, timestamp,
320
- status, error_message, target_file
321
- ) VALUES (?,?,?,?,?,?,?,?,?)`;
322
-
323
- db.run('BEGIN TRANSACTION');
324
- try {
325
- for (const tc of toolCalls) {
326
- db.run(sql, [
327
- tc.id,
328
- tc.part_id,
329
- tc.session_id,
330
- tc.source,
331
- tc.tool_name,
332
- tc.timestamp,
333
- tc.status || null,
334
- tc.error_message || null,
335
- tc.target_file || null,
336
- ]);
337
- }
338
- db.run('COMMIT');
339
- } catch (err) {
340
- db.run('ROLLBACK');
341
- throw err;
342
- }
343
- }
344
-
345
- // ---------------------------------------------------------------------------
346
- // Analysis queries
347
- // ---------------------------------------------------------------------------
348
-
349
- /**
350
- * Insert or replace a session analysis row.
351
- * @param {Object} db
352
- * @param {Object} analysis
353
- */
354
- function upsertSessionAnalysis(db, analysis) {
355
- // v2 columns (H1..O1, difficulty, judge). The legacy v1 columns
356
- // (score_a..score_e, sub_scores, highlight_dims) still exist on the
357
- // table for back-compat with older boss.db files but are no longer
358
- // written; they'll just remain at their historical values (NULL on
359
- // freshly-analysed rows).
360
- const sql = `INSERT OR REPLACE INTO session_analysis (
361
- session_id, source, analyzed_at, status,
362
- difficulty,
363
- score_h1, level_h1,
364
- score_h2, level_h2,
365
- score_h3, level_h3,
366
- score_e1, level_e1,
367
- score_e2, level_e2,
368
- score_o1, level_o1,
369
- sub_scores_v2, llm_judge_v2, judge_source
370
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
371
-
372
- db.run(sql, [
373
- analysis.session_id,
374
- analysis.source || null,
375
- analysis.analyzed_at || null,
376
- analysis.status || 'pending',
377
- analysis.difficulty ?? null,
378
- analysis.score_h1 ?? null, analysis.level_h1 ?? null,
379
- analysis.score_h2 ?? null, analysis.level_h2 ?? null,
380
- analysis.score_h3 ?? null, analysis.level_h3 ?? null,
381
- analysis.score_e1 ?? null, analysis.level_e1 ?? null,
382
- analysis.score_e2 ?? null, analysis.level_e2 ?? null,
383
- analysis.score_o1 ?? null, analysis.level_o1 ?? null,
384
- analysis.sub_scores_v2 || null,
385
- analysis.llm_judge_v2 || null,
386
- analysis.judge_source || null,
387
- ]);
388
- }
389
-
390
- /**
391
- * Get the analysis row for a session.
392
- * @param {Object} db
393
- * @param {string} sessionId
394
- * @returns {Object|null}
395
- */
396
- function getAnalysisBySession(db, sessionId) {
397
- return queryOne(
398
- db,
399
- 'SELECT * FROM session_analysis WHERE session_id = ?',
400
- [sessionId]
401
- );
402
- }
403
-
404
- /**
405
- * Get sessions that need (re-)analysis for a given date.
406
- * Picks sessions that have no analysis row, are 'pending', or whose
407
- * data grew since the last pass (ended_at newer than analyzed_at —
408
- * both ISO-8601 UTC, so string comparison is safe). The last clause
409
- * also retries 'error' rows once the session has new data.
410
- * @param {Object} db
411
- * @param {string} date YYYY-MM-DD
412
- * @returns {Object[]}
413
- */
414
- function getUnanalyzedSessions(db, date) {
415
- return queryAll(
416
- db,
417
- `SELECT s.* FROM unified_session s
418
- LEFT JOIN session_analysis sa ON s.id = sa.session_id
419
- WHERE s.date = ? AND (
420
- sa.status IS NULL OR sa.status = 'pending'
421
- OR (sa.analyzed_at IS NOT NULL AND s.ended_at > sa.analyzed_at)
422
- )
423
- ORDER BY s.started_at`,
424
- [date]
425
- );
426
- }
427
-
428
- /**
429
- * Insert or replace a daily summary row.
430
- * @param {Object} db
431
- * @param {Object} summary
432
- */
433
- function upsertDailySummary(db, summary) {
434
- const sql = `INSERT OR REPLACE INTO daily_summary (
435
- id, date, source, session_count, message_count, tool_call_count,
436
- tokens_input, tokens_output, tokens_reasoning,
437
- tokens_cache_read, tokens_cache_write, cost_usd,
438
- first_activity_at, last_activity_at, active_minutes,
439
- peak_hour, error_count, revert_count, additions, deletions
440
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
441
-
442
- db.run(sql, [
443
- summary.id,
444
- summary.date,
445
- summary.source,
446
- summary.session_count || 0,
447
- summary.message_count || 0,
448
- summary.tool_call_count || 0,
449
- summary.tokens_input || 0,
450
- summary.tokens_output || 0,
451
- summary.tokens_reasoning || 0,
452
- summary.tokens_cache_read || 0,
453
- summary.tokens_cache_write || 0,
454
- summary.cost_usd || 0,
455
- summary.first_activity_at || null,
456
- summary.last_activity_at || null,
457
- summary.active_minutes || 0,
458
- summary.peak_hour ?? null,
459
- summary.error_count || 0,
460
- summary.revert_count || 0,
461
- summary.additions || 0,
462
- summary.deletions || 0,
463
- ]);
464
- }
465
-
466
- /**
467
- * Get daily summary for a specific date and source.
468
- * @param {Object} db
469
- * @param {string} date
470
- * @param {string} [source]
471
- * @returns {Object|null}
472
- */
473
- function getDailySummary(db, date, source) {
474
- // Default to the 'all' rollup row to keep parity with getDailySummaries
475
- // and prevent double-counting across sources.
476
- const effectiveSource = source || 'all';
477
- if (effectiveSource) {
478
- return queryOne(
479
- db,
480
- 'SELECT * FROM daily_summary WHERE date = ? AND source = ?',
481
- [date, effectiveSource]
482
- );
483
- }
484
- return queryOne(
485
- db,
486
- 'SELECT * FROM daily_summary WHERE date = ?',
487
- [date]
488
- );
489
- }
490
-
491
- /**
492
- * Get daily summaries in a date range.
493
- * @param {Object} db
494
- * @param {string} fromDate
495
- * @param {string} toDate
496
- * @param {string} [source]
497
- * @returns {Object[]}
498
- */
499
- function getDailySummaries(db, fromDate, toDate, source) {
500
- // When no source is specified, return the cross-source rollup ('all') to
501
- // avoid double-counting (one row per source PLUS one 'all' row are written
502
- // per date by the daily aggregator).
503
- const effectiveSource = source || 'all';
504
- return queryAll(
505
- db,
506
- 'SELECT * FROM daily_summary WHERE date >= ? AND date <= ? AND source = ? ORDER BY date',
507
- [fromDate, toDate, effectiveSource]
508
- );
509
- }
510
-
511
- // ---------------------------------------------------------------------------
512
- // Hourly activity queries
513
- // ---------------------------------------------------------------------------
514
-
515
- /**
516
- * Insert or replace an hourly activity row.
517
- * @param {Object} db
518
- * @param {Object} activity
519
- */
520
- function upsertHourlyActivity(db, activity) {
521
- const sql = `INSERT OR REPLACE INTO hourly_activity (
522
- date, hour, source,
523
- message_count, session_count, error_count, tool_call_count
524
- ) VALUES (?,?,?,?,?,?,?)`;
525
-
526
- db.run(sql, [
527
- activity.date,
528
- activity.hour,
529
- activity.source,
530
- activity.message_count || 0,
531
- activity.session_count || 0,
532
- activity.error_count || 0,
533
- activity.tool_call_count || 0,
534
- ]);
535
- }
536
-
537
- /**
538
- * Get hourly activity rows for a date, optionally filtered by source.
539
- * @param {Object} db
540
- * @param {string} date
541
- * @param {string} [source]
542
- * @returns {Object[]}
543
- */
544
- function getHourlyActivity(db, date, source) {
545
- if (source) {
546
- return queryAll(
547
- db,
548
- 'SELECT * FROM hourly_activity WHERE date = ? AND source = ? ORDER BY hour',
549
- [date, source]
550
- );
551
- }
552
- return queryAll(
553
- db,
554
- 'SELECT * FROM hourly_activity WHERE date = ? ORDER BY hour',
555
- [date]
556
- );
557
- }
558
-
559
- // ---------------------------------------------------------------------------
560
- // Settings queries
561
- // ---------------------------------------------------------------------------
562
-
563
- /**
564
- * Get a single setting value by key.
565
- * @param {Object} db
566
- * @param {string} key
567
- * @returns {string|null}
568
- */
569
- function getSetting(db, key) {
570
- const row = queryOne(db, 'SELECT value FROM user_settings WHERE key = ?', [key]);
571
- return row ? row.value : null;
572
- }
573
-
574
- /**
575
- * Set a setting value (insert or update).
576
- * @param {Object} db
577
- * @param {string} key
578
- * @param {string} value
579
- */
580
- function setSetting(db, key, value) {
581
- db.run(
582
- 'INSERT OR REPLACE INTO user_settings (key, value) VALUES (?, ?)',
583
- [key, value]
584
- );
585
- }
586
-
587
- /**
588
- * Get all settings as a plain object { key: value }.
589
- * @param {Object} db
590
- * @returns {Object}
591
- */
592
- function getAllSettings(db) {
593
- const rows = queryAll(db, 'SELECT key, value FROM user_settings');
594
- const settings = {};
595
- for (const row of rows) {
596
- settings[row.key] = row.value;
597
- }
598
- return settings;
599
- }
600
-
601
- // ---------------------------------------------------------------------------
602
- // ETL state queries
603
- // ---------------------------------------------------------------------------
604
-
605
- /**
606
- * Get ETL state for a source.
607
- * @param {Object} db
608
- * @param {string} source
609
- * @returns {Object|null}
610
- */
611
- function getEtlState(db, source) {
612
- return queryOne(db, 'SELECT * FROM etl_state WHERE source = ?', [source]);
613
- }
614
-
615
- /**
616
- * Update ETL state for a source (upsert).
617
- * @param {Object} db
618
- * @param {string} source
619
- * @param {Object} data
620
- */
621
- function updateEtlState(db, source, data) {
622
- const sql = `INSERT OR REPLACE INTO etl_state (
623
- source, last_sync_at, last_session_id, last_session_time, status
624
- ) VALUES (?,?,?,?,?)`;
625
-
626
- db.run(sql, [
627
- source,
628
- data.last_sync_at || null,
629
- data.last_session_id || null,
630
- data.last_session_time || null,
631
- data.status || 'idle',
632
- ]);
633
- }
634
-
635
- // ---------------------------------------------------------------------------
636
- // Analysis state queries
637
- // ---------------------------------------------------------------------------
638
-
639
- /**
640
- * Get analysis state (singleton row, id=1).
641
- * @param {Object} db
642
- * @returns {Object|null}
643
- */
644
- function getAnalysisState(db) {
645
- return queryOne(db, 'SELECT * FROM analysis_state WHERE id = 1');
646
- }
647
-
648
- /**
649
- * Update the analysis state (singleton row, id=1).
650
- * @param {Object} db
651
- * @param {Object} data
652
- */
653
- function updateAnalysisState(db, data) {
654
- const sql = `INSERT OR REPLACE INTO analysis_state (
655
- id, status, current_date, analyzed_count, total_count, last_analyzed_at
656
- ) VALUES (1,?,?,?,?,?)`;
657
-
658
- db.run(sql, [
659
- data.status || 'idle',
660
- data.current_date || null,
661
- data.analyzed_count || 0,
662
- data.total_count || 0,
663
- data.last_analyzed_at || null,
664
- ]);
665
- }
666
-
667
- // ---------------------------------------------------------------------------
668
- // Tool config queries
669
- // ---------------------------------------------------------------------------
670
-
671
- /**
672
- * Get configuration for a specific tool.
673
- * @param {Object} db
674
- * @param {string} tool
675
- * @returns {Object|null}
676
- */
677
- function getToolConfig(db, tool) {
678
- return queryOne(db, 'SELECT * FROM tool_config WHERE tool = ?', [tool]);
679
- }
680
-
681
- /**
682
- * Get all tool configurations.
683
- * @param {Object} db
684
- * @returns {Object[]}
685
- */
686
- function getAllToolConfigs(db) {
687
- return queryAll(db, 'SELECT * FROM tool_config ORDER BY tool');
688
- }
689
-
690
- /**
691
- * Insert or replace a tool configuration.
692
- * @param {Object} db
693
- * @param {Object} config
694
- */
695
- function upsertToolConfig(db, config) {
696
- const sql = `INSERT OR REPLACE INTO tool_config (
697
- tool, enabled, data_path, status, label
698
- ) VALUES (?,?,?,?,?)`;
699
-
700
- db.run(sql, [
701
- config.tool,
702
- config.enabled ?? 1,
703
- config.data_path || null,
704
- config.status || 'unknown',
705
- config.label,
706
- ]);
707
- }
708
-
709
- // ---------------------------------------------------------------------------
710
- // Overview (home page) ETL-direct aggregations
711
- // ---------------------------------------------------------------------------
712
- //
713
- // All four helpers below read only from unified_session and never touch the
714
- // analysis-layer tables. This guarantees the home page renders immediately
715
- // after ETL, without waiting for the LLM-driven scoring job to catch up.
716
- //
717
-
718
- /**
719
- * Earliest session date present in unified_session, or null if empty.
720
- * Used by the overview API to resolve the "all-time" date range without
721
- * forking every helper to accept an open-ended bound.
722
- *
723
- * @param {Object} db
724
- * @returns {string|null} YYYY-MM-DD or null
725
- */
726
- function getEarliestSessionDate(db) {
727
- const r = queryOne(db, 'SELECT MIN(date) AS d FROM unified_session');
728
- return r && r.d ? r.d : null;
729
- }
730
-
731
- /**
732
- * Single-day snapshot aggregated across all sources.
733
- * Returns a zero-filled object (never null) so the UI can render safely.
734
- *
735
- * @param {Object} db
736
- * @param {string} date YYYY-MM-DD
737
- * @returns {Object}
738
- */
739
- function getOverviewSnapshot(db, date) {
740
- const row = queryOne(
741
- db,
742
- `SELECT
743
- COUNT(*) AS sessions,
744
- COALESCE(SUM(active_minutes), 0) AS active_minutes,
745
- COALESCE(SUM(cost_usd), 0) AS cost,
746
- COALESCE(SUM(tokens_input), 0) AS tokens_input,
747
- COALESCE(SUM(tokens_output), 0) AS tokens_output,
748
- COALESCE(SUM(message_count), 0) AS messages,
749
- COALESCE(SUM(error_count), 0) AS errors,
750
- COALESCE(SUM(reverted), 0) AS reverted
751
- FROM unified_session
752
- WHERE date = ?`,
753
- [date]
754
- ) || {};
755
-
756
- const tokensInput = row.tokens_input || 0;
757
- const tokensOutput = row.tokens_output || 0;
758
-
759
- return {
760
- date,
761
- sessions: row.sessions || 0,
762
- activeMinutes: row.active_minutes || 0,
763
- cost: Math.round((row.cost || 0) * 10000) / 10000,
764
- tokensInput,
765
- tokensOutput,
766
- tokensTotal: tokensInput + tokensOutput,
767
- messages: row.messages || 0,
768
- errors: row.errors || 0,
769
- reverted: row.reverted || 0,
770
- };
771
- }
772
-
773
- /**
774
- * Daily trend rows for the home page, broken out by source so the UI can
775
- * render two overlaid lines (opencode vs claude-code).
776
- *
777
- * @param {Object} db
778
- * @param {string} fromDate YYYY-MM-DD (inclusive)
779
- * @param {string} toDate YYYY-MM-DD (inclusive)
780
- * @returns {Object[]}
781
- */
782
- function getOverviewTrend(db, fromDate, toDate) {
783
- return queryAll(
784
- db,
785
- `SELECT
786
- date,
787
- source,
788
- COUNT(*) AS sessions,
789
- COALESCE(SUM(cost_usd), 0) AS cost,
790
- COALESCE(SUM(active_minutes), 0) AS active_minutes
791
- FROM unified_session
792
- WHERE date BETWEEN ? AND ?
793
- GROUP BY date, source
794
- ORDER BY date ASC, source ASC`,
795
- [fromDate, toDate]
796
- );
797
- }
798
-
799
- /**
800
- * Top projects within a date range, ranked by total cost (USD).
801
- * Excludes rows where project is NULL / empty.
802
- *
803
- * @param {Object} db
804
- * @param {string} fromDate
805
- * @param {string} toDate
806
- * @param {number} [limit=8]
807
- * @returns {Object[]}
808
- */
809
- function getOverviewTopProjects(db, fromDate, toDate, limit = 8) {
810
- return queryAll(
811
- db,
812
- `SELECT
813
- project,
814
- COUNT(*) AS sessions,
815
- COALESCE(SUM(cost_usd), 0) AS cost,
816
- COALESCE(SUM(active_minutes), 0) AS active_minutes,
817
- COALESCE(SUM(summary_additions), 0) AS additions,
818
- COALESCE(SUM(summary_deletions), 0) AS deletions,
819
- COALESCE(SUM(summary_files), 0) AS files
820
- FROM unified_session
821
- WHERE date BETWEEN ? AND ?
822
- AND project IS NOT NULL
823
- AND project <> ''
824
- GROUP BY project
825
- ORDER BY cost DESC, sessions DESC
826
- LIMIT ?`,
827
- [fromDate, toDate, limit]
828
- );
829
- }
830
-
831
- /**
832
- * Per-day cache effectiveness (hit-rate) for a date window.
833
- * Hit rate = cache_read / (cache_read + cache_write + tokens_input).
834
- * See the implementation below for why cache_write is in the denominator.
835
- * Raw numerator/denominator are returned so the UI can change the formula
836
- * later without another query.
837
- *
838
- * @param {Object} db
839
- * @param {string} fromDate
840
- * @param {string} toDate
841
- * @returns {Object[]}
842
- */
843
- function getOverviewCacheRate(db, fromDate, toDate) {
844
- const rows = queryAll(
845
- db,
846
- `SELECT
847
- date,
848
- COALESCE(SUM(tokens_input), 0) AS tokens_input,
849
- COALESCE(SUM(tokens_cache_read), 0) AS cache_read,
850
- COALESCE(SUM(tokens_cache_write), 0) AS cache_write
851
- FROM unified_session
852
- WHERE date BETWEEN ? AND ?
853
- GROUP BY date
854
- ORDER BY date ASC`,
855
- [fromDate, toDate]
856
- );
857
- return rows.map((r) => {
858
- const input = r.tokens_input || 0;
859
- const cr = r.cache_read || 0;
860
- const cw = r.cache_write || 0;
861
- // Hit rate = reused cached input / total input tokens the model saw.
862
- // Including cache_write in the denominator matters: those are tokens
863
- // that *paid full price* this turn (writing them to the cache); only
864
- // future turns benefit. Excluding cache_write would always inflate
865
- // the rate toward ~100% in long sessions and obscure the moment a
866
- // huge new context block was loaded.
867
- const denom = cr + cw + input;
868
- return {
869
- date: r.date,
870
- input,
871
- cacheRead: cr,
872
- cacheWrite: cw,
873
- hitRate: denom > 0 ? Math.round((cr / denom) * 1000) / 10 : 0, // %
874
- };
875
- });
876
- }
877
-
878
- /**
879
- * Per-day error rate for a date window.
880
- * rate = SUM(error_count) / NULLIF(SUM(message_count), 0)
881
- *
882
- * @param {Object} db
883
- * @param {string} fromDate
884
- * @param {string} toDate
885
- * @returns {Object[]}
886
- */
887
- function getOverviewErrorRate(db, fromDate, toDate) {
888
- const rows = queryAll(
889
- db,
890
- `SELECT
891
- date,
892
- COALESCE(SUM(error_count), 0) AS errors,
893
- COALESCE(SUM(message_count), 0) AS messages,
894
- COALESCE(SUM(tool_call_count),0) AS tool_calls
895
- FROM unified_session
896
- WHERE date BETWEEN ? AND ?
897
- GROUP BY date
898
- ORDER BY date ASC`,
899
- [fromDate, toDate]
900
- );
901
- return rows.map((r) => ({
902
- date: r.date,
903
- errors: r.errors || 0,
904
- messages: r.messages || 0,
905
- toolCalls: r.tool_calls || 0,
906
- rate: r.messages > 0
907
- ? Math.round((r.errors / r.messages) * 10000) / 100 // % with 2 decimals
908
- : 0,
909
- }));
910
- }
911
-
912
- /**
913
- * Tool usage Top-N within a date window, with error-rate per tool.
914
- * Joins unified_tool_call -> unified_session to apply the date filter.
915
- *
916
- * @param {Object} db
917
- * @param {string} fromDate
918
- * @param {string} toDate
919
- * @param {number} [limit=10]
920
- * @returns {Object[]}
921
- */
922
- function getOverviewTopTools(db, fromDate, toDate, limit = 10) {
923
- const rows = queryAll(
924
- db,
925
- `SELECT
926
- tc.tool_name AS tool,
927
- COUNT(*) AS calls,
928
- SUM(CASE WHEN tc.status = 'error' THEN 1 ELSE 0 END) AS errors,
929
- COUNT(DISTINCT tc.session_id) AS sessions
930
- FROM unified_tool_call tc
931
- JOIN unified_session s ON s.id = tc.session_id
932
- WHERE s.date BETWEEN ? AND ?
933
- AND tc.tool_name IS NOT NULL
934
- AND tc.tool_name <> ''
935
- GROUP BY tc.tool_name
936
- ORDER BY calls DESC
937
- LIMIT ?`,
938
- [fromDate, toDate, limit]
939
- );
940
- return rows.map((r) => ({
941
- tool: r.tool,
942
- calls: r.calls || 0,
943
- errors: r.errors || 0,
944
- sessions: r.sessions || 0,
945
- errorRate: r.calls > 0
946
- ? Math.round((r.errors / r.calls) * 1000) / 10 // % with 1 decimal
947
- : 0,
948
- }));
949
- }
950
-
951
- /**
952
- * Most recent sessions across all sources / dates.
953
- *
954
- * Excludes subagents (parent_session_id IS NOT NULL) by default so the
955
- * "最近 10 个会话" panel only shows top-level work; aggregate stats on
956
- * the same page still include them via their own queries.
957
- *
958
- * @param {Object} db
959
- * @param {number} [limit=10]
960
- * @returns {Object[]}
961
- */
962
- function getOverviewRecentSessions(db, limit = 10) {
963
- return queryAll(
964
- db,
965
- `SELECT
966
- id,
967
- source,
968
- started_at,
969
- project,
970
- title,
971
- model,
972
- message_count,
973
- cost_usd,
974
- error_count,
975
- reverted
976
- FROM unified_session
977
- WHERE parent_session_id IS NULL
978
- ORDER BY started_at DESC
979
- LIMIT ?`,
980
- [limit]
981
- );
982
- }
983
-
984
- // ---------------------------------------------------------------------------
985
- // Exports
986
- // ---------------------------------------------------------------------------
987
-
988
- module.exports = {
989
- // Internal helpers (exported for testing / advanced use)
990
- queryAll,
991
- queryOne,
992
-
993
- // Session
994
- upsertSession,
995
- getSessionsByDate,
996
- getSessionById,
997
- getSessionsByDateRange,
998
- countSessionsByDateRange,
999
-
1000
- // Message
1001
- getMessagesBySession,
1002
- bulkInsertMessages,
1003
-
1004
- // Part
1005
- getPartsBySession,
1006
- bulkInsertParts,
1007
-
1008
- // Tool call
1009
- getToolCallsBySession,
1010
- bulkInsertToolCalls,
1011
-
1012
- // Analysis
1013
- upsertSessionAnalysis,
1014
- getAnalysisBySession,
1015
- getUnanalyzedSessions,
1016
- upsertDailySummary,
1017
- getDailySummary,
1018
- getDailySummaries,
1019
-
1020
- // Hourly activity
1021
- upsertHourlyActivity,
1022
- getHourlyActivity,
1023
-
1024
- // Settings
1025
- getSetting,
1026
- setSetting,
1027
- getAllSettings,
1028
-
1029
- // ETL state
1030
- getEtlState,
1031
- updateEtlState,
1032
-
1033
- // Analysis state
1034
- getAnalysisState,
1035
- updateAnalysisState,
1036
-
1037
- // Tool config
1038
- getToolConfig,
1039
- getAllToolConfigs,
1040
- upsertToolConfig,
1041
-
1042
- // Overview (home page) — ETL-direct
1043
- getOverviewSnapshot,
1044
- getOverviewTrend,
1045
- getOverviewTopProjects,
1046
- getOverviewRecentSessions,
1047
- getOverviewCacheRate,
1048
- getOverviewErrorRate,
1049
- getOverviewTopTools,
1050
- getEarliestSessionDate,
1051
- };
1
+ /**
2
+ * Database query helpers for Agent Boss (boss.db)
3
+ * Wraps common operations for the sql.js-backed SQLite database.
4
+ *
5
+ * sql.js API notes:
6
+ * - db.run(sql, params) – execute with optional params array
7
+ * - db.exec(sql) – execute, returns [{columns, values}]
8
+ * - db.prepare(sql) – returns statement; use .bind().step() / .getAsObject()
9
+ * - Positional ? placeholders; params passed as arrays
10
+ *
11
+ * @author Felix
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Internal helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Execute a SELECT and return all matching rows as plain objects.
20
+ * @param {Object} db sql.js Database instance
21
+ * @param {string} sql SQL query
22
+ * @param {Array} params Positional parameters
23
+ * @returns {Object[]}
24
+ */
25
+ function queryAll(db, sql, params = []) {
26
+ const stmt = db.prepare(sql);
27
+ if (params.length) stmt.bind(params);
28
+ const results = [];
29
+ while (stmt.step()) {
30
+ results.push(stmt.getAsObject());
31
+ }
32
+ stmt.free();
33
+ return results;
34
+ }
35
+
36
+ /**
37
+ * Execute a SELECT and return the first matching row, or null.
38
+ * @param {Object} db sql.js Database instance
39
+ * @param {string} sql SQL query
40
+ * @param {Array} params Positional parameters
41
+ * @returns {Object|null}
42
+ */
43
+ function queryOne(db, sql, params = []) {
44
+ const results = queryAll(db, sql, params);
45
+ return results[0] || null;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Session queries
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Insert or replace a unified session row.
54
+ * @param {Object} db
55
+ * @param {Object} session
56
+ */
57
+ function upsertSession(db, session) {
58
+ const sql = `INSERT OR REPLACE INTO unified_session (
59
+ id, source, date, started_at, ended_at, duration_minutes, active_minutes,
60
+ message_count, tokens_input, tokens_output, tokens_reasoning,
61
+ tokens_cache_read, tokens_cache_write, cost_usd, project, title, model,
62
+ error_count, tool_call_count, summary_additions, summary_deletions,
63
+ summary_files, reverted, time_compacting,
64
+ parent_session_id, agent_type
65
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
66
+
67
+ db.run(sql, [
68
+ session.id,
69
+ session.source,
70
+ session.date,
71
+ session.started_at,
72
+ session.ended_at || null,
73
+ session.duration_minutes || 0,
74
+ session.active_minutes ?? null,
75
+ session.message_count || 0,
76
+ session.tokens_input || 0,
77
+ session.tokens_output || 0,
78
+ session.tokens_reasoning || 0,
79
+ session.tokens_cache_read || 0,
80
+ session.tokens_cache_write || 0,
81
+ session.cost_usd || 0,
82
+ session.project || null,
83
+ session.title || null,
84
+ session.model || null,
85
+ session.error_count || 0,
86
+ session.tool_call_count || 0,
87
+ session.summary_additions || 0,
88
+ session.summary_deletions || 0,
89
+ session.summary_files || 0,
90
+ session.reverted || 0,
91
+ session.time_compacting || 0,
92
+ session.parent_session_id || null,
93
+ session.agent_type || null,
94
+ ]);
95
+ }
96
+
97
+ /**
98
+ * Get sessions for a specific date, optionally filtered by source.
99
+ * @param {Object} db
100
+ * @param {string} date YYYY-MM-DD
101
+ * @param {string} [source]
102
+ * @returns {Object[]}
103
+ */
104
+ function getSessionsByDate(db, date, source) {
105
+ if (source) {
106
+ return queryAll(
107
+ db,
108
+ 'SELECT * FROM unified_session WHERE date = ? AND source = ? ORDER BY started_at',
109
+ [date, source]
110
+ );
111
+ }
112
+ return queryAll(
113
+ db,
114
+ 'SELECT * FROM unified_session WHERE date = ? ORDER BY started_at',
115
+ [date]
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Get a single session by ID.
121
+ * @param {Object} db
122
+ * @param {string} id
123
+ * @returns {Object|null}
124
+ */
125
+ function getSessionById(db, id) {
126
+ return queryOne(db, 'SELECT * FROM unified_session WHERE id = ?', [id]);
127
+ }
128
+
129
+ /**
130
+ * Get sessions in a date range with optional source filter and pagination.
131
+ * @param {Object} db
132
+ * @param {string} fromDate YYYY-MM-DD inclusive
133
+ * @param {string} toDate YYYY-MM-DD inclusive
134
+ * @param {string} [source]
135
+ * @param {number} [limit=100]
136
+ * @param {number} [offset=0]
137
+ * @returns {Object[]}
138
+ */
139
+ function getSessionsByDateRange(db, fromDate, toDate, source, limit = 100, offset = 0) {
140
+ if (source) {
141
+ return queryAll(
142
+ db,
143
+ `SELECT * FROM unified_session
144
+ WHERE date >= ? AND date <= ? AND source = ?
145
+ ORDER BY started_at DESC
146
+ LIMIT ? OFFSET ?`,
147
+ [fromDate, toDate, source, limit, offset]
148
+ );
149
+ }
150
+ return queryAll(
151
+ db,
152
+ `SELECT * FROM unified_session
153
+ WHERE date >= ? AND date <= ?
154
+ ORDER BY started_at DESC
155
+ LIMIT ? OFFSET ?`,
156
+ [fromDate, toDate, limit, offset]
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Count sessions in a date range (for pagination).
162
+ * @param {Object} db
163
+ * @param {string} fromDate
164
+ * @param {string} toDate
165
+ * @param {string} [source]
166
+ * @returns {number}
167
+ */
168
+ function countSessionsByDateRange(db, fromDate, toDate, source) {
169
+ const row = source
170
+ ? queryOne(
171
+ db,
172
+ 'SELECT COUNT(*) AS cnt FROM unified_session WHERE date >= ? AND date <= ? AND source = ?',
173
+ [fromDate, toDate, source]
174
+ )
175
+ : queryOne(
176
+ db,
177
+ 'SELECT COUNT(*) AS cnt FROM unified_session WHERE date >= ? AND date <= ?',
178
+ [fromDate, toDate]
179
+ );
180
+ return row ? row.cnt : 0;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Message queries
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Get all messages for a session ordered by timestamp.
189
+ * @param {Object} db
190
+ * @param {string} sessionId
191
+ * @returns {Object[]}
192
+ */
193
+ function getMessagesBySession(db, sessionId) {
194
+ return queryAll(
195
+ db,
196
+ 'SELECT * FROM unified_message WHERE session_id = ? ORDER BY timestamp',
197
+ [sessionId]
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Count user messages per session for all sessions in a date range.
203
+ * One aggregate query (cheaper than N getMessagesBySession calls); used by
204
+ * the report builder's session list.
205
+ *
206
+ * @param {Object} db
207
+ * @param {string} fromDate YYYY-MM-DD inclusive
208
+ * @param {string} toDate YYYY-MM-DD inclusive
209
+ * @returns {Map<string, number>} sessionId -> user message count
210
+ */
211
+ function getUserMessageCountsByDateRange(db, fromDate, toDate) {
212
+ const rows = queryAll(
213
+ db,
214
+ `SELECT m.session_id AS session_id, COUNT(*) AS cnt
215
+ FROM unified_message m
216
+ JOIN unified_session s ON s.id = m.session_id
217
+ WHERE s.date >= ? AND s.date <= ? AND m.role = 'user'
218
+ GROUP BY m.session_id`,
219
+ [fromDate, toDate]
220
+ );
221
+ const map = new Map();
222
+ for (const r of rows) map.set(r.session_id, r.cnt || 0);
223
+ return map;
224
+ }
225
+
226
+ /**
227
+ * Count user / assistant messages for an explicit set of session IDs.
228
+ * Used by the overview "recent sessions" table so its message counts match
229
+ * the per-session detail (real rows), not the ETL-stored message_count.
230
+ *
231
+ * @param {Object} db
232
+ * @param {string[]} sessionIds
233
+ * @returns {Map<string, {user:number, assistant:number}>}
234
+ */
235
+ function getMessageRoleCounts(db, sessionIds) {
236
+ const map = new Map();
237
+ if (!Array.isArray(sessionIds) || sessionIds.length === 0) return map;
238
+ const placeholders = sessionIds.map(() => '?').join(',');
239
+ const rows = queryAll(
240
+ db,
241
+ `SELECT session_id, role, COUNT(*) AS cnt
242
+ FROM unified_message
243
+ WHERE session_id IN (${placeholders})
244
+ GROUP BY session_id, role`,
245
+ sessionIds
246
+ );
247
+ for (const r of rows) {
248
+ const e = map.get(r.session_id) || { user: 0, assistant: 0 };
249
+ if (r.role === 'user') e.user = r.cnt || 0;
250
+ else if (r.role === 'assistant') e.assistant = r.cnt || 0;
251
+ map.set(r.session_id, e);
252
+ }
253
+ return map;
254
+ }
255
+
256
+ /**
257
+ * Insert multiple messages efficiently inside a transaction.
258
+ * @param {Object} db
259
+ * @param {Object[]} messages
260
+ */
261
+ function bulkInsertMessages(db, messages) {
262
+ if (!messages.length) return;
263
+
264
+ const sql = `INSERT INTO unified_message (
265
+ id, session_id, source, role, timestamp,
266
+ tokens_input, tokens_output, tokens_reasoning,
267
+ cost_usd, content_length, is_error, model_id, text
268
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`;
269
+
270
+ db.run('BEGIN TRANSACTION');
271
+ try {
272
+ for (const m of messages) {
273
+ // content_length: prefer explicit value, otherwise derive from text
274
+ const len = m.content_length || (m.text ? m.text.length : 0);
275
+ db.run(sql, [
276
+ m.id,
277
+ m.session_id,
278
+ m.source,
279
+ m.role,
280
+ m.timestamp,
281
+ m.tokens_input || 0,
282
+ m.tokens_output || 0,
283
+ m.tokens_reasoning || 0,
284
+ m.cost_usd || 0,
285
+ len,
286
+ m.is_error || 0,
287
+ m.model_id || null,
288
+ m.text || null,
289
+ ]);
290
+ }
291
+ db.run('COMMIT');
292
+ } catch (err) {
293
+ db.run('ROLLBACK');
294
+ throw err;
295
+ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Part queries
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Get all parts for a session.
304
+ * @param {Object} db
305
+ * @param {string} sessionId
306
+ * @returns {Object[]}
307
+ */
308
+ function getPartsBySession(db, sessionId) {
309
+ return queryAll(
310
+ db,
311
+ 'SELECT * FROM unified_part WHERE session_id = ? ORDER BY timestamp',
312
+ [sessionId]
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Insert multiple parts efficiently inside a transaction.
318
+ * @param {Object} db
319
+ * @param {Object[]} parts
320
+ */
321
+ function bulkInsertParts(db, parts) {
322
+ if (!parts.length) return;
323
+
324
+ const sql = `INSERT INTO unified_part (
325
+ id, message_id, session_id, source, type, timestamp
326
+ ) VALUES (?,?,?,?,?,?)`;
327
+
328
+ db.run('BEGIN TRANSACTION');
329
+ try {
330
+ for (const p of parts) {
331
+ db.run(sql, [
332
+ p.id,
333
+ p.message_id,
334
+ p.session_id,
335
+ p.source,
336
+ p.type,
337
+ p.timestamp,
338
+ ]);
339
+ }
340
+ db.run('COMMIT');
341
+ } catch (err) {
342
+ db.run('ROLLBACK');
343
+ throw err;
344
+ }
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Tool call queries
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Get all tool calls for a session ordered by timestamp.
353
+ * @param {Object} db
354
+ * @param {string} sessionId
355
+ * @returns {Object[]}
356
+ */
357
+ function getToolCallsBySession(db, sessionId) {
358
+ return queryAll(
359
+ db,
360
+ 'SELECT * FROM unified_tool_call WHERE session_id = ? ORDER BY timestamp',
361
+ [sessionId]
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Insert multiple tool calls efficiently inside a transaction.
367
+ * @param {Object} db
368
+ * @param {Object[]} toolCalls
369
+ */
370
+ function bulkInsertToolCalls(db, toolCalls) {
371
+ if (!toolCalls.length) return;
372
+
373
+ const sql = `INSERT INTO unified_tool_call (
374
+ id, part_id, session_id, source, tool_name, timestamp,
375
+ status, error_message, target_file
376
+ ) VALUES (?,?,?,?,?,?,?,?,?)`;
377
+
378
+ db.run('BEGIN TRANSACTION');
379
+ try {
380
+ for (const tc of toolCalls) {
381
+ db.run(sql, [
382
+ tc.id,
383
+ tc.part_id,
384
+ tc.session_id,
385
+ tc.source,
386
+ tc.tool_name,
387
+ tc.timestamp,
388
+ tc.status || null,
389
+ tc.error_message || null,
390
+ tc.target_file || null,
391
+ ]);
392
+ }
393
+ db.run('COMMIT');
394
+ } catch (err) {
395
+ db.run('ROLLBACK');
396
+ throw err;
397
+ }
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Analysis queries
402
+ // ---------------------------------------------------------------------------
403
+
404
+ /**
405
+ * Insert or replace a session analysis row.
406
+ * @param {Object} db
407
+ * @param {Object} analysis
408
+ */
409
+ function upsertSessionAnalysis(db, analysis) {
410
+ // v2 columns (H1..O1, difficulty, judge). The legacy v1 columns
411
+ // (score_a..score_e, sub_scores, highlight_dims) still exist on the
412
+ // table for back-compat with older boss.db files but are no longer
413
+ // written; they'll just remain at their historical values (NULL on
414
+ // freshly-analysed rows).
415
+ const sql = `INSERT OR REPLACE INTO session_analysis (
416
+ session_id, source, analyzed_at, status,
417
+ difficulty,
418
+ score_h1, level_h1,
419
+ score_h2, level_h2,
420
+ score_h3, level_h3,
421
+ score_e1, level_e1,
422
+ score_e2, level_e2,
423
+ score_o1, level_o1,
424
+ sub_scores_v2, llm_judge_v2, judge_source
425
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
426
+
427
+ db.run(sql, [
428
+ analysis.session_id,
429
+ analysis.source || null,
430
+ analysis.analyzed_at || null,
431
+ analysis.status || 'pending',
432
+ analysis.difficulty ?? null,
433
+ analysis.score_h1 ?? null, analysis.level_h1 ?? null,
434
+ analysis.score_h2 ?? null, analysis.level_h2 ?? null,
435
+ analysis.score_h3 ?? null, analysis.level_h3 ?? null,
436
+ analysis.score_e1 ?? null, analysis.level_e1 ?? null,
437
+ analysis.score_e2 ?? null, analysis.level_e2 ?? null,
438
+ analysis.score_o1 ?? null, analysis.level_o1 ?? null,
439
+ analysis.sub_scores_v2 || null,
440
+ analysis.llm_judge_v2 || null,
441
+ analysis.judge_source || null,
442
+ ]);
443
+ }
444
+
445
+ /**
446
+ * Get the analysis row for a session.
447
+ * @param {Object} db
448
+ * @param {string} sessionId
449
+ * @returns {Object|null}
450
+ */
451
+ function getAnalysisBySession(db, sessionId) {
452
+ return queryOne(
453
+ db,
454
+ 'SELECT * FROM session_analysis WHERE session_id = ?',
455
+ [sessionId]
456
+ );
457
+ }
458
+
459
+ /**
460
+ * Get sessions that need (re-)analysis for a given date.
461
+ * Picks sessions that have no analysis row, are 'pending', or whose
462
+ * data grew since the last pass (ended_at newer than analyzed_at —
463
+ * both ISO-8601 UTC, so string comparison is safe). The last clause
464
+ * also retries 'error' rows once the session has new data.
465
+ * @param {Object} db
466
+ * @param {string} date YYYY-MM-DD
467
+ * @returns {Object[]}
468
+ */
469
+ function getUnanalyzedSessions(db, date) {
470
+ return queryAll(
471
+ db,
472
+ `SELECT s.* FROM unified_session s
473
+ LEFT JOIN session_analysis sa ON s.id = sa.session_id
474
+ WHERE s.date = ? AND (
475
+ sa.status IS NULL OR sa.status = 'pending'
476
+ OR (sa.analyzed_at IS NOT NULL AND s.ended_at > sa.analyzed_at)
477
+ )
478
+ ORDER BY s.started_at`,
479
+ [date]
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Insert or replace a daily summary row.
485
+ * @param {Object} db
486
+ * @param {Object} summary
487
+ */
488
+ function upsertDailySummary(db, summary) {
489
+ const sql = `INSERT OR REPLACE INTO daily_summary (
490
+ id, date, source, session_count, message_count, tool_call_count,
491
+ tokens_input, tokens_output, tokens_reasoning,
492
+ tokens_cache_read, tokens_cache_write, cost_usd,
493
+ first_activity_at, last_activity_at, active_minutes,
494
+ peak_hour, error_count, revert_count, additions, deletions
495
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
496
+
497
+ db.run(sql, [
498
+ summary.id,
499
+ summary.date,
500
+ summary.source,
501
+ summary.session_count || 0,
502
+ summary.message_count || 0,
503
+ summary.tool_call_count || 0,
504
+ summary.tokens_input || 0,
505
+ summary.tokens_output || 0,
506
+ summary.tokens_reasoning || 0,
507
+ summary.tokens_cache_read || 0,
508
+ summary.tokens_cache_write || 0,
509
+ summary.cost_usd || 0,
510
+ summary.first_activity_at || null,
511
+ summary.last_activity_at || null,
512
+ summary.active_minutes || 0,
513
+ summary.peak_hour ?? null,
514
+ summary.error_count || 0,
515
+ summary.revert_count || 0,
516
+ summary.additions || 0,
517
+ summary.deletions || 0,
518
+ ]);
519
+ }
520
+
521
+ /**
522
+ * Get daily summary for a specific date and source.
523
+ * @param {Object} db
524
+ * @param {string} date
525
+ * @param {string} [source]
526
+ * @returns {Object|null}
527
+ */
528
+ function getDailySummary(db, date, source) {
529
+ // Default to the 'all' rollup row to keep parity with getDailySummaries
530
+ // and prevent double-counting across sources.
531
+ const effectiveSource = source || 'all';
532
+ if (effectiveSource) {
533
+ return queryOne(
534
+ db,
535
+ 'SELECT * FROM daily_summary WHERE date = ? AND source = ?',
536
+ [date, effectiveSource]
537
+ );
538
+ }
539
+ return queryOne(
540
+ db,
541
+ 'SELECT * FROM daily_summary WHERE date = ?',
542
+ [date]
543
+ );
544
+ }
545
+
546
+ /**
547
+ * Get daily summaries in a date range.
548
+ * @param {Object} db
549
+ * @param {string} fromDate
550
+ * @param {string} toDate
551
+ * @param {string} [source]
552
+ * @returns {Object[]}
553
+ */
554
+ function getDailySummaries(db, fromDate, toDate, source) {
555
+ // When no source is specified, return the cross-source rollup ('all') to
556
+ // avoid double-counting (one row per source PLUS one 'all' row are written
557
+ // per date by the daily aggregator).
558
+ const effectiveSource = source || 'all';
559
+ return queryAll(
560
+ db,
561
+ 'SELECT * FROM daily_summary WHERE date >= ? AND date <= ? AND source = ? ORDER BY date',
562
+ [fromDate, toDate, effectiveSource]
563
+ );
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Hourly activity queries
568
+ // ---------------------------------------------------------------------------
569
+
570
+ /**
571
+ * Insert or replace an hourly activity row.
572
+ * @param {Object} db
573
+ * @param {Object} activity
574
+ */
575
+ function upsertHourlyActivity(db, activity) {
576
+ const sql = `INSERT OR REPLACE INTO hourly_activity (
577
+ date, hour, source,
578
+ message_count, session_count, error_count, tool_call_count
579
+ ) VALUES (?,?,?,?,?,?,?)`;
580
+
581
+ db.run(sql, [
582
+ activity.date,
583
+ activity.hour,
584
+ activity.source,
585
+ activity.message_count || 0,
586
+ activity.session_count || 0,
587
+ activity.error_count || 0,
588
+ activity.tool_call_count || 0,
589
+ ]);
590
+ }
591
+
592
+ /**
593
+ * Get hourly activity rows for a date, optionally filtered by source.
594
+ * @param {Object} db
595
+ * @param {string} date
596
+ * @param {string} [source]
597
+ * @returns {Object[]}
598
+ */
599
+ function getHourlyActivity(db, date, source) {
600
+ if (source) {
601
+ return queryAll(
602
+ db,
603
+ 'SELECT * FROM hourly_activity WHERE date = ? AND source = ? ORDER BY hour',
604
+ [date, source]
605
+ );
606
+ }
607
+ return queryAll(
608
+ db,
609
+ 'SELECT * FROM hourly_activity WHERE date = ? ORDER BY hour',
610
+ [date]
611
+ );
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // Settings queries
616
+ // ---------------------------------------------------------------------------
617
+
618
+ /**
619
+ * Get a single setting value by key.
620
+ * @param {Object} db
621
+ * @param {string} key
622
+ * @returns {string|null}
623
+ */
624
+ function getSetting(db, key) {
625
+ const row = queryOne(db, 'SELECT value FROM user_settings WHERE key = ?', [key]);
626
+ return row ? row.value : null;
627
+ }
628
+
629
+ /**
630
+ * Set a setting value (insert or update).
631
+ * @param {Object} db
632
+ * @param {string} key
633
+ * @param {string} value
634
+ */
635
+ function setSetting(db, key, value) {
636
+ db.run(
637
+ 'INSERT OR REPLACE INTO user_settings (key, value) VALUES (?, ?)',
638
+ [key, value]
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Get all settings as a plain object { key: value }.
644
+ * @param {Object} db
645
+ * @returns {Object}
646
+ */
647
+ function getAllSettings(db) {
648
+ const rows = queryAll(db, 'SELECT key, value FROM user_settings');
649
+ const settings = {};
650
+ for (const row of rows) {
651
+ settings[row.key] = row.value;
652
+ }
653
+ return settings;
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // ETL state queries
658
+ // ---------------------------------------------------------------------------
659
+
660
+ /**
661
+ * Get ETL state for a source.
662
+ * @param {Object} db
663
+ * @param {string} source
664
+ * @returns {Object|null}
665
+ */
666
+ function getEtlState(db, source) {
667
+ return queryOne(db, 'SELECT * FROM etl_state WHERE source = ?', [source]);
668
+ }
669
+
670
+ /**
671
+ * Update ETL state for a source (upsert).
672
+ * @param {Object} db
673
+ * @param {string} source
674
+ * @param {Object} data
675
+ */
676
+ function updateEtlState(db, source, data) {
677
+ const sql = `INSERT OR REPLACE INTO etl_state (
678
+ source, last_sync_at, last_session_id, last_session_time, status
679
+ ) VALUES (?,?,?,?,?)`;
680
+
681
+ db.run(sql, [
682
+ source,
683
+ data.last_sync_at || null,
684
+ data.last_session_id || null,
685
+ data.last_session_time || null,
686
+ data.status || 'idle',
687
+ ]);
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Analysis state queries
692
+ // ---------------------------------------------------------------------------
693
+
694
+ /**
695
+ * Get analysis state (singleton row, id=1).
696
+ * @param {Object} db
697
+ * @returns {Object|null}
698
+ */
699
+ function getAnalysisState(db) {
700
+ return queryOne(db, 'SELECT * FROM analysis_state WHERE id = 1');
701
+ }
702
+
703
+ /**
704
+ * Update the analysis state (singleton row, id=1).
705
+ * @param {Object} db
706
+ * @param {Object} data
707
+ */
708
+ function updateAnalysisState(db, data) {
709
+ const sql = `INSERT OR REPLACE INTO analysis_state (
710
+ id, status, current_date, analyzed_count, total_count, last_analyzed_at
711
+ ) VALUES (1,?,?,?,?,?)`;
712
+
713
+ db.run(sql, [
714
+ data.status || 'idle',
715
+ data.current_date || null,
716
+ data.analyzed_count || 0,
717
+ data.total_count || 0,
718
+ data.last_analyzed_at || null,
719
+ ]);
720
+ }
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // Tool config queries
724
+ // ---------------------------------------------------------------------------
725
+
726
+ /**
727
+ * Get configuration for a specific tool.
728
+ * @param {Object} db
729
+ * @param {string} tool
730
+ * @returns {Object|null}
731
+ */
732
+ function getToolConfig(db, tool) {
733
+ return queryOne(db, 'SELECT * FROM tool_config WHERE tool = ?', [tool]);
734
+ }
735
+
736
+ /**
737
+ * Get all tool configurations.
738
+ * @param {Object} db
739
+ * @returns {Object[]}
740
+ */
741
+ function getAllToolConfigs(db) {
742
+ return queryAll(db, 'SELECT * FROM tool_config ORDER BY tool');
743
+ }
744
+
745
+ /**
746
+ * Insert or replace a tool configuration.
747
+ * @param {Object} db
748
+ * @param {Object} config
749
+ */
750
+ function upsertToolConfig(db, config) {
751
+ const sql = `INSERT OR REPLACE INTO tool_config (
752
+ tool, enabled, data_path, status, label
753
+ ) VALUES (?,?,?,?,?)`;
754
+
755
+ db.run(sql, [
756
+ config.tool,
757
+ config.enabled ?? 1,
758
+ config.data_path || null,
759
+ config.status || 'unknown',
760
+ config.label,
761
+ ]);
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Overview (home page) — ETL-direct aggregations
766
+ // ---------------------------------------------------------------------------
767
+ //
768
+ // All four helpers below read only from unified_session and never touch the
769
+ // analysis-layer tables. This guarantees the home page renders immediately
770
+ // after ETL, without waiting for the LLM-driven scoring job to catch up.
771
+ //
772
+
773
+ /**
774
+ * Earliest session date present in unified_session, or null if empty.
775
+ * Used by the overview API to resolve the "all-time" date range without
776
+ * forking every helper to accept an open-ended bound.
777
+ *
778
+ * @param {Object} db
779
+ * @returns {string|null} YYYY-MM-DD or null
780
+ */
781
+ function getEarliestSessionDate(db) {
782
+ const r = queryOne(db, 'SELECT MIN(date) AS d FROM unified_session');
783
+ return r && r.d ? r.d : null;
784
+ }
785
+
786
+ /**
787
+ * Single-day snapshot aggregated across all sources.
788
+ * Returns a zero-filled object (never null) so the UI can render safely.
789
+ *
790
+ * @param {Object} db
791
+ * @param {string} date YYYY-MM-DD
792
+ * @returns {Object}
793
+ */
794
+ function getOverviewSnapshot(db, date) {
795
+ const row = queryOne(
796
+ db,
797
+ `SELECT
798
+ COUNT(*) AS sessions,
799
+ COALESCE(SUM(active_minutes), 0) AS active_minutes,
800
+ COALESCE(SUM(cost_usd), 0) AS cost,
801
+ COALESCE(SUM(tokens_input), 0) AS tokens_input,
802
+ COALESCE(SUM(tokens_output), 0) AS tokens_output,
803
+ COALESCE(SUM(message_count), 0) AS messages,
804
+ COALESCE(SUM(error_count), 0) AS errors,
805
+ COALESCE(SUM(reverted), 0) AS reverted
806
+ FROM unified_session
807
+ WHERE date = ?`,
808
+ [date]
809
+ ) || {};
810
+
811
+ const tokensInput = row.tokens_input || 0;
812
+ const tokensOutput = row.tokens_output || 0;
813
+
814
+ return {
815
+ date,
816
+ sessions: row.sessions || 0,
817
+ activeMinutes: row.active_minutes || 0,
818
+ cost: Math.round((row.cost || 0) * 10000) / 10000,
819
+ tokensInput,
820
+ tokensOutput,
821
+ tokensTotal: tokensInput + tokensOutput,
822
+ messages: row.messages || 0,
823
+ errors: row.errors || 0,
824
+ reverted: row.reverted || 0,
825
+ };
826
+ }
827
+
828
+ /**
829
+ * Daily trend rows for the home page, broken out by source so the UI can
830
+ * render two overlaid lines (opencode vs claude-code).
831
+ *
832
+ * @param {Object} db
833
+ * @param {string} fromDate YYYY-MM-DD (inclusive)
834
+ * @param {string} toDate YYYY-MM-DD (inclusive)
835
+ * @returns {Object[]}
836
+ */
837
+ function getOverviewTrend(db, fromDate, toDate) {
838
+ return queryAll(
839
+ db,
840
+ `SELECT
841
+ date,
842
+ source,
843
+ COUNT(*) AS sessions,
844
+ COALESCE(SUM(cost_usd), 0) AS cost,
845
+ COALESCE(SUM(active_minutes), 0) AS active_minutes
846
+ FROM unified_session
847
+ WHERE date BETWEEN ? AND ?
848
+ GROUP BY date, source
849
+ ORDER BY date ASC, source ASC`,
850
+ [fromDate, toDate]
851
+ );
852
+ }
853
+
854
+ /**
855
+ * Top projects within a date range, ranked by total cost (USD).
856
+ * Excludes rows where project is NULL / empty.
857
+ *
858
+ * @param {Object} db
859
+ * @param {string} fromDate
860
+ * @param {string} toDate
861
+ * @param {number} [limit=8]
862
+ * @returns {Object[]}
863
+ */
864
+ function getOverviewTopProjects(db, fromDate, toDate, limit = 8) {
865
+ return queryAll(
866
+ db,
867
+ `SELECT
868
+ project,
869
+ COUNT(*) AS sessions,
870
+ COALESCE(SUM(cost_usd), 0) AS cost,
871
+ COALESCE(SUM(active_minutes), 0) AS active_minutes,
872
+ COALESCE(SUM(summary_additions), 0) AS additions,
873
+ COALESCE(SUM(summary_deletions), 0) AS deletions,
874
+ COALESCE(SUM(summary_files), 0) AS files
875
+ FROM unified_session
876
+ WHERE date BETWEEN ? AND ?
877
+ AND project IS NOT NULL
878
+ AND project <> ''
879
+ GROUP BY project
880
+ ORDER BY cost DESC, sessions DESC
881
+ LIMIT ?`,
882
+ [fromDate, toDate, limit]
883
+ );
884
+ }
885
+
886
+ /**
887
+ * Per-day cache effectiveness (hit-rate) for a date window.
888
+ * Hit rate = cache_read / (cache_read + cache_write + tokens_input).
889
+ * See the implementation below for why cache_write is in the denominator.
890
+ * Raw numerator/denominator are returned so the UI can change the formula
891
+ * later without another query.
892
+ *
893
+ * @param {Object} db
894
+ * @param {string} fromDate
895
+ * @param {string} toDate
896
+ * @returns {Object[]}
897
+ */
898
+ function getOverviewCacheRate(db, fromDate, toDate) {
899
+ const rows = queryAll(
900
+ db,
901
+ `SELECT
902
+ date,
903
+ COALESCE(SUM(tokens_input), 0) AS tokens_input,
904
+ COALESCE(SUM(tokens_cache_read), 0) AS cache_read,
905
+ COALESCE(SUM(tokens_cache_write), 0) AS cache_write
906
+ FROM unified_session
907
+ WHERE date BETWEEN ? AND ?
908
+ GROUP BY date
909
+ ORDER BY date ASC`,
910
+ [fromDate, toDate]
911
+ );
912
+ return rows.map((r) => {
913
+ const input = r.tokens_input || 0;
914
+ const cr = r.cache_read || 0;
915
+ const cw = r.cache_write || 0;
916
+ // Hit rate = reused cached input / total input tokens the model saw.
917
+ // Including cache_write in the denominator matters: those are tokens
918
+ // that *paid full price* this turn (writing them to the cache); only
919
+ // future turns benefit. Excluding cache_write would always inflate
920
+ // the rate toward ~100% in long sessions and obscure the moment a
921
+ // huge new context block was loaded.
922
+ const denom = cr + cw + input;
923
+ return {
924
+ date: r.date,
925
+ input,
926
+ cacheRead: cr,
927
+ cacheWrite: cw,
928
+ hitRate: denom > 0 ? Math.round((cr / denom) * 1000) / 10 : 0, // %
929
+ };
930
+ });
931
+ }
932
+
933
+ /**
934
+ * Per-day error rate for a date window.
935
+ * rate = SUM(error_count) / NULLIF(SUM(message_count), 0)
936
+ *
937
+ * @param {Object} db
938
+ * @param {string} fromDate
939
+ * @param {string} toDate
940
+ * @returns {Object[]}
941
+ */
942
+ function getOverviewErrorRate(db, fromDate, toDate) {
943
+ const rows = queryAll(
944
+ db,
945
+ `SELECT
946
+ date,
947
+ COALESCE(SUM(error_count), 0) AS errors,
948
+ COALESCE(SUM(message_count), 0) AS messages,
949
+ COALESCE(SUM(tool_call_count),0) AS tool_calls
950
+ FROM unified_session
951
+ WHERE date BETWEEN ? AND ?
952
+ GROUP BY date
953
+ ORDER BY date ASC`,
954
+ [fromDate, toDate]
955
+ );
956
+ return rows.map((r) => ({
957
+ date: r.date,
958
+ errors: r.errors || 0,
959
+ messages: r.messages || 0,
960
+ toolCalls: r.tool_calls || 0,
961
+ rate: r.messages > 0
962
+ ? Math.round((r.errors / r.messages) * 10000) / 100 // % with 2 decimals
963
+ : 0,
964
+ }));
965
+ }
966
+
967
+ /**
968
+ * Tool usage Top-N within a date window, with error-rate per tool.
969
+ * Joins unified_tool_call -> unified_session to apply the date filter.
970
+ *
971
+ * @param {Object} db
972
+ * @param {string} fromDate
973
+ * @param {string} toDate
974
+ * @param {number} [limit=10]
975
+ * @returns {Object[]}
976
+ */
977
+ function getOverviewTopTools(db, fromDate, toDate, limit = 10) {
978
+ const rows = queryAll(
979
+ db,
980
+ `SELECT
981
+ tc.tool_name AS tool,
982
+ COUNT(*) AS calls,
983
+ SUM(CASE WHEN tc.status = 'error' THEN 1 ELSE 0 END) AS errors,
984
+ COUNT(DISTINCT tc.session_id) AS sessions
985
+ FROM unified_tool_call tc
986
+ JOIN unified_session s ON s.id = tc.session_id
987
+ WHERE s.date BETWEEN ? AND ?
988
+ AND tc.tool_name IS NOT NULL
989
+ AND tc.tool_name <> ''
990
+ GROUP BY tc.tool_name
991
+ ORDER BY calls DESC
992
+ LIMIT ?`,
993
+ [fromDate, toDate, limit]
994
+ );
995
+ return rows.map((r) => ({
996
+ tool: r.tool,
997
+ calls: r.calls || 0,
998
+ errors: r.errors || 0,
999
+ sessions: r.sessions || 0,
1000
+ errorRate: r.calls > 0
1001
+ ? Math.round((r.errors / r.calls) * 1000) / 10 // % with 1 decimal
1002
+ : 0,
1003
+ }));
1004
+ }
1005
+
1006
+ /**
1007
+ * Most recent sessions across all sources / dates.
1008
+ *
1009
+ * Excludes subagents (parent_session_id IS NOT NULL) by default so the
1010
+ * "最近 10 个会话" panel only shows top-level work; aggregate stats on
1011
+ * the same page still include them via their own queries.
1012
+ *
1013
+ * @param {Object} db
1014
+ * @param {number} [limit=10]
1015
+ * @returns {Object[]}
1016
+ */
1017
+ function getOverviewRecentSessions(db, limit = 10) {
1018
+ return queryAll(
1019
+ db,
1020
+ `SELECT
1021
+ id,
1022
+ source,
1023
+ started_at,
1024
+ project,
1025
+ title,
1026
+ model,
1027
+ message_count,
1028
+ cost_usd,
1029
+ error_count,
1030
+ reverted
1031
+ FROM unified_session
1032
+ WHERE parent_session_id IS NULL
1033
+ ORDER BY started_at DESC
1034
+ LIMIT ?`,
1035
+ [limit]
1036
+ );
1037
+ }
1038
+
1039
+ // ---------------------------------------------------------------------------
1040
+ // Exports
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ module.exports = {
1044
+ // Internal helpers (exported for testing / advanced use)
1045
+ queryAll,
1046
+ queryOne,
1047
+
1048
+ // Session
1049
+ upsertSession,
1050
+ getSessionsByDate,
1051
+ getSessionById,
1052
+ getSessionsByDateRange,
1053
+ countSessionsByDateRange,
1054
+
1055
+ // Message
1056
+ getMessagesBySession,
1057
+ getUserMessageCountsByDateRange,
1058
+ getMessageRoleCounts,
1059
+ bulkInsertMessages,
1060
+
1061
+ // Part
1062
+ getPartsBySession,
1063
+ bulkInsertParts,
1064
+
1065
+ // Tool call
1066
+ getToolCallsBySession,
1067
+ bulkInsertToolCalls,
1068
+
1069
+ // Analysis
1070
+ upsertSessionAnalysis,
1071
+ getAnalysisBySession,
1072
+ getUnanalyzedSessions,
1073
+ upsertDailySummary,
1074
+ getDailySummary,
1075
+ getDailySummaries,
1076
+
1077
+ // Hourly activity
1078
+ upsertHourlyActivity,
1079
+ getHourlyActivity,
1080
+
1081
+ // Settings
1082
+ getSetting,
1083
+ setSetting,
1084
+ getAllSettings,
1085
+
1086
+ // ETL state
1087
+ getEtlState,
1088
+ updateEtlState,
1089
+
1090
+ // Analysis state
1091
+ getAnalysisState,
1092
+ updateAnalysisState,
1093
+
1094
+ // Tool config
1095
+ getToolConfig,
1096
+ getAllToolConfigs,
1097
+ upsertToolConfig,
1098
+
1099
+ // Overview (home page) — ETL-direct
1100
+ getOverviewSnapshot,
1101
+ getOverviewTrend,
1102
+ getOverviewTopProjects,
1103
+ getOverviewRecentSessions,
1104
+ getOverviewCacheRate,
1105
+ getOverviewErrorRate,
1106
+ getOverviewTopTools,
1107
+ getEarliestSessionDate,
1108
+ };