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/lib/aggregate.js CHANGED
@@ -20,7 +20,7 @@ function getProjectBaseName(projectPath) {
20
20
  return parts[parts.length - 1] || '';
21
21
  }
22
22
 
23
- function encodeProjectPath(projectPath) {
23
+ export function encodeProjectPath(projectPath) {
24
24
  return projectPath
25
25
  .replace(/:[\\/]/, '--') // D:/ → D--
26
26
  .replace(/[\\/]/g, '-'); // 剩余 / 或 \ → -
@@ -47,7 +47,7 @@ export function collectAllRecords(claudeDir, excludeProjects = [], includeProjec
47
47
 
48
48
  for (const projDir of dirs) {
49
49
  const projPath = join(projectsDir, projDir);
50
- const projName = decodeProjectName(projDir);
50
+ const projName = getProjectDisplayName(projDir);
51
51
 
52
52
  if (encodedIncludes) {
53
53
  if (!encodedIncludes.has(projDir)) {
@@ -74,7 +74,7 @@ export function collectAllRecords(claudeDir, excludeProjects = [], includeProjec
74
74
  const sessionRecords = records.filter(r => r.sessionId);
75
75
  sessionRecords.forEach(r => projSessions.add(r.sessionId));
76
76
  projRequests += records.filter(r => isAssistantRecord(r)).length;
77
- } catch {}
77
+ } catch (e) { console.warn(`[aggregate] 读取文件记录失败: ${filePath}`, e.message); }
78
78
 
79
79
  // 解析子 agent 日志
80
80
  try {
@@ -87,7 +87,7 @@ export function collectAllRecords(claudeDir, excludeProjects = [], includeProjec
87
87
  }
88
88
  projRequests += subRecords.filter(r => isAssistantRecord(r)).length;
89
89
  subRecords.filter(r => r.sessionId).forEach(r => projSessions.add(r.sessionId));
90
- } catch {}
90
+ } catch (e) { console.warn(`[aggregate] 解析子agent失败: ${filePath}`, e.message); }
91
91
  }
92
92
 
93
93
  // 当无主 JSONL 文件时,扫描 sessions-index.json 和 UUID 子目录
@@ -127,7 +127,7 @@ export function collectAllRecords(claudeDir, excludeProjects = [], includeProjec
127
127
  projRequests++;
128
128
  }
129
129
  }
130
- } catch {}
130
+ } catch (e) { console.warn(`[aggregate] 解析sessions-index失败: ${indexPath}`, e.message); }
131
131
  }
132
132
 
133
133
  // 扫描 UUID 子目录中的 subagent 文件
@@ -146,7 +146,7 @@ export function collectAllRecords(claudeDir, excludeProjects = [], includeProjec
146
146
  }
147
147
  projRequests += subRecords.filter(r => isAssistantRecord(r)).length;
148
148
  subRecords.filter(r => r.sessionId).forEach(r => projSessions.add(r.sessionId));
149
- } catch {}
149
+ } catch (e) { console.warn(`[aggregate] 扫描UUID子目录失败: ${uuidDir}`, e.message); }
150
150
  }
151
151
  }
152
152
 
@@ -187,7 +187,10 @@ export function deduplicateRecords(records) {
187
187
  return deduped;
188
188
  }
189
189
 
190
- function decodeProjectName(dirName) {
190
+ // 有损解码:encode 中 / 和 \ 都映射为 -,decode 无法区分 - 是路径分隔符还是项目名原字符
191
+ // 因此 decode 结果可能不准确(如 ccusage-report → ccusage/report)
192
+ // 内部匹配用原始目录名,此函数仅用于展示
193
+ export function decodeProjectName(dirName) {
191
194
  let decoded = dirName
192
195
  .replace(/^([A-Z])-/, '$1:/')
193
196
  .replace(/--/g, '/')
@@ -197,7 +200,6 @@ function decodeProjectName(dirName) {
197
200
  decoded = '.../' + decoded.slice(3);
198
201
  }
199
202
 
200
- // 去掉尾部多余斜杠
201
203
  decoded = decoded.replace(/\/+$/, '');
202
204
 
203
205
  if (/^[A-Z]:$/.test(decoded)) {
@@ -207,6 +209,13 @@ function decodeProjectName(dirName) {
207
209
  return decoded || '[未知项目]';
208
210
  }
209
211
 
212
+ // 从有损 decode 结果中提取项目显示名:取最后一个路径段
213
+ export function getProjectDisplayName(dirName) {
214
+ const decoded = decodeProjectName(dirName);
215
+ const segments = decoded.replace(/\\/g, '/').replace(/\/+$/, '').split('/');
216
+ return segments[segments.length - 1] || decoded || '[未知项目]';
217
+ }
218
+
210
219
  export function computeUsageStats(records, scenarioKeywords, costMode = 'auto') {
211
220
  const stats = {
212
221
  sessionCount: new Set(records.filter(r => !(r.metadata?.isSubagent || r.isSubagent)).map(r => r.sessionId).filter(Boolean)).size,
@@ -404,7 +413,16 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
404
413
  }
405
414
 
406
415
  export function filterRecordsByPeriod(records, period, refDate, options = {}) {
407
- const d = new Date(refDate);
416
+ // 字符串日期 "2026-05-26" new Date() 解析为 UTC 午夜,
417
+ // formatDate 用本地时间,UTC- 时区下日期会偏移一天。
418
+ // 修复:将 YYYY-MM-DD 字符串按本地时间解析
419
+ let d;
420
+ if (typeof refDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(refDate)) {
421
+ const [y, m, day] = refDate.split('-').map(Number);
422
+ d = new Date(y, m - 1, day);
423
+ } else {
424
+ d = new Date(refDate);
425
+ }
408
426
  let start, end;
409
427
 
410
428
  switch (period) {
@@ -22,6 +22,19 @@ export function classifyAttribution(input = {}) {
22
22
  ...evidence.map(e => e.tool),
23
23
  ]);
24
24
 
25
+ // Step blame: confirmed by line-level tracking
26
+ if (input.lineBlame?.source === 'step_blame') {
27
+ return {
28
+ commitHash: input.commitHash || null,
29
+ classification: 'confirmed_ai',
30
+ primaryTool: input.primaryTool || null,
31
+ tools: unique([...(Array.isArray(input.tools) ? input.tools : []), input.primaryTool].filter(Boolean)),
32
+ evidence: [...(input.evidence || []), 'step_blame'],
33
+ source: 'auto',
34
+ reason: 'step_blame',
35
+ };
36
+ }
37
+
25
38
  if (override?.classification) {
26
39
  const classification = normalizeClassification(override.classification);
27
40
  return {
@@ -0,0 +1,141 @@
1
+ import { closeSync, existsSync, openSync, statSync, unlinkSync } from 'fs';
2
+ import { isAbsolute, join, resolve } from 'path';
3
+ import { StepTracker } from './step-tracker.js';
4
+
5
+ export const ORIGINS = Object.freeze({
6
+ CLAUDE_CODE: 'claude_code',
7
+ CODEX_CLI: 'codex_cli',
8
+ OPENCODE: 'opencode',
9
+ });
10
+
11
+ function sleep(ms) {
12
+ return new Promise(resolveSleep => setTimeout(resolveSleep, ms));
13
+ }
14
+
15
+ async function withDbLock(dbPath, fn) {
16
+ const lockPath = `${dbPath}.lock`;
17
+ const deadline = Date.now() + 2_000;
18
+ let fd = null;
19
+
20
+ while (Date.now() < deadline) {
21
+ try {
22
+ fd = openSync(lockPath, 'wx');
23
+ break;
24
+ } catch {
25
+ try {
26
+ if (Date.now() - statSync(lockPath).mtimeMs > 10_000) unlinkSync(lockPath);
27
+ } catch {}
28
+ await sleep(50);
29
+ }
30
+ }
31
+
32
+ if (fd === null) return null;
33
+
34
+ try {
35
+ return await fn();
36
+ } finally {
37
+ try { closeSync(fd); } catch {}
38
+ try { unlinkSync(lockPath); } catch {}
39
+ }
40
+ }
41
+
42
+ export function normalizeOriginSessionId(origin, sessionId) {
43
+ const normalizedOrigin = origin || ORIGINS.CLAUDE_CODE;
44
+ const rawSessionId = String(sessionId || 'unknown');
45
+ const prefix = `${normalizedOrigin}:`;
46
+ return rawSessionId.startsWith(prefix) ? rawSessionId : `${prefix}${rawSessionId}`;
47
+ }
48
+
49
+ export async function recordToolUse(payload = {}) {
50
+ const cwd = resolve(payload.cwd || process.cwd());
51
+ const dbPath = payload.dbPath
52
+ ? (isAbsolute(payload.dbPath) ? payload.dbPath : join(cwd, payload.dbPath))
53
+ : join(cwd, '.ccusage', 'steps.db');
54
+
55
+ if (!existsSync(dbPath)) {
56
+ return { recorded: false, reason: 'not_initialized' };
57
+ }
58
+
59
+ const origin = payload.origin || ORIGINS.CLAUDE_CODE;
60
+ const sessionId = normalizeOriginSessionId(origin, payload.sessionId);
61
+
62
+ const result = await withDbLock(dbPath, async () => {
63
+ const tracker = new StepTracker(cwd, { dbPath });
64
+ try {
65
+ await tracker.open();
66
+ const stepId = await tracker.recordStep({
67
+ origin,
68
+ sessionId,
69
+ toolUseId: payload.toolUseId,
70
+ toolName: payload.toolName,
71
+ toolInput: payload.toolInput,
72
+ toolResponse: payload.toolResponse,
73
+ cwd,
74
+ timestamp: payload.timestamp || new Date().toISOString(),
75
+ });
76
+ return stepId
77
+ ? { recorded: true, stepId, sessionId, origin }
78
+ : { recorded: false, reason: 'no_target_files', sessionId, origin };
79
+ } finally {
80
+ tracker.close();
81
+ }
82
+ });
83
+
84
+ return result || { recorded: false, reason: 'lock_timeout', sessionId, origin };
85
+ }
86
+
87
+ function normalizeToolCall(call = {}) {
88
+ const tool = call.tool || {};
89
+ return {
90
+ toolUseId: call.toolUseId || call.tool_use_id || call.id || call.call_id || call.callId,
91
+ toolName: call.toolName || call.tool_name || call.name || tool.name,
92
+ toolInput: call.toolInput || call.tool_input || call.input || call.args || tool.input || {},
93
+ toolResponse: call.toolResponse || call.tool_response || call.output || call.response || tool.output,
94
+ };
95
+ }
96
+
97
+ export async function recordToolBatch(payload = {}) {
98
+ const cwd = resolve(payload.cwd || process.cwd());
99
+ const dbPath = payload.dbPath
100
+ ? (isAbsolute(payload.dbPath) ? payload.dbPath : join(cwd, payload.dbPath))
101
+ : join(cwd, '.ccusage', 'steps.db');
102
+
103
+ if (!existsSync(dbPath)) {
104
+ return { recorded: false, reason: 'not_initialized' };
105
+ }
106
+
107
+ const origin = payload.origin || ORIGINS.CLAUDE_CODE;
108
+ const sessionId = normalizeOriginSessionId(origin, payload.sessionId);
109
+ const toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls.map(normalizeToolCall) : [];
110
+ if (toolCalls.length === 0) {
111
+ return { recorded: false, reason: 'empty_batch', sessionId, origin };
112
+ }
113
+
114
+ const batchId = payload.batchId || toolCalls
115
+ .map(call => call.toolUseId || `${call.toolName || 'tool'}:${JSON.stringify(call.toolInput || {})}`)
116
+ .join(',');
117
+
118
+ const result = await withDbLock(dbPath, async () => {
119
+ const tracker = new StepTracker(cwd, { dbPath });
120
+ try {
121
+ await tracker.open();
122
+ const stepId = await tracker.recordStep({
123
+ origin,
124
+ sessionId,
125
+ toolUseId: `batch:${batchId}`,
126
+ toolName: 'ToolBatch',
127
+ toolInput: { toolCalls },
128
+ toolCalls,
129
+ cwd,
130
+ timestamp: payload.timestamp || new Date().toISOString(),
131
+ });
132
+ return stepId
133
+ ? { recorded: true, stepId, sessionId, origin, toolCallCount: toolCalls.length }
134
+ : { recorded: false, reason: 'no_target_files', sessionId, origin, toolCallCount: toolCalls.length };
135
+ } finally {
136
+ tracker.close();
137
+ }
138
+ });
139
+
140
+ return result || { recorded: false, reason: 'lock_timeout', sessionId, origin };
141
+ }
package/lib/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
+ import { DEFAULT_ATTRIBUTION_OPTIONS } from './git-attribution-options.js';
4
5
 
5
6
  const CONFIG_LOCATIONS = [
6
7
  join(homedir(), '.lumencode.json'),
@@ -25,8 +26,31 @@ const DEFAULT_CONFIG = {
25
26
  review: ['review', '审查', '检查', '代码审查', '/review'],
26
27
  planning: ['计划', 'plan', '设计', '架构', '方案', 'design', 'architect'],
27
28
  },
29
+ aiAttribution: DEFAULT_ATTRIBUTION_OPTIONS,
30
+ stepTracking: {
31
+ enabled: true,
32
+ dbPath: '.ccusage/steps.db',
33
+ maxFileSize: 10 * 1024 * 1024,
34
+ ignorePatterns: ['node_modules/', '.git/', 'dist/', 'build/', '.next/', '.cache/'],
35
+ },
28
36
  };
29
37
 
38
+ // 深合并:对嵌套对象和数组做合并而非覆盖
39
+ function deepMerge(target, source) {
40
+ const result = { ...target };
41
+ for (const key of Object.keys(source)) {
42
+ const sv = source[key];
43
+ const tv = target[key];
44
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) &&
45
+ tv && typeof tv === 'object' && !Array.isArray(tv)) {
46
+ result[key] = deepMerge(tv, sv);
47
+ } else {
48
+ result[key] = sv;
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
30
54
  export function loadConfig(configPath) {
31
55
  let config = { ...DEFAULT_CONFIG };
32
56
 
@@ -35,7 +59,7 @@ export function loadConfig(configPath) {
35
59
  if (existsSync(configPath)) {
36
60
  try {
37
61
  const userConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
38
- config = { ...config, ...userConfig };
62
+ config = deepMerge(config, userConfig);
39
63
  return config;
40
64
  } catch (e) {
41
65
  console.error(`配置文件读取失败: ${configPath}`, e.message);
@@ -50,7 +74,7 @@ export function loadConfig(configPath) {
50
74
  if (existsSync(p)) {
51
75
  try {
52
76
  const userConfig = JSON.parse(readFileSync(p, 'utf-8'));
53
- config = { ...config, ...userConfig };
77
+ config = deepMerge(config, userConfig);
54
78
  return config;
55
79
  } catch (e) {
56
80
  console.error(`配置文件读取失败: ${p}`, e.message);
@@ -0,0 +1,37 @@
1
+ export function scoreSessionCandidate(commit, session, context = {}) {
2
+ const distanceMs = context.distanceMs ?? Number.MAX_SAFE_INTEGER;
3
+ const fileOverlapRatio = context.fileOverlapRatio ?? 0;
4
+ const matchedFiles = context.matchedFiles ?? [];
5
+ let score = 0;
6
+ const reasons = [];
7
+
8
+ if (context.hasStrongBashMatch) {
9
+ score += 100;
10
+ reasons.push('bash_git_commit');
11
+ }
12
+ if (context.projectMatches) {
13
+ score += 40;
14
+ reasons.push('project_match');
15
+ }
16
+ if (fileOverlapRatio > 0) {
17
+ score += Math.round(fileOverlapRatio * 35);
18
+ reasons.push('file_overlap');
19
+ }
20
+ if (Number.isFinite(distanceMs)) {
21
+ score += Math.max(0, 20 - Math.floor(distanceMs / 60000));
22
+ reasons.push('time_proximity');
23
+ }
24
+ if (session.primaryTool) {
25
+ score += 5;
26
+ reasons.push('primary_tool');
27
+ }
28
+
29
+ return {
30
+ sessionId: session.id,
31
+ score,
32
+ reasons,
33
+ distanceMs,
34
+ fileOverlapRatio,
35
+ matchedFiles,
36
+ };
37
+ }
@@ -0,0 +1,105 @@
1
+ export const DEFAULT_ATTRIBUTION_OPTIONS = {
2
+ windows: {
3
+ weakWindowMinutes: 30,
4
+ crossDayWindowDays: 3,
5
+ },
6
+ confidenceThresholds: {
7
+ high: 0.75,
8
+ medium: 0.45,
9
+ low: 0.20,
10
+ },
11
+ confidenceWeights: {
12
+ high: 1.0,
13
+ medium: 0.7,
14
+ low: 0.2,
15
+ none: 0,
16
+ },
17
+ scoreWeights: {
18
+ explicitSignature: 0.85,
19
+ explicitAuthor: 0.80,
20
+ genericAISignature: 0.70,
21
+ sessionStrong: 0.40,
22
+ sessionCrossDay: 0.25,
23
+ sessionWeak: 0.15,
24
+ sessionCrossDayWeak: 0.10,
25
+ fileOverlap: 0.30,
26
+ styleBulletList: 0.15,
27
+ styleConventionalScope: 0.05,
28
+ styleImperativeMood: 0.10,
29
+ styleLongStructuredBody: 0.05,
30
+ baselineDeviationHigh: 0.15,
31
+ baselineDeviationMedium: 0.08,
32
+ negativeMergeCommit: -0.50,
33
+ negativeInformal: -0.20,
34
+ negativeSmallScope: -0.15,
35
+ negativeWIP: -0.15,
36
+ humanBaselineMatch: -0.10,
37
+ },
38
+ explicitSignalPolicy: {
39
+ coAuthor: 'strong',
40
+ generatedWith: 'strong',
41
+ assistedBy: 'strong',
42
+ robotEmoji: 'medium',
43
+ genericAIKeywords: 'medium',
44
+ },
45
+ };
46
+
47
+ function finiteNumber(value) {
48
+ return typeof value === 'number' && Number.isFinite(value);
49
+ }
50
+
51
+ function positiveNumber(value, fallback) {
52
+ return finiteNumber(value) && value > 0 ? value : fallback;
53
+ }
54
+
55
+ function ratioNumber(value, fallback) {
56
+ return finiteNumber(value) && value >= 0 && value <= 1 ? value : fallback;
57
+ }
58
+
59
+ function normalizeThresholds(input = {}, defaults) {
60
+ let high = ratioNumber(input.high, defaults.high);
61
+ let medium = ratioNumber(input.medium, defaults.medium);
62
+ let low = ratioNumber(input.low, defaults.low);
63
+
64
+ if (high < medium) high = defaults.high;
65
+ if (medium < low) medium = defaults.medium;
66
+ if (high < medium) medium = defaults.medium;
67
+ if (medium < low) low = defaults.low;
68
+
69
+ return { high, medium, low };
70
+ }
71
+
72
+ function normalizeWeights(input = {}, defaults) {
73
+ return {
74
+ high: ratioNumber(input.high, defaults.high),
75
+ medium: ratioNumber(input.medium, defaults.medium),
76
+ low: ratioNumber(input.low, defaults.low),
77
+ none: ratioNumber(input.none, defaults.none),
78
+ };
79
+ }
80
+
81
+ function scoreWeight(value, fallback) {
82
+ return finiteNumber(value) && value >= -1 && value <= 1 ? value : fallback;
83
+ }
84
+
85
+ function normalizeScoreWeights(input = {}, defaults) {
86
+ const result = {};
87
+ for (const [key, fallback] of Object.entries(defaults)) {
88
+ result[key] = scoreWeight(input[key], fallback);
89
+ }
90
+ return result;
91
+ }
92
+
93
+ export function resolveAttributionOptions(input = {}) {
94
+ const defaults = DEFAULT_ATTRIBUTION_OPTIONS;
95
+ return {
96
+ windows: {
97
+ weakWindowMinutes: positiveNumber(input.windows?.weakWindowMinutes, defaults.windows.weakWindowMinutes),
98
+ crossDayWindowDays: positiveNumber(input.windows?.crossDayWindowDays, defaults.windows.crossDayWindowDays),
99
+ },
100
+ confidenceThresholds: normalizeThresholds(input.confidenceThresholds, defaults.confidenceThresholds),
101
+ confidenceWeights: normalizeWeights(input.confidenceWeights, defaults.confidenceWeights),
102
+ scoreWeights: normalizeScoreWeights(input.scoreWeights, defaults.scoreWeights),
103
+ explicitSignalPolicy: { ...defaults.explicitSignalPolicy, ...(input.explicitSignalPolicy || {}) },
104
+ };
105
+ }
@@ -0,0 +1,41 @@
1
+ export function normalizePathForGit(value) {
2
+ if (!value) return '';
3
+ return String(value).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase();
4
+ }
5
+
6
+ export function normalizeCommitFilePath(value) {
7
+ return normalizePathForGit(value).replace(/^\.?\//, '');
8
+ }
9
+
10
+ export function toRepoRelativePath(filePath, repoPath) {
11
+ const file = normalizePathForGit(filePath);
12
+ const repo = normalizePathForGit(repoPath);
13
+ if (!file) return '';
14
+ if (!repo || !file.includes('/')) return normalizeCommitFilePath(file.replace(/^[a-z]:\//i, ''));
15
+ if (file === repo) return '';
16
+ if (file.startsWith(repo + '/')) return normalizeCommitFilePath(file.slice(repo.length + 1));
17
+ return normalizeCommitFilePath(file);
18
+ }
19
+
20
+ export function projectKey(value) {
21
+ return normalizePathForGit(value);
22
+ }
23
+
24
+ function pathContains(parent, child) {
25
+ return parent === child || child.startsWith(parent + '/');
26
+ }
27
+
28
+ export function projectMatches(commitRepo, sessionProject) {
29
+ const a = projectKey(commitRepo);
30
+ const b = projectKey(sessionProject);
31
+ if (!a || !b) return true;
32
+ if (pathContains(a, b) || pathContains(b, a)) return true;
33
+ if (!a.includes('/') || !b.includes('/')) {
34
+ return a.split('/').pop() === b.split('/').pop();
35
+ }
36
+ return false;
37
+ }
38
+
39
+ export function filePathMatches(a, b) {
40
+ return normalizeCommitFilePath(a) === normalizeCommitFilePath(b);
41
+ }