@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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/index.ts +67 -203
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-simplify
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@geminixiang/pi-simplify.svg)](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
- if (!file) continue;
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
- // Build display items with section headers
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
- if (safeCandidates.length > 0) {
231
+ for (const section of sections) {
340
232
  displayItems.push({
341
- value: "__section__safe__",
342
- label: `── Safe to delete (${safeCandidates.length}) ──`,
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 safeCandidates) {
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: "Will be deleted",
242
+ description: section.config.description,
351
243
  });
352
- selectableItems.push({ index: globalIndex, candidate: c });
353
- globalIndex++;
244
+ selectableItems.push({ index: globalIndex++, candidate: c });
354
245
  }
355
246
  }
356
247
 
357
- if (confirmCandidates.length > 0) {
358
- displayItems.push({
359
- value: "__section__confirm__",
360
- label: `── Needs confirmation (${confirmCandidates.length}) ──`,
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 selectableInfo = selectableItems.find((si) => si.index === i);
418
- if (selectableInfo) {
419
- const selectableIdx = selectableItems.indexOf(selectableInfo);
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
- // Race condition fix: pi.sendUserMessage() queues the message, but
639
- // ctx.waitForIdle() resolves immediately if the agent is still in idle.
640
- // We must wait for the agent to transition out of idle before waiting.
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
- resolveTurnStarted = r;
500
+ resolvePendingTurnStart = r;
645
501
  });
646
- pi.on("turn_start", () => resolveTurnStarted!());
502
+
647
503
  pi.sendUserMessage(fullPrompt);
648
- await turnStarted;
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 outcome = parseCandidates(analysisText);
523
+ const candidates = parseCandidatesJSON(analysisText);
658
524
 
659
- if (outcome.kind === "unparseable") {
660
- const debugPath = dumpDebug(analysisText);
661
- const suffix = debugPath ? ` Raw response saved to ${debugPath}` : "";
525
+ if (candidates === null) {
662
526
  ctx.ui.notify(
663
- `Could not parse cleanup candidates — the model did not return the expected JSON block.${suffix}`,
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminixiang/pi-simplify",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Simplify: Clean up leftover code after feature implementation",
5
5
  "keywords": [
6
6
  "pi-package"