agentboss 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +34 -0
  2. package/bin/aboss.js +288 -0
  3. package/client/dist/assets/index-C1wFD_Vo.css +1 -0
  4. package/client/dist/assets/index-DBj1Ujlx.js +137 -0
  5. package/client/dist/index.html +34 -0
  6. package/package.json +64 -0
  7. package/server/analysis/daily-aggregator.js +258 -0
  8. package/server/analysis/difficulty.js +129 -0
  9. package/server/analysis/dimensions/ai-knowledge.js +172 -0
  10. package/server/analysis/dimensions/ai-tools.js +161 -0
  11. package/server/analysis/dimensions/judgement.js +107 -0
  12. package/server/analysis/dimensions/llm-merge.js +57 -0
  13. package/server/analysis/dimensions/output-quality.js +167 -0
  14. package/server/analysis/dimensions/problem-definition.js +104 -0
  15. package/server/analysis/dimensions/system-thinking.js +225 -0
  16. package/server/analysis/evidence-builder.js +104 -0
  17. package/server/analysis/job.js +273 -0
  18. package/server/analysis/report-builder.js +581 -0
  19. package/server/analysis/scoring-v2.js +72 -0
  20. package/server/analysis/text-signals.js +179 -0
  21. package/server/analysis/thresholds-v2.js +358 -0
  22. package/server/api/advice.js +124 -0
  23. package/server/api/analysis.js +141 -0
  24. package/server/api/execution.js +330 -0
  25. package/server/api/metrics.js +277 -0
  26. package/server/api/overview.js +308 -0
  27. package/server/api/project.js +255 -0
  28. package/server/api/reports.js +125 -0
  29. package/server/api/sessions.js +118 -0
  30. package/server/api/settings.js +119 -0
  31. package/server/db/connection.js +175 -0
  32. package/server/db/queries.js +1051 -0
  33. package/server/db/schema.js +487 -0
  34. package/server/etl/active-time.js +150 -0
  35. package/server/etl/backfill-subagents.js +178 -0
  36. package/server/etl/claude-code.js +826 -0
  37. package/server/etl/detect.js +341 -0
  38. package/server/etl/judge-filter.js +117 -0
  39. package/server/etl/opencode.js +606 -0
  40. package/server/execution/job.js +662 -0
  41. package/server/execution/prompt.js +227 -0
  42. package/server/execution/runner.js +218 -0
  43. package/server/index.js +94 -0
  44. package/server/llm/advice-prompt.js +339 -0
  45. package/server/llm/advice.js +384 -0
  46. package/server/llm/analysis-prompt.js +162 -0
  47. package/server/llm/cli-runner.js +249 -0
  48. package/server/llm/judge-prompts.js +179 -0
  49. package/server/llm/judge.js +118 -0
  50. package/server/llm/project-advice-prompt.js +332 -0
  51. package/server/llm/project-advice.js +491 -0
  52. package/server/llm/session-analyzer.js +122 -0
  53. package/server/utils/project.js +80 -0
@@ -0,0 +1,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
+ * 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
+ };