@tonycasey/lisa 2.25.4 → 2.26.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 (72) hide show
  1. package/dist/lib/application/handlers/SessionStartHandler.d.ts +15 -0
  2. package/dist/lib/application/handlers/SessionStartHandler.d.ts.map +1 -1
  3. package/dist/lib/application/handlers/SessionStartHandler.js +116 -5
  4. package/dist/lib/application/handlers/SessionStartHandler.js.map +1 -1
  5. package/dist/lib/application/services/GitTriageService.d.ts +45 -0
  6. package/dist/lib/application/services/GitTriageService.d.ts.map +1 -0
  7. package/dist/lib/application/services/GitTriageService.js +360 -0
  8. package/dist/lib/application/services/GitTriageService.js.map +1 -0
  9. package/dist/lib/application/services/SessionContextFormatter.d.ts +11 -1
  10. package/dist/lib/application/services/SessionContextFormatter.d.ts.map +1 -1
  11. package/dist/lib/application/services/SessionContextFormatter.js +59 -3
  12. package/dist/lib/application/services/SessionContextFormatter.js.map +1 -1
  13. package/dist/lib/application/services/index.d.ts +1 -0
  14. package/dist/lib/application/services/index.d.ts.map +1 -1
  15. package/dist/lib/application/services/index.js +3 -1
  16. package/dist/lib/application/services/index.js.map +1 -1
  17. package/dist/lib/domain/interfaces/ICommitEnricher.d.ts +81 -0
  18. package/dist/lib/domain/interfaces/ICommitEnricher.d.ts.map +1 -0
  19. package/dist/lib/domain/interfaces/ICommitEnricher.js +33 -0
  20. package/dist/lib/domain/interfaces/ICommitEnricher.js.map +1 -0
  21. package/dist/lib/domain/interfaces/IGitClient.d.ts +86 -0
  22. package/dist/lib/domain/interfaces/IGitClient.d.ts.map +1 -1
  23. package/dist/lib/domain/interfaces/IGitTriageService.d.ts +180 -0
  24. package/dist/lib/domain/interfaces/IGitTriageService.d.ts.map +1 -0
  25. package/dist/lib/domain/interfaces/IGitTriageService.js +12 -0
  26. package/dist/lib/domain/interfaces/IGitTriageService.js.map +1 -0
  27. package/dist/lib/domain/interfaces/ILisaServices.d.ts +6 -0
  28. package/dist/lib/domain/interfaces/ILisaServices.d.ts.map +1 -1
  29. package/dist/lib/domain/interfaces/index.d.ts +3 -1
  30. package/dist/lib/domain/interfaces/index.d.ts.map +1 -1
  31. package/dist/lib/domain/interfaces/index.js +4 -1
  32. package/dist/lib/domain/interfaces/index.js.map +1 -1
  33. package/dist/lib/infrastructure/adapters/claude/session-start.d.ts +1 -0
  34. package/dist/lib/infrastructure/adapters/claude/session-start.d.ts.map +1 -1
  35. package/dist/lib/infrastructure/adapters/claude/session-start.js +18 -4
  36. package/dist/lib/infrastructure/adapters/claude/session-start.js.map +1 -1
  37. package/dist/lib/infrastructure/di/ServiceFactory.d.ts +2 -0
  38. package/dist/lib/infrastructure/di/ServiceFactory.d.ts.map +1 -1
  39. package/dist/lib/infrastructure/di/bootstrap.d.ts.map +1 -1
  40. package/dist/lib/infrastructure/di/bootstrap.js +11 -1
  41. package/dist/lib/infrastructure/di/bootstrap.js.map +1 -1
  42. package/dist/lib/infrastructure/di/tokens.d.ts +2 -0
  43. package/dist/lib/infrastructure/di/tokens.d.ts.map +1 -1
  44. package/dist/lib/infrastructure/di/tokens.js +1 -0
  45. package/dist/lib/infrastructure/di/tokens.js.map +1 -1
  46. package/dist/lib/infrastructure/git/GitClient.d.ts +11 -1
  47. package/dist/lib/infrastructure/git/GitClient.d.ts.map +1 -1
  48. package/dist/lib/infrastructure/git/GitClient.js +137 -0
  49. package/dist/lib/infrastructure/git/GitClient.js.map +1 -1
  50. package/dist/lib/infrastructure/logging/Logger.d.ts +13 -1
  51. package/dist/lib/infrastructure/logging/Logger.d.ts.map +1 -1
  52. package/dist/lib/infrastructure/logging/Logger.js +25 -7
  53. package/dist/lib/infrastructure/logging/Logger.js.map +1 -1
  54. package/dist/lib/infrastructure/logging/factory.d.ts +5 -1
  55. package/dist/lib/infrastructure/logging/factory.d.ts.map +1 -1
  56. package/dist/lib/infrastructure/logging/factory.js +3 -0
  57. package/dist/lib/infrastructure/logging/factory.js.map +1 -1
  58. package/dist/lib/infrastructure/logging/index.d.ts +1 -1
  59. package/dist/lib/infrastructure/logging/index.d.ts.map +1 -1
  60. package/dist/lib/infrastructure/logging/index.js.map +1 -1
  61. package/dist/lib/infrastructure/services/CommitEnricher.d.ts +26 -0
  62. package/dist/lib/infrastructure/services/CommitEnricher.d.ts.map +1 -0
  63. package/dist/lib/infrastructure/services/CommitEnricher.js +214 -0
  64. package/dist/lib/infrastructure/services/CommitEnricher.js.map +1 -0
  65. package/dist/lib/infrastructure/services/prompts/commit-extraction.d.ts +26 -0
  66. package/dist/lib/infrastructure/services/prompts/commit-extraction.d.ts.map +1 -0
  67. package/dist/lib/infrastructure/services/prompts/commit-extraction.js +151 -0
  68. package/dist/lib/infrastructure/services/prompts/commit-extraction.js.map +1 -0
  69. package/dist/opencode/lisa.js +907 -18
  70. package/dist/package.json +1 -1
  71. package/dist/project/.lisa/rules/shared/git-rules.md +12 -3
  72. package/package.json +1 -1
@@ -2379,6 +2379,27 @@ var init_ITranscriptEnricher = __esm({
2379
2379
  }
2380
2380
  });
2381
2381
 
2382
+ // src/lib/domain/interfaces/ICommitEnricher.ts
2383
+ function isValidCommitFactType(value) {
2384
+ return COMMIT_FACT_TYPE_VALUES.includes(value);
2385
+ }
2386
+ var COMMIT_FACT_TYPE_VALUES;
2387
+ var init_ICommitEnricher = __esm({
2388
+ "src/lib/domain/interfaces/ICommitEnricher.ts"() {
2389
+ "use strict";
2390
+ COMMIT_FACT_TYPE_VALUES = [
2391
+ "feature",
2392
+ "decision",
2393
+ "refactor",
2394
+ "migration",
2395
+ "bugfix",
2396
+ "breaking-change",
2397
+ "convention",
2398
+ "dependency"
2399
+ ];
2400
+ }
2401
+ });
2402
+
2382
2403
  // src/lib/domain/interfaces/INlCurationService.ts
2383
2404
  function isValidNlCurationIntent(value) {
2384
2405
  return NL_CURATION_INTENT_VALUES.includes(value);
@@ -2693,6 +2714,7 @@ var init_interfaces = __esm({
2693
2714
  init_ILlmService();
2694
2715
  init_ILlmUsageTracker();
2695
2716
  init_ITranscriptEnricher();
2717
+ init_ICommitEnricher();
2696
2718
  init_INlCurationService();
2697
2719
  init_events();
2698
2720
  init_types2();
@@ -6903,13 +6925,14 @@ var init_clients = __esm({
6903
6925
  });
6904
6926
 
6905
6927
  // src/lib/application/services/SessionContextFormatter.ts
6906
- var RECENT_HOURS, MAX_RECENT_MEMORIES, GROUP_WINDOW_MINUTES, EXCLUDED_RELATIONSHIPS, SessionContextFormatter;
6928
+ var RECENT_HOURS, MAX_RECENT_MEMORIES, GROUP_WINDOW_MINUTES, MAX_HOTSPOTS, EXCLUDED_RELATIONSHIPS, SessionContextFormatter;
6907
6929
  var init_SessionContextFormatter = __esm({
6908
6930
  "src/lib/application/services/SessionContextFormatter.ts"() {
6909
6931
  "use strict";
6910
6932
  RECENT_HOURS = 24;
6911
6933
  MAX_RECENT_MEMORIES = 5;
6912
6934
  GROUP_WINDOW_MINUTES = 5;
6935
+ MAX_HOTSPOTS = 3;
6913
6936
  EXCLUDED_RELATIONSHIPS = /* @__PURE__ */ new Set([
6914
6937
  "USER_SUBMITS_DIRECTION",
6915
6938
  "DIRECTION_IS_TOPIC",
@@ -6957,7 +6980,7 @@ var init_SessionContextFormatter = __esm({
6957
6980
  /**
6958
6981
  * Format the full context content for injection into session context.
6959
6982
  */
6960
- formatContextContent(trigger, memories, tasks, taskCounts, context, gitCommits = [], querySince) {
6983
+ formatContextContent(trigger, memories, tasks, taskCounts, context, gitCommits = [], querySince, gitTriage) {
6961
6984
  const { projectName, userName, folderType, projectRoot, branch } = context;
6962
6985
  const lines = [];
6963
6986
  lines.push(this.getTriggerMessage(trigger, memories.timedOut));
@@ -6976,7 +6999,10 @@ var init_SessionContextFormatter = __esm({
6976
6999
  lines.push(` ${memories.initReview}`);
6977
7000
  lines.push("");
6978
7001
  }
6979
- if (gitCommits.length > 0) {
7002
+ if (gitTriage && gitTriage.totalCommits > 0) {
7003
+ lines.push("");
7004
+ lines.push(...this.formatGitTriageSummary(gitTriage));
7005
+ } else if (gitCommits.length > 0) {
6980
7006
  lines.push("");
6981
7007
  lines.push(`Recent commits (${gitCommits.length}):`);
6982
7008
  gitCommits.slice(0, 5).forEach((c) => {
@@ -7155,6 +7181,46 @@ var init_SessionContextFormatter = __esm({
7155
7181
  return `${month} ${day} ${time}`;
7156
7182
  }
7157
7183
  }
7184
+ /**
7185
+ * Format git triage results as a compact summary.
7186
+ * Shows aggregate counts rather than individual commits.
7187
+ */
7188
+ formatGitTriageSummary(triage) {
7189
+ const lines = [];
7190
+ lines.push(`Git history: scanned ${triage.totalCommits.toLocaleString()} commits`);
7191
+ if (triage.belowThreshold > 0) {
7192
+ lines.push(` \u2192 ${triage.belowThreshold.toLocaleString()} below threshold (skipped)`);
7193
+ }
7194
+ if (triage.minorInterest > 0) {
7195
+ lines.push(` \u2192 ${triage.minorInterest.toLocaleString()} minor interest`);
7196
+ }
7197
+ const highCount = triage.highInterest.length;
7198
+ if (highCount > 0) {
7199
+ lines.push(` \u2192 ${highCount.toLocaleString()} high interest`);
7200
+ if (triage.linkedToPRs > 0) {
7201
+ lines.push(` \u2192 ${triage.linkedToPRs} linked to PRs`);
7202
+ }
7203
+ if (triage.linkedToTags > 0) {
7204
+ lines.push(` \u2192 ${triage.linkedToTags} linked to tags/releases`);
7205
+ }
7206
+ }
7207
+ if (triage.hotspots.length > 0) {
7208
+ const topHotspots = triage.hotspots.slice(0, MAX_HOTSPOTS);
7209
+ const hotspotList = topHotspots.map((h) => this.formatHotspotPath(h.path)).join(", ");
7210
+ lines.push(` Hotspots: ${hotspotList}`);
7211
+ }
7212
+ return lines;
7213
+ }
7214
+ /**
7215
+ * Format a file path for hotspot display (truncate long paths).
7216
+ */
7217
+ formatHotspotPath(path15) {
7218
+ const parts = path15.split("/");
7219
+ if (parts.length <= 2) {
7220
+ return path15;
7221
+ }
7222
+ return `.../${parts.slice(-2).join("/")}`;
7223
+ }
7158
7224
  };
7159
7225
  }
7160
7226
  });
@@ -7357,6 +7423,300 @@ var init_MemoryContextLoader = __esm({
7357
7423
  }
7358
7424
  });
7359
7425
 
7426
+ // src/lib/application/services/GitTriageService.ts
7427
+ var DEFAULT_THRESHOLD, DEFAULT_DAYS, CONVENTIONAL_PREFIXES, CONVENTIONAL_COMMIT_REGEX, DECISION_KEYWORDS, SCORES, TAG_ADJACENT_DISTANCE, STAT_FETCH_MARGIN, GitTriageService;
7428
+ var init_GitTriageService = __esm({
7429
+ "src/lib/application/services/GitTriageService.ts"() {
7430
+ "use strict";
7431
+ DEFAULT_THRESHOLD = 3;
7432
+ DEFAULT_DAYS = 90;
7433
+ CONVENTIONAL_PREFIXES = [
7434
+ "feat",
7435
+ "fix",
7436
+ "refactor",
7437
+ "docs",
7438
+ "test",
7439
+ "chore",
7440
+ "style",
7441
+ "perf",
7442
+ "ci",
7443
+ "build",
7444
+ "revert"
7445
+ ];
7446
+ CONVENTIONAL_COMMIT_REGEX = new RegExp(
7447
+ `^(${CONVENTIONAL_PREFIXES.join("|")})(\\(.+\\))?[!]?:`,
7448
+ "i"
7449
+ );
7450
+ DECISION_KEYWORDS = [
7451
+ "migrate",
7452
+ "replace",
7453
+ "rewrite",
7454
+ "deprecate",
7455
+ "breaking",
7456
+ "workaround",
7457
+ "decision",
7458
+ "architecture",
7459
+ "redesign",
7460
+ "overhaul"
7461
+ ];
7462
+ SCORES = {
7463
+ largeDiffFiles: 3,
7464
+ // 10+ files changed
7465
+ largeDiffLines: 2,
7466
+ // 500+ lines changed
7467
+ mergeCommitWithPR: 3,
7468
+ // merge commit with PR reference
7469
+ conventionalPrefix: 1,
7470
+ // has conventional commit prefix
7471
+ decisionKeywords: 2,
7472
+ // contains decision keywords
7473
+ createsNewDirectory: 2,
7474
+ // creates new top-level dir
7475
+ tagAdjacent: 2,
7476
+ // within N commits of a tag
7477
+ longMessageBody: 1
7478
+ // multi-line body
7479
+ };
7480
+ TAG_ADJACENT_DISTANCE = 3;
7481
+ STAT_FETCH_MARGIN = 2;
7482
+ GitTriageService = class {
7483
+ constructor(git) {
7484
+ this.git = git;
7485
+ }
7486
+ async triage(options) {
7487
+ const startTime = Date.now();
7488
+ const threshold = options?.threshold ?? DEFAULT_THRESHOLD;
7489
+ const cwd = options?.cwd;
7490
+ const since = options?.since ?? this.getDefaultSince();
7491
+ const until = options?.until ?? /* @__PURE__ */ new Date();
7492
+ const tags = this.git.listTags(cwd);
7493
+ const tagShas = new Set(tags.map((t) => t.sha));
7494
+ const tagInfo = tags.map((t) => ({
7495
+ name: t.name,
7496
+ sha: t.sha,
7497
+ isVersionTag: this.isVersionTag(t.name)
7498
+ }));
7499
+ const rawCommits = this.git.logDetailed({
7500
+ since: since.toISOString(),
7501
+ until: until.toISOString(),
7502
+ maxCount: options?.maxCommits,
7503
+ cwd
7504
+ });
7505
+ if (rawCommits.length === 0) {
7506
+ return this.emptyResult(startTime, tagInfo);
7507
+ }
7508
+ const shaToPosition = /* @__PURE__ */ new Map();
7509
+ for (let i = 0; i < rawCommits.length; i++) {
7510
+ shaToPosition.set(rawCommits[i].sha, i);
7511
+ }
7512
+ const tagPositions = /* @__PURE__ */ new Set();
7513
+ for (const tag of tags) {
7514
+ const pos = shaToPosition.get(tag.sha);
7515
+ if (pos !== void 0) {
7516
+ for (let i = Math.max(0, pos - TAG_ADJACENT_DISTANCE); i <= pos + TAG_ADJACENT_DISTANCE; i++) {
7517
+ tagPositions.add(i);
7518
+ }
7519
+ }
7520
+ }
7521
+ const fileStats = /* @__PURE__ */ new Map();
7522
+ const scoredCommits = [];
7523
+ const belowThreshold = [];
7524
+ const minorInterest = [];
7525
+ for (let i = 0; i < rawCommits.length; i++) {
7526
+ const raw = rawCommits[i];
7527
+ const commit = this.parseCommit(raw);
7528
+ const isTagAdjacent = tagPositions.has(i);
7529
+ const quickSignals = this.detectSignals(commit, null, tagShas, isTagAdjacent);
7530
+ const quickScore = this.calculateScore(quickSignals);
7531
+ let stats = null;
7532
+ let finalSignals = quickSignals;
7533
+ let finalScore = quickScore;
7534
+ if (quickScore >= threshold - STAT_FETCH_MARGIN || options?.fetchAllStats) {
7535
+ const rawStats = this.git.getCommitStats(commit.sha, cwd);
7536
+ stats = this.parseStats(rawStats);
7537
+ finalSignals = this.detectSignals(commit, stats, tagShas, isTagAdjacent);
7538
+ finalScore = this.calculateScore(finalSignals);
7539
+ for (const entry of rawStats) {
7540
+ const existing = fileStats.get(entry.path) ?? { commits: 0, lines: 0 };
7541
+ existing.commits++;
7542
+ existing.lines += (entry.added ?? 0) + (entry.deleted ?? 0);
7543
+ fileStats.set(entry.path, existing);
7544
+ }
7545
+ }
7546
+ const scored = {
7547
+ commit,
7548
+ stats,
7549
+ signals: finalSignals,
7550
+ score: finalScore,
7551
+ passedTriage: finalScore >= threshold
7552
+ };
7553
+ if (finalScore >= threshold) {
7554
+ scoredCommits.push(scored);
7555
+ } else if (finalScore > 0) {
7556
+ minorInterest.push(scored);
7557
+ } else {
7558
+ belowThreshold.push(scored);
7559
+ }
7560
+ }
7561
+ scoredCommits.sort((a, b) => b.score - a.score);
7562
+ const hotspots = Array.from(fileStats.entries()).map(([path15, data]) => ({
7563
+ path: path15,
7564
+ commitCount: data.commits,
7565
+ totalLinesChanged: data.lines
7566
+ })).sort((a, b) => b.commitCount - a.commitCount || b.totalLinesChanged - a.totalLinesChanged).slice(0, 20);
7567
+ const linkedToPRs = scoredCommits.filter((s) => s.signals.prNumber !== null).length;
7568
+ const linkedToTags = scoredCommits.filter((s) => s.signals.isTagAdjacent).length;
7569
+ return {
7570
+ totalCommits: rawCommits.length,
7571
+ belowThreshold: belowThreshold.length,
7572
+ minorInterest: minorInterest.length,
7573
+ highInterest: scoredCommits,
7574
+ linkedToPRs,
7575
+ linkedToTags,
7576
+ hotspots,
7577
+ tags: tagInfo,
7578
+ durationMs: Date.now() - startTime
7579
+ };
7580
+ }
7581
+ scoreCommit(commit, stats, tagShas, isTagAdjacent) {
7582
+ const tagAdjacent = isTagAdjacent ?? tagShas.has(commit.sha);
7583
+ const signals = this.detectSignals(commit, stats, tagShas, tagAdjacent);
7584
+ const score = this.calculateScore(signals);
7585
+ return {
7586
+ commit,
7587
+ stats,
7588
+ signals,
7589
+ score,
7590
+ passedTriage: score >= DEFAULT_THRESHOLD
7591
+ };
7592
+ }
7593
+ /**
7594
+ * Parse raw git log commit into domain type.
7595
+ */
7596
+ parseCommit(raw) {
7597
+ const refs = raw.refNames.split(",").map((r) => r.trim()).filter((r) => r && r !== "HEAD");
7598
+ return {
7599
+ sha: raw.sha,
7600
+ shortSha: raw.shortSha,
7601
+ subject: raw.subject,
7602
+ body: raw.body,
7603
+ parentCount: raw.parentShas.length,
7604
+ author: raw.authorName,
7605
+ authorEmail: raw.authorEmail,
7606
+ timestamp: new Date(raw.authorTimestamp * 1e3),
7607
+ refs
7608
+ };
7609
+ }
7610
+ /**
7611
+ * Parse raw stat entries into domain type.
7612
+ */
7613
+ parseStats(entries) {
7614
+ let insertions = 0;
7615
+ let deletions = 0;
7616
+ const filesAdded = [];
7617
+ const filesDeleted = [];
7618
+ const directories = /* @__PURE__ */ new Set();
7619
+ for (const entry of entries) {
7620
+ insertions += entry.added ?? 0;
7621
+ deletions += entry.deleted ?? 0;
7622
+ if (entry.isNew) {
7623
+ filesAdded.push(entry.path);
7624
+ const dir = entry.path.split("/")[0];
7625
+ if (dir && dir !== entry.path) {
7626
+ directories.add(dir);
7627
+ }
7628
+ }
7629
+ if (entry.isDeleted) {
7630
+ filesDeleted.push(entry.path);
7631
+ }
7632
+ }
7633
+ return {
7634
+ filesChanged: entries.length,
7635
+ insertions,
7636
+ deletions,
7637
+ filesAdded,
7638
+ filesDeleted,
7639
+ directoriesCreated: Array.from(directories)
7640
+ };
7641
+ }
7642
+ /**
7643
+ * Detect interest signals for a commit.
7644
+ */
7645
+ detectSignals(commit, stats, _tagShas, isTagAdjacent) {
7646
+ const largeDiffFiles = stats !== null && stats.filesChanged >= 10;
7647
+ const largeDiffLines = stats !== null && stats.insertions + stats.deletions >= 500;
7648
+ const prMatch = commit.subject.match(/Merge pull request #(\d+)/i) ?? commit.subject.match(/\(#(\d+)\)/) ?? commit.body.match(/PR[:\s#]+(\d+)/i);
7649
+ const prNumber = prMatch ? parseInt(prMatch[1], 10) : null;
7650
+ const mergeCommitWithPR = commit.parentCount >= 2 && prNumber !== null;
7651
+ const conventionalMatch = commit.subject.match(CONVENTIONAL_COMMIT_REGEX);
7652
+ const hasConventionalPrefix = conventionalMatch !== null;
7653
+ const conventionalType = conventionalMatch ? conventionalMatch[1].toLowerCase() : null;
7654
+ const messageText = `${commit.subject} ${commit.body}`.toLowerCase();
7655
+ const hasDecisionKeywords = DECISION_KEYWORDS.some((kw) => messageText.includes(kw));
7656
+ const createsNewDirectory = stats !== null && stats.directoriesCreated.length > 0;
7657
+ const hasLongMessageBody = commit.body.split("\n").filter((l) => l.trim()).length >= 2;
7658
+ return {
7659
+ largeDiffFiles,
7660
+ largeDiffLines,
7661
+ mergeCommitWithPR,
7662
+ hasConventionalPrefix,
7663
+ conventionalType,
7664
+ hasDecisionKeywords,
7665
+ createsNewDirectory,
7666
+ isTagAdjacent,
7667
+ hasLongMessageBody,
7668
+ prNumber
7669
+ };
7670
+ }
7671
+ /**
7672
+ * Calculate interest score from signals.
7673
+ */
7674
+ calculateScore(signals) {
7675
+ let score = 0;
7676
+ if (signals.largeDiffFiles) score += SCORES.largeDiffFiles;
7677
+ if (signals.largeDiffLines) score += SCORES.largeDiffLines;
7678
+ if (signals.mergeCommitWithPR) score += SCORES.mergeCommitWithPR;
7679
+ if (signals.hasConventionalPrefix) score += SCORES.conventionalPrefix;
7680
+ if (signals.hasDecisionKeywords) score += SCORES.decisionKeywords;
7681
+ if (signals.createsNewDirectory) score += SCORES.createsNewDirectory;
7682
+ if (signals.isTagAdjacent) score += SCORES.tagAdjacent;
7683
+ if (signals.hasLongMessageBody) score += SCORES.longMessageBody;
7684
+ return score;
7685
+ }
7686
+ /**
7687
+ * Get default "since" date (3 months ago).
7688
+ */
7689
+ getDefaultSince() {
7690
+ const date = /* @__PURE__ */ new Date();
7691
+ date.setDate(date.getDate() - DEFAULT_DAYS);
7692
+ return date;
7693
+ }
7694
+ /**
7695
+ * Check if a tag name looks like a version tag.
7696
+ */
7697
+ isVersionTag(name) {
7698
+ return /^v?\d+\.\d+(\.\d+)?/.test(name);
7699
+ }
7700
+ /**
7701
+ * Create an empty result (for repos with no commits in range).
7702
+ */
7703
+ emptyResult(startTime, tags) {
7704
+ return {
7705
+ totalCommits: 0,
7706
+ belowThreshold: 0,
7707
+ minorInterest: 0,
7708
+ highInterest: [],
7709
+ linkedToPRs: 0,
7710
+ linkedToTags: 0,
7711
+ hotspots: [],
7712
+ tags,
7713
+ durationMs: Date.now() - startTime
7714
+ };
7715
+ }
7716
+ };
7717
+ }
7718
+ });
7719
+
7360
7720
  // src/lib/infrastructure/git/GitClient.ts
7361
7721
  var GitClient_exports = {};
7362
7722
  __export(GitClient_exports, {
@@ -7367,7 +7727,7 @@ var init_GitClient = __esm({
7367
7727
  "src/lib/infrastructure/git/GitClient.ts"() {
7368
7728
  "use strict";
7369
7729
  import_child_process6 = require("child_process");
7370
- GitClient = class {
7730
+ GitClient = class _GitClient {
7371
7731
  log(options) {
7372
7732
  const args = ["log"];
7373
7733
  if (options.since) args.push(`--since=${options.since}`);
@@ -7425,6 +7785,154 @@ var init_GitClient = __esm({
7425
7785
  return false;
7426
7786
  }
7427
7787
  }
7788
+ static {
7789
+ /**
7790
+ * Record separator for parsing detailed log output.
7791
+ * Using ASCII RS (Record Separator) to avoid conflicts with commit content.
7792
+ */
7793
+ this.RECORD_SEP = "";
7794
+ }
7795
+ static {
7796
+ this.FIELD_SEP = "";
7797
+ }
7798
+ logDetailed(options) {
7799
+ const format = [
7800
+ "%H",
7801
+ // full sha
7802
+ "%h",
7803
+ // short sha
7804
+ "%P",
7805
+ // parent shas (space-separated)
7806
+ "%s",
7807
+ // subject
7808
+ "%b",
7809
+ // body
7810
+ "%an",
7811
+ // author name
7812
+ "%ae",
7813
+ // author email
7814
+ "%at",
7815
+ // author timestamp (unix)
7816
+ "%D"
7817
+ // ref names
7818
+ ].join(_GitClient.FIELD_SEP);
7819
+ const args = [
7820
+ "log",
7821
+ `--format=${_GitClient.RECORD_SEP}${format}`
7822
+ ];
7823
+ if (options.since) args.push(`--since=${options.since}`);
7824
+ if (options.until) args.push(`--until=${options.until}`);
7825
+ if (options.maxCount) args.push(`-n${options.maxCount}`);
7826
+ try {
7827
+ const output = (0, import_child_process6.execFileSync)("git", args, {
7828
+ encoding: "utf8",
7829
+ cwd: options.cwd,
7830
+ stdio: ["pipe", "pipe", "pipe"],
7831
+ maxBuffer: 50 * 1024 * 1024
7832
+ // 50MB buffer for large repos
7833
+ }).trim();
7834
+ if (!output) return [];
7835
+ const records = output.split(_GitClient.RECORD_SEP).filter((r) => r.trim());
7836
+ return records.map((record) => {
7837
+ const fields = record.split(_GitClient.FIELD_SEP);
7838
+ return {
7839
+ sha: fields[0] || "",
7840
+ shortSha: fields[1] || "",
7841
+ parentShas: (fields[2] || "").split(" ").filter((s) => s),
7842
+ subject: fields[3] || "",
7843
+ body: (fields[4] || "").trim(),
7844
+ authorName: fields[5] || "",
7845
+ authorEmail: fields[6] || "",
7846
+ authorTimestamp: parseInt(fields[7] || "0", 10),
7847
+ refNames: fields[8] || ""
7848
+ };
7849
+ });
7850
+ } catch {
7851
+ return [];
7852
+ }
7853
+ }
7854
+ getCommitStats(sha, cwd) {
7855
+ try {
7856
+ const output = (0, import_child_process6.execFileSync)(
7857
+ "git",
7858
+ ["show", "--numstat", "--format=", sha],
7859
+ {
7860
+ encoding: "utf8",
7861
+ cwd,
7862
+ stdio: ["pipe", "pipe", "pipe"]
7863
+ }
7864
+ ).trim();
7865
+ if (!output) return [];
7866
+ const statusOutput = (0, import_child_process6.execFileSync)(
7867
+ "git",
7868
+ ["show", "--name-status", "--format=", sha],
7869
+ {
7870
+ encoding: "utf8",
7871
+ cwd,
7872
+ stdio: ["pipe", "pipe", "pipe"]
7873
+ }
7874
+ ).trim();
7875
+ const statusMap = /* @__PURE__ */ new Map();
7876
+ for (const line of statusOutput.split("\n")) {
7877
+ const match = line.match(/^([AMDRC])\t(.+)$/);
7878
+ if (match) {
7879
+ statusMap.set(match[2], match[1]);
7880
+ }
7881
+ }
7882
+ return output.split("\n").filter((line) => line.trim()).map((line) => {
7883
+ const parts = line.split(" ");
7884
+ const added = parts[0] === "-" ? null : parseInt(parts[0], 10);
7885
+ const deleted = parts[1] === "-" ? null : parseInt(parts[1], 10);
7886
+ const path15 = parts[2] || "";
7887
+ const status = statusMap.get(path15) || "M";
7888
+ return {
7889
+ added,
7890
+ deleted,
7891
+ path: path15,
7892
+ isNew: status === "A",
7893
+ isDeleted: status === "D"
7894
+ };
7895
+ });
7896
+ } catch {
7897
+ return [];
7898
+ }
7899
+ }
7900
+ listTags(cwd) {
7901
+ try {
7902
+ const output = (0, import_child_process6.execFileSync)(
7903
+ "git",
7904
+ ["tag", "-l", "--format=%(refname:short) %(objectname)"],
7905
+ {
7906
+ encoding: "utf8",
7907
+ cwd,
7908
+ stdio: ["pipe", "pipe", "pipe"]
7909
+ }
7910
+ ).trim();
7911
+ if (!output) return [];
7912
+ return output.split("\n").filter((line) => line.trim()).map((line) => {
7913
+ const [name, sha] = line.split(" ");
7914
+ return { name: name || "", sha: sha || "" };
7915
+ });
7916
+ } catch {
7917
+ return [];
7918
+ }
7919
+ }
7920
+ countCommits(cwd) {
7921
+ try {
7922
+ const output = (0, import_child_process6.execFileSync)(
7923
+ "git",
7924
+ ["rev-list", "--count", "HEAD"],
7925
+ {
7926
+ encoding: "utf8",
7927
+ cwd,
7928
+ stdio: ["pipe", "pipe", "pipe"]
7929
+ }
7930
+ ).trim();
7931
+ return parseInt(output, 10) || 0;
7932
+ } catch {
7933
+ return 0;
7934
+ }
7935
+ }
7428
7936
  };
7429
7937
  }
7430
7938
  });
@@ -7438,6 +7946,7 @@ var init_SessionStartHandler = __esm({
7438
7946
  init_SessionContextFormatter();
7439
7947
  init_GitIntrospectionService();
7440
7948
  init_MemoryContextLoader();
7949
+ init_GitTriageService();
7441
7950
  RECENT_HOURS2 = 24;
7442
7951
  SessionStartHandler = class {
7443
7952
  constructor(contextOrServices, memoryOrGitClient, tasks, mcp, router, logger, githubSync, gitClient) {
@@ -7451,6 +7960,7 @@ var init_SessionStartHandler = __esm({
7451
7960
  this.router = services.router;
7452
7961
  this.logger = services.logger;
7453
7962
  this.githubSync = services.githubSync;
7963
+ this.commitEnricher = services.commitEnricher;
7454
7964
  resolvedGitClient = memoryOrGitClient;
7455
7965
  } else {
7456
7966
  this.context = contextOrServices;
@@ -7475,6 +7985,7 @@ var init_SessionStartHandler = __esm({
7475
7985
  this.router,
7476
7986
  this.logger
7477
7987
  );
7988
+ this.triageService = new GitTriageService(resolvedGitClient);
7478
7989
  }
7479
7990
  /**
7480
7991
  * Handle a session start request.
@@ -7483,13 +7994,19 @@ var init_SessionStartHandler = __esm({
7483
7994
  const { hierarchicalGroupIds, projectAliases, branch, projectName, userName, folderType, projectRoot } = this.context;
7484
7995
  await this.syncGitHubOnStartup(request.trigger, hierarchicalGroupIds, projectName);
7485
7996
  const dateOptions = this.computeDateOptions(request.trigger);
7486
- const memories = await this.memoryLoader.loadMemory(
7487
- hierarchicalGroupIds,
7488
- projectAliases,
7489
- branch,
7490
- dateOptions
7491
- );
7492
- const gitCommits = await this.gitService.loadGitCommits(dateOptions.since, projectRoot);
7997
+ const [memories, gitTriage] = await Promise.all([
7998
+ this.memoryLoader.loadMemory(
7999
+ hierarchicalGroupIds,
8000
+ projectAliases,
8001
+ branch,
8002
+ dateOptions
8003
+ ),
8004
+ this.runGitTriage(dateOptions.since, projectRoot)
8005
+ ]);
8006
+ const gitCommits = gitTriage ? [] : await this.gitService.loadGitCommits(dateOptions.since, projectRoot);
8007
+ if (gitTriage && request.trigger === "startup") {
8008
+ this.enrichAndSaveCommits(gitTriage.highInterest, hierarchicalGroupIds[0]).catch((err) => this.logger?.warn("Commit enrichment failed", { error: err.message }));
8009
+ }
7493
8010
  const tasks = this.processTasks(memories.tasks);
7494
8011
  const taskCounts = this.countTasks(tasks);
7495
8012
  const contextContent = this.formatter.formatContextContent(
@@ -7499,7 +8016,8 @@ var init_SessionStartHandler = __esm({
7499
8016
  taskCounts,
7500
8017
  { projectName, userName, folderType, projectRoot, branch },
7501
8018
  gitCommits,
7502
- dateOptions.since
8019
+ dateOptions.since,
8020
+ gitTriage
7503
8021
  );
7504
8022
  const message = this.formatter.getTriggerMessage(request.trigger, memories.timedOut);
7505
8023
  return {
@@ -7536,6 +8054,96 @@ var init_SessionStartHandler = __esm({
7536
8054
  this.logger?.warn("GitHub sync failed", { error: error.message });
7537
8055
  }
7538
8056
  }
8057
+ /**
8058
+ * Run git triage to analyze commit history.
8059
+ * Returns null if triage fails (non-blocking).
8060
+ */
8061
+ async runGitTriage(since, projectRoot) {
8062
+ try {
8063
+ const result = await this.triageService.triage({
8064
+ since,
8065
+ cwd: projectRoot
8066
+ });
8067
+ this.logger?.debug("Git triage completed", {
8068
+ total: result.totalCommits,
8069
+ highInterest: result.highInterest.length,
8070
+ durationMs: result.durationMs
8071
+ });
8072
+ return result;
8073
+ } catch (error) {
8074
+ this.logger?.warn("Git triage failed", { error: error.message });
8075
+ return null;
8076
+ }
8077
+ }
8078
+ /**
8079
+ * Enrich high-interest commits and save facts to memory (fire-and-forget).
8080
+ */
8081
+ async enrichAndSaveCommits(commits, groupId) {
8082
+ if (!this.commitEnricher || commits.length === 0) return;
8083
+ try {
8084
+ const existingShas = await this.checkEnrichedCommits(groupId);
8085
+ const toEnrich = commits.filter((c) => !existingShas.has(c.commit.shortSha));
8086
+ if (toEnrich.length === 0) {
8087
+ this.logger?.debug("All commits already enriched, skipping");
8088
+ return;
8089
+ }
8090
+ this.logger?.debug("Enriching commits", { count: toEnrich.length });
8091
+ const result = await this.commitEnricher.enrich(toEnrich, { maxCommits: 5 });
8092
+ if (result.facts.length === 0) {
8093
+ this.logger?.debug("No facts extracted from commits");
8094
+ return;
8095
+ }
8096
+ const shaToShort = /* @__PURE__ */ new Map();
8097
+ for (const commit of toEnrich) {
8098
+ if (commit.commit.sha && commit.commit.shortSha) {
8099
+ shaToShort.set(commit.commit.sha, commit.commit.shortSha);
8100
+ shaToShort.set(commit.commit.shortSha, commit.commit.shortSha);
8101
+ }
8102
+ }
8103
+ for (const fact of result.facts) {
8104
+ const commitSha = shaToShort.get(fact.commitSha) ?? fact.commitSha;
8105
+ const tags = [
8106
+ "type:commit-enrichment",
8107
+ `commit:${commitSha}`,
8108
+ `factType:${fact.type}`,
8109
+ `confidence:${fact.confidence}`,
8110
+ "source:git-enrichment",
8111
+ ...fact.tags.map((t) => `tag:${t}`)
8112
+ ];
8113
+ await this.memory.addFact(groupId, fact.text, tags);
8114
+ }
8115
+ this.logger?.info("Commit enrichment complete", {
8116
+ processed: result.commitsProcessed,
8117
+ facts: result.facts.length,
8118
+ inputTokens: result.usage.inputTokens,
8119
+ outputTokens: result.usage.outputTokens
8120
+ });
8121
+ } catch (error) {
8122
+ this.logger?.warn("Commit enrichment error", { error: error.message });
8123
+ }
8124
+ }
8125
+ /**
8126
+ * Check which commits have already been enriched (by short SHA tag in memory).
8127
+ */
8128
+ async checkEnrichedCommits(groupId) {
8129
+ const enrichedShas = /* @__PURE__ */ new Set();
8130
+ try {
8131
+ const existingFacts = await this.memory.searchFacts(
8132
+ [groupId],
8133
+ "type:commit-enrichment",
8134
+ 100
8135
+ );
8136
+ for (const fact of existingFacts) {
8137
+ const shaTag = fact.tags?.find((t) => t.startsWith("commit:"));
8138
+ if (shaTag) {
8139
+ enrichedShas.add(shaTag.replace("commit:", ""));
8140
+ }
8141
+ }
8142
+ } catch (error) {
8143
+ this.logger?.debug("Could not check existing enrichments", { error: error.message });
8144
+ }
8145
+ return enrichedShas;
8146
+ }
7539
8147
  /**
7540
8148
  * Compute date options based on trigger type.
7541
8149
  */
@@ -10561,6 +11169,7 @@ var INFRA_TOKENS = {
10561
11169
  LlmGuard: /* @__PURE__ */ Symbol.for("Lisa.LlmGuard"),
10562
11170
  SummarizationService: /* @__PURE__ */ Symbol.for("Lisa.SummarizationService"),
10563
11171
  TranscriptEnricher: /* @__PURE__ */ Symbol.for("Lisa.TranscriptEnricher"),
11172
+ CommitEnricher: /* @__PURE__ */ Symbol.for("Lisa.CommitEnricher"),
10564
11173
  LlmDeduplicationEnhancer: /* @__PURE__ */ Symbol.for("Lisa.LlmDeduplicationEnhancer"),
10565
11174
  NlCurationService: /* @__PURE__ */ Symbol.for("Lisa.NlCurationService")
10566
11175
  };
@@ -11152,6 +11761,7 @@ var Logger = class _Logger {
11152
11761
  this.getCorrelationId = getCorrelationId2 ?? (() => void 0);
11153
11762
  this.bindings = bindings ?? {};
11154
11763
  this.boundContext = boundContext ?? {};
11764
+ this.asyncWrites = options.asyncWrites ?? false;
11155
11765
  const logDir = import_path2.default.resolve(options.logDir);
11156
11766
  this.ensureLogDir(logDir);
11157
11767
  this.logFile = import_path2.default.join(logDir, `lisa-${getDateString()}.log`);
@@ -11187,6 +11797,7 @@ var Logger = class _Logger {
11187
11797
  }
11188
11798
  /**
11189
11799
  * Write a log entry.
11800
+ * Uses async (fire-and-forget) writes when asyncWrites is enabled.
11190
11801
  */
11191
11802
  writeLog(level, levelStr, message, context) {
11192
11803
  if (!this.shouldLog(level)) return;
@@ -11195,9 +11806,18 @@ var Logger = class _Logger {
11195
11806
  if (this.options.enableFile) {
11196
11807
  const fileLine = `${timestamp} ${levelStr.padEnd(5)} ${message}${contextStr}
11197
11808
  `;
11198
- try {
11199
- import_fs.default.appendFileSync(this.logFile, fileLine);
11200
- } catch {
11809
+ if (this.asyncWrites) {
11810
+ void (async () => {
11811
+ try {
11812
+ await import_fs.default.promises.appendFile(this.logFile, fileLine);
11813
+ } catch {
11814
+ }
11815
+ })();
11816
+ } else {
11817
+ try {
11818
+ import_fs.default.appendFileSync(this.logFile, fileLine);
11819
+ } catch {
11820
+ }
11201
11821
  }
11202
11822
  }
11203
11823
  if (this.options.enableConsole) {
@@ -11226,7 +11846,7 @@ var Logger = class _Logger {
11226
11846
  }
11227
11847
  child(bindings) {
11228
11848
  return new _Logger(
11229
- this.options,
11849
+ { ...this.options, asyncWrites: this.asyncWrites },
11230
11850
  void 0,
11231
11851
  this.getCorrelationId,
11232
11852
  { ...this.bindings, ...bindings },
@@ -11295,7 +11915,7 @@ var Logger = class _Logger {
11295
11915
  */
11296
11916
  withContext(context) {
11297
11917
  return new _Logger(
11298
- this.options,
11918
+ { ...this.options, asyncWrites: this.asyncWrites },
11299
11919
  void 0,
11300
11920
  this.getCorrelationId,
11301
11921
  this.bindings,
@@ -14649,6 +15269,266 @@ function extractJson3(text) {
14649
15269
  return null;
14650
15270
  }
14651
15271
 
15272
+ // src/lib/infrastructure/services/CommitEnricher.ts
15273
+ init_ICommitEnricher();
15274
+ init_IMemoryQuality();
15275
+
15276
+ // src/lib/infrastructure/services/prompts/commit-extraction.ts
15277
+ var DEFAULT_MAX_FACTS3 = 10;
15278
+ function buildCommitExtractionPrompt(commits, options = {}) {
15279
+ const extractTypes = options.extractTypes;
15280
+ const maxFacts = Math.min(commits.length * 2, DEFAULT_MAX_FACTS3);
15281
+ const validTypes = extractTypes && extractTypes.length > 0 ? extractTypes : [
15282
+ "feature",
15283
+ "decision",
15284
+ "refactor",
15285
+ "migration",
15286
+ "bugfix",
15287
+ "breaking-change",
15288
+ "convention",
15289
+ "dependency"
15290
+ ];
15291
+ const system = [
15292
+ "You are analyzing git commits to extract structured facts for project memory.",
15293
+ "Your task is to identify important information worth remembering long-term.",
15294
+ "",
15295
+ "Fact types to extract:",
15296
+ "- feature: New functionality or capability added to the project",
15297
+ "- decision: Architecture or technology choice evident from the commit",
15298
+ "- refactor: Code restructuring without behavior change",
15299
+ "- migration: Data migration, API migration, or technology migration",
15300
+ "- bugfix: Bug fix with root cause (what was broken and why)",
15301
+ "- breaking-change: Changes that break backward compatibility",
15302
+ "- convention: New patterns or coding conventions introduced",
15303
+ "- dependency: Significant dependency additions, updates, or removals",
15304
+ "",
15305
+ "Confidence levels:",
15306
+ "- high: Clear from commit message, subject, or explicit description",
15307
+ "- medium: Reasonably inferred from commit context and signals",
15308
+ "- low: Tentative inference, may need verification",
15309
+ "",
15310
+ "Guidelines:",
15311
+ "- Each fact should be self-contained and understandable without the commit context.",
15312
+ "- Include relevant file paths, technologies, or components in tags.",
15313
+ "- For bugfixes, try to identify the root cause if evident from the message.",
15314
+ "- Focus on facts that would be valuable for future developers.",
15315
+ "- Do not extract trivial changes (formatting, typos, version bumps).",
15316
+ `- Extract at most ${maxFacts} facts total across all commits.`,
15317
+ extractTypes && extractTypes.length > 0 ? `- Only extract facts of these types: ${extractTypes.join(", ")}` : "- Extract facts of any type listed above.",
15318
+ "",
15319
+ "Respond with ONLY a valid JSON object in this exact format:",
15320
+ "{",
15321
+ ' "facts": [',
15322
+ " {",
15323
+ ' "text": "Clear description of the fact",',
15324
+ ` "type": "one of: ${validTypes.join(", ")}",`,
15325
+ ' "confidence": "high | medium | low",',
15326
+ ' "tags": ["relevant", "tags"],',
15327
+ ' "commitSha": "short SHA of the source commit",',
15328
+ ' "rationale": "Why this fact is worth remembering"',
15329
+ " }",
15330
+ " ]",
15331
+ "}"
15332
+ ].join("\n");
15333
+ const user = formatCommitsForPrompt(commits);
15334
+ return { system, user };
15335
+ }
15336
+ function formatCommitsForPrompt(commits) {
15337
+ const parts = [];
15338
+ parts.push("## Commits to Analyze");
15339
+ parts.push("");
15340
+ for (let i = 0; i < commits.length; i++) {
15341
+ const { commit, stats, signals } = commits[i];
15342
+ parts.push(`### Commit ${i + 1}: ${commit.shortSha}`);
15343
+ parts.push(`**Subject:** ${commit.subject}`);
15344
+ if (commit.body && commit.body.trim().length > 0) {
15345
+ const truncatedBody = commit.body.length > 500 ? commit.body.slice(0, 500) + "..." : commit.body;
15346
+ parts.push(`**Body:** ${truncatedBody}`);
15347
+ }
15348
+ parts.push(`**Timestamp:** ${commit.timestamp.toISOString()}`);
15349
+ parts.push(`**Author:** ${commit.author}`);
15350
+ if (stats) {
15351
+ parts.push(`**Files changed:** ${stats.filesChanged}`);
15352
+ parts.push(`**Lines:** +${stats.insertions} -${stats.deletions}`);
15353
+ if (stats.directoriesCreated.length > 0) {
15354
+ parts.push(`**New directories:** ${stats.directoriesCreated.join(", ")}`);
15355
+ }
15356
+ }
15357
+ const signalList = formatSignals(signals);
15358
+ if (signalList.length > 0) {
15359
+ parts.push(`**Signals:** ${signalList.join(", ")}`);
15360
+ }
15361
+ parts.push("");
15362
+ }
15363
+ return parts.join("\n");
15364
+ }
15365
+ function formatSignals(signals) {
15366
+ const result = [];
15367
+ if (signals.largeDiffFiles) {
15368
+ result.push("large-diff-files");
15369
+ }
15370
+ if (signals.largeDiffLines) {
15371
+ result.push("large-diff-lines");
15372
+ }
15373
+ if (signals.mergeCommitWithPR && signals.prNumber) {
15374
+ result.push(`merge-PR-#${signals.prNumber}`);
15375
+ }
15376
+ if (signals.hasConventionalPrefix && signals.conventionalType) {
15377
+ result.push(`conventional:${signals.conventionalType}`);
15378
+ }
15379
+ if (signals.hasDecisionKeywords) {
15380
+ result.push("decision-keywords");
15381
+ }
15382
+ if (signals.createsNewDirectory) {
15383
+ result.push("new-directory");
15384
+ }
15385
+ if (signals.isTagAdjacent) {
15386
+ result.push("tag-adjacent");
15387
+ }
15388
+ if (signals.hasLongMessageBody) {
15389
+ result.push("detailed-message");
15390
+ }
15391
+ return result;
15392
+ }
15393
+
15394
+ // src/lib/infrastructure/services/CommitEnricher.ts
15395
+ var DEFAULT_MAX_COMMITS = 5;
15396
+ var DEFAULT_MAX_TOKENS = 4096;
15397
+ var EMPTY_USAGE2 = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
15398
+ function createCommitEnricher(llmGuard, logger) {
15399
+ return {
15400
+ async enrich(commits, options = {}) {
15401
+ try {
15402
+ const skipShas = new Set(options.skipShas ?? []);
15403
+ const filteredCommits = commits.filter((c) => !skipShas.has(c.commit.shortSha));
15404
+ if (filteredCommits.length === 0) {
15405
+ return {
15406
+ facts: [],
15407
+ commitsProcessed: 0,
15408
+ commitsSkipped: commits.length,
15409
+ usage: EMPTY_USAGE2
15410
+ };
15411
+ }
15412
+ const maxCommits = Math.max(0, options.maxCommits ?? DEFAULT_MAX_COMMITS);
15413
+ const toProcess = filteredCommits.slice(0, maxCommits);
15414
+ if (toProcess.length === 0) {
15415
+ return {
15416
+ facts: [],
15417
+ commitsProcessed: 0,
15418
+ commitsSkipped: commits.length,
15419
+ usage: EMPTY_USAGE2
15420
+ };
15421
+ }
15422
+ const skipped = commits.length - toProcess.length;
15423
+ const allowedShas = new Set(
15424
+ toProcess.flatMap((c) => [c.commit.sha, c.commit.shortSha].filter(Boolean))
15425
+ );
15426
+ const prompt = buildCommitExtractionPrompt(toProcess, options);
15427
+ const response = await llmGuard.complete(prompt.user, "extraction", {
15428
+ systemPrompt: prompt.system,
15429
+ temperature: 0.3,
15430
+ maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS
15431
+ });
15432
+ const parsed = parseCommitExtractionResponse(response.text, options, allowedShas);
15433
+ return {
15434
+ facts: parsed.facts,
15435
+ commitsProcessed: toProcess.length,
15436
+ commitsSkipped: skipped,
15437
+ usage: response.usage
15438
+ };
15439
+ } catch (error) {
15440
+ const errorMsg = error instanceof Error ? error.message.split("\n")[0] : "Unknown error";
15441
+ logger?.warn("Commit enrichment failed, returning empty result", {
15442
+ error: errorMsg
15443
+ });
15444
+ return {
15445
+ facts: [],
15446
+ commitsProcessed: 0,
15447
+ commitsSkipped: commits.length,
15448
+ usage: EMPTY_USAGE2
15449
+ };
15450
+ }
15451
+ }
15452
+ };
15453
+ }
15454
+ function parseCommitExtractionResponse(responseText, options = {}, allowedShas) {
15455
+ const jsonText = extractJson4(responseText);
15456
+ if (!jsonText) {
15457
+ return { facts: [] };
15458
+ }
15459
+ let parsed;
15460
+ try {
15461
+ parsed = JSON.parse(jsonText);
15462
+ } catch {
15463
+ return { facts: [] };
15464
+ }
15465
+ if (typeof parsed !== "object" || parsed === null) {
15466
+ return { facts: [] };
15467
+ }
15468
+ const obj = parsed;
15469
+ const rawFacts = Array.isArray(obj.facts) ? obj.facts : [];
15470
+ const allowedTypes = options.extractTypes;
15471
+ const facts = [];
15472
+ for (const raw of rawFacts) {
15473
+ const fact = validateCommitFact(raw, allowedTypes, allowedShas);
15474
+ if (fact) {
15475
+ facts.push(fact);
15476
+ }
15477
+ }
15478
+ return { facts };
15479
+ }
15480
+ function extractJson4(text) {
15481
+ const trimmed = text.trim();
15482
+ let start = trimmed.indexOf("{");
15483
+ while (start !== -1) {
15484
+ for (let end = trimmed.lastIndexOf("}"); end > start; end = trimmed.lastIndexOf("}", end - 1)) {
15485
+ const candidate = trimmed.slice(start, end + 1);
15486
+ try {
15487
+ JSON.parse(candidate);
15488
+ return candidate;
15489
+ } catch {
15490
+ }
15491
+ }
15492
+ start = trimmed.indexOf("{", start + 1);
15493
+ }
15494
+ return null;
15495
+ }
15496
+ function validateCommitFact(raw, allowedTypes, allowedShas) {
15497
+ if (typeof raw !== "object" || raw === null) return null;
15498
+ const obj = raw;
15499
+ if (typeof obj.text !== "string" || obj.text.trim().length === 0) return null;
15500
+ if (typeof obj.type !== "string" || !isValidCommitFactType(obj.type)) return null;
15501
+ if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(obj.type)) {
15502
+ return null;
15503
+ }
15504
+ if (typeof obj.commitSha !== "string" || obj.commitSha.trim().length === 0) return null;
15505
+ const commitSha = obj.commitSha.trim();
15506
+ if (allowedShas && !allowedShas.has(commitSha)) return null;
15507
+ let confidence = "medium";
15508
+ if (typeof obj.confidence === "string") {
15509
+ if (isValidConfidence(obj.confidence)) {
15510
+ confidence = obj.confidence;
15511
+ }
15512
+ }
15513
+ const tags = [];
15514
+ if (Array.isArray(obj.tags)) {
15515
+ for (const tag of obj.tags) {
15516
+ if (typeof tag === "string" && tag.trim().length > 0) {
15517
+ tags.push(tag.trim());
15518
+ }
15519
+ }
15520
+ }
15521
+ const rationale = typeof obj.rationale === "string" && obj.rationale.trim().length > 0 ? obj.rationale.trim() : void 0;
15522
+ return {
15523
+ text: obj.text.trim(),
15524
+ type: obj.type,
15525
+ confidence,
15526
+ tags,
15527
+ commitSha,
15528
+ rationale
15529
+ };
15530
+ }
15531
+
14652
15532
  // src/lib/domain/interfaces/dal/types.ts
14653
15533
  var DEFAULT_QUERY_OPTIONS = {
14654
15534
  limit: 10,
@@ -16375,7 +17255,7 @@ async function bootstrapContainer(config = {}) {
16375
17255
  container.registerInstance(TOKENS.ApiKey, apiKey);
16376
17256
  }
16377
17257
  container.registerInstance(TOKENS.ServiceConfig, config);
16378
- const logger = config.logger ?? (config.disableLogging ? createNullLogger() : createLogger());
17258
+ const logger = config.logger ?? (config.disableLogging ? createNullLogger() : createLogger({ asyncWrites: config.asyncLogging }));
16379
17259
  container.registerInstance(TOKENS.Logger, logger);
16380
17260
  const context = ContextDetector.detect(projectRoot);
16381
17261
  container.registerInstance(TOKENS.Context, context);
@@ -16438,6 +17318,15 @@ async function bootstrapContainer(config = {}) {
16438
17318
  },
16439
17319
  "transient"
16440
17320
  );
17321
+ container.register(
17322
+ TOKENS.CommitEnricher,
17323
+ async () => {
17324
+ const guard = await container.resolve(TOKENS.LlmGuard);
17325
+ const log = logger.child({ service: "commit-enricher" });
17326
+ return createCommitEnricher(guard, log);
17327
+ },
17328
+ "transient"
17329
+ );
16441
17330
  container.register(
16442
17331
  TOKENS.SessionCaptureService,
16443
17332
  async () => {