@dreki-gg/pi-code-reviewer 0.6.0 → 0.6.2

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.
@@ -1,3 +1,6 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
5
  import { Type } from 'typebox';
3
6
 
@@ -7,16 +10,34 @@ import { discoverLenses, getLensContent } from '../lenses';
7
10
  import { resolveModelPlan } from '../model-plan';
8
11
  import { runPipeline } from '../passes';
9
12
  import {
10
- buildDiffSection,
11
13
  buildLensResult,
14
+ buildPipelineResult,
12
15
  buildReviewBasePrompt,
16
+ buildSinglePassResult,
13
17
  pickLensToolOutputs,
14
- renderPipelineReport,
15
18
  runTools,
16
19
  } from '../reviewer';
17
- import type { DiffSource } from '../diff';
20
+ import type { ReviewPointer } from '../reviewer';
18
21
  import type { LensResult, ReviewConfig } from '../types';
19
22
 
23
+ /**
24
+ * Spill the full review context to a temp Markdown file and return a pointer
25
+ * (path + byte size + line count). Both pi's tool-output and `read` caps are
26
+ * ~50KB / 2000 lines, so large reviews would otherwise be truncated and lost
27
+ * on compaction. The on-disk file survives compaction and can be paged.
28
+ *
29
+ * Node-only IO (no Bun) per the extension runtime constraint.
30
+ */
31
+ async function writeReviewTempFile(content: string): Promise<ReviewPointer> {
32
+ const path = join(tmpdir(), `pi-code-review-${Date.now()}.md`);
33
+ await writeFile(path, content, 'utf8');
34
+ return {
35
+ path,
36
+ bytes: Buffer.byteLength(content, 'utf8'),
37
+ lines: content.split('\n').length,
38
+ };
39
+ }
40
+
20
41
  export function registerReviewTool(pi: ExtensionAPI) {
21
42
  pi.registerTool({
22
43
  name: 'code_review',
@@ -155,17 +176,18 @@ export function registerReviewTool(pi: ExtensionAPI) {
155
176
  const allPassesFailed =
156
177
  config.review.passes > 0 && pipeline.telemetry.failedPasses >= config.review.passes;
157
178
  if (!allPassesFailed) {
158
- return {
159
- content: [{ type: 'text', text: renderPipelineReport(pipeline, diff) }],
160
- details: {
161
- mode: 'pipeline',
162
- lensCount: lensNames.length,
179
+ return buildPipelineResult(
180
+ {
181
+ pipeline,
182
+ diff,
183
+ basePrompt,
184
+ lensNames,
163
185
  availableLenses: [...available.keys()],
164
186
  changedFiles,
165
- findings: pipeline.findings,
166
- telemetry: pipeline.telemetry,
167
187
  },
168
- };
188
+ writeReviewTempFile,
189
+ onUpdate,
190
+ );
169
191
  }
170
192
  onUpdate?.({
171
193
  content: [{ type: 'text', text: 'all review passes failed — single-pass fallback' }],
@@ -187,20 +209,25 @@ export function registerReviewTool(pi: ExtensionAPI) {
187
209
 
188
210
  ctx.ui.setStatus('code-review', undefined);
189
211
 
190
- // Fallback: return the review task for a single downstream pass (the
191
- // agent produces findings in its follow-up message). Used when no model
192
- // is available (e.g. print mode) or passes are disabled in config.
193
- const text = buildToolContext(results, diff);
194
-
195
- return {
196
- content: [{ type: 'text', text }],
197
- details: {
198
- mode: 'single-pass',
199
- lensCount: lensNames.length,
212
+ // Fallback: spill the full single-pass review context to a temp file and
213
+ // return a compact summary + pointer (degrades gracefully on empty
214
+ // context or a write failure). Used when no model is available (e.g.
215
+ // print mode) or passes are disabled in config.
216
+ //
217
+ // This is the PRIMARY truncation culprit: the full context embeds the
218
+ // diff (up to 50KB) plus every lens's tool outputs (20KB each), which
219
+ // easily blows past pi's 50KB tool-output cap.
220
+ return buildSinglePassResult(
221
+ {
222
+ results,
223
+ diff,
224
+ lensNames,
200
225
  availableLenses: [...available.keys()],
201
226
  changedFiles,
202
227
  },
203
- };
228
+ writeReviewTempFile,
229
+ onUpdate,
230
+ );
204
231
  },
205
232
  });
206
233
  }
@@ -219,42 +246,3 @@ function resolveLensNames(
219
246
  return [...available.keys()];
220
247
  }
221
248
 
222
- /**
223
- * Build the agent-facing review instructions appended to the report. The diff
224
- * is embedded ONCE (not per lens) followed by each lens's section — large
225
- * diffs would otherwise be repeated for every lens, bloating the tool output.
226
- */
227
- function buildToolContext(results: LensResult[], diff: DiffSource): string {
228
- const sections = results.map((r) => r._lensSection).filter(Boolean) as string[];
229
- if (sections.length === 0) return '';
230
-
231
- return [
232
- `# Code Review — ${new Date().toISOString().slice(0, 10)}`,
233
- '',
234
- '## Changes',
235
- '```',
236
- diff.stat.trim() || '(no diffstat)',
237
- '```',
238
- '',
239
- 'Evaluate the diff through each lens below; the tool outputs are automated analysis.',
240
- '',
241
- buildDiffSection(diff),
242
- '',
243
- '## Lenses',
244
- '',
245
- ...sections,
246
- '',
247
- '## Instructions',
248
- '',
249
- 'For each lens above, review the diff against its criteria and output a JSON array of findings:',
250
- '',
251
- '```json',
252
- '[',
253
- ' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
254
- ']',
255
- '```',
256
- '',
257
- 'After each lens JSON array, write a 2-3 sentence summary.',
258
- 'If a lens has no findings, return an empty array `[]` and note the code looks good.',
259
- ].join('\n');
260
- }
@@ -1,5 +1,9 @@
1
1
  import { platform } from 'node:os';
2
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import type {
3
+ AgentToolResult,
4
+ AgentToolUpdateCallback,
5
+ ExtensionAPI,
6
+ } from '@earendil-works/pi-coding-agent';
3
7
  import { Effect } from 'effect';
4
8
 
5
9
  import type { DiffSource } from './diff';
@@ -118,6 +122,71 @@ export function buildReviewBasePrompt(lensSections: string[], diff: DiffSource):
118
122
  ].join('\n');
119
123
  }
120
124
 
125
+ /** Pointer to the temp file holding the full review context. */
126
+ export type ReviewPointer = { path: string; bytes: number; lines: number };
127
+
128
+ /** Round bytes to whole KB for a human-readable size (min 1KB). */
129
+ function toKb(bytes: number): number {
130
+ return Math.max(1, Math.round(bytes / 1024));
131
+ }
132
+
133
+ /**
134
+ * Condense a git `--stat` block into a one-line "N files, +ins -del" summary.
135
+ * Returns '' when the diffstat has no recognizable summary line.
136
+ */
137
+ function summarizeDiffStat(stat: string): string {
138
+ const lastLine = stat.trim().split('\n').pop()?.trim() ?? '';
139
+ const files = lastLine.match(/(\d+) files? changed/)?.[1];
140
+ if (!files) return '';
141
+ const insertions = lastLine.match(/(\d+) insertions?\(\+\)/)?.[1];
142
+ const deletions = lastLine.match(/(\d+) deletions?\(-\)/)?.[1];
143
+ const parts = [`${files} file${files === '1' ? '' : 's'}`];
144
+ if (insertions) parts.push(`+${insertions}`);
145
+ if (deletions) parts.push(`-${deletions}`);
146
+ return parts.join(', ');
147
+ }
148
+
149
+ /**
150
+ * Compact inline header for the single-pass fallback. The full review context
151
+ * (diff, lenses, instructions) lives in a temp file — see {@link buildPointer} —
152
+ * so this only names the lenses and the diff scope. Pure (no IO).
153
+ */
154
+ export function buildInlineSummary(lensNames: string[], diff: DiffSource): string {
155
+ const stat = summarizeDiffStat(diff.stat);
156
+ const diffLine = stat ? `${diff.label} (${stat})` : diff.label;
157
+ return [
158
+ '# Code Review Summary',
159
+ `- **Lenses**: ${lensNames.join(', ') || '(none)'}`,
160
+ `- **Diff**: ${diffLine}`,
161
+ ].join('\n');
162
+ }
163
+
164
+ /**
165
+ * Inline pointer to the temp file holding the full review context. pi's tool
166
+ * output / `read` caps are both ~50KB / 2000 lines, so the directive tells the
167
+ * agent to page large content with `read` offset/limit. Pure (no IO).
168
+ *
169
+ * `mode` switches the action sentence: single-pass needs the agent to perform
170
+ * the whole review from the file; pipeline only needs it to drill into the diff
171
+ * behind an already-rendered finding.
172
+ */
173
+ export function buildPointer(pointer: ReviewPointer, mode: 'single-pass' | 'pipeline'): string {
174
+ const size = `(${pointer.lines} lines, ${toKb(pointer.bytes)}KB)`;
175
+ if (mode === 'single-pass') {
176
+ return [
177
+ '📄 Full review context (diff, lens definitions, tool outputs, instructions)',
178
+ `saved to: \`${pointer.path}\``,
179
+ `${size}. **Read that file** to perform the review — page large content with`,
180
+ '`read` offset/limit.',
181
+ ].join('\n');
182
+ }
183
+ return [
184
+ '---',
185
+ `📄 Full diff + lens context saved to: \`${pointer.path}\``,
186
+ `${size}. Use \`read\` (offset/limit) to inspect the diff behind a finding.`,
187
+ ].join('\n');
188
+ }
189
+
121
190
  const SEVERITY_EMOJI: Record<ValidatedFinding['severity'], string> = {
122
191
  blocker: '🔴',
123
192
  warning: '🟡',
@@ -263,6 +332,158 @@ export function buildLensResult(
263
332
  };
264
333
  }
265
334
 
335
+ /**
336
+ * Build the agent-facing review instructions for the single-pass fallback. The
337
+ * diff is embedded ONCE (not per lens) followed by each lens's section — large
338
+ * diffs would otherwise be repeated for every lens, bloating the tool output.
339
+ * Returns '' when no lens produced a section (nothing to review).
340
+ */
341
+ export function buildToolContext(results: LensResult[], diff: DiffSource): string {
342
+ const sections = results.map((r) => r._lensSection).filter(Boolean) as string[];
343
+ if (sections.length === 0) return '';
344
+
345
+ return [
346
+ `# Code Review — ${new Date().toISOString().slice(0, 10)}`,
347
+ '',
348
+ '## Changes',
349
+ '```',
350
+ diff.stat.trim() || '(no diffstat)',
351
+ '```',
352
+ '',
353
+ 'Evaluate the diff through each lens below; the tool outputs are automated analysis.',
354
+ '',
355
+ buildDiffSection(diff),
356
+ '',
357
+ '## Lenses',
358
+ '',
359
+ ...sections,
360
+ '',
361
+ '## Instructions',
362
+ '',
363
+ 'For each lens above, review the diff against its criteria and output a JSON array of findings:',
364
+ '',
365
+ '```json',
366
+ '[',
367
+ ' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
368
+ ']',
369
+ '```',
370
+ '',
371
+ 'After each lens JSON array, write a 2-3 sentence summary.',
372
+ 'If a lens has no findings, return an empty array `[]` and note the code looks good.',
373
+ ].join('\n');
374
+ }
375
+
376
+ /** Persist the full review context somewhere durable, returning a pointer. */
377
+ export type ReviewTempWriter = (content: string) => Promise<ReviewPointer>;
378
+
379
+ type ReviewToolResult = AgentToolResult<Record<string, unknown>>;
380
+
381
+ /**
382
+ * Assemble the single-pass fallback result. The full review context is spilled
383
+ * to a temp file (via the injected {@link ReviewTempWriter}) so it survives
384
+ * pi's tool-output cap; the inline payload is just a summary + pointer.
385
+ * Degrades gracefully: an empty context yields a "no applicable lenses" notice,
386
+ * and a temp-write failure falls back to the (truncation-prone) inline context
387
+ * rather than throwing out of the tool.
388
+ */
389
+ export async function buildSinglePassResult(
390
+ args: {
391
+ results: LensResult[];
392
+ diff: DiffSource;
393
+ lensNames: string[];
394
+ availableLenses: string[];
395
+ changedFiles: string[];
396
+ },
397
+ writeTemp: ReviewTempWriter,
398
+ onUpdate?: AgentToolUpdateCallback,
399
+ ): Promise<ReviewToolResult> {
400
+ const fullContext = buildToolContext(args.results, args.diff);
401
+ const baseDetails: Record<string, unknown> = {
402
+ mode: 'single-pass',
403
+ lensCount: args.lensNames.length,
404
+ availableLenses: args.availableLenses,
405
+ changedFiles: args.changedFiles,
406
+ };
407
+
408
+ // No lens produced any context (e.g. the requested lenses matched none of the
409
+ // available ones) — there is nothing to review, so don't point the agent at
410
+ // an empty temp file.
411
+ if (!fullContext.trim()) {
412
+ return {
413
+ content: [
414
+ {
415
+ type: 'text',
416
+ text: `No applicable lenses for this review. Available: ${args.availableLenses.join(', ') || '(none)'}.`,
417
+ },
418
+ ],
419
+ details: baseDetails,
420
+ };
421
+ }
422
+
423
+ try {
424
+ const pointer = await writeTemp(fullContext);
425
+ const summary = `${buildInlineSummary(args.lensNames, args.diff)}\n\n${buildPointer(pointer, 'single-pass')}`;
426
+ return {
427
+ content: [{ type: 'text', text: summary }],
428
+ details: { ...baseDetails, contextFile: pointer.path },
429
+ };
430
+ } catch (cause) {
431
+ onUpdate?.({
432
+ content: [{ type: 'text', text: 'temp-file write failed — returning inline context' }],
433
+ details: { writeError: cause instanceof Error ? cause.message : String(cause) },
434
+ });
435
+ return { content: [{ type: 'text', text: fullContext }], details: baseDetails };
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Assemble the pipeline result. The validated findings are the valuable output
441
+ * and stay inline; the diff + lens context is spilled to a temp file (via the
442
+ * injected {@link ReviewTempWriter}) purely so the agent can drill into the
443
+ * diff behind a finding. A write failure must NOT discard a completed pipeline,
444
+ * so on failure the findings are returned WITHOUT a pointer.
445
+ */
446
+ export async function buildPipelineResult(
447
+ args: {
448
+ pipeline: PipelineResult;
449
+ diff: DiffSource;
450
+ basePrompt: string;
451
+ lensNames: string[];
452
+ availableLenses: string[];
453
+ changedFiles: string[];
454
+ },
455
+ writeTemp: ReviewTempWriter,
456
+ onUpdate?: AgentToolUpdateCallback,
457
+ ): Promise<ReviewToolResult> {
458
+ const report = renderPipelineReport(args.pipeline, args.diff);
459
+ let text = report;
460
+ let contextFile: string | undefined;
461
+ try {
462
+ const pointer = await writeTemp(args.basePrompt);
463
+ text = `${report}\n\n${buildPointer(pointer, 'pipeline')}`;
464
+ contextFile = pointer.path;
465
+ } catch (cause) {
466
+ onUpdate?.({
467
+ content: [
468
+ { type: 'text', text: 'temp-file write failed — findings returned without diff pointer' },
469
+ ],
470
+ details: { writeError: cause instanceof Error ? cause.message : String(cause) },
471
+ });
472
+ }
473
+ return {
474
+ content: [{ type: 'text', text }],
475
+ details: {
476
+ mode: 'pipeline',
477
+ lensCount: args.lensNames.length,
478
+ availableLenses: args.availableLenses,
479
+ changedFiles: args.changedFiles,
480
+ findings: args.pipeline.findings,
481
+ telemetry: args.pipeline.telemetry,
482
+ ...(contextFile ? { contextFile } : {}),
483
+ },
484
+ };
485
+ }
486
+
266
487
  /** Promise wrapper: run a deduped tool set once, building a live Executor from `pi`. */
267
488
  export function runTools(
268
489
  pi: Pick<ExtensionAPI, 'exec'>,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-code-reviewer",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Multi-lens code review extension for pi — configurable review criteria per project",
5
5
  "keywords": [
6
6
  "pi-package"