@oh-my-pi/pi-coding-agent 13.7.4 → 13.7.6

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,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.7.6] - 2026-03-04
6
+ ### Added
7
+
8
+ - Exported `dedupeParseErrors` utility function to deduplicate parse error messages while preserving order
9
+
10
+ ### Fixed
11
+
12
+ - Reduced duplicate parse error messages when multiple patterns fail on the same file
13
+ - Normalized parse error output in ast-grep to remove pattern-specific prefixes and show only file-level errors
14
+
5
15
  ## [13.7.4] - 2026-03-04
6
16
  ### Added
7
17
  - Added `fetch.useKagiSummarizer` setting to toggle Kagi Universal Summarizer usage in the fetch tool.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.7.4",
4
+ "version": "13.7.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.7.4",
45
- "@oh-my-pi/pi-agent-core": "13.7.4",
46
- "@oh-my-pi/pi-ai": "13.7.4",
47
- "@oh-my-pi/pi-natives": "13.7.4",
48
- "@oh-my-pi/pi-tui": "13.7.4",
49
- "@oh-my-pi/pi-utils": "13.7.4",
44
+ "@oh-my-pi/omp-stats": "13.7.6",
45
+ "@oh-my-pi/pi-agent-core": "13.7.6",
46
+ "@oh-my-pi/pi-ai": "13.7.6",
47
+ "@oh-my-pi/pi-natives": "13.7.6",
48
+ "@oh-my-pi/pi-tui": "13.7.6",
49
+ "@oh-my-pi/pi-utils": "13.7.6",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -1,41 +1,40 @@
1
- Applies precise file edits using `LINE#ID` tags from `read` output.
1
+ Applies precise, surgical file edits by referencing `LINE#ID` tags from `read` output. Each tag uniquely identifies a line, so edits remain stable even when lines shift.
2
2
 
3
3
  <workflow>
4
- 1. You **SHOULD** issue a `read` call before editing if you have no tagged context for a file.
5
- 2. You **MUST** pick the smallest operation per change site.
6
- 3. You **MUST** submit one `edit` call per file with all operations, think your changes through before submitting.
4
+ Follow these steps in order for every edit:
5
+ 1. You **SHOULD** issue a `read` call before editing to get fresh `LINE#ID` tags. Editing without current tags causes mismatches because other edits or external changes may have shifted line numbers since your last read.
6
+ 2. You **MUST** submit one `edit` call per file with all operations. Multiple calls to the same file require re-reading between each one (tags shift after each edit), so batching avoids wasted round-trips. Think your changes through before submitting.
7
+ 3. You **MUST** pick the smallest operation per change site. Each operation should be one logical mutation — a single replace, insert, or delete. Combining unrelated changes into one operation makes errors harder to diagnose and recover from.
7
8
  </workflow>
8
9
 
9
10
  <operations>
10
11
  **`path`** — the path to the file to edit.
11
12
  **`move`** — if set, move the file to the given path.
12
13
  **`delete`** — if true, delete the file.
13
- **`edits.[n].pos`** — the anchor line. Meaning depends on `op`:
14
+ **`edits[n].pos`** — the anchor line. Meaning depends on `op`:
14
15
  - `replace`: start of range (or the single line to replace)
15
16
  - `prepend`: insert new lines **before** this line; omit for beginning of file
16
17
  - `append`: insert new lines **after** this line; omit for end of file
17
- **`edits.[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
18
- **`edits.[n].lines`** — the replacement content:
18
+ **`edits[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
19
+ **`edits[n].lines`** — the replacement content:
19
20
  - `["line1", "line2"]` — replace with these lines (array of strings)
20
21
  - `"line1"` — shorthand for `["line1"]` (single-line replace)
21
22
  - `[""]` — replace content with a blank line (line preserved, content cleared)
22
23
  - `null` or `[]` — **delete** the line(s) entirely
23
24
 
24
- Tags should be referenced from the last `read` output.
25
- Edits are applied bottom-up, so earlier tags stay valid even when later ops add or remove lines.
25
+ Tags are applied bottom-up: later edits (by position) are applied first, so earlier tags remain valid even when subsequent ops add or remove lines. Tags **MUST** be referenced from the most recent `read` output.
26
26
  </operations>
27
27
 
28
28
  <rules>
29
- 1. **Minimize scope:** You **MUST** use one logical mutation per operation.
30
- 2. **Prefer insertion over neighbor rewrites:** You **SHOULD** anchor on structural boundaries (`}`, `]`, `},`), not interior lines.
31
- 3. **Range end tag (inclusive):** `end` is inclusive and **MUST** point to the final line being replaced.
32
- - If `lines` includes a closing boundary token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original boundary line.
33
- - You **MUST NOT** set `end` to an interior line and then re-add the boundary token in `lines`; that duplicates the next surviving line.
29
+ 1. **Anchor on unique, structural lines.** You **SHOULD** choose anchors like function signatures, class declarations, or distinct statements — lines that appear exactly once. Blank lines, `}`, and `return null;` repeat throughout a file; anchoring on them risks matching the wrong location. When inserting between blocks, anchor on the nearest unique declaration using `prepend` or `append`.
30
+ 2. **Prefer insertion over neighbor rewrites.** You **SHOULD** use `prepend`/`append` anchored on a structural boundary (`}`, `]`, `},`) rather than replacing adjacent lines when adding code near existing code. This keeps the edit minimal and avoids accidentally rewriting lines that should stay.
31
+ 3. **Include boundary lines in the replaced range.** `end` is inclusive and **MUST** point to the final line being replaced. If your replacement `lines` include a closing token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original closing line. Otherwise the original closer survives and you get a duplicate.
34
32
  </rules>
35
33
 
36
34
  <recovery>
37
- **Tag mismatch (`>>>`):** You **MUST** retry using fresh tags from the error snippet. If snippet lacks context, or if you repeatedly fail, you **MUST** re-read the file and issue less ambitious edits, i.e. single op.
38
- **No-op (`identical`):** You **MUST NOT** resubmit. Re-read target lines and adjust the edit.
35
+ Edits can fail in two ways. Here is exactly what to do for each:
36
+ 1. **Tag mismatch (`>>>`):** The file changed since your last read, so the tag no longer matches. You **MUST** retry using the fresh tags from the error snippet. If the snippet lacks enough context, or if you fail repeatedly, you **MUST** re-read the entire file and submit a simpler, single-op edit.
37
+ 2. **No-op (`identical`):** Your replacement is identical to the existing content — nothing changed. You **MUST NOT** resubmit the same edit. Re-read the target lines to understand what is actually there, then adjust your edit.
39
38
  </recovery>
40
39
 
41
40
  <example name="single-line replace">
@@ -121,6 +120,7 @@ Range — add `end`:
121
120
  </example>
122
121
 
123
122
  <example name="inclusive end avoids duplicate boundary">
123
+ This example demonstrates why `end` must include the original closing line when your replacement also contains that closer.
124
124
  ```ts
125
125
  {{hlinefull 70 "if (ok) {"}}
126
126
  {{hlinefull 71 " run();"}}
@@ -159,7 +159,6 @@ Good — include original `}` in the replaced range when replacement keeps `}`:
159
159
  }]
160
160
  }
161
161
  ```
162
- Also apply the same rule to `);`, `],`, and `},` closers: if replacement includes the closer token, `end` must include the original closer line.
163
162
  </example>
164
163
 
165
164
  <example name="insert between sibling declarations">
@@ -191,7 +190,7 @@ Use a trailing `""` to preserve the blank line between top-level sibling declara
191
190
  </example>
192
191
 
193
192
  <example name="disambiguate anchors">
194
- Blank lines and repeated patterns (`}`, `return null;`) appear many times never anchor on them when a unique line exists nearby.
193
+ Blank lines and repeated patterns (`}`, `return null;`) appear many times. Always anchor on a unique line nearby instead.
195
194
  ```ts
196
195
  {{hlinefull 101 "}"}}
197
196
  {{hlinefull 102 ""}}
@@ -233,8 +232,8 @@ Good — anchor on the unique declaration line:
233
232
 
234
233
  <critical>
235
234
  - Edit payload: `{ path, edits[] }`. Each entry: `op`, `lines`, optional `pos`/`end`. No extra keys.
236
- - Every tag **MUST** be copied exactly from fresh tool result as `N#ID`.
237
- - You **MUST** re-read after each edit call before issuing another on same file.
238
- - Formatting is a batch operation. You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only change is whitespace, it is formatting; do not touch it.
239
- - `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character) you **MUST NOT** use `\\t` (two characters: backslash + t), which produces the literal string `\t` in the file.
235
+ - Every tag **MUST** be copied exactly from your most recent `read` output as `N#ID`. Stale or mistyped tags cause mismatches.
236
+ - You **MUST** re-read the file after each edit call before issuing another on the same file. Tags shift after every edit, so reusing old tags produces mismatches.
237
+ - You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only difference is whitespace, it is formatting; leave it alone.
238
+ - `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character). Using `\\t` (backslash + t) writes the literal two-character string `\t` into the file.
240
239
  </critical>
@@ -16,6 +16,7 @@ import type { ToolSession } from ".";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
18
18
  import {
19
+ dedupeParseErrors,
19
20
  formatCount,
20
21
  formatEmptyMessage,
21
22
  formatErrorMessage,
@@ -140,6 +141,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
140
141
  signal,
141
142
  });
142
143
 
144
+ const dedupedParseErrors = dedupeParseErrors(result.parseErrors);
143
145
  const formatPath = (filePath: string): string => {
144
146
  const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
145
147
  if (isDirectory) {
@@ -178,15 +180,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
178
180
  filesSearched: result.filesSearched,
179
181
  applied: result.applied,
180
182
  limitReached: result.limitReached,
181
- parseErrors: result.parseErrors,
183
+ parseErrors: dedupedParseErrors,
182
184
  scopePath,
183
185
  files: fileList,
184
186
  fileReplacements: [],
185
187
  };
186
188
 
187
189
  if (result.totalReplacements === 0) {
188
- const parseMessage = result.parseErrors?.length
189
- ? `\n${formatParseErrors(result.parseErrors).join("\n")}`
190
+ const parseMessage = dedupedParseErrors.length
191
+ ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
190
192
  : "";
191
193
  return toolResult(baseDetails).text(`No replacements made${parseMessage}`).done();
192
194
  }
@@ -258,8 +260,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
258
260
  if (result.limitReached) {
259
261
  outputLines.push("", "Limit reached; narrow path or increase limit.");
260
262
  }
261
- if (result.parseErrors?.length) {
262
- outputLines.push("", ...formatParseErrors(result.parseErrors));
263
+ if (dedupedParseErrors.length) {
264
+ outputLines.push("", ...formatParseErrors(dedupedParseErrors));
263
265
  }
264
266
 
265
267
  // Register pending action so `resolve` can apply or discard these previewed changes
@@ -281,13 +283,14 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
281
283
  maxFiles,
282
284
  failOnParseError: false,
283
285
  });
286
+ const dedupedApplyParseErrors = dedupeParseErrors(applyResult.parseErrors);
284
287
  const appliedDetails: AstEditToolDetails = {
285
288
  totalReplacements: applyResult.totalReplacements,
286
289
  filesTouched: applyResult.filesTouched,
287
290
  filesSearched: applyResult.filesSearched,
288
291
  applied: applyResult.applied,
289
292
  limitReached: applyResult.limitReached,
290
- parseErrors: applyResult.parseErrors,
293
+ parseErrors: dedupedApplyParseErrors,
291
294
  scopePath,
292
295
  files: fileList,
293
296
  fileReplacements,
@@ -16,6 +16,7 @@ import type { ToolSession } from ".";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
18
18
  import {
19
+ dedupeParseErrors,
19
20
  formatCount,
20
21
  formatEmptyMessage,
21
22
  formatErrorMessage,
@@ -133,6 +134,11 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
133
134
  signal,
134
135
  });
135
136
 
137
+ const normalizedParseErrors = (result.parseErrors ?? []).map(error => {
138
+ const parseError = error.match(/^.+: (.+: parse error \(syntax tree contains error nodes\))$/);
139
+ return parseError?.[1] ?? error;
140
+ });
141
+ const dedupedParseErrors = dedupeParseErrors(normalizedParseErrors);
136
142
  const formatPath = (filePath: string): string => {
137
143
  const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
138
144
  if (isDirectory) {
@@ -165,15 +171,15 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
165
171
  fileCount: result.filesWithMatches,
166
172
  filesSearched: result.filesSearched,
167
173
  limitReached: result.limitReached,
168
- parseErrors: result.parseErrors,
174
+ parseErrors: dedupedParseErrors,
169
175
  scopePath,
170
176
  files: fileList,
171
177
  fileMatches: [],
172
178
  };
173
179
 
174
180
  if (result.matches.length === 0) {
175
- const parseMessage = result.parseErrors?.length
176
- ? `\n${formatParseErrors(result.parseErrors).join("\n")}`
181
+ const parseMessage = dedupedParseErrors.length
182
+ ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
177
183
  : "";
178
184
  return toolResult(baseDetails).text(`No matches found${parseMessage}`).done();
179
185
  }
@@ -253,8 +259,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
253
259
  if (result.limitReached) {
254
260
  outputLines.push("", "Result limit reached; narrow path pattern or increase limit.");
255
261
  }
256
- if (result.parseErrors?.length) {
257
- outputLines.push("", ...formatParseErrors(result.parseErrors));
262
+ if (dedupedParseErrors.length) {
263
+ outputLines.push("", ...formatParseErrors(dedupedParseErrors));
258
264
  }
259
265
 
260
266
  return toolResult(details).text(outputLines.join("\n")).done();
@@ -533,10 +533,25 @@ export function wrapBrackets(text: string, theme: Theme): string {
533
533
 
534
534
  export const PARSE_ERRORS_LIMIT = 20;
535
535
 
536
+ export function dedupeParseErrors(errors: string[] | undefined): string[] {
537
+ if (!errors || errors.length === 0) return [];
538
+ const seen = new Set<string>();
539
+ const deduped: string[] = [];
540
+ for (const error of errors) {
541
+ if (seen.has(error)) continue;
542
+ seen.add(error);
543
+ deduped.push(error);
544
+ }
545
+ return deduped;
546
+ }
547
+
536
548
  export function formatParseErrors(errors: string[]): string[] {
537
- if (errors.length === 0) return [];
538
- const capped = errors.slice(0, PARSE_ERRORS_LIMIT);
549
+ const deduped = dedupeParseErrors(errors);
550
+ if (deduped.length === 0) return [];
551
+ const capped = deduped.slice(0, PARSE_ERRORS_LIMIT);
539
552
  const header =
540
- errors.length > PARSE_ERRORS_LIMIT ? `Parse issues (${PARSE_ERRORS_LIMIT} / ${errors.length}):` : "Parse issues:";
553
+ deduped.length > PARSE_ERRORS_LIMIT
554
+ ? `Parse issues (${PARSE_ERRORS_LIMIT} / ${deduped.length}):`
555
+ : "Parse issues:";
541
556
  return [header, ...capped.map(err => `- ${err}`)];
542
557
  }