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,487 @@
1
+ /**
2
+ * Database schema for Agent Boss (boss.db)
3
+ * All tables as defined in design doc §5
4
+ *
5
+ * Uses sql.js (SQLite compiled to WebAssembly) for zero-native-dependency
6
+ * portability. The database lives in-memory with explicit file persistence
7
+ * managed by connection.js.
8
+ *
9
+ * @author Felix
10
+ */
11
+
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ /**
16
+ * Returns the default path to boss.db.
17
+ *
18
+ * @returns {string} Absolute path to ~/.agent-boss/boss.db
19
+ */
20
+ function getDbPath() {
21
+ const dir = path.join(os.homedir(), '.agent-boss');
22
+ return path.join(dir, 'boss.db');
23
+ }
24
+
25
+ /**
26
+ * Initialise all tables, indexes, and default seed data.
27
+ *
28
+ * sql.js notes:
29
+ * - db.run(sql, params) executes a single statement (with optional bindings).
30
+ * - db.exec(sql) executes one-or-more statements (no bindings).
31
+ * - No WAL mode — sql.js operates in-memory; persistence is handled
32
+ * externally via db.export() + fs.writeFileSync().
33
+ *
34
+ * @param {import('sql.js').Database} db An sql.js Database instance
35
+ */
36
+ function initDatabase(db) {
37
+ // Enable foreign keys (WAL & busy_timeout are N/A for in-memory sql.js)
38
+ db.run('PRAGMA foreign_keys = ON;');
39
+
40
+ // ------------------------------------------------------------------
41
+ // §5.1 Unified intermediate layer (ETL writes)
42
+ // ------------------------------------------------------------------
43
+
44
+ db.exec(`
45
+ CREATE TABLE IF NOT EXISTS unified_session (
46
+ id TEXT PRIMARY KEY,
47
+ source TEXT NOT NULL,
48
+ date TEXT NOT NULL,
49
+ started_at TEXT NOT NULL,
50
+ ended_at TEXT,
51
+ duration_minutes INTEGER,
52
+ active_minutes INTEGER,
53
+ message_count INTEGER DEFAULT 0,
54
+ tokens_input INTEGER DEFAULT 0,
55
+ tokens_output INTEGER DEFAULT 0,
56
+ tokens_reasoning INTEGER DEFAULT 0,
57
+ tokens_cache_read INTEGER DEFAULT 0,
58
+ tokens_cache_write INTEGER DEFAULT 0,
59
+ cost_usd REAL DEFAULT 0,
60
+ project TEXT,
61
+ title TEXT,
62
+ model TEXT,
63
+ error_count INTEGER DEFAULT 0,
64
+ tool_call_count INTEGER DEFAULT 0,
65
+ summary_additions INTEGER DEFAULT 0,
66
+ summary_deletions INTEGER DEFAULT 0,
67
+ summary_files INTEGER DEFAULT 0,
68
+ reverted INTEGER DEFAULT 0,
69
+ time_compacting REAL DEFAULT 0
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_unified_session_date
73
+ ON unified_session(date);
74
+ CREATE INDEX IF NOT EXISTS idx_unified_session_source_date
75
+ ON unified_session(source, date);
76
+ CREATE INDEX IF NOT EXISTS idx_unified_session_project
77
+ ON unified_session(project);
78
+ `);
79
+
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS unified_message (
82
+ id TEXT PRIMARY KEY,
83
+ session_id TEXT NOT NULL,
84
+ source TEXT NOT NULL,
85
+ role TEXT NOT NULL,
86
+ timestamp TEXT NOT NULL,
87
+ tokens_input INTEGER DEFAULT 0,
88
+ tokens_output INTEGER DEFAULT 0,
89
+ tokens_reasoning INTEGER DEFAULT 0,
90
+ cost_usd REAL DEFAULT 0,
91
+ content_length INTEGER DEFAULT 0,
92
+ is_error INTEGER DEFAULT 0,
93
+ model_id TEXT
94
+ );
95
+
96
+ CREATE INDEX IF NOT EXISTS idx_unified_message_session
97
+ ON unified_message(session_id, timestamp);
98
+ CREATE INDEX IF NOT EXISTS idx_unified_message_source
99
+ ON unified_message(source, timestamp);
100
+ `);
101
+
102
+ db.exec(`
103
+ CREATE TABLE IF NOT EXISTS unified_part (
104
+ id TEXT PRIMARY KEY,
105
+ message_id TEXT NOT NULL,
106
+ session_id TEXT NOT NULL,
107
+ source TEXT NOT NULL,
108
+ type TEXT NOT NULL,
109
+ timestamp TEXT NOT NULL
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_unified_part_session
113
+ ON unified_part(session_id);
114
+ CREATE INDEX IF NOT EXISTS idx_unified_part_message
115
+ ON unified_part(message_id);
116
+ `);
117
+
118
+ db.exec(`
119
+ CREATE TABLE IF NOT EXISTS unified_tool_call (
120
+ id TEXT PRIMARY KEY,
121
+ part_id TEXT NOT NULL,
122
+ session_id TEXT NOT NULL,
123
+ source TEXT NOT NULL,
124
+ tool_name TEXT NOT NULL,
125
+ timestamp TEXT NOT NULL,
126
+ status TEXT,
127
+ error_message TEXT,
128
+ target_file TEXT
129
+ );
130
+
131
+ CREATE INDEX IF NOT EXISTS idx_unified_tool_call_session
132
+ ON unified_tool_call(session_id, timestamp);
133
+ CREATE INDEX IF NOT EXISTS idx_unified_tool_call_tool
134
+ ON unified_tool_call(tool_name);
135
+ `);
136
+
137
+ // ------------------------------------------------------------------
138
+ // §5.2 Analysis results layer (analysis job writes)
139
+ // ------------------------------------------------------------------
140
+
141
+ db.exec(`
142
+ CREATE TABLE IF NOT EXISTS session_analysis (
143
+ session_id TEXT PRIMARY KEY,
144
+ source TEXT,
145
+ analyzed_at TEXT,
146
+ score_a REAL,
147
+ score_b REAL,
148
+ score_c REAL,
149
+ score_d REAL,
150
+ score_e REAL,
151
+ sub_scores TEXT,
152
+ highlight_dims TEXT,
153
+ status TEXT DEFAULT 'pending'
154
+ );
155
+ `);
156
+
157
+ db.exec(`
158
+ CREATE TABLE IF NOT EXISTS daily_summary (
159
+ id TEXT PRIMARY KEY,
160
+ date TEXT NOT NULL,
161
+ source TEXT NOT NULL,
162
+ session_count INTEGER DEFAULT 0,
163
+ message_count INTEGER DEFAULT 0,
164
+ tool_call_count INTEGER DEFAULT 0,
165
+ tokens_input INTEGER DEFAULT 0,
166
+ tokens_output INTEGER DEFAULT 0,
167
+ tokens_reasoning INTEGER DEFAULT 0,
168
+ tokens_cache_read INTEGER DEFAULT 0,
169
+ tokens_cache_write INTEGER DEFAULT 0,
170
+ cost_usd REAL DEFAULT 0,
171
+ first_activity_at TEXT,
172
+ last_activity_at TEXT,
173
+ active_minutes INTEGER DEFAULT 0,
174
+ peak_hour INTEGER,
175
+ error_count INTEGER DEFAULT 0,
176
+ revert_count INTEGER DEFAULT 0,
177
+ additions INTEGER DEFAULT 0,
178
+ deletions INTEGER DEFAULT 0
179
+ );
180
+
181
+ CREATE INDEX IF NOT EXISTS idx_daily_summary_date
182
+ ON daily_summary(date);
183
+ CREATE INDEX IF NOT EXISTS idx_daily_summary_source_date
184
+ ON daily_summary(source, date);
185
+ `);
186
+
187
+ db.exec(`
188
+ CREATE TABLE IF NOT EXISTS hourly_activity (
189
+ date TEXT NOT NULL,
190
+ hour INTEGER NOT NULL,
191
+ source TEXT NOT NULL,
192
+ message_count INTEGER DEFAULT 0,
193
+ session_count INTEGER DEFAULT 0,
194
+ error_count INTEGER DEFAULT 0,
195
+ tool_call_count INTEGER DEFAULT 0,
196
+ PRIMARY KEY (date, hour, source)
197
+ );
198
+ `);
199
+
200
+ db.exec(`
201
+ CREATE TABLE IF NOT EXISTS dimension_score (
202
+ id TEXT PRIMARY KEY,
203
+ date TEXT,
204
+ period TEXT,
205
+ source TEXT,
206
+ score_a REAL,
207
+ score_b REAL,
208
+ score_c REAL,
209
+ score_d REAL,
210
+ score_e REAL,
211
+ session_count INTEGER
212
+ );
213
+ `);
214
+
215
+ db.exec(`
216
+ CREATE TABLE IF NOT EXISTS insight (
217
+ id TEXT PRIMARY KEY,
218
+ date TEXT,
219
+ period TEXT,
220
+ dimension TEXT,
221
+ type TEXT,
222
+ text TEXT,
223
+ severity TEXT,
224
+ evidence TEXT
225
+ );
226
+ `);
227
+
228
+ db.exec(`
229
+ CREATE TABLE IF NOT EXISTS collab_bill (
230
+ id TEXT PRIMARY KEY,
231
+ date TEXT NOT NULL,
232
+ period TEXT NOT NULL,
233
+ source TEXT,
234
+ rework_minutes REAL DEFAULT 0,
235
+ abandon_minutes REAL DEFAULT 0,
236
+ idle_output_minutes REAL DEFAULT 0,
237
+ model_mismatch_cost REAL DEFAULT 0,
238
+ model_mismatch_count INTEGER DEFAULT 0,
239
+ sunk_cost_usd REAL DEFAULT 0,
240
+ sunk_cost_minutes REAL DEFAULT 0,
241
+ sunk_session_count INTEGER DEFAULT 0,
242
+ hourly_rate REAL
243
+ );
244
+ `);
245
+
246
+ // ------------------------------------------------------------------
247
+ // §5.3 Configuration & state layer
248
+ // ------------------------------------------------------------------
249
+
250
+ db.exec(`
251
+ CREATE TABLE IF NOT EXISTS tool_config (
252
+ tool TEXT PRIMARY KEY,
253
+ enabled INTEGER DEFAULT 1,
254
+ data_path TEXT,
255
+ status TEXT DEFAULT 'unknown',
256
+ label TEXT NOT NULL
257
+ );
258
+ `);
259
+
260
+ db.exec(`
261
+ CREATE TABLE IF NOT EXISTS user_settings (
262
+ key TEXT PRIMARY KEY,
263
+ value TEXT
264
+ );
265
+ `);
266
+
267
+ db.exec(`
268
+ CREATE TABLE IF NOT EXISTS etl_state (
269
+ source TEXT PRIMARY KEY,
270
+ last_sync_at TEXT,
271
+ last_session_id TEXT,
272
+ last_session_time TEXT,
273
+ status TEXT DEFAULT 'idle'
274
+ );
275
+ `);
276
+
277
+ db.exec(`
278
+ CREATE TABLE IF NOT EXISTS analysis_state (
279
+ id INTEGER PRIMARY KEY DEFAULT 1,
280
+ status TEXT DEFAULT 'idle',
281
+ current_date TEXT,
282
+ analyzed_count INTEGER DEFAULT 0,
283
+ total_count INTEGER DEFAULT 0,
284
+ last_analyzed_at TEXT
285
+ );
286
+ `);
287
+
288
+ // ------------------------------------------------------------------
289
+ // §5.4 Advice execution layer
290
+ //
291
+ // Per-AdviceItem runs of opencode/claude inside the session's project
292
+ // directory. See server/execution/job.js and
293
+ // docs/superpowers/specs/2026-06-13-advice-execution-design.md.
294
+ // ------------------------------------------------------------------
295
+
296
+ db.exec(`
297
+ CREATE TABLE IF NOT EXISTS execution_run (
298
+ id TEXT PRIMARY KEY,
299
+ session_id TEXT NOT NULL,
300
+ advice_key TEXT NOT NULL,
301
+ advice_snapshot TEXT NOT NULL,
302
+ project TEXT NOT NULL,
303
+ executor TEXT NOT NULL,
304
+ status TEXT NOT NULL,
305
+ started_at TEXT,
306
+ ended_at TEXT,
307
+ exit_code INTEGER,
308
+ stdout TEXT,
309
+ stderr TEXT,
310
+ error TEXT,
311
+ duration_ms INTEGER
312
+ );
313
+
314
+ CREATE INDEX IF NOT EXISTS idx_execution_run_session
315
+ ON execution_run(session_id, advice_key, started_at DESC);
316
+ CREATE INDEX IF NOT EXISTS idx_execution_run_status
317
+ ON execution_run(status);
318
+ `);
319
+
320
+ // ----- Seed default rows -----
321
+
322
+ const defaultSettings = [
323
+ ['hourly_rate', '0'],
324
+ ['display_currency', 'USD'],
325
+ ['currency_rate', '1'],
326
+ ['idle_threshold_minutes', '5'],
327
+ ['llm_tool_preference', 'auto'],
328
+ // v2: opt-in LLM judge for E1/O1 dimensions
329
+ ['enable_llm_judge', '0'],
330
+ ];
331
+
332
+ const stmt = db.prepare(
333
+ 'INSERT OR IGNORE INTO user_settings (key, value) VALUES (?, ?)'
334
+ );
335
+ for (const [key, value] of defaultSettings) {
336
+ stmt.bind([key, value]);
337
+ stmt.step();
338
+ stmt.reset();
339
+ }
340
+ stmt.free();
341
+
342
+ // Default analysis state row
343
+ db.run(
344
+ 'INSERT OR IGNORE INTO analysis_state (id, status) VALUES (?, ?)',
345
+ [1, 'idle']
346
+ );
347
+
348
+ // ------------------------------------------------------------------
349
+ // §5.4 v2 capability-model additions (additive; tolerated on legacy)
350
+ // ------------------------------------------------------------------
351
+
352
+ applyV2Migrations(db);
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Migration helpers
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Add a column to an existing table only when missing. Uses
361
+ * `PRAGMA table_info` to discover existing columns. This is the cheapest
362
+ * possible migration runner — good enough while the project is pre-1.0
363
+ * and the only writer is a single in-memory sql.js instance.
364
+ *
365
+ * @param {object} db sql.js database
366
+ * @param {string} table table name
367
+ * @param {string} col column name to add
368
+ * @param {string} ddl the column DDL fragment, e.g. "TEXT DEFAULT NULL"
369
+ */
370
+ function ensureColumn(db, table, col, ddl) {
371
+ const res = db.exec(`PRAGMA table_info(${table})`);
372
+ if (!res || !res[0]) return;
373
+ const cols = res[0].values.map((r) => r[1]);
374
+ if (cols.includes(col)) return;
375
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${ddl}`);
376
+ }
377
+
378
+ /** Add columns introduced for the v2 capability model. */
379
+ function applyV2Migrations(db) {
380
+ // unified_message: store text payload so text-based signals can run
381
+ // without re-opening the source DB. Defaults to NULL on legacy rows.
382
+ ensureColumn(db, 'unified_message', 'text', 'TEXT');
383
+
384
+ // unified_session: track which sessions are subagents (delegated by a
385
+ // parent task call) and what flavour they are ('build' / 'explore' /
386
+ // null for top-level). Used so the "会话列表" views hide subagents by
387
+ // default while statistics (cost / token / advice generation) still
388
+ // include them. Legacy rows have NULL parent_session_id; a startup
389
+ // backfill (server/etl/backfill-subagents.js) repairs them from the
390
+ // source opencode.db.
391
+ ensureColumn(db, 'unified_session', 'parent_session_id', 'TEXT');
392
+ ensureColumn(db, 'unified_session', 'agent_type', 'TEXT');
393
+ db.exec(`
394
+ CREATE INDEX IF NOT EXISTS idx_unified_session_parent
395
+ ON unified_session(parent_session_id);
396
+ `);
397
+
398
+ // session_analysis: v2 scores per dimension + difficulty + LLM cache.
399
+ ensureColumn(db, 'session_analysis', 'difficulty', 'INTEGER');
400
+ ensureColumn(db, 'session_analysis', 'score_h1', 'REAL');
401
+ ensureColumn(db, 'session_analysis', 'level_h1', 'INTEGER');
402
+ ensureColumn(db, 'session_analysis', 'score_h2', 'REAL');
403
+ ensureColumn(db, 'session_analysis', 'level_h2', 'INTEGER');
404
+ ensureColumn(db, 'session_analysis', 'score_h3', 'REAL');
405
+ ensureColumn(db, 'session_analysis', 'level_h3', 'INTEGER');
406
+ ensureColumn(db, 'session_analysis', 'score_e1', 'REAL');
407
+ ensureColumn(db, 'session_analysis', 'level_e1', 'INTEGER');
408
+ ensureColumn(db, 'session_analysis', 'score_e2', 'REAL');
409
+ ensureColumn(db, 'session_analysis', 'level_e2', 'INTEGER');
410
+ ensureColumn(db, 'session_analysis', 'score_o1', 'REAL');
411
+ ensureColumn(db, 'session_analysis', 'level_o1', 'INTEGER');
412
+ ensureColumn(db, 'session_analysis', 'sub_scores_v2', 'TEXT');
413
+ ensureColumn(db, 'session_analysis', 'llm_judge_v2', 'TEXT');
414
+ ensureColumn(db, 'session_analysis', 'judge_source', 'TEXT');
415
+
416
+ // Per-session AI advice (cost / accuracy / context / skills / workflow).
417
+ // Stores the latest llm-generated suggestion blob. Versioned via
418
+ // ADVICE_PROMPT_VERSION inside the JSON payload — bumping that constant
419
+ // makes old caches self-invalidate without a schema change. See
420
+ // server/llm/advice-prompt.js and docs/superpowers/specs/2026-06-13-session-advice-design.md.
421
+ ensureColumn(db, 'session_analysis', 'llm_advice', 'TEXT');
422
+
423
+ // -- execution_run: scope-extended for project-level executions --
424
+ //
425
+ // Original rows store a session_id and look up the AdviceItem in
426
+ // session_analysis.llm_advice. Project-level runs use scope='project',
427
+ // a synthetic key in scope_id (canonical project path + scope details),
428
+ // and look up the AdviceItem in project_advice.llm_advice. Older rows
429
+ // (from before this migration) implicitly have scope='session' /
430
+ // scope_id=session_id; the read path treats NULL scope as 'session'.
431
+ ensureColumn(db, 'execution_run', 'scope', "TEXT"); // 'session' | 'project'
432
+ ensureColumn(db, 'execution_run', 'scope_id', 'TEXT'); // session_id OR canonical project path
433
+ ensureColumn(db, 'execution_run', 'scope_meta', 'TEXT'); // JSON {scope:'project', period, from, to} for project runs
434
+ db.exec(`
435
+ CREATE INDEX IF NOT EXISTS idx_execution_run_scope
436
+ ON execution_run(scope, scope_id, advice_key, started_at DESC);
437
+ `);
438
+
439
+ // Project-level AI advice (cross-session meta-analysis). Aggregates the
440
+ // already-cached per-session llm_advice payloads under a given project
441
+ // for a given window into a single new payload. PK is project + scope
442
+ // + window_from + window_to so the same project can be analysed multiple
443
+ // times (e.g. yesterday window AND weekly window) without colliding.
444
+ //
445
+ // See server/llm/project-advice.js and the API at /api/project/:key/advice.
446
+ db.exec(`
447
+ CREATE TABLE IF NOT EXISTS project_advice (
448
+ project TEXT NOT NULL,
449
+ scope TEXT NOT NULL, -- 'daily' | 'weekly' | 'all'
450
+ window_from TEXT NOT NULL, -- inclusive YYYY-MM-DD ('' when scope='all')
451
+ window_to TEXT NOT NULL, -- inclusive YYYY-MM-DD ('' when scope='all')
452
+ session_count INTEGER DEFAULT 0,
453
+ session_ids TEXT, -- JSON array of session ids used
454
+ llm_advice TEXT, -- JSON payload (same shape as session llm_advice + crossSessionPatterns)
455
+ v INTEGER, -- PROJECT_ADVICE_PROMPT_VERSION at time of generation
456
+ cli TEXT,
457
+ cached_at TEXT,
458
+ PRIMARY KEY (project, scope, window_from, window_to)
459
+ );
460
+
461
+ CREATE INDEX IF NOT EXISTS idx_project_advice_project
462
+ ON project_advice(project);
463
+ `);
464
+
465
+ // Rolling capability aggregate (H3 lives here).
466
+ db.exec(`
467
+ CREATE TABLE IF NOT EXISTS capability_rollup_v2 (
468
+ id TEXT PRIMARY KEY,
469
+ period TEXT NOT NULL,
470
+ end_date TEXT NOT NULL,
471
+ window_days INTEGER NOT NULL,
472
+ score_h1 REAL, level_h1 INTEGER,
473
+ score_h2 REAL, level_h2 INTEGER,
474
+ score_h3 REAL, level_h3 INTEGER,
475
+ score_e1 REAL, level_e1 INTEGER,
476
+ score_e2 REAL, level_e2 INTEGER,
477
+ score_o1 REAL, level_o1 INTEGER,
478
+ sub_scores_v2 TEXT,
479
+ computed_at TEXT
480
+ );
481
+
482
+ CREATE INDEX IF NOT EXISTS idx_capability_rollup_v2_end
483
+ ON capability_rollup_v2(period, end_date);
484
+ `);
485
+ }
486
+
487
+ module.exports = { initDatabase, getDbPath, applyV2Migrations };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Active-time calculation for Agent Boss sessions (design doc §4.8)
3
+ *
4
+ * Algorithm:
5
+ * 1. Take all messages for a session from unified_message, ordered by timestamp
6
+ * 2. Calculate time intervals between adjacent messages
7
+ * 3. Intervals <= IDLE_THRESHOLD (default 5 min) are "active"
8
+ * 4. Intervals > IDLE_THRESHOLD are "idle" and excluded
9
+ * 5. active_minutes = SUM(active intervals) + end cap (1 min, capped)
10
+ *
11
+ * @author Felix
12
+ */
13
+
14
+ const { getSetting, queryAll } = require('../db/queries');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Constants
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Default idle threshold in minutes */
21
+ const DEFAULT_IDLE_THRESHOLD_MINUTES = 5;
22
+
23
+ /** End cap added after the last active interval (minutes) */
24
+ const END_CAP_MINUTES = 1;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Pure computation helper
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Calculate active minutes for a single session given its messages.
32
+ *
33
+ * @param {Array<{timestamp: string}>} messages - array of objects with ISO 8601
34
+ * timestamp strings, sorted ASC
35
+ * @param {number} idleThresholdMs - threshold in milliseconds; intervals longer
36
+ * than this are considered idle
37
+ * @returns {number} active minutes (integer, >= 0)
38
+ */
39
+ function computeActiveMinutes(messages, idleThresholdMs) {
40
+ if (!messages || messages.length === 0) {
41
+ return 0;
42
+ }
43
+ if (messages.length === 1) {
44
+ return 1; // minimum for a single-message session
45
+ }
46
+
47
+ // Parse timestamps once
48
+ const times = messages.map((m) => new Date(m.timestamp).getTime());
49
+
50
+ let activeMs = 0;
51
+
52
+ for (let i = 1; i < times.length; i++) {
53
+ const interval = times[i] - times[i - 1];
54
+ if (interval <= idleThresholdMs) {
55
+ activeMs += interval;
56
+ }
57
+ // idle intervals are simply skipped
58
+ }
59
+
60
+ // End cap: add 1 minute, but don't exceed the last interval if it's < 1 min
61
+ const lastInterval = times[times.length - 1] - times[times.length - 2];
62
+ const endCapMs = END_CAP_MINUTES * 60 * 1000;
63
+ const cappedEndMs = Math.min(endCapMs, lastInterval);
64
+ activeMs += cappedEndMs;
65
+
66
+ // Convert to integer minutes (ceil so sub-minute activity isn't lost)
67
+ return Math.ceil(activeMs / 60000);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Main batch function
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Calculate active_minutes for all sessions that don't have it yet.
76
+ *
77
+ * Resolution order for idle threshold:
78
+ * 1. options.idleThresholdMinutes (caller override)
79
+ * 2. user_settings row with key 'idle_threshold_minutes'
80
+ * 3. DEFAULT_IDLE_THRESHOLD_MINUTES (5)
81
+ *
82
+ * @param {object} db - sql.js database instance (boss.db)
83
+ * @param {object} [options] - { idleThresholdMinutes: number }
84
+ * @returns {number} count of sessions updated
85
+ */
86
+ function calculateActiveTime(db, options = {}) {
87
+ // --- resolve idle threshold ---
88
+ let thresholdMinutes = options.idleThresholdMinutes;
89
+
90
+ if (thresholdMinutes == null) {
91
+ const settingValue = getSetting(db, 'idle_threshold_minutes');
92
+ if (settingValue != null) {
93
+ thresholdMinutes = Number(settingValue);
94
+ }
95
+ }
96
+
97
+ if (thresholdMinutes == null || Number.isNaN(thresholdMinutes)) {
98
+ thresholdMinutes = DEFAULT_IDLE_THRESHOLD_MINUTES;
99
+ }
100
+
101
+ const idleThresholdMs = thresholdMinutes * 60 * 1000;
102
+
103
+ // --- find sessions that still need active_minutes ---
104
+ const sessions = queryAll(
105
+ db,
106
+ 'SELECT id FROM unified_session WHERE active_minutes IS NULL'
107
+ );
108
+
109
+ if (sessions.length === 0) {
110
+ return 0;
111
+ }
112
+
113
+ // --- process each session inside a single transaction ---
114
+ let updatedCount = 0;
115
+
116
+ db.run('BEGIN TRANSACTION');
117
+ try {
118
+ for (const session of sessions) {
119
+ const messages = queryAll(
120
+ db,
121
+ 'SELECT timestamp FROM unified_message WHERE session_id = ? ORDER BY timestamp ASC',
122
+ [session.id]
123
+ );
124
+
125
+ const activeMinutes = computeActiveMinutes(messages, idleThresholdMs);
126
+
127
+ db.run(
128
+ 'UPDATE unified_session SET active_minutes = ? WHERE id = ?',
129
+ [activeMinutes, session.id]
130
+ );
131
+
132
+ updatedCount++;
133
+ }
134
+ db.run('COMMIT');
135
+ } catch (err) {
136
+ db.run('ROLLBACK');
137
+ throw err;
138
+ }
139
+
140
+ return updatedCount;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Exports
145
+ // ---------------------------------------------------------------------------
146
+
147
+ module.exports = {
148
+ calculateActiveTime,
149
+ computeActiveMinutes,
150
+ };