@geminixiang/pi-simplify 0.0.4 → 0.0.5
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 +2 -0
- package/index.ts +67 -203
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-simplify
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@geminixiang/pi-simplify)
|
|
4
|
+
|
|
3
5
|
A [pi coding agent](https://github.com/mariozechner/pi) extension that cleans up leftover code after feature implementation.
|
|
4
6
|
|
|
5
7
|
## What it does
|
package/index.ts
CHANGED
|
@@ -9,10 +9,6 @@
|
|
|
9
9
|
* - Duplicate logic
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { writeFileSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
|
|
16
12
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
17
13
|
import { matchesKey, Key, truncateToWidth, type SelectItem } from "@mariozechner/pi-tui";
|
|
18
14
|
|
|
@@ -98,7 +94,6 @@ You may write prose analysis first, but you MUST end your response with a single
|
|
|
98
94
|
"risk": "safe",
|
|
99
95
|
"file": "path/to/file.ext",
|
|
100
96
|
"lines": "12-15",
|
|
101
|
-
"type": "debug",
|
|
102
97
|
"reason": "Why this should be removed",
|
|
103
98
|
"action": "delete"
|
|
104
99
|
}
|
|
@@ -110,9 +105,8 @@ Field notes:
|
|
|
110
105
|
- \`risk\` — one of: "safe", "confirm", "review"
|
|
111
106
|
- \`file\` — repository-relative path, no backticks, no markdown
|
|
112
107
|
- \`lines\` — line number or range ("42" or "42-57"); empty string if unknown
|
|
113
|
-
- \`type\` — one of: "dead-code", "debug", "commented", "over-eng", "duplicate"
|
|
114
108
|
- \`reason\` — single plain-text sentence
|
|
115
|
-
- \`action\` — one of: "delete", "inline", "confirm"
|
|
109
|
+
- \`action\` — one of: "delete", "inline", "confirm" (appended to reason)
|
|
116
110
|
|
|
117
111
|
If there are no candidates, output \`{"candidates": []}\`. Do NOT output anything after the closing \`\`\` fence.
|
|
118
112
|
`;
|
|
@@ -122,6 +116,15 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
122
116
|
// the AI makes tool calls). Overwriting would drop the JSON block if it
|
|
123
117
|
// appears in an earlier message and a later one adds a short confirmation.
|
|
124
118
|
let assistantTextBuffer = "";
|
|
119
|
+
let resolvePendingTurnStart: (() => void) | null = null;
|
|
120
|
+
|
|
121
|
+
pi.on("turn_start", () => {
|
|
122
|
+
if (!resolvePendingTurnStart) return;
|
|
123
|
+
const resolve = resolvePendingTurnStart;
|
|
124
|
+
resolvePendingTurnStart = null;
|
|
125
|
+
resolve();
|
|
126
|
+
});
|
|
127
|
+
|
|
125
128
|
pi.on("message_end", (event) => {
|
|
126
129
|
const msg = event.message;
|
|
127
130
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return;
|
|
@@ -171,7 +174,8 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
171
174
|
for (const item of list) {
|
|
172
175
|
if (!item || typeof item !== "object") continue;
|
|
173
176
|
const file = stripMarkdown(String(item.file ?? item.location ?? ""));
|
|
174
|
-
|
|
177
|
+
// Validate path is within repo (no path traversal)
|
|
178
|
+
if (!file || file.includes("..") || file.startsWith("/")) continue;
|
|
175
179
|
const reason = stripMarkdown(String(item.reason ?? item.description ?? ""));
|
|
176
180
|
const action = item.action ? ` [${stripMarkdown(String(item.action))}]` : "";
|
|
177
181
|
out.push({
|
|
@@ -190,131 +194,6 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
190
194
|
return null;
|
|
191
195
|
}
|
|
192
196
|
|
|
193
|
-
/**
|
|
194
|
-
* Tolerant markdown parser — fallback for responses that don't emit the JSON block.
|
|
195
|
-
* Accepts many header/bullet variations that LLMs produce in practice.
|
|
196
|
-
*/
|
|
197
|
-
function parseCandidatesMarkdown(response: string): SimplifyResult[] {
|
|
198
|
-
const candidates: SimplifyResult[] = [];
|
|
199
|
-
const lines = response.split("\n");
|
|
200
|
-
|
|
201
|
-
// Match `[safe]` / `[confirm]` / `[review]` anywhere on a "header-ish" line.
|
|
202
|
-
// A line counts as a header if the risk tag is preceded only by markdown noise:
|
|
203
|
-
// whitespace, #, *, -, digits, dots, parens, colons, backticks, underscores.
|
|
204
|
-
const HEADER_PREFIX = /^[\s#*\-–—•▸→0-9.)\]:`_]*$/;
|
|
205
|
-
const RISK_TAG = /\[(safe|confirm|review)\]/i;
|
|
206
|
-
|
|
207
|
-
let current: (Partial<SimplifyResult> & { _reason?: string; _action?: string }) | null = null;
|
|
208
|
-
|
|
209
|
-
const commit = () => {
|
|
210
|
-
if (!current) return;
|
|
211
|
-
if (!current.risk || !current.file) {
|
|
212
|
-
current = null;
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const reason = current._reason?.trim() || current.lines?.trim() || "(no reason provided)";
|
|
216
|
-
const action = current._action ? ` [${current._action.trim()}]` : "";
|
|
217
|
-
candidates.push({
|
|
218
|
-
file: current.file,
|
|
219
|
-
lines: current.lines || "",
|
|
220
|
-
reason: reason + action,
|
|
221
|
-
risk: current.risk as "safe" | "confirm" | "review",
|
|
222
|
-
});
|
|
223
|
-
current = null;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
for (const rawLine of lines) {
|
|
227
|
-
const line = rawLine;
|
|
228
|
-
const tagMatch = line.match(RISK_TAG);
|
|
229
|
-
if (tagMatch) {
|
|
230
|
-
const prefix = line.slice(0, line.indexOf(tagMatch[0]));
|
|
231
|
-
if (HEADER_PREFIX.test(prefix)) {
|
|
232
|
-
// New candidate — commit the previous one
|
|
233
|
-
commit();
|
|
234
|
-
const risk = normalizeRisk(tagMatch[1]);
|
|
235
|
-
// Everything after the tag on this line
|
|
236
|
-
const rest = stripMarkdown(
|
|
237
|
-
line.slice(line.indexOf(tagMatch[0]) + tagMatch[0].length).replace(/^[\s\-–—:]+/, ""),
|
|
238
|
-
);
|
|
239
|
-
// Try to extract a file path (with optional :line or :line-line)
|
|
240
|
-
const fileMatch = rest.match(/([^\s()`*,]+?\.[a-zA-Z0-9]+)(?::(\d+(?:-\d+)?))?/);
|
|
241
|
-
current = { risk };
|
|
242
|
-
if (fileMatch) {
|
|
243
|
-
current.file = fileMatch[1];
|
|
244
|
-
current.lines = fileMatch[2] || "";
|
|
245
|
-
// Remaining text becomes a description/reason fallback
|
|
246
|
-
const desc = rest
|
|
247
|
-
.replace(fileMatch[0], "")
|
|
248
|
-
.replace(/^[\s\-–—:]+/, "")
|
|
249
|
-
.trim();
|
|
250
|
-
if (desc) current.lines = current.lines ? `${current.lines}: ${desc}` : desc;
|
|
251
|
-
} else {
|
|
252
|
-
// No recognizable file on header — treat rest as description
|
|
253
|
-
current.lines = rest;
|
|
254
|
-
}
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (!current) continue;
|
|
260
|
-
|
|
261
|
-
// Field extraction: tolerate `- Key:`, `* Key:`, `**Key:**`, or bare `Key:`.
|
|
262
|
-
const fieldMatch = line.match(
|
|
263
|
-
/^\s*(?:[-*•]\s*)?\*{0,2}(File|Location|Path|Reason|Why|Action|Lines?)\*{0,2}\s*:?\s*[:\-–—]?\s*(.+?)\s*$/i,
|
|
264
|
-
);
|
|
265
|
-
if (fieldMatch) {
|
|
266
|
-
const key = fieldMatch[1].toLowerCase();
|
|
267
|
-
const value = stripMarkdown(fieldMatch[2]);
|
|
268
|
-
if (key === "file" || key === "location" || key === "path") {
|
|
269
|
-
// Split `path.ts:12-15` into file + lines if current.file is still missing/weak
|
|
270
|
-
const withLine = value.match(/^(.+?)(?::(\d+(?:-\d+)?))?$/);
|
|
271
|
-
if (withLine) {
|
|
272
|
-
current.file = current.file || withLine[1];
|
|
273
|
-
if (withLine[2]) current.lines = withLine[2];
|
|
274
|
-
} else {
|
|
275
|
-
current.file = current.file || value;
|
|
276
|
-
}
|
|
277
|
-
} else if (key === "reason" || key === "why") {
|
|
278
|
-
current._reason = value;
|
|
279
|
-
} else if (key === "action") {
|
|
280
|
-
current._action = value;
|
|
281
|
-
} else if (key === "line" || key === "lines") {
|
|
282
|
-
current.lines = value;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
commit();
|
|
288
|
-
return candidates;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Write raw AI output to a debug file so the user can see what the model actually returned. */
|
|
292
|
-
function dumpDebug(response: string): string {
|
|
293
|
-
const path = join(tmpdir(), `pi-simplify-debug-${Date.now()}.md`);
|
|
294
|
-
try {
|
|
295
|
-
writeFileSync(path, response, "utf8");
|
|
296
|
-
return path;
|
|
297
|
-
} catch {
|
|
298
|
-
return "";
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
type ParseOutcome = { kind: "ok"; candidates: SimplifyResult[] } | { kind: "unparseable" };
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Parse the AI's response to extract cleanup candidates.
|
|
306
|
-
* - "ok" with non-empty list → show selector
|
|
307
|
-
* - "ok" with empty list → AI legitimately found nothing
|
|
308
|
-
* - "unparseable" → AI output didn't match any known format; dump for debug
|
|
309
|
-
*/
|
|
310
|
-
function parseCandidates(response: string): ParseOutcome {
|
|
311
|
-
const json = parseCandidatesJSON(response);
|
|
312
|
-
if (json !== null) return { kind: "ok", candidates: json };
|
|
313
|
-
const md = parseCandidatesMarkdown(response);
|
|
314
|
-
if (md.length > 0) return { kind: "ok", candidates: md };
|
|
315
|
-
return { kind: "unparseable" };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
197
|
/**
|
|
319
198
|
* Show selection dialog for candidates with proper multi-select support
|
|
320
199
|
*/
|
|
@@ -331,72 +210,45 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
331
210
|
const confirmCandidates = candidates.filter((c) => c.risk === "confirm");
|
|
332
211
|
const reviewCandidates = candidates.filter((c) => c.risk === "review");
|
|
333
212
|
|
|
334
|
-
|
|
213
|
+
const RISK_CONFIG = {
|
|
214
|
+
safe: { label: "Safe to delete", description: "Will be deleted", selected: true },
|
|
215
|
+
confirm: { label: "Needs confirmation", description: "Select to delete", selected: false },
|
|
216
|
+
review: { label: "Needs review", description: "Review before deleting", selected: false },
|
|
217
|
+
} as const;
|
|
218
|
+
|
|
219
|
+
const sections = [
|
|
220
|
+
{ key: "safe" as const, items: safeCandidates },
|
|
221
|
+
{ key: "confirm" as const, items: confirmCandidates },
|
|
222
|
+
{ key: "review" as const, items: reviewCandidates },
|
|
223
|
+
]
|
|
224
|
+
.map(({ key, items }) => ({ key, items, config: RISK_CONFIG[key], total: items.length }))
|
|
225
|
+
.filter((s) => s.total > 0);
|
|
226
|
+
|
|
335
227
|
const displayItems: SelectItem[] = [];
|
|
336
228
|
const selectableItems: { index: number; candidate: SimplifyResult }[] = [];
|
|
337
229
|
let globalIndex = 0;
|
|
338
230
|
|
|
339
|
-
|
|
231
|
+
for (const section of sections) {
|
|
340
232
|
displayItems.push({
|
|
341
|
-
value:
|
|
342
|
-
label: `──
|
|
233
|
+
value: `__section__${section.key}__`,
|
|
234
|
+
label: `── ${section.config.label} (${section.total}) ──`,
|
|
343
235
|
description: "",
|
|
344
236
|
});
|
|
345
237
|
globalIndex++;
|
|
346
|
-
for (const c of
|
|
238
|
+
for (const c of section.items) {
|
|
347
239
|
displayItems.push({
|
|
348
240
|
value: JSON.stringify(c),
|
|
349
241
|
label: `${c.file} - ${c.reason}`,
|
|
350
|
-
description:
|
|
242
|
+
description: section.config.description,
|
|
351
243
|
});
|
|
352
|
-
selectableItems.push({ index: globalIndex
|
|
353
|
-
globalIndex++;
|
|
244
|
+
selectableItems.push({ index: globalIndex++, candidate: c });
|
|
354
245
|
}
|
|
355
246
|
}
|
|
356
247
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
description: "",
|
|
362
|
-
});
|
|
363
|
-
globalIndex++;
|
|
364
|
-
for (const c of confirmCandidates) {
|
|
365
|
-
displayItems.push({
|
|
366
|
-
value: JSON.stringify(c),
|
|
367
|
-
label: `${c.file} - ${c.reason}`,
|
|
368
|
-
description: "Select to delete",
|
|
369
|
-
});
|
|
370
|
-
selectableItems.push({ index: globalIndex, candidate: c });
|
|
371
|
-
globalIndex++;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (reviewCandidates.length > 0) {
|
|
376
|
-
displayItems.push({
|
|
377
|
-
value: "__section__review__",
|
|
378
|
-
label: `── Needs review (${reviewCandidates.length}) ──`,
|
|
379
|
-
description: "",
|
|
380
|
-
});
|
|
381
|
-
globalIndex++;
|
|
382
|
-
for (const c of reviewCandidates) {
|
|
383
|
-
displayItems.push({
|
|
384
|
-
value: JSON.stringify(c),
|
|
385
|
-
label: `${c.file} - ${c.reason}`,
|
|
386
|
-
description: "Review before deleting",
|
|
387
|
-
});
|
|
388
|
-
selectableItems.push({ index: globalIndex, candidate: c });
|
|
389
|
-
globalIndex++;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Track selected selectable items (by their selectableItems index)
|
|
394
|
-
const selectedSet = new Set<number>();
|
|
395
|
-
|
|
396
|
-
// Pre-select all safe items (indices 0 to safeCandidates.length - 1 in selectableItems)
|
|
397
|
-
for (let i = 0; i < safeCandidates.length; i++) {
|
|
398
|
-
selectedSet.add(i);
|
|
399
|
-
}
|
|
248
|
+
// Pre-select all safe items
|
|
249
|
+
const selectedSet = new Set<number>(
|
|
250
|
+
safeCandidates.length > 0 ? Array.from({ length: safeCandidates.length }, (_, i) => i) : [],
|
|
251
|
+
);
|
|
400
252
|
|
|
401
253
|
const maxVisible = 12;
|
|
402
254
|
let scrollOffset = 0;
|
|
@@ -414,10 +266,9 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
414
266
|
if (item.value.startsWith("__section__")) {
|
|
415
267
|
visibleItems.push({ selectableIdx: null, item });
|
|
416
268
|
} else {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
visibleItems.push({ selectableIdx, item });
|
|
269
|
+
const selectableIdx = selectableItems.findIndex((si) => si.index === i);
|
|
270
|
+
if (selectableIdx >= 0) {
|
|
271
|
+
visibleItems.push({ selectableIdx, item: displayItems[i] });
|
|
421
272
|
}
|
|
422
273
|
}
|
|
423
274
|
}
|
|
@@ -565,7 +416,7 @@ export default function simplifyExtension(pi: ExtensionAPI) {
|
|
|
565
416
|
const safeItems = selected.filter((c) => c.risk === "safe");
|
|
566
417
|
const confirmItems = selected.filter((c) => c.risk === "confirm");
|
|
567
418
|
|
|
568
|
-
// Build cleanup prompt
|
|
419
|
+
// Build cleanup prompt with verification step
|
|
569
420
|
const cleanupPrompt = `# Cleanup Instructions
|
|
570
421
|
|
|
571
422
|
Delete the following code:
|
|
@@ -578,8 +429,14 @@ For each item:
|
|
|
578
429
|
3. If the removal affects other code, stop and report the issue
|
|
579
430
|
4. After all deletions, verify the code still works by running any existing tests
|
|
580
431
|
|
|
432
|
+
IMPORTANT: After completing deletions:
|
|
433
|
+
- Run \`npm test\` or equivalent test command
|
|
434
|
+
- If tests fail, report which tests failed and whether it's related to the cleanup
|
|
435
|
+
- If there are no tests, at least verify the files parse correctly (e.g., \`node --check\`)
|
|
436
|
+
|
|
581
437
|
Report:
|
|
582
438
|
- What was deleted
|
|
439
|
+
- Test results (pass/fail)
|
|
583
440
|
- Any issues encountered
|
|
584
441
|
- Files that may need further attention`;
|
|
585
442
|
|
|
@@ -635,17 +492,26 @@ Report:
|
|
|
635
492
|
// Reset capture buffer, then send prompt and wait.
|
|
636
493
|
// message_end events append to assistantTextBuffer during the turn.
|
|
637
494
|
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
495
|
+
// Wait for the queued analysis turn to actually begin before waiting for
|
|
496
|
+
// idle; otherwise waitForIdle() can return immediately while the agent is
|
|
497
|
+
// still idle and the queued prompt has not started yet.
|
|
641
498
|
assistantTextBuffer = "";
|
|
642
|
-
let resolveTurnStarted: () => void;
|
|
643
499
|
const turnStarted = new Promise<void>((r) => {
|
|
644
|
-
|
|
500
|
+
resolvePendingTurnStart = r;
|
|
645
501
|
});
|
|
646
|
-
|
|
502
|
+
|
|
647
503
|
pi.sendUserMessage(fullPrompt);
|
|
648
|
-
|
|
504
|
+
|
|
505
|
+
const started = await Promise.race<boolean>([
|
|
506
|
+
turnStarted.then(() => true),
|
|
507
|
+
new Promise<boolean>((r) => setTimeout(() => r(false), 10000)),
|
|
508
|
+
]);
|
|
509
|
+
if (!started) {
|
|
510
|
+
resolvePendingTurnStart = null;
|
|
511
|
+
ctx.ui.notify("Analysis did not start within 10 seconds. Please try again.", "warning");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
649
515
|
await ctx.waitForIdle();
|
|
650
516
|
const analysisText = assistantTextBuffer;
|
|
651
517
|
|
|
@@ -654,19 +520,16 @@ Report:
|
|
|
654
520
|
return;
|
|
655
521
|
}
|
|
656
522
|
|
|
657
|
-
const
|
|
523
|
+
const candidates = parseCandidatesJSON(analysisText);
|
|
658
524
|
|
|
659
|
-
if (
|
|
660
|
-
const debugPath = dumpDebug(analysisText);
|
|
661
|
-
const suffix = debugPath ? ` Raw response saved to ${debugPath}` : "";
|
|
525
|
+
if (candidates === null) {
|
|
662
526
|
ctx.ui.notify(
|
|
663
|
-
|
|
527
|
+
"Could not parse cleanup candidates — the model did not return the expected JSON block.",
|
|
664
528
|
"warning",
|
|
665
529
|
);
|
|
666
530
|
return;
|
|
667
531
|
}
|
|
668
532
|
|
|
669
|
-
const candidates = outcome.candidates;
|
|
670
533
|
if (candidates.length === 0) {
|
|
671
534
|
ctx.ui.notify("No cleanup candidates found!", "info");
|
|
672
535
|
return;
|
|
@@ -734,7 +597,7 @@ Return a summary of what was deleted.`;
|
|
|
734
597
|
/**
|
|
735
598
|
* Status indicator on startup
|
|
736
599
|
*/
|
|
737
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
600
|
+
/**pi.on("session_start", async (_event, ctx) => {
|
|
738
601
|
const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
739
602
|
if (code === 0) {
|
|
740
603
|
ctx.ui.setStatus(
|
|
@@ -743,4 +606,5 @@ Return a summary of what was deleted.`;
|
|
|
743
606
|
);
|
|
744
607
|
}
|
|
745
608
|
});
|
|
609
|
+
*/
|
|
746
610
|
}
|