pi-critique 0.1.1 → 0.1.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.
- package/README.md +13 -2
- package/index.ts +63 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# pi-critique
|
|
2
2
|
|
|
3
|
-
Structured AI critique for writing and code in [pi](https://github.com/badlogic/pi-mono). Submits a critique prompt to the model, which returns numbered critiques (C1, C2, ...) with inline markers in the
|
|
3
|
+
Structured AI critique for writing and code in [pi](https://github.com/badlogic/pi-mono). Submits a critique prompt to the model, which returns numbered critiques (C1, C2, ...) with inline markers in the document text. Pairs well with [pi-annotated-reply](https://github.com/omaclaren/pi-annotated-reply) and [pi-markdown-preview](https://github.com/omaclaren/pi-markdown-preview) but works standalone.
|
|
4
|
+
|
|
5
|
+
**Non-destructive by default:** `/critique` is intended to analyze, not edit, your source file.
|
|
4
6
|
|
|
5
7
|
## Commands
|
|
6
8
|
|
|
7
9
|
| Command | Description |
|
|
8
10
|
|---------|-------------|
|
|
9
11
|
| `/critique` | Critique the last assistant response |
|
|
10
|
-
| `/critique <path>` | Critique a file |
|
|
12
|
+
| `/critique <path>` | Critique a file (non-destructive; original file is unchanged) |
|
|
11
13
|
| `/critique --code` | Force code review lens |
|
|
12
14
|
| `/critique --writing` | Force writing critique lens |
|
|
13
15
|
| `/critique --no-inline` | Critiques list only, no annotated document |
|
|
@@ -31,6 +33,13 @@ The model adapts critique types to the genre:
|
|
|
31
33
|
|
|
32
34
|
Types are not fixed — the model chooses what fits the content.
|
|
33
35
|
|
|
36
|
+
## Non-destructive behaviour
|
|
37
|
+
|
|
38
|
+
- For normal files, the extension reads content and sends it to the model for critique. It does not directly edit your source file.
|
|
39
|
+
- For large files (see below), the model is instructed to write annotations to a separate file: `<filename>.critique.<ext>`.
|
|
40
|
+
- During auto-submitted `/critique <path>` runs, a safety guard blocks write/edit tool calls to prevent accidental in-place edits.
|
|
41
|
+
- To actually apply changes to the original file, run a separate explicit editing step (for example via `/reply` + a follow-up prompt).
|
|
42
|
+
|
|
34
43
|
## Lenses
|
|
35
44
|
|
|
36
45
|
The extension auto-detects whether content is code or writing based on file extension. Override with `--code` or `--writing`.
|
|
@@ -41,6 +50,8 @@ Code files (`.ts`, `.py`, `.rs`, `.go`, etc.) get a code review prompt. Writing
|
|
|
41
50
|
|
|
42
51
|
Files over 500 lines are handled differently to save tokens: the extension passes the file path to the model (rather than embedding the content), and the model reads the file with its tools and writes an annotated copy to `<filename>.critique.<ext>` on disk.
|
|
43
52
|
|
|
53
|
+
The original file path is preserved; annotations are written to the `*.critique.*` copy.
|
|
54
|
+
|
|
44
55
|
## Example output
|
|
45
56
|
|
|
46
57
|
```markdown
|
package/index.ts
CHANGED
|
@@ -195,6 +195,21 @@ function expandHome(pathInput: string): string {
|
|
|
195
195
|
return join(home, pathInput.slice(2));
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
function normalizePathForComparison(pathInput: string, cwd: string): string {
|
|
199
|
+
const withoutAt = pathInput.startsWith("@") ? pathInput.slice(1) : pathInput;
|
|
200
|
+
const expanded = expandHome(withoutAt.trim());
|
|
201
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pathsEqual(a: string, b: string): boolean {
|
|
205
|
+
if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
|
|
206
|
+
return a === b;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type CritiqueWriteGuard =
|
|
210
|
+
| { mode: "deny-all"; sourcePath: string }
|
|
211
|
+
| { mode: "allow-annotated-only"; sourcePath: string; annotatedPath: string };
|
|
212
|
+
|
|
198
213
|
function detectLens(filePath?: string): Lens {
|
|
199
214
|
if (!filePath) return "writing";
|
|
200
215
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -230,9 +245,8 @@ function getLastAssistantMarkdown(ctx: ExtensionCommandContext): string | undefi
|
|
|
230
245
|
return undefined;
|
|
231
246
|
}
|
|
232
247
|
|
|
233
|
-
function readFileContent(filePath: string, cwd: string): { ok: true; content: string; label: string } | { ok: false; message: string } {
|
|
234
|
-
const
|
|
235
|
-
const resolved = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
|
248
|
+
function readFileContent(filePath: string, cwd: string): { ok: true; content: string; label: string; resolvedPath: string } | { ok: false; message: string } {
|
|
249
|
+
const resolved = normalizePathForComparison(filePath, cwd);
|
|
236
250
|
|
|
237
251
|
try {
|
|
238
252
|
const stats = statSync(resolved);
|
|
@@ -249,7 +263,7 @@ function readFileContent(filePath: string, cwd: string): { ok: true; content: st
|
|
|
249
263
|
if (content.includes("\u0000")) {
|
|
250
264
|
return { ok: false, message: `File appears to be binary: ${filePath}` };
|
|
251
265
|
}
|
|
252
|
-
return { ok: true, content, label: filePath };
|
|
266
|
+
return { ok: true, content, label: filePath, resolvedPath: resolved };
|
|
253
267
|
} catch (error) {
|
|
254
268
|
const msg = error instanceof Error ? error.message : String(error);
|
|
255
269
|
return { ok: false, message: `Failed to read file: ${msg}` };
|
|
@@ -332,6 +346,38 @@ function parseArgs(args: string): ParsedArgs {
|
|
|
332
346
|
}
|
|
333
347
|
|
|
334
348
|
export default function (pi: ExtensionAPI) {
|
|
349
|
+
let critiqueWriteGuard: CritiqueWriteGuard | undefined;
|
|
350
|
+
|
|
351
|
+
pi.on("agent_end", async () => {
|
|
352
|
+
critiqueWriteGuard = undefined;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
356
|
+
if (!critiqueWriteGuard) return;
|
|
357
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
358
|
+
|
|
359
|
+
const inputPath = (event.input as { path?: unknown } | undefined)?.path;
|
|
360
|
+
if (typeof inputPath !== "string" || !inputPath.trim()) {
|
|
361
|
+
return { block: true, reason: "Blocked by /critique safety guard: write/edit path is missing." };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const targetPath = normalizePathForComparison(inputPath, ctx.cwd);
|
|
365
|
+
|
|
366
|
+
if (critiqueWriteGuard.mode === "deny-all") {
|
|
367
|
+
return {
|
|
368
|
+
block: true,
|
|
369
|
+
reason: `Blocked by /critique safety guard: critique runs are non-destructive (source: ${critiqueWriteGuard.sourcePath}). Use a separate follow-up prompt to apply edits.`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!pathsEqual(targetPath, critiqueWriteGuard.annotatedPath)) {
|
|
374
|
+
return {
|
|
375
|
+
block: true,
|
|
376
|
+
reason: `Blocked by /critique safety guard: only the annotated output path is writable (${critiqueWriteGuard.annotatedPath}); source preserved (${critiqueWriteGuard.sourcePath}).`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
335
381
|
pi.registerCommand("critique", {
|
|
336
382
|
description: "Critique a file or the last response. Usage: /critique [path] [--code|--writing] [--no-inline] [--edit]",
|
|
337
383
|
handler: async (args, ctx) => {
|
|
@@ -361,9 +407,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
361
407
|
}
|
|
362
408
|
|
|
363
409
|
await ctx.waitForIdle();
|
|
410
|
+
critiqueWriteGuard = undefined;
|
|
364
411
|
|
|
365
412
|
let content: string;
|
|
366
413
|
let label: string;
|
|
414
|
+
let sourcePath: string | undefined;
|
|
367
415
|
|
|
368
416
|
if (parsed.file) {
|
|
369
417
|
const result = readFileContent(parsed.file, ctx.cwd);
|
|
@@ -373,6 +421,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
421
|
}
|
|
374
422
|
content = result.content;
|
|
375
423
|
label = result.label;
|
|
424
|
+
sourcePath = result.resolvedPath;
|
|
376
425
|
} else {
|
|
377
426
|
const markdown = getLastAssistantMarkdown(ctx);
|
|
378
427
|
if (!markdown) {
|
|
@@ -389,19 +438,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
438
|
|
|
390
439
|
// Large files: pass filepath, model reads and writes annotated copy to disk
|
|
391
440
|
if (isLargeFile) {
|
|
392
|
-
const
|
|
393
|
-
const resolvedPath = isAbsolute(expanded) ? expanded : resolve(ctx.cwd, expanded);
|
|
441
|
+
const resolvedPath = sourcePath ?? normalizePathForComparison(parsed.file!, ctx.cwd);
|
|
394
442
|
const ext = extname(resolvedPath);
|
|
395
443
|
const base = resolvedPath.slice(0, resolvedPath.length - ext.length);
|
|
396
444
|
const annotatedPath = `${base}.critique${ext}`;
|
|
445
|
+
const normalizedAnnotatedPath = normalizePathForComparison(annotatedPath, ctx.cwd);
|
|
397
446
|
const prompt = buildLargeFilePrompt(lens, resolvedPath, annotatedPath);
|
|
398
447
|
|
|
399
448
|
if (parsed.edit) {
|
|
400
449
|
ctx.ui.setEditorText(prompt);
|
|
401
450
|
ctx.ui.notify(`Critique prompt (${lens}) for ${label} loaded into editor. Edit and submit when ready.`, "info");
|
|
402
451
|
} else {
|
|
452
|
+
critiqueWriteGuard = {
|
|
453
|
+
mode: "allow-annotated-only",
|
|
454
|
+
sourcePath: resolvedPath,
|
|
455
|
+
annotatedPath: normalizedAnnotatedPath,
|
|
456
|
+
};
|
|
403
457
|
pi.sendUserMessage(prompt);
|
|
404
|
-
ctx.ui.notify(`Critiquing ${label} (${lens}, ${contentLines} lines).
|
|
458
|
+
ctx.ui.notify(`Critiquing ${label} (${lens}, ${contentLines} lines). Original preserved; annotated copy → ${annotatedPath}`, "info");
|
|
405
459
|
}
|
|
406
460
|
return;
|
|
407
461
|
}
|
|
@@ -416,8 +470,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
416
470
|
ctx.ui.setEditorText(prompt);
|
|
417
471
|
ctx.ui.notify(`Critique prompt (${lens}) for ${label} loaded into editor. Edit and submit when ready.`, "info");
|
|
418
472
|
} else {
|
|
473
|
+
critiqueWriteGuard = sourcePath ? { mode: "deny-all", sourcePath } : undefined;
|
|
419
474
|
pi.sendUserMessage(prompt);
|
|
420
|
-
ctx.ui.notify(`Critiquing ${label} (${lens}${parsed.inline ? ", inline" : ""})... Respond with [accept C1], [reject C2: reason], etc.`, "info");
|
|
475
|
+
ctx.ui.notify(`Critiquing ${label} (${lens}${parsed.inline ? ", inline" : ""})... Original file unchanged. Respond with [accept C1], [reject C2: reason], etc.`, "info");
|
|
421
476
|
}
|
|
422
477
|
},
|
|
423
478
|
});
|
package/package.json
CHANGED