lumencode 1.1.0 → 1.2.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 (3) hide show
  1. package/lib/git.js +271 -2
  2. package/lib/server.js +36 -1
  3. package/package.json +1 -1
package/lib/git.js CHANGED
@@ -180,7 +180,7 @@ function resolveToolFromSignals(signals) {
180
180
  return null;
181
181
  }
182
182
 
183
- function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null } = {}) {
183
+ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null, negativeSignals = [] } = {}) {
184
184
  const normalizedSignals = [...new Set(signals)];
185
185
  return {
186
186
  isAI: isCountedAIConfidence(confidence),
@@ -191,6 +191,7 @@ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], at
191
191
  aiEvidence: normalizedSignals,
192
192
  attributionType,
193
193
  detectedTool,
194
+ negativeSignals,
194
195
  };
195
196
  }
196
197
 
@@ -248,6 +249,203 @@ function loadAttributionOverrides() {
248
249
  }
249
250
  }
250
251
 
252
+ // ── Style-based heuristic detection (fallback when no explicit signals) ──
253
+
254
+ const IMPERATIVE_VERBS = /^(?:add|fix|update|remove|refactor|implement|create|delete|replace|rename|move|extract|improve|optimize|simplify|restructure|rewrite|migrate|upgrade|downgrade|revert|bump|clean|format|lint|docs?|test|build|ci|chore|perf|style|init|setup|config|configure|enable|disable|support|handle|ensure|validate|verify|check|detect|parse|compute|merge|split|group|sort|filter|map|reduce|export|import|load|save|read|write|reset|toggle|switch|convert|transform|wrap|unwrap|escape|unescape|encode|decode|serialize|deserialize|compress|decompress|encrypt|decrypt|hash|sign|verify|auth|login|logout|register|unregister|subscribe|unsubscribe|connect|disconnect|bind|unbind|attach|detach|mount|unmount|open|close|show|hide|display|render|draw|paint|print|log|trace|debug|info|warn|error|fatal|throw|catch|retry|abort|cancel|timeout|expire|flush|clear|reset|restore|backup|archive|deploy|release|publish|install|uninstall)\b/i;
255
+
256
+ function detectStyleSignals(subject, body) {
257
+ const signals = [];
258
+ let score = 0;
259
+
260
+ // Bullet list: 3+ lines starting with "- " or "* "
261
+ const bulletLines = (body || '').split('\n').filter(l => /^\s*[-*]\s+\S/.test(l));
262
+ if (bulletLines.length >= 3) {
263
+ signals.push('styleBulletList');
264
+ score += 2;
265
+ }
266
+
267
+ // Conventional commit with scope: type(scope): subject
268
+ if (CONVENTIONAL_RE.test(subject)) {
269
+ const m = subject.match(CONVENTIONAL_RE);
270
+ if (m && m[2]) {
271
+ signals.push('styleConventionalScope');
272
+ score += 1;
273
+ }
274
+ }
275
+
276
+ // Long structured body: >150 chars with 3+ non-empty lines
277
+ const bodyLines = (body || '').split('\n').filter(l => l.trim());
278
+ if ((body || '').length > 150 && bodyLines.length >= 3) {
279
+ signals.push('styleLongStructuredBody');
280
+ score += 1;
281
+ }
282
+
283
+ // Technical detail: body contains file paths or parenthetical notes
284
+ if (/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.[a-z]{1,4}/.test(body) || /\([^)]{10,}\)/.test(body)) {
285
+ signals.push('styleTechnicalDetail');
286
+ score += 1;
287
+ }
288
+
289
+ // Imperative mood: 3+ body lines start with imperative verbs (strip bullet prefix first)
290
+ const imperativeCount = bodyLines.filter(l => IMPERATIVE_VERBS.test(l.trim().replace(/^[-*]\s+/, ''))).length;
291
+ if (imperativeCount >= 3) {
292
+ signals.push('styleImperativeMood');
293
+ score += 1;
294
+ }
295
+
296
+ return { signals, score };
297
+ }
298
+
299
+ // ── Negative signals (reduce false positives) ──
300
+
301
+ const WIP_RE = /\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i;
302
+ const MERGE_RE = /^Merge\s+(?:branch|pull|remote|tag)/i;
303
+
304
+ export function detectNegativeSignals(subject, body, linesAdded, linesDeleted, fileCount) {
305
+ const signals = [];
306
+ const trimmedBody = (body || '').trim();
307
+ const trimmedSubject = (subject || '').trim();
308
+ const totalLines = (linesAdded || 0) + (linesDeleted || 0);
309
+ const files = fileCount || 0;
310
+
311
+ // Informal: very short subject AND body, no conventional commit prefix
312
+ const isConventional = CONVENTIONAL_RE.test(trimmedSubject);
313
+ if (trimmedSubject.length <= 10 && trimmedBody.length <= 10 && !/\n/.test(trimmedBody) && !isConventional) {
314
+ signals.push('humanInformal');
315
+ }
316
+ if (MERGE_RE.test(trimmedSubject)) {
317
+ signals.push('humanMergeCommit');
318
+ }
319
+ if (totalLines <= 2 && files <= 1) {
320
+ signals.push('humanSmallScope');
321
+ }
322
+ if (WIP_RE.test(trimmedSubject)) {
323
+ signals.push('humanWIP');
324
+ }
325
+ return signals;
326
+ }
327
+
328
+ // ── Developer behavioral baseline ──
329
+
330
+ function computeAuthorBaseline(commitList) {
331
+ const authorStats = new Map();
332
+ for (const c of commitList || []) {
333
+ const email = c.author || 'unknown';
334
+ if (!authorStats.has(email)) {
335
+ authorStats.set(email, {
336
+ count: 0, totalSubjectLen: 0, totalBodyLen: 0,
337
+ bulletCount: 0, convCount: 0, totalFiles: 0, totalLines: 0,
338
+ });
339
+ }
340
+ const s = authorStats.get(email);
341
+ s.count++;
342
+ s.totalSubjectLen += (c.subject || '').length;
343
+ s.totalBodyLen += (c.body || '').length;
344
+ s.totalFiles += (c.files || []).length;
345
+ s.totalLines += (c.linesAdded || 0) + (c.linesDeleted || 0);
346
+ if (/^\s*[-*]\s+\S/m.test(c.body || '')) s.bulletCount++;
347
+ if (CONVENTIONAL_RE.test(c.subject)) s.convCount++;
348
+ }
349
+ const baselines = new Map();
350
+ for (const [email, s] of authorStats) {
351
+ if (s.count < 3) { baselines.set(email, null); continue; }
352
+ baselines.set(email, {
353
+ avgSubjectLen: s.totalSubjectLen / s.count,
354
+ avgBodyLen: s.totalBodyLen / s.count,
355
+ bulletRatio: s.bulletCount / s.count,
356
+ convRatio: s.convCount / s.count,
357
+ avgFiles: s.totalFiles / s.count,
358
+ avgLines: s.totalLines / s.count,
359
+ });
360
+ }
361
+ return baselines;
362
+ }
363
+
364
+ function computeBaselineDeviation(commit, baseline) {
365
+ if (!baseline) return 0;
366
+ let deviation = 0;
367
+ let factors = 0;
368
+ const body = commit.body || '';
369
+
370
+ if (baseline.avgBodyLen > 0) {
371
+ const ratio = body.length / baseline.avgBodyLen;
372
+ deviation += ratio > 2 ? 0.3 : (ratio > 1.5 ? 0.15 : 0);
373
+ factors++;
374
+ }
375
+ const hasBullets = /^\s*[-*]\s+\S/m.test(body);
376
+ if (baseline.bulletRatio < 0.1 && hasBullets) { deviation += 0.3; factors++; }
377
+ const fileCount = (commit.files || []).length;
378
+ if (baseline.avgFiles > 0) {
379
+ const ratio = fileCount / baseline.avgFiles;
380
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
381
+ factors++;
382
+ }
383
+ const lineCount = (commit.linesAdded || 0) + (commit.linesDeleted || 0);
384
+ if (baseline.avgLines > 0) {
385
+ const ratio = lineCount / baseline.avgLines;
386
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
387
+ factors++;
388
+ }
389
+
390
+ return factors > 0 ? Math.min(deviation / factors, 1) : 0;
391
+ }
392
+
393
+ // ── Composite continuous scoring ──
394
+
395
+ function computeContinuousScore(commit) {
396
+ let score = 0;
397
+ const signals = new Set(commit.aiSignals || []);
398
+
399
+ // Explicit signatures
400
+ if (signals.has('coAuthor') || signals.has('generatedWith') || signals.has('assistedBy')) score += 0.85;
401
+ if (signals.has('coAuthorCopilot') || signals.has('coAuthorCursor') || signals.has('coAuthorCodex')) score += 0.85;
402
+ if (signals.has('robotEmoji') || signals.has('coAuthorOpencode')) score += 0.85;
403
+ if (signals.has('authorClaude') || signals.has('authorBot')) score += 0.80;
404
+ if (signals.has('generatedWithAider') || signals.has('aiderTag')) score += 0.85;
405
+ if (signals.has('generatedWithCodex') || signals.has('coAuthorCodex')) score += 0.85;
406
+ if (signals.has('coAuthorWindsurf') || signals.has('coAuthorAugment') || signals.has('coAuthorCline')) score += 0.85;
407
+ if (signals.has('aiGenerated') || signals.has('generatedByAI') || signals.has('viaAI') || signals.has('aiTag')) score += 0.70;
408
+
409
+ // Session signals
410
+ if (commit.sessionAttribution === 'strong') score += 0.40;
411
+ else if (commit.sessionAttribution === 'cross-day') score += 0.25;
412
+ else if (commit.sessionAttribution === 'weak') score += 0.15;
413
+ else if (commit.sessionAttribution === 'cross-day-weak') score += 0.10;
414
+
415
+ // File overlap
416
+ const overlap = commit.aiEvidenceDetails?.fileOverlapRatio || 0;
417
+ score += overlap * 0.30;
418
+
419
+ // Style heuristic
420
+ if (signals.has('styleBulletList')) score += 0.15;
421
+ if (signals.has('styleConventionalScope')) score += 0.05;
422
+ if (signals.has('styleImperativeMood')) score += 0.10;
423
+ if (signals.has('styleLongStructuredBody')) score += 0.05;
424
+
425
+ // Baseline deviation
426
+ if (signals.has('baselineDeviationHigh')) score += 0.15;
427
+ else if (signals.has('baselineDeviationMedium')) score += 0.08;
428
+
429
+ // Negative signals
430
+ const negSignals = new Set(commit.negativeSignals || []);
431
+ if (negSignals.has('humanMergeCommit')) score -= 0.50;
432
+ if (negSignals.has('humanInformal')) score -= 0.20;
433
+ if (negSignals.has('humanSmallScope')) score -= 0.15;
434
+ if (negSignals.has('humanWIP')) score -= 0.15;
435
+
436
+ // Baseline match (human pattern)
437
+ if (signals.has('humanBaselineMatch')) score -= 0.10;
438
+
439
+ return Math.max(0, Math.min(1, score));
440
+ }
441
+
442
+ function scoreToConfidence(score) {
443
+ if (score >= 0.75) return AI_CONFIDENCE.HIGH;
444
+ if (score >= 0.45) return AI_CONFIDENCE.MEDIUM;
445
+ if (score >= 0.20) return AI_CONFIDENCE.LOW;
446
+ return AI_CONFIDENCE.NONE;
447
+ }
448
+
251
449
  export function detectAICommit(subject = '', author = '', body = '') {
252
450
  const haystack = `${subject}\n${body}`;
253
451
  const signals = [];
@@ -285,6 +483,32 @@ export function detectAICommit(subject = '', author = '', body = '') {
285
483
  detectedTool,
286
484
  });
287
485
  }
486
+
487
+ // Negative signals: skip style heuristic for obvious human patterns
488
+ // Only check message-level signals here (line/file counts not available)
489
+ const subjectNeg = (subject || '').trim();
490
+ const bodyNeg = (body || '').trim();
491
+ const negSignals = [];
492
+ const isConvSubject = CONVENTIONAL_RE.test(subjectNeg);
493
+ if (subjectNeg.length <= 10 && bodyNeg.length <= 10 && !/\n/.test(bodyNeg) && !isConvSubject) negSignals.push('humanInformal');
494
+ if (/^Merge\s+(?:branch|pull|remote|tag)/i.test(subjectNeg)) negSignals.push('humanMergeCommit');
495
+ if (/\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i.test(subjectNeg)) negSignals.push('humanWIP');
496
+ if (negSignals.length > 0) {
497
+ return createAIAttribution({ negativeSignals: negSignals });
498
+ }
499
+
500
+ // Fallback: style-based heuristic detection
501
+ const { signals: styleSignals, score } = detectStyleSignals(subject, body);
502
+ if (styleSignals.length > 0 && score >= 4) {
503
+ const confidence = score >= 6 ? AI_CONFIDENCE.MEDIUM : AI_CONFIDENCE.LOW;
504
+ return createAIAttribution({
505
+ confidence,
506
+ signals: styleSignals,
507
+ attributionType: score >= 6 ? 'style_heuristic_strong' : 'style_heuristic',
508
+ detectedTool: null,
509
+ });
510
+ }
511
+
288
512
  return createAIAttribution();
289
513
  }
290
514
 
@@ -442,6 +666,7 @@ export function parseGitLogOutput(output, repo = '') {
442
666
  current.aiEvidence = ai.aiEvidence;
443
667
  current.attributionType = ai.attributionType;
444
668
  current.detectedTool = ai.detectedTool || null;
669
+ current.negativeSignals = ai.negativeSignals || [];
445
670
  current.sessionId = null; // 由 finalize 阶段填充
446
671
 
447
672
  result.commits++;
@@ -474,7 +699,12 @@ export function parseGitLogOutput(output, repo = '') {
474
699
  const header = line.slice(COMMIT_SENTINEL.length);
475
700
  const parts = header.split('|');
476
701
  const hash = parts[0] || '';
477
- const date = (parts[1] || '').slice(0, 19);
702
+ const dateRaw = (parts[1] || '');
703
+ // Normalize to UTC ISO for consistent comparison with session dates
704
+ const dateMs = Date.parse(dateRaw);
705
+ const date = Number.isFinite(dateMs)
706
+ ? new Date(dateMs).toISOString().slice(0, 19) + 'Z'
707
+ : dateRaw.slice(0, 19);
478
708
  const author = parts[2] || '';
479
709
  const subject = parts.slice(3).join('|');
480
710
  current = {
@@ -1031,6 +1261,9 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1031
1261
  }
1032
1262
  }
1033
1263
 
1264
+ // Step 1.5: compute developer behavioral baselines
1265
+ const authorBaselines = computeAuthorBaseline(merged.commitList);
1266
+
1034
1267
  // Step 2: 信心度评估(保持现有逻辑)
1035
1268
  for (const c of merged.commitList || []) {
1036
1269
  if (!c.sessionId || c.attributionType === 'explicit') continue;
@@ -1096,6 +1329,30 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1096
1329
  signals.push('strongWithoutFileOverlap');
1097
1330
  }
1098
1331
 
1332
+ // Developer baseline deviation
1333
+ const baseline = authorBaselines.get(c.author);
1334
+ const deviation = computeBaselineDeviation(c, baseline);
1335
+ if (deviation >= 0.4) {
1336
+ signals.push('baselineDeviationHigh');
1337
+ if (confidence === AI_CONFIDENCE.MEDIUM) confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
1338
+ } else if (deviation >= 0.2) {
1339
+ signals.push('baselineDeviationMedium');
1340
+ } else if (deviation <= 0.05 && baseline && c.attributionType !== 'explicit') {
1341
+ signals.push('humanBaselineMatch');
1342
+ }
1343
+
1344
+ // Negative signals: downgrade confidence
1345
+ const negSignals = detectNegativeSignals(c.subject, c.body, c.linesAdded, c.linesDeleted, (c.files || []).length);
1346
+ if (negSignals.includes('humanMergeCommit')) {
1347
+ confidence = AI_CONFIDENCE.NONE;
1348
+ attributionType = 'human_merge';
1349
+ } else if (negSignals.length > 0) {
1350
+ if (confidence === AI_CONFIDENCE.HIGH) confidence = AI_CONFIDENCE.MEDIUM;
1351
+ else if (confidence === AI_CONFIDENCE.MEDIUM) confidence = AI_CONFIDENCE.LOW;
1352
+ else if (confidence === AI_CONFIDENCE.LOW) confidence = AI_CONFIDENCE.NONE;
1353
+ c.negativeSignals = negSignals;
1354
+ }
1355
+
1099
1356
  const ai = createAIAttribution({
1100
1357
  confidence,
1101
1358
  signals,
@@ -1109,6 +1366,18 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1109
1366
  c.attributionType = ai.attributionType;
1110
1367
  }
1111
1368
 
1369
+ // Step 2.5: composite continuous scoring for all commits
1370
+ for (const c of merged.commitList || []) {
1371
+ c.aiScore = computeContinuousScore(c);
1372
+ const mappedConfidence = scoreToConfidence(c.aiScore);
1373
+ // Only override if no explicit signature and continuous score disagrees
1374
+ if (c.attributionType !== 'explicit') {
1375
+ c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
1376
+ c.isAI = isCountedAIConfidence(c.aiConfidence);
1377
+ c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
1378
+ }
1379
+ }
1380
+
1112
1381
  const attributionItems = [];
1113
1382
  for (const c of merged.commitList || []) {
1114
1383
  const commitOverride = mergedOverrides.commits[c.hash] || null;
package/lib/server.js CHANGED
@@ -562,7 +562,42 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
562
562
  });
563
563
 
564
564
  server.listen(PORT, '127.0.0.1', () => {
565
- console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
565
+ const B = '\x1b[1m';
566
+ const R = '\x1b[0m';
567
+ const cyan = '\x1b[96m';
568
+ const green = '\x1b[92m';
569
+ const yellow = '\x1b[93m';
570
+ const blue = '\x1b[94m';
571
+ const dim = '\x1b[2m';
572
+
573
+ const banner = [
574
+ '',
575
+ `${B}${cyan} _ _____ _ ${R}`,
576
+ `${B}${cyan} | | / ____| | | ${R}`,
577
+ `${B}${cyan} | | _ _ _ __ ___ ___ _ __ | | ___ __| | ___ ${R}`,
578
+ `${B}${cyan} | | | | | | '_ \` _ \\ / _ \\ '_ \\| | / _ \\ / _\` |/ _ \\${R}`,
579
+ `${B}${cyan} | |___| |_| | | | | | | __/ | | | |___| (_) | (_| | __/${R}`,
580
+ `${B}${cyan} |______\\__,_|_| |_| |_|\\___|_| |_|\\_____\\___/ \\__,_|\\___|${R}`,
581
+ '',
582
+ ].join('\n');
583
+
584
+ process.stdout.write(banner + '\n');
585
+ process.stdout.write(` ${green}${B}v${appVersion}${R} ${yellow}AI Coding Assistant Analytics${R}\n`);
586
+ process.stdout.write('\n');
587
+
588
+ if (config.claudeDir) {
589
+ process.stdout.write(` ${dim}●${R} ${B}Data Dir${R} ${config.claudeDir}\n`);
590
+ }
591
+ if (configPath) {
592
+ process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
593
+ }
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
+ process.stdout.write('\n');
599
+ process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${PORT}${R}\n`);
600
+ process.stdout.write('\n');
566
601
 
567
602
  // Auto-open browser
568
603
  const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumencode",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
5
5
  "type": "module",
6
6
  "bin": {