pi-lens 2.0.39 → 2.0.41

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/CHANGELOG.md CHANGED
@@ -2,12 +2,18 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [2.0.40] - 2026-03-27
6
+
7
+ ### Changed
8
+ - **Passive capture on every file edit**: `captureSnapshot()` now called from `tool_call` hook with 5s debounce. Zero latency — reuses complexity metrics already computed for real-time feedback.
9
+ - **Skip duplicate snapshots**: Same commit + same MI = no write (reduces noise).
10
+
5
11
  ## [2.0.39] - 2026-03-27
6
12
 
7
13
  ### Added
8
14
  - **Historical metrics tracking**: New `clients/metrics-history.ts` module captures complexity snapshots per commit. Tracks MI, cognitive complexity, and nesting depth across sessions.
9
15
  - **Trend analysis in `/lens-metrics`**: New "Trend" column shows 📈/📉/➡️ with MI delta. "Trend Summary" section aggregates improving/stable/regressing counts with worst regressions.
10
- - **Passive capture**: Snapshots captured on session start cache + every `/lens-metrics` run. Max 20 snapshots per file (sliding window).
16
+ - **Passive capture**: Snapshots captured on every file edit (tool_call hook) + `/lens-metrics` run. Max 20 snapshots per file (sliding window).
11
17
 
12
18
  ## [2.0.38] - 2026-03-27
13
19
 
@@ -64,11 +64,19 @@ export function saveHistory(history) {
64
64
  const historyPath = path.join(historyDir, "metrics-history.json");
65
65
  fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
66
66
  }
67
+ // In-memory cache to avoid loading/saving on every capture
68
+ let pendingHistory = null;
69
+ let saveTimer = null;
70
+ const SAVE_DEBOUNCE_MS = 5000; // Save at most every 5 seconds
67
71
  /**
68
72
  * Capture a snapshot for a file's current metrics
73
+ * Auto-saves to disk (debounced) for passive tracking
69
74
  */
70
- export function captureSnapshot(filePath, metrics, history) {
71
- const hist = history ?? loadHistory();
75
+ export function captureSnapshot(filePath, metrics) {
76
+ // Use in-memory cache if available, otherwise load from disk
77
+ if (!pendingHistory) {
78
+ pendingHistory = loadHistory();
79
+ }
72
80
  const relativePath = path.relative(process.cwd(), filePath);
73
81
  const commit = getCurrentCommit();
74
82
  const snapshot = {
@@ -79,8 +87,13 @@ export function captureSnapshot(filePath, metrics, history) {
79
87
  nesting: metrics.maxNestingDepth,
80
88
  lines: metrics.linesOfCode,
81
89
  };
82
- const existing = hist.files[relativePath];
90
+ const existing = pendingHistory.files[relativePath];
83
91
  if (existing) {
92
+ // Skip if same commit + same MI (no change worth recording)
93
+ const latest = existing.latest;
94
+ if (latest.commit === commit && latest.mi === snapshot.mi) {
95
+ return;
96
+ }
84
97
  // Append to history (cap at MAX_HISTORY_PER_FILE)
85
98
  existing.history.push(snapshot);
86
99
  if (existing.history.length > MAX_HISTORY_PER_FILE) {
@@ -91,21 +104,55 @@ export function captureSnapshot(filePath, metrics, history) {
91
104
  }
92
105
  else {
93
106
  // New file
94
- hist.files[relativePath] = {
107
+ pendingHistory.files[relativePath] = {
95
108
  latest: snapshot,
96
109
  history: [snapshot],
97
110
  trend: "stable",
98
111
  };
99
112
  }
100
- return hist;
113
+ // Debounced save to disk
114
+ if (saveTimer)
115
+ clearTimeout(saveTimer);
116
+ saveTimer = setTimeout(() => {
117
+ if (pendingHistory) {
118
+ saveHistory(pendingHistory);
119
+ pendingHistory = null;
120
+ }
121
+ }, SAVE_DEBOUNCE_MS);
101
122
  }
102
123
  /**
103
- * Capture snapshots for multiple files
124
+ * Capture snapshots for multiple files (explicit, immediate save)
125
+ * Used by /lens-metrics for batch capture
104
126
  */
105
127
  export function captureSnapshots(files) {
106
128
  let history = loadHistory();
107
129
  for (const file of files) {
108
- history = captureSnapshot(file.filePath, file.metrics, history);
130
+ const relativePath = path.relative(process.cwd(), file.filePath);
131
+ const commit = getCurrentCommit();
132
+ const snapshot = {
133
+ commit,
134
+ timestamp: new Date().toISOString(),
135
+ mi: Math.round(file.metrics.maintainabilityIndex * 10) / 10,
136
+ cognitive: file.metrics.cognitiveComplexity,
137
+ nesting: file.metrics.maxNestingDepth,
138
+ lines: file.metrics.linesOfCode,
139
+ };
140
+ const existing = history.files[relativePath];
141
+ if (existing) {
142
+ existing.history.push(snapshot);
143
+ if (existing.history.length > MAX_HISTORY_PER_FILE) {
144
+ existing.history = existing.history.slice(-MAX_HISTORY_PER_FILE);
145
+ }
146
+ existing.latest = snapshot;
147
+ existing.trend = computeTrend(existing.history);
148
+ }
149
+ else {
150
+ history.files[relativePath] = {
151
+ latest: snapshot,
152
+ history: [snapshot],
153
+ trend: "stable",
154
+ };
155
+ }
109
156
  }
110
157
  saveHistory(history);
111
158
  return history;
@@ -99,8 +99,14 @@ export function saveHistory(history: MetricsHistory): void {
99
99
  fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
100
100
  }
101
101
 
102
+ // In-memory cache to avoid loading/saving on every capture
103
+ let pendingHistory: MetricsHistory | null = null;
104
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
105
+ const SAVE_DEBOUNCE_MS = 5000; // Save at most every 5 seconds
106
+
102
107
  /**
103
108
  * Capture a snapshot for a file's current metrics
109
+ * Auto-saves to disk (debounced) for passive tracking
104
110
  */
105
111
  export function captureSnapshot(
106
112
  filePath: string,
@@ -110,9 +116,12 @@ export function captureSnapshot(
110
116
  maxNestingDepth: number;
111
117
  linesOfCode: number;
112
118
  },
113
- history?: MetricsHistory,
114
- ): MetricsHistory {
115
- const hist = history ?? loadHistory();
119
+ ): void {
120
+ // Use in-memory cache if available, otherwise load from disk
121
+ if (!pendingHistory) {
122
+ pendingHistory = loadHistory();
123
+ }
124
+
116
125
  const relativePath = path.relative(process.cwd(), filePath);
117
126
  const commit = getCurrentCommit();
118
127
 
@@ -125,9 +134,14 @@ export function captureSnapshot(
125
134
  lines: metrics.linesOfCode,
126
135
  };
127
136
 
128
- const existing = hist.files[relativePath];
137
+ const existing = pendingHistory.files[relativePath];
129
138
 
130
139
  if (existing) {
140
+ // Skip if same commit + same MI (no change worth recording)
141
+ const latest = existing.latest;
142
+ if (latest.commit === commit && latest.mi === snapshot.mi) {
143
+ return;
144
+ }
131
145
  // Append to history (cap at MAX_HISTORY_PER_FILE)
132
146
  existing.history.push(snapshot);
133
147
  if (existing.history.length > MAX_HISTORY_PER_FILE) {
@@ -137,18 +151,26 @@ export function captureSnapshot(
137
151
  existing.trend = computeTrend(existing.history);
138
152
  } else {
139
153
  // New file
140
- hist.files[relativePath] = {
154
+ pendingHistory.files[relativePath] = {
141
155
  latest: snapshot,
142
156
  history: [snapshot],
143
157
  trend: "stable",
144
158
  };
145
159
  }
146
160
 
147
- return hist;
161
+ // Debounced save to disk
162
+ if (saveTimer) clearTimeout(saveTimer);
163
+ saveTimer = setTimeout(() => {
164
+ if (pendingHistory) {
165
+ saveHistory(pendingHistory);
166
+ pendingHistory = null;
167
+ }
168
+ }, SAVE_DEBOUNCE_MS);
148
169
  }
149
170
 
150
171
  /**
151
- * Capture snapshots for multiple files
172
+ * Capture snapshots for multiple files (explicit, immediate save)
173
+ * Used by /lens-metrics for batch capture
152
174
  */
153
175
  export function captureSnapshots(
154
176
  files: Array<{
@@ -164,7 +186,34 @@ export function captureSnapshots(
164
186
  let history = loadHistory();
165
187
 
166
188
  for (const file of files) {
167
- history = captureSnapshot(file.filePath, file.metrics, history);
189
+ const relativePath = path.relative(process.cwd(), file.filePath);
190
+ const commit = getCurrentCommit();
191
+
192
+ const snapshot: MetricSnapshot = {
193
+ commit,
194
+ timestamp: new Date().toISOString(),
195
+ mi: Math.round(file.metrics.maintainabilityIndex * 10) / 10,
196
+ cognitive: file.metrics.cognitiveComplexity,
197
+ nesting: file.metrics.maxNestingDepth,
198
+ lines: file.metrics.linesOfCode,
199
+ };
200
+
201
+ const existing = history.files[relativePath];
202
+
203
+ if (existing) {
204
+ existing.history.push(snapshot);
205
+ if (existing.history.length > MAX_HISTORY_PER_FILE) {
206
+ existing.history = existing.history.slice(-MAX_HISTORY_PER_FILE);
207
+ }
208
+ existing.latest = snapshot;
209
+ existing.trend = computeTrend(existing.history);
210
+ } else {
211
+ history.files[relativePath] = {
212
+ latest: snapshot,
213
+ history: [snapshot],
214
+ trend: "stable",
215
+ };
216
+ }
168
217
  }
169
218
 
170
219
  saveHistory(history);
package/index.ts CHANGED
@@ -14,7 +14,7 @@ import { buildInterviewer } from "./clients/interviewer.js";
14
14
  import { JscpdClient } from "./clients/jscpd-client.js";
15
15
  import { KnipClient } from "./clients/knip-client.js";
16
16
  import { MetricsClient } from "./clients/metrics-client.js";
17
- import { captureSnapshots, getTrendSummary, formatTrendCell } from "./clients/metrics-history.js";
17
+ import { captureSnapshot, captureSnapshots, getTrendSummary, formatTrendCell } from "./clients/metrics-history.js";
18
18
  import { RuffClient } from "./clients/ruff-client.js";
19
19
  import { RustClient } from "./clients/rust-client.js";
20
20
  import { getSourceFiles } from "./clients/scan-utils.js";
@@ -452,8 +452,8 @@ export default function (pi: ExtensionAPI) {
452
452
 
453
453
  // All files table (sorted by MI ascending)
454
454
  report += `## All Files\n\n`;
455
- report += `| Grade | File | MI | Cognitive | Nesting | LOC | Trend |\n`;
456
- report += `|-------|------|-----|-----------|---------|-----|-------|\n`;
455
+ report += `| Grade | File | MI | Cognitive | LOC | Entropy | Trend |\n`;
456
+ report += `|-------|------|-----|-----------|-----|---------|-------|\n`;
457
457
 
458
458
  const sorted = [...results].sort(
459
459
  (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
@@ -470,8 +470,9 @@ export default function (pi: ExtensionAPI) {
470
470
  // Make path relative for readability
471
471
  const relPath = path.relative(targetPath, f.filePath);
472
472
  const trendCell = formatTrendCell(f.filePath, history);
473
+ const entropyCell = f.codeEntropy > 0 ? f.codeEntropy.toFixed(2) : "—";
473
474
 
474
- report += `| ${grade} | ${relPath} | ${mi.toFixed(1)} | ${f.cognitiveComplexity} | ${f.maxNestingDepth} | ${f.linesOfCode} | ${trendCell} |\n`;
475
+ report += `| ${grade} | ${relPath} | ${mi.toFixed(1)} | ${f.cognitiveComplexity} | ${f.linesOfCode} | ${entropyCell} | ${trendCell} |\n`;
475
476
  }
476
477
  report += `\n`;
477
478
 
@@ -902,7 +903,7 @@ export default function (pi: ExtensionAPI) {
902
903
  );
903
904
  if (!nodeFs.existsSync(filePath)) return;
904
905
 
905
- // Record complexity baseline for TS/JS files
906
+ // Record complexity baseline for TS/JS files + capture history snapshot
906
907
  if (
907
908
  complexityClient.isSupportedFile(filePath) &&
908
909
  !complexityBaselines.has(filePath)
@@ -910,6 +911,13 @@ export default function (pi: ExtensionAPI) {
910
911
  const baseline = complexityClient.analyzeFile(filePath);
911
912
  if (baseline) {
912
913
  complexityBaselines.set(filePath, baseline);
914
+ // Capture snapshot for historical tracking (async, non-blocking)
915
+ captureSnapshot(filePath, {
916
+ maintainabilityIndex: baseline.maintainabilityIndex,
917
+ cognitiveComplexity: baseline.cognitiveComplexity,
918
+ maxNestingDepth: baseline.maxNestingDepth,
919
+ linesOfCode: baseline.linesOfCode,
920
+ });
913
921
  }
914
922
  }
915
923
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "2.0.39",
3
+ "version": "2.0.41",
4
4
  "description": "Real-time code quality feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, complexity metrics, duplicate detection. Includes automated fix loop (/lens-booboo-fix) and interactive architectural refactoring (/lens-booboo-refactor) with browser-based interviews.",
5
5
  "repository": {
6
6
  "type": "git",