@statforge/claudestat 1.6.1 → 1.7.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 (48) hide show
  1. package/README.md +3 -1
  2. package/dashboard/dist/assets/AnalyticsView-DDGLDoCN.js +7 -0
  3. package/dashboard/dist/assets/HistoryView-DkPfrNrv.js +1 -0
  4. package/dashboard/dist/assets/LineChart-BOWYkkEW.js +2 -0
  5. package/dashboard/dist/assets/ProjectsView-VRoRiEL4.js +6 -0
  6. package/dashboard/dist/assets/SystemView-B2zbIxhY.js +1 -0
  7. package/dashboard/dist/assets/TopView-C2qdsy0Y.js +1 -0
  8. package/dashboard/dist/assets/index-CMhe3KaT.js +84 -0
  9. package/dashboard/dist/assets/shared-BbBtsdh1.js +1 -0
  10. package/dashboard/dist/assets/{vendor-lucide-Cym0q5l_.js → vendor-lucide-ClCW-axQ.js} +79 -64
  11. package/dashboard/dist/assets/{vendor-react-B_Jzs0gY.js → vendor-react-gHSHIE2L.js} +1 -1
  12. package/dashboard/dist/index.html +3 -3
  13. package/dist/daemon.js +57 -1
  14. package/dist/db.d.ts +76 -2
  15. package/dist/db.js +295 -65
  16. package/dist/doctor.js +1 -1
  17. package/dist/enricher.d.ts +3 -2
  18. package/dist/enricher.js +10 -5
  19. package/dist/index.js +12 -1
  20. package/dist/intelligence.d.ts +55 -0
  21. package/dist/intelligence.js +163 -1
  22. package/dist/paths.d.ts +5 -0
  23. package/dist/paths.js +8 -0
  24. package/dist/pricing.d.ts +2 -0
  25. package/dist/pricing.js +12 -1
  26. package/dist/routes/events.js +136 -5
  27. package/dist/routes/history.js +6 -2
  28. package/dist/routes/intents.d.ts +1 -0
  29. package/dist/routes/intents.js +155 -0
  30. package/dist/routes/misc.js +131 -3
  31. package/dist/routes/opencode-reader.js +39 -3
  32. package/dist/routes/projects.js +10 -1
  33. package/dist/routes/replay.d.ts +1 -0
  34. package/dist/routes/replay.js +29 -0
  35. package/dist/routes/reports.js +7 -0
  36. package/dist/routes/top.js +8 -1
  37. package/dist/watchers/adapter.d.ts +1 -0
  38. package/dist/watchers/claude-code.d.ts +16 -1
  39. package/dist/watchers/claude-code.js +201 -76
  40. package/dist/watchers/opencode.d.ts +1 -0
  41. package/dist/watchers/opencode.js +152 -14
  42. package/package.json +1 -1
  43. package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
  44. package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
  45. package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
  46. package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
  47. package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
  48. package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
package/dist/db.js CHANGED
@@ -13,7 +13,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
14
14
  };
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.dbOps = exports.CLAUDESTAT_DIR = void 0;
16
+ exports.dbOps = exports.TOOL_RESPONSE_MAX_BYTES = exports.CLAUDESTAT_DIR = void 0;
17
+ exports.capToolResponse = capToolResponse;
17
18
  const node_sqlite_1 = require("node:sqlite");
18
19
  const path_1 = __importDefault(require("path"));
19
20
  const fs_1 = __importDefault(require("fs"));
@@ -22,35 +23,22 @@ exports.CLAUDESTAT_DIR = (0, paths_1.getClaudestatDir)();
22
23
  const DB_PATH = process.env.CLAUDESTAT_DB_PATH ?? path_1.default.join(exports.CLAUDESTAT_DIR, 'events.db');
23
24
  fs_1.default.mkdirSync(exports.CLAUDESTAT_DIR, { recursive: true });
24
25
  const db = new node_sqlite_1.DatabaseSync(DB_PATH);
25
- // Migraciones: añadir columnas nuevas sin romper instalaciones previas
26
- try {
27
- db.exec(`ALTER TABLE sessions ADD COLUMN project_path TEXT`);
28
- }
29
- catch { /* ya existe */ }
30
- try {
31
- db.exec(`ALTER TABLE sessions ADD COLUMN ai_summary TEXT`);
26
+ // ─── Tool response size cap ────────────────────────────────────────────────────
27
+ exports.TOOL_RESPONSE_MAX_BYTES = 8192;
28
+ function capToolResponse(raw) {
29
+ if (Buffer.byteLength(raw, 'utf8') <= exports.TOOL_RESPONSE_MAX_BYTES)
30
+ return raw;
31
+ const buf = Buffer.from(raw, 'utf8').subarray(0, exports.TOOL_RESPONSE_MAX_BYTES);
32
+ return buf.toString('utf8') + `…[truncated: ${raw.length} chars]`;
32
33
  }
33
- catch { /* ya existe */ }
34
- try {
35
- db.exec(`
36
- CREATE TABLE IF NOT EXISTS weekly_reports (
37
- id INTEGER PRIMARY KEY AUTOINCREMENT,
38
- date TEXT NOT NULL UNIQUE,
39
- report_markdown TEXT NOT NULL,
40
- created_at TEXT DEFAULT (datetime('now'))
41
- )
42
- `);
43
- }
44
- catch { /* ya existe */ }
45
- try {
46
- db.exec(`
34
+ // ─── Base tables (idempotent safe on every start) ───────────────────────────
35
+ // meta must exist before runMigrations reads schema_version
36
+ db.exec(`
47
37
  CREATE TABLE IF NOT EXISTS meta (
48
38
  key TEXT PRIMARY KEY,
49
39
  value TEXT NOT NULL
50
40
  )
51
41
  `);
52
- }
53
- catch { /* ya existe */ }
54
42
  db.exec(`
55
43
  CREATE TABLE IF NOT EXISTS sessions (
56
44
  id TEXT PRIMARY KEY,
@@ -82,7 +70,6 @@ db.exec(`
82
70
  );
83
71
  `);
84
72
  // Índices para acelerar las subqueries de getRecentSessions (N+3 pattern)
85
- // Wrapped en try-catch para no romper instalaciones que ya los tienen
86
73
  try {
87
74
  db.exec(`CREATE INDEX IF NOT EXISTS idx_events_session_type ON events(session_id, type)`);
88
75
  }
@@ -99,32 +86,96 @@ try {
99
86
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`);
100
87
  }
101
88
  catch { }
102
- try {
103
- db.exec(`ALTER TABLE sessions ADD COLUMN dominant_model TEXT`);
104
- }
105
- catch { }
106
- try {
107
- db.exec(`ALTER TABLE events ADD COLUMN skill_parent TEXT`);
108
- }
109
- catch { }
110
- try {
111
- db.exec(`ALTER TABLE sessions ADD COLUMN parent_session_id TEXT`);
112
- }
113
- catch { }
114
- try {
115
- db.exec(`ALTER TABLE sessions ADD COLUMN source TEXT DEFAULT 'claude-code'`);
116
- }
117
- catch { }
118
- try {
119
- db.exec(`ALTER TABLE events ADD COLUMN source TEXT DEFAULT 'claude-code'`);
89
+ // ─── Schema migrations ─────────────────────────────────────────────────────────
90
+ // Each migration runs exactly once, tracked by meta.schema_version.
91
+ // try/catch per migration handles existing installs where columns already exist.
92
+ const MIGRATIONS = [
93
+ { version: 1, sql: `ALTER TABLE sessions ADD COLUMN project_path TEXT` },
94
+ { version: 2, sql: `ALTER TABLE sessions ADD COLUMN ai_summary TEXT` },
95
+ { version: 3, sql: `CREATE TABLE IF NOT EXISTS weekly_reports (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ date TEXT NOT NULL UNIQUE,
98
+ report_markdown TEXT NOT NULL,
99
+ created_at TEXT DEFAULT (datetime('now'))
100
+ )` },
101
+ { version: 4, sql: `ALTER TABLE sessions ADD COLUMN dominant_model TEXT` },
102
+ { version: 5, sql: `ALTER TABLE events ADD COLUMN skill_parent TEXT` },
103
+ { version: 6, sql: `ALTER TABLE sessions ADD COLUMN parent_session_id TEXT` },
104
+ { version: 7, sql: `ALTER TABLE sessions ADD COLUMN source TEXT DEFAULT 'claude-code'` },
105
+ { version: 8, sql: `ALTER TABLE events ADD COLUMN source TEXT DEFAULT 'claude-code'` },
106
+ { version: 9, sql: `ALTER TABLE sessions ADD COLUMN exact_retries INTEGER DEFAULT 0` },
107
+ { version: 10, sql: `ALTER TABLE sessions ADD COLUMN error_rate REAL DEFAULT 0` },
108
+ { version: 11, sql: `ALTER TABLE sessions ADD COLUMN file_churn_score REAL DEFAULT 0` },
109
+ { version: 12, sql: `ALTER TABLE sessions ADD COLUMN seq_cycle_count INTEGER DEFAULT 0` },
110
+ { version: 13, sql: `ALTER TABLE sessions ADD COLUMN avg_output_chars INTEGER DEFAULT 0` },
111
+ { version: 14, sql: `ALTER TABLE sessions ADD COLUMN error_block_count INTEGER DEFAULT 0` },
112
+ { version: 15, sql: `CREATE TABLE IF NOT EXISTS assistant_turns (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ session_id TEXT NOT NULL,
115
+ turn_index INTEGER NOT NULL,
116
+ ts INTEGER,
117
+ text_preview TEXT,
118
+ tool_calls TEXT,
119
+ error_count INTEGER DEFAULT 0,
120
+ output_chars INTEGER DEFAULT 0,
121
+ UNIQUE(session_id, turn_index) ON CONFLICT REPLACE
122
+ )` },
123
+ { version: 16, sql: `ALTER TABLE sessions ADD COLUMN semantic_loop_count INTEGER DEFAULT 0` },
124
+ { version: 17, sql: `ALTER TABLE assistant_turns ADD COLUMN context_used INTEGER DEFAULT 0` },
125
+ { version: 18, sql: `
126
+ CREATE TABLE IF NOT EXISTS file_intents (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ tool TEXT NOT NULL,
129
+ session_id TEXT NOT NULL,
130
+ task_desc TEXT,
131
+ status TEXT NOT NULL DEFAULT 'active',
132
+ acquired_at INTEGER NOT NULL,
133
+ released_at INTEGER,
134
+ last_heartbeat INTEGER
135
+ );
136
+ CREATE INDEX IF NOT EXISTS idx_intents_status ON file_intents(status);
137
+ ` },
138
+ { version: 19, sql: `
139
+ CREATE TABLE IF NOT EXISTS file_intent_files (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ intent_id INTEGER NOT NULL REFERENCES file_intents(id) ON DELETE CASCADE,
142
+ file_path TEXT NOT NULL,
143
+ operation TEXT NOT NULL DEFAULT 'write',
144
+ line_start INTEGER,
145
+ line_end INTEGER
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_intent_files_path ON file_intent_files(file_path);
148
+ ` },
149
+ { version: 20, sql: `ALTER TABLE events ADD COLUMN external_id TEXT` },
150
+ { version: 21, sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_events_ext ON events(session_id, external_id) WHERE external_id IS NOT NULL` },
151
+ ];
152
+ function runMigrations() {
153
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
154
+ const currentVersion = row ? parseInt(row.value, 10) : 0;
155
+ const upsertVersion = db.prepare(`INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
156
+ for (const m of MIGRATIONS) {
157
+ if (m.version <= currentVersion)
158
+ continue;
159
+ try {
160
+ db.exec(m.sql);
161
+ }
162
+ catch { /* column/table already exists on prior installs */ }
163
+ upsertVersion.run(String(m.version));
164
+ }
120
165
  }
121
- catch { }
166
+ runMigrations(); // must run before stmts — some queries reference migrated columns
122
167
  // ─── Prepared statements (se compilan una vez al iniciar) ─────────────────────
123
168
  const stmts = {
124
169
  upsertSession: db.prepare(`
125
170
  INSERT INTO sessions (id, cwd, started_at, last_event_at, source)
126
171
  VALUES (?, ?, ?, ?, COALESCE(?, 'claude-code'))
127
- ON CONFLICT(id) DO UPDATE SET last_event_at = excluded.last_event_at
172
+ ON CONFLICT(id) DO UPDATE SET
173
+ last_event_at = excluded.last_event_at,
174
+ source = COALESCE(excluded.source, source)
175
+ `),
176
+ insertSessionIfAbsent: db.prepare(`
177
+ INSERT OR IGNORE INTO sessions (id, cwd, started_at, last_event_at, source)
178
+ VALUES (?, ?, ?, ?, COALESCE(?, 'claude-code'))
128
179
  `),
129
180
  updateSessionCost: db.prepare(`
130
181
  UPDATE sessions SET
@@ -135,12 +186,20 @@ const stmts = {
135
186
  total_cache_creation = ?,
136
187
  efficiency_score = ?,
137
188
  loops_detected = ?,
138
- dominant_model = ?
189
+ dominant_model = ?,
190
+ exact_retries = ?,
191
+ error_rate = ?,
192
+ file_churn_score = ?,
193
+ seq_cycle_count = ?
139
194
  WHERE id = ?
140
195
  `),
141
196
  insertEvent: db.prepare(`
142
197
  INSERT INTO events (session_id, type, tool_name, tool_input, ts, cwd, skill_parent, source)
143
198
  VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(?, 'claude-code'))
199
+ `),
200
+ insertOcEvent: db.prepare(`
201
+ INSERT OR IGNORE INTO events (session_id, type, tool_name, ts, duration_ms, external_id, source)
202
+ VALUES (?, 'Done', ?, ?, 0, ?, 'opencode')
144
203
  `),
145
204
  pairPost: db.prepare(`
146
205
  UPDATE events SET type = 'Done', tool_response = ?, duration_ms = ?
@@ -171,7 +230,7 @@ const stmts = {
171
230
  SELECT * FROM sessions WHERE id = ?
172
231
  `),
173
232
  updateSessionProject: db.prepare(`
174
- UPDATE sessions SET project_path = ? WHERE id = ? AND project_path IS NULL
233
+ UPDATE sessions SET project_path = ? WHERE id = ?
175
234
  `),
176
235
  getRecentSessions: db.prepare(`
177
236
  SELECT s.*, s.ai_summary,
@@ -219,6 +278,13 @@ const stmts = {
219
278
  WHERE s.project_path = ? AND e.type = 'Done' AND e.tool_name IS NOT NULL
220
279
  GROUP BY e.tool_name
221
280
  ORDER BY count DESC
281
+ `),
282
+ getProjectCliHours: db.prepare(`
283
+ SELECT project_path, COALESCE(source, 'claude-code') as source,
284
+ SUM(last_event_at - started_at) as total_ms
285
+ FROM sessions
286
+ WHERE project_path IS NOT NULL AND last_event_at IS NOT NULL
287
+ GROUP BY project_path, COALESCE(source, 'claude-code')
222
288
  `),
223
289
  // Session-level aggregates for pattern analysis (cache, loops, cost, efficiency)
224
290
  getProjectSessionStats: db.prepare(`
@@ -284,6 +350,7 @@ const stmts = {
284
350
  analyticsDaily: db.prepare(`
285
351
  SELECT
286
352
  date(started_at / 1000, 'unixepoch', 'localtime') AS date,
353
+ COALESCE(source, 'claude-code') AS source,
287
354
  COUNT(*) AS sessions,
288
355
  COALESCE(SUM(total_cost_usd), 0) AS cost,
289
356
  COALESCE(SUM(total_input_tokens), 0) AS input_tokens,
@@ -293,31 +360,34 @@ const stmts = {
293
360
  COALESCE(AVG(CASE WHEN efficiency_score > 0 THEN efficiency_score END), 100) AS avg_efficiency
294
361
  FROM sessions
295
362
  WHERE started_at >= ?
296
- GROUP BY date
363
+ GROUP BY date, COALESCE(source, 'claude-code')
297
364
  ORDER BY date ASC
298
365
  `),
299
366
  analyticsByModel: db.prepare(`
300
367
  SELECT
301
368
  date(started_at / 1000, 'unixepoch', 'localtime') AS date,
302
369
  COALESCE(dominant_model, 'claude-sonnet-4-6') AS model,
370
+ COALESCE(source, 'claude-code') AS source,
303
371
  COALESCE(SUM(total_input_tokens + total_output_tokens + total_cache_read), 0) AS tokens,
304
372
  COALESCE(SUM(total_cost_usd), 0) AS cost
305
373
  FROM sessions
306
374
  WHERE started_at >= ?
307
- GROUP BY date, model
375
+ AND (dominant_model IS NULL OR dominant_model NOT LIKE '<%')
376
+ GROUP BY date, model, COALESCE(source, 'claude-code')
308
377
  ORDER BY date ASC
309
378
  `),
310
379
  analyticsProjectHours: db.prepare(`
311
380
  SELECT
312
381
  COALESCE(project_path, 'No project') AS project,
382
+ COALESCE(source, 'claude-code') AS source,
313
383
  COUNT(*) AS sessions,
314
384
  COALESCE(SUM(last_event_at - started_at), 0) / 3600000.0 AS hours,
315
385
  COALESCE(SUM(total_cost_usd), 0) AS cost
316
386
  FROM sessions
317
387
  WHERE started_at >= ?
318
- GROUP BY project
388
+ GROUP BY project, COALESCE(source, 'claude-code')
319
389
  ORDER BY hours DESC
320
- LIMIT 8
390
+ LIMIT 16
321
391
  `),
322
392
  getTopToolsByCost: db.prepare(`
323
393
  WITH session_totals AS (
@@ -334,6 +404,7 @@ const stmts = {
334
404
  )
335
405
  SELECT
336
406
  tps.tool_name,
407
+ COALESCE(s.source, 'claude-code') AS source,
337
408
  SUM(tps.cnt) AS count,
338
409
  SUM(tps.tool_dur) AS total_duration_ms,
339
410
  SUM(CASE WHEN st.total_done > 0
@@ -342,8 +413,8 @@ const stmts = {
342
413
  FROM tool_per_session tps
343
414
  JOIN session_totals st ON tps.session_id = st.session_id
344
415
  JOIN sessions s ON tps.session_id = s.id
345
- WHERE s.total_cost_usd > 0
346
- GROUP BY tps.tool_name
416
+ WHERE (? = 'all' OR COALESCE(s.source, 'claude-code') = ?)
417
+ GROUP BY tps.tool_name, COALESCE(s.source, 'claude-code')
347
418
  ORDER BY total_cost_usd DESC
348
419
  LIMIT ?
349
420
  `),
@@ -362,6 +433,7 @@ const stmts = {
362
433
  )
363
434
  SELECT
364
435
  tps.tool_name,
436
+ COALESCE(s.source, 'claude-code') AS source,
365
437
  SUM(tps.cnt) AS count,
366
438
  SUM(tps.tool_dur) AS total_duration_ms,
367
439
  SUM(CASE WHEN st.total_done > 0
@@ -370,8 +442,8 @@ const stmts = {
370
442
  FROM tool_per_session tps
371
443
  JOIN session_totals st ON tps.session_id = st.session_id
372
444
  JOIN sessions s ON tps.session_id = s.id
373
- WHERE s.total_cost_usd > 0
374
- GROUP BY tps.tool_name
445
+ WHERE (? = 'all' OR COALESCE(s.source, 'claude-code') = ?)
446
+ GROUP BY tps.tool_name, COALESCE(s.source, 'claude-code')
375
447
  ORDER BY count DESC
376
448
  LIMIT ?
377
449
  `),
@@ -390,6 +462,7 @@ const stmts = {
390
462
  )
391
463
  SELECT
392
464
  tps.tool_name,
465
+ COALESCE(s.source, 'claude-code') AS source,
393
466
  SUM(tps.cnt) AS count,
394
467
  SUM(tps.tool_dur) AS total_duration_ms,
395
468
  SUM(CASE WHEN st.total_done > 0
@@ -398,8 +471,8 @@ const stmts = {
398
471
  FROM tool_per_session tps
399
472
  JOIN session_totals st ON tps.session_id = st.session_id
400
473
  JOIN sessions s ON tps.session_id = s.id
401
- WHERE s.total_cost_usd > 0
402
- GROUP BY tps.tool_name
474
+ WHERE (? = 'all' OR COALESCE(s.source, 'claude-code') = ?)
475
+ GROUP BY tps.tool_name, COALESCE(s.source, 'claude-code')
403
476
  ORDER BY total_duration_ms DESC
404
477
  LIMIT ?
405
478
  `),
@@ -440,6 +513,13 @@ const stmts = {
440
513
  GROUP BY project_path
441
514
  ORDER BY total_cost DESC
442
515
  LIMIT 5
516
+ `),
517
+ updateSessionSemantic: db.prepare(`
518
+ UPDATE sessions SET avg_output_chars = ?, error_block_count = ?, semantic_loop_count = ? WHERE id = ?
519
+ `),
520
+ getAssistantTurns: db.prepare(`
521
+ SELECT turn_index, ts, text_preview, tool_calls, error_count, output_chars, context_used
522
+ FROM assistant_turns WHERE session_id = ? ORDER BY turn_index ASC
443
523
  `),
444
524
  getHourlyDistribution: db.prepare(`
445
525
  SELECT
@@ -490,6 +570,12 @@ exports.dbOps = {
490
570
  const res = stmts.insertEvent.run(e.session_id, e.type, e.tool_name ?? null, e.tool_input ?? null, e.ts, e.cwd ?? null, e.skill_parent ?? null, e.source ?? null);
491
571
  return Number(res.lastInsertRowid);
492
572
  },
573
+ insertOcEvent(sessionId, toolName, ts, externalId) {
574
+ stmts.insertOcEvent.run(sessionId, toolName, ts, externalId);
575
+ },
576
+ insertSessionIfAbsent(s) {
577
+ stmts.insertSessionIfAbsent.run(s.id, s.cwd ?? null, s.started_at, s.last_event_at ?? s.started_at, s.source ?? null);
578
+ },
493
579
  /**
494
580
  * Al llegar PostToolUse, actualizamos el PreToolUse pendiente más reciente
495
581
  * del mismo tool para esta sesión. Esto convierte el par Pre+Post en
@@ -503,13 +589,13 @@ exports.dbOps = {
503
589
  ORDER BY ts DESC LIMIT 1
504
590
  `).get(sessionId, toolName);
505
591
  if (pending) {
506
- stmts.pairPost.run(response, postTs - pending.ts, sessionId, toolName);
592
+ stmts.pairPost.run(capToolResponse(response), postTs - pending.ts, sessionId, toolName);
507
593
  return pending.id;
508
594
  }
509
595
  return null;
510
596
  },
511
- updateSessionCost(sessionId, cost, efficiencyScore, loopsDetected) {
512
- stmts.updateSessionCost.run(cost.cost_usd, cost.input_tokens, cost.output_tokens, cost.cache_read, cost.cache_creation, efficiencyScore, loopsDetected, cost.lastModel ?? null, sessionId);
597
+ updateSessionCost(sessionId, cost, efficiencyScore, loopsDetected, exactRetries = 0, errorRate = 0, fileChurnScore = 1, seqCycleCount = 0) {
598
+ stmts.updateSessionCost.run(cost.cost_usd, cost.input_tokens, cost.output_tokens, cost.cache_read, cost.cache_creation, efficiencyScore, loopsDetected, cost.lastModel ?? null, exactRetries, errorRate, fileChurnScore, seqCycleCount, sessionId);
513
599
  },
514
600
  getSessionEvents(sessionId) {
515
601
  return stmts.getSessionEvents.all(sessionId);
@@ -530,6 +616,13 @@ exports.dbOps = {
530
616
  return stmts.getSessionEventsRecent.all(sessionId, limit);
531
617
  },
532
618
  updateSessionProject(sessionId, projectPath) {
619
+ const existing = this.getSession(sessionId);
620
+ if (existing?.project_path) {
621
+ const cur = existing.project_path.endsWith('/')
622
+ ? existing.project_path : existing.project_path + '/';
623
+ if (!projectPath.startsWith(cur))
624
+ return;
625
+ }
533
626
  stmts.updateSessionProject.run(projectPath, sessionId);
534
627
  },
535
628
  getRecentSessions(days) {
@@ -545,6 +638,9 @@ exports.dbOps = {
545
638
  getProjectSessionStats(projectPath) {
546
639
  return stmts.getProjectSessionStats.get(projectPath);
547
640
  },
641
+ getProjectCliHours() {
642
+ return stmts.getProjectCliHours.all();
643
+ },
548
644
  updateSessionSummary(sessionId, summary) {
549
645
  stmts.updateSessionSummary.run(summary, sessionId);
550
646
  },
@@ -591,15 +687,31 @@ exports.dbOps = {
591
687
  getAnalyticsDaily(since) { return stmts.analyticsDaily.all(since); },
592
688
  getAnalyticsByModel(since) { return stmts.analyticsByModel.all(since); },
593
689
  getProjectHours(since) { return stmts.analyticsProjectHours.all(since); },
594
- getTopTools(days = 30, by = 'cost', limit = 10) {
690
+ getCoachEvents(sessionIds) {
691
+ if (sessionIds.length === 0)
692
+ return [];
693
+ const placeholders = sessionIds.map(() => '?').join(',');
694
+ const cutoff = Date.now() - 2 * 60 * 60000;
695
+ return db.prepare(`
696
+ SELECT session_id, type, tool_name, tool_input, ts
697
+ FROM events
698
+ WHERE session_id IN (${placeholders})
699
+ AND type IN ('PreToolUse', 'Done', 'Stop')
700
+ AND ts > ?
701
+ ORDER BY ts ASC
702
+ `).all(...sessionIds, cutoff);
703
+ },
704
+ getTopTools(days = 30, by = 'cost', limit = 10, source = 'all') {
595
705
  const since = Date.now() - days * 86400000;
596
706
  const stmt = by === 'count' ? stmts.getTopToolsByCount
597
707
  : by === 'duration' ? stmts.getTopToolsByDuration
598
708
  : stmts.getTopToolsByCost;
599
- const tools = stmt.all(since, since, limit);
600
- const other = stmts.getUnattributedCost.get(since, since, since);
601
- if (other.other_cost_usd > 0) {
602
- tools.push({ tool_name: 'Other', count: 0, total_duration_ms: 0, total_cost_usd: other.other_cost_usd });
709
+ const tools = stmt.all(since, since, source, source, limit);
710
+ if (source === 'all') {
711
+ const other = stmts.getUnattributedCost.get(since, since, since);
712
+ if (other.other_cost_usd > 0) {
713
+ tools.push({ tool_name: 'Other', source: 'claude-code', count: 0, total_duration_ms: 0, total_cost_usd: other.other_cost_usd });
714
+ }
603
715
  }
604
716
  return tools;
605
717
  },
@@ -626,6 +738,30 @@ exports.dbOps = {
626
738
  const since = Date.now() - days * 86400000;
627
739
  return stmts.getHourlyDistribution.all(since);
628
740
  },
741
+ updateSessionSemantic(sessionId, avgOutputChars, errorBlockCount, semanticLoopCount = 0) {
742
+ stmts.updateSessionSemantic.run(avgOutputChars, errorBlockCount, semanticLoopCount, sessionId);
743
+ },
744
+ upsertAssistantTurns(sessionId, turns) {
745
+ db.prepare(`DELETE FROM assistant_turns WHERE session_id = ?`).run(sessionId);
746
+ const insert = db.prepare(`
747
+ INSERT INTO assistant_turns (session_id, turn_index, ts, text_preview, tool_calls, error_count, output_chars, context_used)
748
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
749
+ `);
750
+ for (const t of turns) {
751
+ insert.run(sessionId, t.turn_index, t.ts ?? null, t.text_preview ?? null, JSON.stringify(t.tool_calls ?? []), t.error_count, t.output_chars, t.context_used);
752
+ }
753
+ },
754
+ getAssistantTurns(sessionId) {
755
+ return stmts.getAssistantTurns.all(sessionId).map(r => ({
756
+ turn_index: r.turn_index,
757
+ ts: r.ts ?? undefined,
758
+ text_preview: r.text_preview ?? undefined,
759
+ tool_calls: r.tool_calls ? JSON.parse(r.tool_calls) : [],
760
+ error_count: r.error_count,
761
+ output_chars: r.output_chars,
762
+ context_used: r.context_used ?? 0,
763
+ }));
764
+ },
629
765
  getCacheReadByModel(days) {
630
766
  const since = Date.now() - days * 86400000;
631
767
  return db.prepare(`
@@ -648,4 +784,98 @@ exports.dbOps = {
648
784
  ORDER BY total_cost DESC
649
785
  `).all(since);
650
786
  },
787
+ // ─── File intent coordination ──────────────────────────────────────────────
788
+ insertIntent(tool, sessionId, taskDesc) {
789
+ const res = db.prepare(`
790
+ INSERT INTO file_intents (tool, session_id, task_desc, status, acquired_at, last_heartbeat)
791
+ VALUES (?, ?, ?, 'active', ?, ?)
792
+ `).run(tool, sessionId, taskDesc ?? null, Date.now(), Date.now());
793
+ return Number(res.lastInsertRowid);
794
+ },
795
+ insertIntentFile(intentId, filePath, operation, lineStart, lineEnd) {
796
+ db.prepare(`
797
+ INSERT INTO file_intent_files (intent_id, file_path, operation, line_start, line_end)
798
+ VALUES (?, ?, ?, ?, ?)
799
+ `).run(intentId, filePath, operation, lineStart ?? null, lineEnd ?? null);
800
+ },
801
+ getWriteConflicts(filePaths, excludeTool) {
802
+ if (filePaths.length === 0)
803
+ return [];
804
+ const placeholders = filePaths.map(() => '?').join(',');
805
+ return db.prepare(`
806
+ SELECT f.file_path, i.tool, i.session_id, i.task_desc, i.acquired_at
807
+ FROM file_intent_files f
808
+ JOIN file_intents i ON i.id = f.intent_id
809
+ WHERE f.file_path IN (${placeholders})
810
+ AND f.operation = 'write'
811
+ AND i.status = 'active'
812
+ AND i.tool != ?
813
+ `).all(...filePaths, excludeTool);
814
+ },
815
+ getIntent(id) {
816
+ return db.prepare(`SELECT tool, session_id, task_desc FROM file_intents WHERE id = ?`).get(id);
817
+ },
818
+ releaseIntent(id) {
819
+ db.prepare(`
820
+ UPDATE file_intents SET status = 'done', released_at = ? WHERE id = ?
821
+ `).run(Date.now(), id);
822
+ },
823
+ heartbeatIntent(id) {
824
+ db.prepare(`
825
+ UPDATE file_intents SET last_heartbeat = ? WHERE id = ? AND status = 'active'
826
+ `).run(Date.now(), id);
827
+ },
828
+ getIntentFiles(id) {
829
+ return db.prepare(`
830
+ SELECT file_path, operation FROM file_intent_files WHERE intent_id = ?
831
+ `).all(id);
832
+ },
833
+ getActiveToolsInProject(projectPath, excludeTool) {
834
+ const since = Date.now() - 10 * 60000;
835
+ return db.prepare(`
836
+ SELECT COALESCE(source, 'claude-code') AS source, MAX(last_event_at) AS last_event_at
837
+ FROM sessions
838
+ WHERE project_path = ?
839
+ AND COALESCE(source, 'claude-code') != ?
840
+ AND last_event_at >= ?
841
+ GROUP BY COALESCE(source, 'claude-code')
842
+ `).all(projectPath, excludeTool, since);
843
+ },
844
+ releaseOrphanedIntents() {
845
+ db.prepare(`UPDATE file_intents SET status = 'stale' WHERE status = 'active'`).run();
846
+ db.prepare(`DELETE FROM file_intents WHERE status IN ('done','stale')`).run();
847
+ },
848
+ markStaleIntents() {
849
+ const cutoff = Date.now() - 10 * 60000;
850
+ const stale = db.prepare(`
851
+ SELECT id, tool, task_desc FROM file_intents
852
+ WHERE status = 'active' AND (last_heartbeat IS NULL OR last_heartbeat < ?)
853
+ `).all(cutoff);
854
+ if (stale.length > 0) {
855
+ const ids = stale.map(s => s.id);
856
+ db.prepare(`UPDATE file_intents SET status = 'stale' WHERE id IN (${ids.map(() => '?').join(',')})`).run(...ids);
857
+ }
858
+ return stale;
859
+ },
860
+ deleteOldStaleIntents() {
861
+ const cutoff = Date.now() - 60 * 60000;
862
+ db.prepare(`DELETE FROM file_intents WHERE status IN ('done','stale') AND COALESCE(released_at, acquired_at) < ?`).run(cutoff);
863
+ },
864
+ hasActiveIntent(tool, filePath) {
865
+ const row = db.prepare(`
866
+ SELECT 1 FROM file_intents i
867
+ JOIN file_intent_files f ON f.intent_id = i.id
868
+ WHERE i.tool = ? AND f.file_path = ? AND i.status = 'active'
869
+ LIMIT 1
870
+ `).get(tool, filePath);
871
+ return !!row;
872
+ },
873
+ getActiveIntents() {
874
+ return db.prepare(`
875
+ SELECT i.id, i.tool, f.file_path
876
+ FROM file_intents i
877
+ JOIN file_intent_files f ON f.intent_id = i.id
878
+ WHERE i.status = 'active'
879
+ `).all();
880
+ },
651
881
  };
package/dist/doctor.js CHANGED
@@ -131,7 +131,7 @@ async function runDoctor() {
131
131
  if (activeBinary) {
132
132
  try {
133
133
  const runningVersion = (0, child_process_1.execSync)(`${activeBinary} --version`, { stdio: 'pipe' })
134
- .toString().trim().replace(/^v?/, '');
134
+ .toString().split('\n')[0].trim().replace(/^v?/, '');
135
135
  versionOk = runningVersion === installedVersion;
136
136
  if (!versionOk) {
137
137
  versionNote = `Active binary reports v${runningVersion}, installed package is v${installedVersion}`;
@@ -16,8 +16,9 @@ import './watchers/opencode';
16
16
  import './watchers/amp';
17
17
  import './watchers/droid';
18
18
  import './watchers/codebuff';
19
- import type { CostUpdate } from './db';
20
- export { getAllBlockCostsForSession, getContextWindow, getSessionPrompts } from './watchers/claude-code';
19
+ import { type CostUpdate } from './db';
20
+ export { getAllBlockCostsForSession, getSessionPrompts } from './watchers/claude-code';
21
+ export { getContextWindow } from './pricing';
21
22
  export type CostUpdateCallback = (sessionId: string, cost: CostUpdate, source?: string) => void;
22
23
  export type CompactDetectedCallback = (sessionId: string) => void;
23
24
  export declare function startEnricher(onUpdate: CostUpdateCallback, onCompact?: CompactDetectedCallback): void;
package/dist/enricher.js CHANGED
@@ -15,7 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.getSessionPrompts = exports.getContextWindow = exports.getAllBlockCostsForSession = void 0;
18
+ exports.getContextWindow = exports.getSessionPrompts = exports.getAllBlockCostsForSession = void 0;
19
19
  exports.startEnricher = startEnricher;
20
20
  exports.stopEnricher = stopEnricher;
21
21
  exports.cleanupSession = cleanupSession;
@@ -30,11 +30,13 @@ require("./watchers/opencode");
30
30
  require("./watchers/amp");
31
31
  require("./watchers/droid");
32
32
  require("./watchers/codebuff");
33
+ const db_1 = require("./db");
33
34
  // Re-export Claude Code-specific utilities for routes/stream and routes/misc
34
35
  var claude_code_1 = require("./watchers/claude-code");
35
36
  Object.defineProperty(exports, "getAllBlockCostsForSession", { enumerable: true, get: function () { return claude_code_1.getAllBlockCostsForSession; } });
36
- Object.defineProperty(exports, "getContextWindow", { enumerable: true, get: function () { return claude_code_1.getContextWindow; } });
37
37
  Object.defineProperty(exports, "getSessionPrompts", { enumerable: true, get: function () { return claude_code_1.getSessionPrompts; } });
38
+ var pricing_1 = require("./pricing");
39
+ Object.defineProperty(exports, "getContextWindow", { enumerable: true, get: function () { return pricing_1.getContextWindow; } });
38
40
  const prevContextBySession = new Map();
39
41
  let watcher = null;
40
42
  const pendingFiles = new Map();
@@ -90,7 +92,7 @@ function startEnricher(onUpdate, onCompact) {
90
92
  console.log(`[enricher] Watching ${adapters.map(a => a.label).join(', ')}`);
91
93
  watcher = chokidar_1.default.watch(watchPaths, {
92
94
  persistent: true,
93
- ignoreInitial: true,
95
+ ignoreInitial: false,
94
96
  awaitWriteFinish: {
95
97
  stabilityThreshold: 200,
96
98
  pollInterval: 100,
@@ -119,16 +121,19 @@ function startEnricher(onUpdate, onCompact) {
119
121
  watcher.on('add', handleFile);
120
122
  // ─── Poll-based adapters (e.g. OpenCode SQLite) ─────────────────────────────
121
123
  const POLL_INTERVAL_MS = 10000;
124
+ const POLL_LOOKBACK_MS = 7 * 24 * 60 * 60000; // backfill 7 days on first start
122
125
  for (const adapter of adapters) {
123
126
  if (!(0, adapter_1.isPollable)(adapter))
124
127
  continue;
125
- let lastPoll = Date.now();
128
+ let lastPoll = Date.now() - POLL_LOOKBACK_MS;
126
129
  const interval = setInterval(async () => {
127
130
  const since = lastPoll;
128
131
  lastPoll = Date.now();
129
132
  const sessions = await adapter.pollSessions(since);
130
- for (const { sessionId, cost } of sessions) {
133
+ for (const { sessionId, cost, cwd } of sessions) {
131
134
  onUpdate(sessionId, cost, adapter.name);
135
+ if (cwd)
136
+ db_1.dbOps.updateSessionProject(sessionId, cwd);
132
137
  }
133
138
  }, POLL_INTERVAL_MS);
134
139
  interval.unref();
package/dist/index.js CHANGED
@@ -62,9 +62,20 @@ if (!SKIP_UPDATE_NOTICE.has(subcommand)) {
62
62
  catch {
63
63
  fetchLatestVersion();
64
64
  }
65
+ const semverGt = (a, b) => {
66
+ const [pa, pb] = [a.split('.').map(Number), b.split('.').map(Number)];
67
+ for (let i = 0; i < 3; i++) {
68
+ const aVal = pa[i] ?? 0, bVal = pb[i] ?? 0;
69
+ if (aVal > bVal)
70
+ return true;
71
+ if (aVal < bVal)
72
+ return false;
73
+ }
74
+ return false;
75
+ };
65
76
  const _exit = process.exit.bind(process);
66
77
  process.exit = ((code) => {
67
- if ((code ?? 0) === 0 && cachedLatest && cachedLatest !== PKG_VERSION) {
78
+ if ((code ?? 0) === 0 && cachedLatest && semverGt(cachedLatest, PKG_VERSION)) {
68
79
  console.log(`\n ✦ Update available: ${PKG_VERSION} → ${cachedLatest}`);
69
80
  console.log(` Run: npm install -g @statforge/claudestat\n`);
70
81
  }