lumencode 1.2.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/README.md +41 -0
- package/hooks/claude-post-tool-batch.js +51 -0
- package/hooks/codex-hook.js +56 -0
- package/hooks/init-steps.js +10 -0
- package/hooks/install-codex.js +9 -0
- package/hooks/install.js +14 -0
- package/hooks/opencode-hook.js +45 -0
- package/hooks/post-tool-use.js +42 -0
- package/index.js +236 -22
- package/lib/aggregate.js +27 -9
- package/lib/attribution.js +13 -0
- package/lib/capture-recorder.js +141 -0
- package/lib/config.js +26 -2
- package/lib/git-attribution-candidates.js +37 -0
- package/lib/git-attribution-options.js +105 -0
- package/lib/git-paths.js +41 -0
- package/lib/git.js +350 -167
- package/lib/hooks-manager.js +379 -0
- package/lib/line-blame.js +140 -0
- package/lib/parser.js +40 -18
- package/lib/parsers/base.js +69 -67
- package/lib/parsers/claude.js +51 -53
- package/lib/parsers/codex.js +21 -9
- package/lib/parsers/index.js +153 -151
- package/lib/parsers/opencode.js +28 -20
- package/lib/report.js +3 -3
- package/lib/server.js +213 -35
- package/lib/step-schema.js +217 -0
- package/lib/step-tracker.js +323 -0
- package/package.json +8 -2
- package/public/api.js +21 -0
- package/public/app.js +127 -2
- package/public/config.js +2 -0
- package/public/git-insights.js +19 -0
- package/public/index.html +69 -0
- package/public/style.css +85 -1
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/
|
|
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);
|
|
@@ -567,8 +727,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
567
727
|
const cyan = '\x1b[96m';
|
|
568
728
|
const green = '\x1b[92m';
|
|
569
729
|
const yellow = '\x1b[93m';
|
|
570
|
-
const blue = '\x1b[94m';
|
|
571
|
-
const dim = '\x1b[2m';
|
|
730
|
+
const blue = '\x1b[94m';
|
|
731
|
+
const dim = '\x1b[2m';
|
|
732
|
+
const actualPort = server.address()?.port || PORT;
|
|
572
733
|
|
|
573
734
|
const banner = [
|
|
574
735
|
'',
|
|
@@ -591,18 +752,35 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
|
|
|
591
752
|
if (configPath) {
|
|
592
753
|
process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
|
|
593
754
|
}
|
|
594
|
-
const repoCount = config.repos?.length || 0;
|
|
595
|
-
if (repoCount > 0) {
|
|
596
|
-
process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
|
|
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`);
|
|
600
774
|
process.stdout.write('\n');
|
|
601
775
|
|
|
602
|
-
// Auto-open browser
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
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
|
+
}
|