lumencode 1.2.0 → 1.3.1
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 +49 -8
- 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 +312 -123
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'path';
|
|
4
|
+
import { StepDatabase } from './step-schema.js';
|
|
5
|
+
import { computeBlame, buildInitialBlameMap } from './line-blame.js';
|
|
6
|
+
import {
|
|
7
|
+
normalizeCommitFilePath,
|
|
8
|
+
toRepoRelativePath,
|
|
9
|
+
} from './git-paths.js';
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
12
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '__pycache__']);
|
|
13
|
+
|
|
14
|
+
function shouldIgnore(filePath) {
|
|
15
|
+
const parts = filePath.replace(/\\/g, '/').split('/');
|
|
16
|
+
return parts.some(p => IGNORED_DIRS.has(p));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function generateStepHash(data) {
|
|
20
|
+
const raw = typeof data === 'string' ? data : JSON.stringify(data);
|
|
21
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizePath(filePath, projectRoot) {
|
|
25
|
+
if (!filePath) return '';
|
|
26
|
+
const relative = toRepoRelativePath(filePath, projectRoot);
|
|
27
|
+
return normalizeCommitFilePath(relative);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractPatchTargetFiles(patchText, projectRoot) {
|
|
31
|
+
const files = [];
|
|
32
|
+
const patch = String(patchText || '');
|
|
33
|
+
const markerRe = /^\*\*\* (?:Add File|Update File|Delete File|Move to):\s+(.+)$/gm;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = markerRe.exec(patch)) !== null) {
|
|
36
|
+
const normalized = normalizePath(match[1].trim(), projectRoot);
|
|
37
|
+
if (normalized) files.push(normalized);
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract file paths from tool input (reuses logic from git.js)
|
|
43
|
+
function extractTargetFiles(toolName, toolInput, projectRoot) {
|
|
44
|
+
const files = [];
|
|
45
|
+
const input = toolInput || {};
|
|
46
|
+
const normalizedTool = String(toolName || '').toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (['write', 'edit', 'multiedit', 'notebookedit'].includes(normalizedTool)) {
|
|
49
|
+
const rawPath = input.file_path || input.filePath || input.filepath || input.path || '';
|
|
50
|
+
if (rawPath) {
|
|
51
|
+
const normalized = normalizePath(rawPath, projectRoot);
|
|
52
|
+
if (normalized) files.push(normalized);
|
|
53
|
+
}
|
|
54
|
+
} else if (normalizedTool === 'bash') {
|
|
55
|
+
// Minimal extraction for shell commands touching files
|
|
56
|
+
const cmd = input.command || '';
|
|
57
|
+
const redirectRe = />>?\s*['"]?([^&|;\s<>$`'"]+)['"]?/g;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = redirectRe.exec(cmd)) !== null) {
|
|
60
|
+
const p = normalizePath(m[1], projectRoot);
|
|
61
|
+
if (p) files.push(p);
|
|
62
|
+
}
|
|
63
|
+
} else if (normalizedTool === 'apply_patch') {
|
|
64
|
+
files.push(...extractPatchTargetFiles(input.patchText || input.patch_text || input.patch, projectRoot));
|
|
65
|
+
} else if (String(toolName || '').startsWith('mcp__')) {
|
|
66
|
+
const rawPath = input.relative_path || input.file_path || input.path || '';
|
|
67
|
+
if (rawPath) {
|
|
68
|
+
const normalized = normalizePath(rawPath, projectRoot);
|
|
69
|
+
if (normalized) files.push(normalized);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return files.filter(f => !shouldIgnore(f));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class StepTracker {
|
|
77
|
+
constructor(projectRoot, options = {}) {
|
|
78
|
+
this.projectRoot = resolve(projectRoot || process.cwd());
|
|
79
|
+
this.dbPath = options.dbPath
|
|
80
|
+
? (isAbsolute(options.dbPath) ? options.dbPath : join(this.projectRoot, options.dbPath))
|
|
81
|
+
: join(this.projectRoot, '.ccusage', 'steps.db');
|
|
82
|
+
this.db = null;
|
|
83
|
+
this.maxFileSize = options.maxFileSize || MAX_FILE_SIZE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async open() {
|
|
87
|
+
this.db = new StepDatabase();
|
|
88
|
+
await this.db.open(this.dbPath);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
if (this.db) {
|
|
94
|
+
this.db.save();
|
|
95
|
+
this.db.close();
|
|
96
|
+
this.db = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isAvailable() {
|
|
101
|
+
return this.db && this.db.getStepCount() > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async isAvailableAsync() {
|
|
105
|
+
if (!existsSync(this.dbPath)) return false;
|
|
106
|
+
try {
|
|
107
|
+
const tempDb = new StepDatabase();
|
|
108
|
+
await tempDb.open(this.dbPath);
|
|
109
|
+
const count = tempDb.getStepCount();
|
|
110
|
+
tempDb.close();
|
|
111
|
+
return count > 0;
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Record a tool use step ──
|
|
116
|
+
|
|
117
|
+
async recordStep(payload) {
|
|
118
|
+
if (!this.db) await this.open();
|
|
119
|
+
|
|
120
|
+
const sessionId = payload.sessionId || 'unknown';
|
|
121
|
+
const origin = payload.origin || 'claude_code';
|
|
122
|
+
const toolName = payload.toolName || '';
|
|
123
|
+
const toolUseId = payload.toolUseId || generateStepHash(`${sessionId}:${Date.now()}`);
|
|
124
|
+
const timestamp = payload.timestamp ? new Date(payload.timestamp).getTime() : Date.now();
|
|
125
|
+
|
|
126
|
+
// Extract target files from tool input
|
|
127
|
+
const explicitTargets = Array.isArray(payload.targetFiles)
|
|
128
|
+
? payload.targetFiles.map(file => normalizePath(file, this.projectRoot)).filter(Boolean)
|
|
129
|
+
: [];
|
|
130
|
+
const batchTargets = Array.isArray(payload.toolCalls)
|
|
131
|
+
? payload.toolCalls.flatMap(call => extractTargetFiles(
|
|
132
|
+
call.toolName || call.tool_name || call.name || call.tool || '',
|
|
133
|
+
call.toolInput || call.tool_input || call.input || call.args || {},
|
|
134
|
+
this.projectRoot
|
|
135
|
+
))
|
|
136
|
+
: [];
|
|
137
|
+
const targetFiles = [...new Set([
|
|
138
|
+
...explicitTargets,
|
|
139
|
+
...batchTargets,
|
|
140
|
+
...extractTargetFiles(toolName, payload.toolInput || {}, this.projectRoot),
|
|
141
|
+
])];
|
|
142
|
+
if (targetFiles.length === 0) return null;
|
|
143
|
+
|
|
144
|
+
// Get parent step for session
|
|
145
|
+
const parentStepId = this.db.getSessionHead(sessionId);
|
|
146
|
+
|
|
147
|
+
// Generate step hash
|
|
148
|
+
const stepHash = generateStepHash(
|
|
149
|
+
`${parentStepId}:${sessionId}:${toolName}:${toolUseId}:${timestamp}`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Compute blame for each target file
|
|
153
|
+
for (const filePath of targetFiles) {
|
|
154
|
+
const absPath = resolve(this.projectRoot, filePath);
|
|
155
|
+
if (!existsSync(absPath)) continue;
|
|
156
|
+
|
|
157
|
+
const stat = statSync(absPath);
|
|
158
|
+
if (stat.size > this.maxFileSize) continue;
|
|
159
|
+
|
|
160
|
+
const newContent = readFileSync(absPath, 'utf-8');
|
|
161
|
+
|
|
162
|
+
// Get old content and blame from parent step
|
|
163
|
+
let oldContent = null;
|
|
164
|
+
let oldBlameMap = null;
|
|
165
|
+
if (parentStepId) {
|
|
166
|
+
oldContent = this.db.getFileBlob(parentStepId, filePath);
|
|
167
|
+
oldBlameMap = this.db.getBlameMap(parentStepId, filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let newBlameMap;
|
|
171
|
+
if (oldContent !== null) {
|
|
172
|
+
newBlameMap = computeBlame(oldContent, newContent, oldBlameMap, stepHash);
|
|
173
|
+
} else {
|
|
174
|
+
newBlameMap = buildInitialBlameMap(newContent, stepHash);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.db.upsertStepFile(stepHash, filePath, newBlameMap, newContent);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Write step record
|
|
181
|
+
this.db.insertStep({
|
|
182
|
+
id: stepHash,
|
|
183
|
+
parentId: parentStepId,
|
|
184
|
+
sessionId,
|
|
185
|
+
origin,
|
|
186
|
+
ts: timestamp,
|
|
187
|
+
toolName,
|
|
188
|
+
toolUseId,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Update session head
|
|
192
|
+
this.db.upsertSession({
|
|
193
|
+
id: sessionId,
|
|
194
|
+
origin,
|
|
195
|
+
headStepId: stepHash,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.db.save();
|
|
199
|
+
return stepHash;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Line attribution for a commit ──
|
|
203
|
+
|
|
204
|
+
getLineAttributionForCommit(commit) {
|
|
205
|
+
if (!this.db || !commit.sessionId) return null;
|
|
206
|
+
|
|
207
|
+
const result = {
|
|
208
|
+
aiLines: 0,
|
|
209
|
+
humanLines: 0,
|
|
210
|
+
aiDeletedLines: 0,
|
|
211
|
+
humanDeletedLines: 0,
|
|
212
|
+
totalLines: 0,
|
|
213
|
+
fileBreakdown: {},
|
|
214
|
+
source: 'step_blame',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const file of commit.files || []) {
|
|
218
|
+
const filePath = normalizeCommitFilePath(file.path);
|
|
219
|
+
if (shouldIgnore(filePath)) continue;
|
|
220
|
+
|
|
221
|
+
// Find the latest step for this file in the session
|
|
222
|
+
const stepFiles = this.db.getStepFilesForPath(filePath, 5);
|
|
223
|
+
const sessionSteps = stepFiles.filter(sf => sf.session_id === commit.sessionId);
|
|
224
|
+
if (sessionSteps.length === 0) continue;
|
|
225
|
+
|
|
226
|
+
const latestStep = sessionSteps[0]; // already sorted DESC by ts
|
|
227
|
+
const blameMap = this.db.getBlameMap(latestStep.step_id, filePath);
|
|
228
|
+
if (!blameMap || !blameMap.lines) continue;
|
|
229
|
+
|
|
230
|
+
// Count AI vs human lines in the blame map
|
|
231
|
+
const stepIds = new Set(sessionSteps.map(s => s.step_id));
|
|
232
|
+
let fileAI = 0;
|
|
233
|
+
let fileHuman = 0;
|
|
234
|
+
|
|
235
|
+
for (const lineStep of blameMap.lines) {
|
|
236
|
+
if (stepIds.has(lineStep)) {
|
|
237
|
+
fileAI++;
|
|
238
|
+
} else {
|
|
239
|
+
fileHuman++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const totalChanged = (file.added || 0) + (file.deleted || 0);
|
|
244
|
+
if (totalChanged === 0) continue;
|
|
245
|
+
|
|
246
|
+
// Proportionally attribute added/deleted lines
|
|
247
|
+
const fileTotal = fileAI + fileHuman;
|
|
248
|
+
const aiRatio = fileTotal > 0 ? fileAI / fileTotal : 0;
|
|
249
|
+
|
|
250
|
+
const fileAIAdded = Math.round((file.added || 0) * aiRatio);
|
|
251
|
+
const fileHumanAdded = (file.added || 0) - fileAIAdded;
|
|
252
|
+
const fileAIDeleted = Math.round((file.deleted || 0) * aiRatio);
|
|
253
|
+
const fileHumanDeleted = (file.deleted || 0) - fileAIDeleted;
|
|
254
|
+
|
|
255
|
+
result.aiLines += fileAIAdded;
|
|
256
|
+
result.humanLines += fileHumanAdded;
|
|
257
|
+
result.aiDeletedLines += fileAIDeleted;
|
|
258
|
+
result.humanDeletedLines += fileHumanDeleted;
|
|
259
|
+
result.totalLines += totalChanged;
|
|
260
|
+
result.fileBreakdown[filePath] = { aiLines: fileAIAdded, humanLines: fileHumanAdded, aiDeletedLines: fileAIDeleted, humanDeletedLines: fileHumanDeleted };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.totalLines === 0) return null;
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Backfill from existing session data ──
|
|
268
|
+
|
|
269
|
+
async backfillFromSession(session) {
|
|
270
|
+
if (!this.db) await this.open();
|
|
271
|
+
|
|
272
|
+
const sessionId = session.id || 'unknown';
|
|
273
|
+
let stepCount = 0;
|
|
274
|
+
|
|
275
|
+
for (const tc of session.toolSequence || []) {
|
|
276
|
+
const targetFiles = extractTargetFiles(tc.name, tc.input, this.projectRoot);
|
|
277
|
+
if (targetFiles.length === 0) continue;
|
|
278
|
+
|
|
279
|
+
const timestamp = tc.timestamp ? new Date(tc.timestamp).getTime() : Date.now();
|
|
280
|
+
const parentStepId = this.db.getSessionHead(sessionId);
|
|
281
|
+
const stepHash = generateStepHash(
|
|
282
|
+
`backfill:${parentStepId}:${sessionId}:${tc.name}:${timestamp}`
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
for (const filePath of targetFiles) {
|
|
286
|
+
const absPath = resolve(this.projectRoot, filePath);
|
|
287
|
+
if (!existsSync(absPath)) continue;
|
|
288
|
+
|
|
289
|
+
const stat = statSync(absPath);
|
|
290
|
+
if (stat.size > this.maxFileSize) continue;
|
|
291
|
+
|
|
292
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
293
|
+
const blameMap = buildInitialBlameMap(content, stepHash);
|
|
294
|
+
this.db.upsertStepFile(stepHash, filePath, blameMap);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.db.insertStep({
|
|
298
|
+
id: stepHash,
|
|
299
|
+
parentId: parentStepId,
|
|
300
|
+
sessionId,
|
|
301
|
+
ts: timestamp,
|
|
302
|
+
toolName: tc.name,
|
|
303
|
+
toolUseId: `backfill-${stepCount}`,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.db.upsertSession({ id: sessionId, headStepId: stepHash });
|
|
307
|
+
stepCount++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (stepCount > 0) this.db.save();
|
|
311
|
+
return stepCount;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Stats ──
|
|
315
|
+
|
|
316
|
+
getStats() {
|
|
317
|
+
if (!this.db) return { stepCount: 0, sessionCount: 0 };
|
|
318
|
+
return {
|
|
319
|
+
stepCount: this.db.getStepCount(),
|
|
320
|
+
sessionCount: this.db.getSessionCount(),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lumencode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node index.js",
|
|
11
|
-
"test": "node --test test
|
|
11
|
+
"test": "node --test test",
|
|
12
|
+
"hooks:init": "node hooks/init-steps.js",
|
|
13
|
+
"hooks:install": "node hooks/install.js",
|
|
14
|
+
"hooks:install-claude": "node hooks/install.js",
|
|
15
|
+
"hooks:install-codex": "node hooks/install-codex.js"
|
|
12
16
|
},
|
|
13
17
|
"keywords": [
|
|
14
18
|
"lumencode",
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
"license": "MIT",
|
|
30
34
|
"author": "zhangyaowen",
|
|
31
35
|
"files": [
|
|
36
|
+
"hooks/",
|
|
32
37
|
"lib/",
|
|
33
38
|
"public/",
|
|
34
39
|
"data/pricing.json",
|
|
@@ -39,6 +44,7 @@
|
|
|
39
44
|
"node": ">=18.0.0"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
47
|
+
"diff-match-patch": "^1.0.5",
|
|
42
48
|
"sql.js": "^1.14.1"
|
|
43
49
|
}
|
|
44
50
|
}
|
package/public/api.js
CHANGED
|
@@ -107,6 +107,27 @@ export async function fetchSessions(params) {
|
|
|
107
107
|
return cachedFetch(`${API.SESSIONS}?${qs}`);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export async function fetchStepStats() {
|
|
111
|
+
return cachedFetch(API.STEP_STATS, {}, 10_000);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchHooksStatus() {
|
|
115
|
+
const res = await fetch(API.HOOKS);
|
|
116
|
+
if (!res.ok) throw new Error('获取 hooks 状态失败');
|
|
117
|
+
return res.json();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function updateHooks(action, tools = ['claude', 'codex', 'opencode']) {
|
|
121
|
+
const res = await fetch(API.HOOKS, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ action, tools: tools.join(',') }),
|
|
125
|
+
});
|
|
126
|
+
const data = await res.json().catch(() => ({}));
|
|
127
|
+
if (!res.ok) throw new Error(data.error || '更新 hooks 失败');
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
export async function fetchWorkReport(params) {
|
|
111
132
|
const qs = new URLSearchParams(params).toString();
|
|
112
133
|
const res = await fetch(`${API.REPORT}?${qs}&format=work`);
|