lumencode 1.1.0 → 1.3.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.
package/lib/server.js CHANGED
@@ -9,8 +9,10 @@ import { normalizeProjectPath } from './aggregate.js';
9
9
  import { invalidateFileCache } from './cache.js';
10
10
  import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
11
11
  import { identifyBillingBlocks } from './blocks.js';
12
- import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
- import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
12
+ import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
+ import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
14
+ import { StepTracker } from './step-tracker.js';
15
+ import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
14
16
 
15
17
  // basename 提取,兼容不同路径格式
16
18
  function getProjectBaseName(p) {
@@ -25,7 +27,7 @@ let appVersion = '0.0.0';
25
27
  try {
26
28
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
27
29
  appVersion = pkg.version || '0.0.0';
28
- } catch {}
30
+ } catch (e) { console.warn("[server] error", e.message); }
29
31
 
30
32
  const MIME = {
31
33
  '.html': 'text/html',
@@ -37,13 +39,42 @@ const MIME = {
37
39
  '.ico': 'image/x-icon',
38
40
  };
39
41
 
40
- export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
41
- function computeIncludeProjects(cfg) {
42
- if (cfg.repos && cfg.repos.length > 0) {
43
- return cfg.repos.map(r => normalizeProjectPath(r));
44
- }
45
- return null;
46
- }
42
+ export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
43
+ function computeIncludeProjects(cfg) {
44
+ if (cfg.repos && cfg.repos.length > 0) {
45
+ return cfg.repos.map(r => normalizeProjectPath(r));
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function getHookProjectRoots(cfg) {
51
+ if (!Array.isArray(cfg.repos)) return [];
52
+ return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
53
+ }
54
+
55
+ function getConfiguredHooksStatus(cfg) {
56
+ const projectRoots = getHookProjectRoots(cfg);
57
+ const projects = projectRoots.map(root => getHooksStatus(root));
58
+ const total = projects.length;
59
+ const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
60
+ const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
61
+ const toolStatus = (tool) => ({
62
+ enabled: total > 0 && enabledCount(tool) === total,
63
+ enabledCount: enabledCount(tool),
64
+ total,
65
+ });
66
+
67
+ return {
68
+ targetMode: 'configured-projects',
69
+ projectCount: total,
70
+ projects,
71
+ stepsInitialized: total > 0 && stepsReadyCount === total,
72
+ stepsReadyCount,
73
+ claude: toolStatus(HOOK_TOOLS.CLAUDE),
74
+ codex: toolStatus(HOOK_TOOLS.CODEX),
75
+ opencode: toolStatus(HOOK_TOOLS.OPENCODE),
76
+ };
77
+ }
47
78
 
48
79
  const PORT = process.env.LUMENCODE_PORT || 4567;
49
80
 
@@ -78,9 +109,55 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
78
109
  }
79
110
  }
80
111
 
81
- function invalidateReportCache() {
82
- _reportCache.clear();
83
- }
112
+ function invalidateReportCache() {
113
+ _reportCache.clear();
114
+ }
115
+
116
+ function writeJson(res, statusCode, data) {
117
+ res.writeHead(statusCode, {
118
+ 'Content-Type': 'application/json',
119
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
120
+ });
121
+ res.end(JSON.stringify(data));
122
+ }
123
+
124
+ function parseHookTools(value) {
125
+ if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
126
+ const tools = [];
127
+ for (const raw of String(value).split(',')) {
128
+ const tool = raw.trim().toLowerCase();
129
+ if (!tool) continue;
130
+ if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
131
+ else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
132
+ else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
133
+ }
134
+ return [...new Set(tools)];
135
+ }
136
+
137
+ function readJsonBody(req, res, callback) {
138
+ let body = '';
139
+ let bodySize = 0;
140
+ const MAX_BODY = 1024 * 1024; // 1MB
141
+ req.on('data', chunk => {
142
+ bodySize += chunk.length;
143
+ if (bodySize > MAX_BODY) { req.destroy(); return; }
144
+ body += chunk;
145
+ });
146
+ req.on('end', () => {
147
+ if (bodySize > MAX_BODY) {
148
+ writeJson(res, 413, { error: '请求体过大' });
149
+ return;
150
+ }
151
+ try {
152
+ Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
153
+ console.error('API error:', err.message);
154
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
155
+ });
156
+ } catch {
157
+ writeJson(res, 400, { error: 'JSON 解析失败' });
158
+ }
159
+ });
160
+ }
84
161
 
85
162
  function getCachedParse(config, includeProjects) {
86
163
  const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
@@ -112,8 +189,60 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
112
189
  res.setHeader('X-XSS-Protection', '1; mode=block');
113
190
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
114
191
 
115
- // API endpoint
116
- if (url.pathname === '/api/tools') {
192
+ // API endpoint
193
+ if (url.pathname === '/api/hooks') {
194
+ try {
195
+ if (req.method === 'GET') {
196
+ writeJson(res, 200, getConfiguredHooksStatus(config));
197
+ return;
198
+ }
199
+
200
+ if (req.method === 'POST') {
201
+ readJsonBody(req, res, async (body) => {
202
+ const action = body.action || 'enable';
203
+ const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
204
+ if (tools.length === 0) {
205
+ writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
206
+ return;
207
+ }
208
+ const projectRoots = getHookProjectRoots(config);
209
+ if (projectRoots.length === 0) {
210
+ writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
211
+ return;
212
+ }
213
+ const stepTracking = [];
214
+ const results = [];
215
+ for (const projectRoot of projectRoots) {
216
+ let projectStepTracking = null;
217
+ if (action !== 'disable') {
218
+ projectStepTracking = await initStepTracking(projectRoot);
219
+ stepTracking.push({ projectRoot, ...projectStepTracking });
220
+ }
221
+ const projectResults = action === 'disable'
222
+ ? disableHooks(projectRoot, tools, { backup: true })
223
+ : enableHooks(projectRoot, tools, { backup: true });
224
+ results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
225
+ }
226
+ writeJson(res, 200, {
227
+ success: true,
228
+ action,
229
+ stepTracking,
230
+ results,
231
+ status: getConfiguredHooksStatus(config),
232
+ });
233
+ });
234
+ return;
235
+ }
236
+
237
+ writeJson(res, 405, { error: 'Method not allowed' });
238
+ } catch (err) {
239
+ console.error('API error:', err.message);
240
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
241
+ }
242
+ return;
243
+ }
244
+
245
+ if (url.pathname === '/api/tools') {
117
246
  try {
118
247
  const tools = await detectAvailableTools(config);
119
248
  const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
@@ -254,9 +383,12 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
254
383
  const sessions = groupBySessions(projFiltered);
255
384
  const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
256
385
  let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
257
- repoGit = finalizeGitStats(repoGit, sessions);
386
+ repoGit = await finalizeGitStats(repoGit, sessions, {
387
+ attribution: config.aiAttribution,
388
+ stepTracking: config.stepTracking,
389
+ });
258
390
  projGitStats = repoGit;
259
- } catch {}
391
+ } catch (e) { console.warn("[server] error", e.message); }
260
392
  }
261
393
  } else {
262
394
  projGitStats = null;
@@ -348,9 +480,12 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
348
480
  const extEnd = new Date(end);
349
481
  extEnd.setDate(extEnd.getDate() + 2);
350
482
  const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
351
- finalizeGitStats(gitStats, sessions);
483
+ await finalizeGitStats(gitStats, sessions, {
484
+ attribution: config.aiAttribution,
485
+ stepTracking: config.stepTracking,
486
+ });
352
487
  }
353
- } catch {}
488
+ } catch (e) { console.warn("[server] error", e.message); }
354
489
  }
355
490
 
356
491
  // 精简返回字段,保留效率指标
@@ -500,7 +635,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
500
635
  if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
501
636
  if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
502
637
  if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
503
- if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
638
+ if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
639
+ if (newConfig.stepTracking !== undefined) config.stepTracking = newConfig.stepTracking;
504
640
  invalidateFileCache();
505
641
  invalidateGitCache();
506
642
  _parsedCache = null; // 配置变更后清除解析缓存
@@ -521,6 +657,30 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
521
657
  return;
522
658
  }
523
659
 
660
+ // Step blame stats API
661
+ if (url.pathname === '/api/step-stats') {
662
+ let stepStats = { stepCount: 0, sessionCount: 0, available: false };
663
+ try {
664
+ if (config.stepTracking?.enabled !== false) {
665
+ for (const repo of config.repos || []) {
666
+ const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
667
+ if (await tracker.isAvailableAsync()) {
668
+ await tracker.open();
669
+ stepStats = { ...tracker.getStats(), available: true };
670
+ tracker.close();
671
+ break;
672
+ }
673
+ }
674
+ }
675
+ } catch { /* step tracking not available */ }
676
+ res.writeHead(200, {
677
+ 'Content-Type': 'application/json',
678
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
679
+ });
680
+ res.end(JSON.stringify(stepStats));
681
+ return;
682
+ }
683
+
524
684
  // Favicon - 返回空响应避免 404 控制台报错
525
685
  if (url.pathname === '/favicon.ico') {
526
686
  res.writeHead(204);
@@ -562,12 +722,65 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
562
722
  });
563
723
 
564
724
  server.listen(PORT, '127.0.0.1', () => {
565
- console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
566
-
567
- // Auto-open browser
568
- const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
569
- import('child_process').then(({ exec }) => {
570
- exec(`${openCmd} http://localhost:${PORT}`, () => {});
571
- });
572
- });
573
- }
725
+ const B = '\x1b[1m';
726
+ const R = '\x1b[0m';
727
+ const cyan = '\x1b[96m';
728
+ const green = '\x1b[92m';
729
+ const yellow = '\x1b[93m';
730
+ const blue = '\x1b[94m';
731
+ const dim = '\x1b[2m';
732
+ const actualPort = server.address()?.port || PORT;
733
+
734
+ const banner = [
735
+ '',
736
+ `${B}${cyan} _ _____ _ ${R}`,
737
+ `${B}${cyan} | | / ____| | | ${R}`,
738
+ `${B}${cyan} | | _ _ _ __ ___ ___ _ __ | | ___ __| | ___ ${R}`,
739
+ `${B}${cyan} | | | | | | '_ \` _ \\ / _ \\ '_ \\| | / _ \\ / _\` |/ _ \\${R}`,
740
+ `${B}${cyan} | |___| |_| | | | | | | __/ | | | |___| (_) | (_| | __/${R}`,
741
+ `${B}${cyan} |______\\__,_|_| |_| |_|\\___|_| |_|\\_____\\___/ \\__,_|\\___|${R}`,
742
+ '',
743
+ ].join('\n');
744
+
745
+ process.stdout.write(banner + '\n');
746
+ process.stdout.write(` ${green}${B}v${appVersion}${R} ${yellow}AI Coding Assistant Analytics${R}\n`);
747
+ process.stdout.write('\n');
748
+
749
+ if (config.claudeDir) {
750
+ process.stdout.write(` ${dim}●${R} ${B}Data Dir${R} ${config.claudeDir}\n`);
751
+ }
752
+ if (configPath) {
753
+ process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
754
+ }
755
+ const repoCount = config.repos?.length || 0;
756
+ if (repoCount > 0) {
757
+ process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
758
+ }
759
+ const hookStatus = getConfiguredHooksStatus(config);
760
+ const hookParts = [
761
+ `Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
762
+ `Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
763
+ `OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
764
+ `steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
765
+ ];
766
+ process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
767
+ if (hookStatus.projectCount === 0) {
768
+ process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
769
+ } else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
770
+ process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
771
+ }
772
+ process.stdout.write('\n');
773
+ process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
774
+ process.stdout.write('\n');
775
+
776
+ // Auto-open browser
777
+ if (process.env.LUMENCODE_NO_OPEN !== '1') {
778
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
779
+ import('child_process').then(({ exec }) => {
780
+ exec(`${openCmd} http://localhost:${actualPort}`, () => {});
781
+ });
782
+ }
783
+ });
784
+
785
+ return server;
786
+ }
@@ -0,0 +1,217 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ let SQL = null;
5
+
6
+ async function getSql() {
7
+ if (SQL) return SQL;
8
+ const initSqlJs = (await import('sql.js')).default;
9
+ SQL = await initSqlJs();
10
+ return SQL;
11
+ }
12
+
13
+ const SCHEMA = `
14
+ CREATE TABLE IF NOT EXISTS steps (
15
+ id TEXT PRIMARY KEY,
16
+ parent_id TEXT,
17
+ session_id TEXT NOT NULL,
18
+ origin TEXT NOT NULL DEFAULT 'claude_code',
19
+ ts INTEGER NOT NULL,
20
+ tool_name TEXT NOT NULL,
21
+ tool_use_id TEXT NOT NULL,
22
+ tree_hash TEXT
23
+ );
24
+ CREATE INDEX IF NOT EXISTS idx_steps_session ON steps(session_id, ts);
25
+ CREATE INDEX IF NOT EXISTS idx_steps_parent ON steps(parent_id);
26
+
27
+ CREATE TABLE IF NOT EXISTS step_files (
28
+ step_id TEXT NOT NULL,
29
+ path TEXT NOT NULL,
30
+ blob_hash TEXT,
31
+ blame_map TEXT,
32
+ content_blob TEXT,
33
+ PRIMARY KEY (step_id, path)
34
+ );
35
+ CREATE INDEX IF NOT EXISTS idx_step_files_path ON step_files(path);
36
+
37
+ CREATE TABLE IF NOT EXISTS sessions (
38
+ id TEXT PRIMARY KEY,
39
+ origin TEXT NOT NULL DEFAULT 'claude_code',
40
+ started_at INTEGER NOT NULL,
41
+ last_seen_at INTEGER NOT NULL,
42
+ head_step_id TEXT
43
+ );
44
+ `;
45
+
46
+ export class StepDatabase {
47
+ constructor() {
48
+ this.db = null;
49
+ this.dbPath = null;
50
+ }
51
+
52
+ async open(dbPath) {
53
+ this.dbPath = dbPath;
54
+ const Sql = await getSql();
55
+
56
+ const dir = dirname(dbPath);
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+
59
+ if (existsSync(dbPath)) {
60
+ const buf = readFileSync(dbPath);
61
+ this.db = new Sql.Database(buf);
62
+ } else {
63
+ this.db = new Sql.Database();
64
+ }
65
+
66
+ this.db.run('PRAGMA journal_mode = WAL');
67
+ this.db.run('PRAGMA synchronous = NORMAL');
68
+ this.db.exec(SCHEMA);
69
+ // Migration: add content_blob column if missing (existing DBs)
70
+ try { this.db.run('ALTER TABLE step_files ADD COLUMN content_blob TEXT'); } catch { /* already exists */ }
71
+ return this;
72
+ }
73
+
74
+ close() {
75
+ if (!this.db) return;
76
+ try {
77
+ const data = this.db.export();
78
+ writeFileSync(this.dbPath, Buffer.from(data));
79
+ } catch { /* best effort */ }
80
+ this.db.close();
81
+ this.db = null;
82
+ }
83
+
84
+ save() {
85
+ if (!this.db || !this.dbPath) return;
86
+ try {
87
+ const data = this.db.export();
88
+ writeFileSync(this.dbPath, Buffer.from(data));
89
+ } catch { /* best effort */ }
90
+ }
91
+
92
+ // ── Step CRUD ──
93
+
94
+ insertStep(step) {
95
+ this.db.run(
96
+ `INSERT OR REPLACE INTO steps (id, parent_id, session_id, origin, ts, tool_name, tool_use_id, tree_hash)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
98
+ [step.id, step.parentId || null, step.sessionId, step.origin || 'claude_code',
99
+ step.ts, step.toolName, step.toolUseId, step.treeHash || null]
100
+ );
101
+ }
102
+
103
+ getStepsBySession(sessionId, limit = 100) {
104
+ const stmt = this.db.prepare(
105
+ `SELECT id, parent_id, session_id, origin, ts, tool_name, tool_use_id, tree_hash
106
+ FROM steps WHERE session_id = ? ORDER BY ts DESC LIMIT ?`
107
+ );
108
+ stmt.bind([sessionId, limit]);
109
+ const rows = [];
110
+ while (stmt.step()) rows.push(stmt.getAsObject());
111
+ stmt.free();
112
+ return rows;
113
+ }
114
+
115
+ getSessionHead(sessionId) {
116
+ const stmt = this.db.prepare('SELECT head_step_id FROM sessions WHERE id = ?');
117
+ stmt.bind([sessionId]);
118
+ let head = null;
119
+ if (stmt.step()) head = stmt.getAsObject().head_step_id;
120
+ stmt.free();
121
+ return head;
122
+ }
123
+
124
+ getStepById(stepId) {
125
+ const stmt = this.db.prepare(
126
+ `SELECT id, parent_id, session_id, origin, ts, tool_name, tool_use_id, tree_hash
127
+ FROM steps WHERE id = ?`
128
+ );
129
+ stmt.bind([stepId]);
130
+ let row = null;
131
+ if (stmt.step()) row = stmt.getAsObject();
132
+ stmt.free();
133
+ return row;
134
+ }
135
+
136
+ // ── Step files ──
137
+
138
+ upsertStepFile(stepId, path, blameMap, content) {
139
+ const blameJson = blameMap ? JSON.stringify(blameMap) : null;
140
+ this.db.run(
141
+ `INSERT OR REPLACE INTO step_files (step_id, path, blame_map, content_blob) VALUES (?, ?, ?, ?)`,
142
+ [stepId, path, blameJson, content || null]
143
+ );
144
+ }
145
+
146
+ getBlameMap(stepId, path) {
147
+ const stmt = this.db.prepare('SELECT blame_map FROM step_files WHERE step_id = ? AND path = ?');
148
+ stmt.bind([stepId, path]);
149
+ let result = null;
150
+ if (stmt.step()) {
151
+ const row = stmt.getAsObject();
152
+ if (row.blame_map) {
153
+ try { result = JSON.parse(row.blame_map); } catch { /* ignore */ }
154
+ }
155
+ }
156
+ stmt.free();
157
+ return result;
158
+ }
159
+
160
+ getFileBlob(stepId, path) {
161
+ const stmt = this.db.prepare('SELECT content_blob FROM step_files WHERE step_id = ? AND path = ?');
162
+ stmt.bind([stepId, path]);
163
+ let result = null;
164
+ if (stmt.step()) {
165
+ const row = stmt.getAsObject();
166
+ result = row.content_blob || null;
167
+ }
168
+ stmt.free();
169
+ return result;
170
+ }
171
+
172
+ getStepFilesForPath(path, limit = 20) {
173
+ const stmt = this.db.prepare(
174
+ `SELECT sf.step_id, sf.path, sf.blame_map, s.session_id, s.ts, s.tool_name
175
+ FROM step_files sf JOIN steps s ON sf.step_id = s.id
176
+ WHERE sf.path = ? ORDER BY s.ts DESC LIMIT ?`
177
+ );
178
+ stmt.bind([path, limit]);
179
+ const rows = [];
180
+ while (stmt.step()) rows.push(stmt.getAsObject());
181
+ stmt.free();
182
+ return rows;
183
+ }
184
+
185
+ // ── Session management ──
186
+
187
+ upsertSession(session) {
188
+ const now = Date.now();
189
+ this.db.run(
190
+ `INSERT INTO sessions (id, origin, started_at, last_seen_at, head_step_id)
191
+ VALUES (?, ?, ?, ?, ?)
192
+ ON CONFLICT(id) DO UPDATE SET
193
+ last_seen_at = ?,
194
+ head_step_id = COALESCE(?, head_step_id)`,
195
+ [session.id, session.origin || 'claude_code', now, now, session.headStepId || null,
196
+ now, session.headStepId || null]
197
+ );
198
+ }
199
+
200
+ getSessionCount() {
201
+ const stmt = this.db.prepare('SELECT COUNT(*) as cnt FROM sessions');
202
+ stmt.bind([]);
203
+ let count = 0;
204
+ if (stmt.step()) count = stmt.getAsObject().cnt;
205
+ stmt.free();
206
+ return count;
207
+ }
208
+
209
+ getStepCount() {
210
+ const stmt = this.db.prepare('SELECT COUNT(*) as cnt FROM steps');
211
+ stmt.bind([]);
212
+ let count = 0;
213
+ if (stmt.step()) count = stmt.getAsObject().cnt;
214
+ stmt.free();
215
+ return count;
216
+ }
217
+ }