@oh-my-pi/pi-coding-agent 12.18.3 → 12.19.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.
Files changed (233) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +35 -27
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/hashline.ts +6 -286
  82. package/src/patch/index.ts +6 -57
  83. package/src/patch/normalize.ts +22 -65
  84. package/src/patch/shared.ts +16 -16
  85. package/src/prompts/system/custom-system-prompt.md +0 -10
  86. package/src/prompts/system/system-prompt.md +69 -89
  87. package/src/prompts/tools/async-result.md +5 -0
  88. package/src/prompts/tools/bash.md +5 -0
  89. package/src/prompts/tools/cancel-job.md +7 -0
  90. package/src/prompts/tools/hashline.md +0 -16
  91. package/src/prompts/tools/poll-jobs.md +7 -0
  92. package/src/prompts/tools/task.md +4 -0
  93. package/src/sdk.ts +70 -6
  94. package/src/session/agent-session.ts +43 -6
  95. package/src/session/agent-storage.ts +69 -278
  96. package/src/session/auth-storage.ts +14 -1430
  97. package/src/session/session-manager.ts +69 -5
  98. package/src/session/session-storage.ts +1 -5
  99. package/src/session/streaming-output.ts +637 -76
  100. package/src/slash-commands/builtin-registry.ts +8 -0
  101. package/src/ssh/connection-manager.ts +4 -12
  102. package/src/ssh/sshfs-mount.ts +3 -7
  103. package/src/ssh/utils.ts +8 -0
  104. package/src/system-prompt.ts +24 -90
  105. package/src/task/executor.ts +11 -1
  106. package/src/task/index.ts +258 -13
  107. package/src/task/parallel.ts +32 -0
  108. package/src/task/render.ts +15 -7
  109. package/src/task/types.ts +5 -0
  110. package/src/tools/ask.ts +4 -7
  111. package/src/tools/bash-interactive.ts +4 -5
  112. package/src/tools/bash.ts +125 -41
  113. package/src/tools/cancel-job.ts +93 -0
  114. package/src/tools/fetch.ts +7 -27
  115. package/src/tools/find.ts +3 -3
  116. package/src/tools/gemini-image.ts +15 -14
  117. package/src/tools/grep.ts +3 -3
  118. package/src/tools/index.ts +13 -29
  119. package/src/tools/json-tree.ts +12 -1
  120. package/src/tools/jtd-to-json-schema.ts +10 -74
  121. package/src/tools/jtd-to-typescript.ts +10 -72
  122. package/src/tools/jtd-utils.ts +102 -0
  123. package/src/tools/notebook.ts +4 -9
  124. package/src/tools/output-meta.ts +52 -26
  125. package/src/tools/path-utils.ts +13 -7
  126. package/src/tools/poll-jobs.ts +178 -0
  127. package/src/tools/python.ts +32 -35
  128. package/src/tools/read.ts +61 -82
  129. package/src/tools/render-utils.ts +8 -159
  130. package/src/tools/ssh.ts +7 -20
  131. package/src/tools/submit-result.ts +1 -1
  132. package/src/tools/tool-errors.ts +0 -30
  133. package/src/tools/tool-result.ts +1 -2
  134. package/src/tools/write.ts +8 -10
  135. package/src/tui/code-cell.ts +8 -3
  136. package/src/tui/status-line.ts +4 -4
  137. package/src/tui/types.ts +0 -1
  138. package/src/tui/utils.ts +1 -14
  139. package/src/utils/command-args.ts +76 -0
  140. package/src/utils/file-mentions.ts +15 -19
  141. package/src/utils/frontmatter.ts +5 -10
  142. package/src/utils/shell-snapshot.ts +0 -11
  143. package/src/utils/title-generator.ts +0 -12
  144. package/src/web/scrapers/artifacthub.ts +7 -16
  145. package/src/web/scrapers/arxiv.ts +3 -8
  146. package/src/web/scrapers/aur.ts +8 -22
  147. package/src/web/scrapers/biorxiv.ts +5 -14
  148. package/src/web/scrapers/bluesky.ts +13 -36
  149. package/src/web/scrapers/brew.ts +5 -10
  150. package/src/web/scrapers/cheatsh.ts +2 -12
  151. package/src/web/scrapers/chocolatey.ts +63 -26
  152. package/src/web/scrapers/choosealicense.ts +3 -18
  153. package/src/web/scrapers/cisa-kev.ts +4 -18
  154. package/src/web/scrapers/clojars.ts +6 -33
  155. package/src/web/scrapers/coingecko.ts +25 -33
  156. package/src/web/scrapers/crates-io.ts +7 -26
  157. package/src/web/scrapers/crossref.ts +4 -18
  158. package/src/web/scrapers/devto.ts +11 -41
  159. package/src/web/scrapers/discogs.ts +7 -10
  160. package/src/web/scrapers/discourse.ts +6 -31
  161. package/src/web/scrapers/dockerhub.ts +12 -35
  162. package/src/web/scrapers/fdroid.ts +8 -33
  163. package/src/web/scrapers/firefox-addons.ts +10 -34
  164. package/src/web/scrapers/flathub.ts +7 -24
  165. package/src/web/scrapers/github-gist.ts +2 -12
  166. package/src/web/scrapers/github.ts +9 -47
  167. package/src/web/scrapers/gitlab.ts +130 -185
  168. package/src/web/scrapers/go-pkg.ts +12 -22
  169. package/src/web/scrapers/hackage.ts +88 -43
  170. package/src/web/scrapers/hackernews.ts +25 -45
  171. package/src/web/scrapers/hex.ts +19 -36
  172. package/src/web/scrapers/huggingface.ts +26 -91
  173. package/src/web/scrapers/iacr.ts +3 -8
  174. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  175. package/src/web/scrapers/lemmy.ts +5 -23
  176. package/src/web/scrapers/lobsters.ts +16 -28
  177. package/src/web/scrapers/mastodon.ts +24 -43
  178. package/src/web/scrapers/maven.ts +6 -21
  179. package/src/web/scrapers/mdn.ts +7 -11
  180. package/src/web/scrapers/metacpan.ts +9 -41
  181. package/src/web/scrapers/musicbrainz.ts +4 -28
  182. package/src/web/scrapers/npm.ts +8 -25
  183. package/src/web/scrapers/nuget.ts +14 -37
  184. package/src/web/scrapers/nvd.ts +6 -28
  185. package/src/web/scrapers/ollama.ts +7 -34
  186. package/src/web/scrapers/open-vsx.ts +5 -19
  187. package/src/web/scrapers/opencorporates.ts +30 -14
  188. package/src/web/scrapers/openlibrary.ts +49 -33
  189. package/src/web/scrapers/orcid.ts +4 -18
  190. package/src/web/scrapers/osv.ts +7 -24
  191. package/src/web/scrapers/packagist.ts +9 -24
  192. package/src/web/scrapers/pub-dev.ts +7 -50
  193. package/src/web/scrapers/pubmed.ts +54 -21
  194. package/src/web/scrapers/pypi.ts +8 -26
  195. package/src/web/scrapers/rawg.ts +11 -19
  196. package/src/web/scrapers/readthedocs.ts +4 -9
  197. package/src/web/scrapers/reddit.ts +5 -15
  198. package/src/web/scrapers/repology.ts +8 -20
  199. package/src/web/scrapers/rfc.ts +5 -14
  200. package/src/web/scrapers/rubygems.ts +6 -21
  201. package/src/web/scrapers/searchcode.ts +8 -36
  202. package/src/web/scrapers/sec-edgar.ts +4 -18
  203. package/src/web/scrapers/semantic-scholar.ts +15 -35
  204. package/src/web/scrapers/snapcraft.ts +5 -19
  205. package/src/web/scrapers/sourcegraph.ts +5 -43
  206. package/src/web/scrapers/spdx.ts +4 -18
  207. package/src/web/scrapers/spotify.ts +4 -23
  208. package/src/web/scrapers/stackoverflow.ts +8 -13
  209. package/src/web/scrapers/terraform.ts +9 -37
  210. package/src/web/scrapers/tldr.ts +3 -7
  211. package/src/web/scrapers/twitter.ts +3 -7
  212. package/src/web/scrapers/types.ts +105 -27
  213. package/src/web/scrapers/utils.ts +97 -103
  214. package/src/web/scrapers/vimeo.ts +7 -27
  215. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  216. package/src/web/scrapers/w3c.ts +6 -14
  217. package/src/web/scrapers/wikidata.ts +5 -19
  218. package/src/web/scrapers/wikipedia.ts +2 -12
  219. package/src/web/scrapers/youtube.ts +5 -34
  220. package/src/web/search/index.ts +0 -9
  221. package/src/web/search/providers/anthropic.ts +3 -2
  222. package/src/web/search/providers/brave.ts +3 -18
  223. package/src/web/search/providers/exa.ts +1 -12
  224. package/src/web/search/providers/kimi.ts +5 -44
  225. package/src/web/search/providers/perplexity.ts +1 -12
  226. package/src/web/search/providers/synthetic.ts +3 -26
  227. package/src/web/search/providers/utils.ts +36 -0
  228. package/src/web/search/providers/zai.ts +9 -50
  229. package/src/web/search/types.ts +0 -28
  230. package/src/web/search/utils.ts +17 -0
  231. package/src/tools/output-utils.ts +0 -63
  232. package/src/tools/truncate.ts +0 -385
  233. package/src/web/search/auth.ts +0 -178
@@ -17,7 +17,7 @@ import { theme } from "../../modes/theme/theme";
17
17
  import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
18
18
  import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
19
19
  import type { SessionContext } from "../../session/session-manager";
20
- import { formatSize } from "../../tools/truncate";
20
+ import { formatBytes, formatDuration } from "../../tools/render-utils";
21
21
 
22
22
  type TextBlock = { type: "text"; text: string };
23
23
 
@@ -97,6 +97,30 @@ export class UiHelpers {
97
97
  case "hookMessage":
98
98
  case "custom": {
99
99
  if (message.display) {
100
+ if (message.customType === "async-result") {
101
+ const details = (
102
+ message as CustomMessage<{
103
+ jobId?: string;
104
+ type?: "bash" | "task";
105
+ label?: string;
106
+ durationMs?: number;
107
+ }>
108
+ ).details;
109
+ const jobId = details?.jobId ?? "unknown";
110
+ const typeLabel = details?.type ? `[${details.type}]` : "[job]";
111
+ const duration =
112
+ typeof details?.durationMs === "number" ? formatDuration(details.durationMs) : undefined;
113
+ const line = [
114
+ theme.fg("success", `${theme.status.success} Background job completed`),
115
+ theme.fg("dim", typeLabel),
116
+ theme.fg("accent", jobId),
117
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
118
+ ]
119
+ .filter(Boolean)
120
+ .join(" ");
121
+ this.ctx.chatContainer.addChild(new Text(line, 1, 0));
122
+ break;
123
+ }
100
124
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
101
125
  const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
102
126
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -130,7 +154,7 @@ export class UiHelpers {
130
154
  for (const file of message.files) {
131
155
  let suffix: string;
132
156
  if (file.skippedReason === "tooLarge") {
133
- const size = typeof file.byteSize === "number" ? formatSize(file.byteSize) : "unknown size";
157
+ const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
134
158
  suffix = `(skipped: ${size})`;
135
159
  } else {
136
160
  suffix = file.image
@@ -21,159 +21,6 @@ export type HashlineEdit =
21
21
  | { op: "append"; after?: LineTag; content: string[] }
22
22
  | { op: "prepend"; before?: LineTag; content: string[] }
23
23
  | { op: "insert"; after: LineTag; before: LineTag; content: string[] };
24
- export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
25
- export type EditSpec = HashlineEdit | ReplaceTextEdit;
26
-
27
- /**
28
- * Compare two strings ignoring all whitespace differences.
29
- *
30
- * Returns true when the non-whitespace characters are identical — meaning
31
- * the only differences are in spaces, tabs, or other whitespace.
32
- */
33
- function equalsIgnoringWhitespace(a: string, b: string): boolean {
34
- // Fast path: identical strings
35
- if (a === b) return true;
36
- // Compare with all whitespace removed
37
- return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
38
- }
39
-
40
- function stripAllWhitespace(s: string): string {
41
- return s.replace(/\s+/g, "");
42
- }
43
-
44
- function stripTrailingContinuationTokens(s: string): string {
45
- // Heuristic: models often merge a continuation line into the prior line
46
- // while also changing the trailing operator (e.g. `&&` → `||`).
47
- // Strip common trailing continuation tokens so we can still detect merges.
48
- return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "");
49
- }
50
-
51
- function stripMergeOperatorChars(s: string): string {
52
- // Used for merge detection when the model changes a logical operator like
53
- // `||` → `??` while also merging adjacent lines.
54
- return s.replace(/[|&?]/g, "");
55
- }
56
-
57
- function leadingWhitespace(s: string): string {
58
- const match = s.match(/^\s*/);
59
- return match ? match[0] : "";
60
- }
61
-
62
- function restoreLeadingIndent(templateLine: string, line: string): string {
63
- if (line.length === 0) return line;
64
- const templateIndent = leadingWhitespace(templateLine);
65
- if (templateIndent.length === 0) return line;
66
- const indent = leadingWhitespace(line);
67
- if (indent.length > 0) return line;
68
- return templateIndent + line;
69
- }
70
-
71
- function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
72
- if (oldLines.length !== newLines.length) return newLines;
73
- let changed = false;
74
- const out = new Array<string>(newLines.length);
75
- for (let i = 0; i < newLines.length; i++) {
76
- const restored = restoreLeadingIndent(oldLines[i], newLines[i]);
77
- out[i] = restored;
78
- if (restored !== newLines[i]) changed = true;
79
- }
80
- return changed ? out : newLines;
81
- }
82
-
83
- /**
84
- * Undo pure formatting rewrites where the model reflows a single logical line
85
- * into multiple lines (or similar), but the token stream is identical.
86
- */
87
- function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] {
88
- if (oldLines.length === 0 || newLines.length < 2) return newLines;
89
-
90
- const canonToOld = new Map<string, { line: string; count: number }>();
91
- for (const line of oldLines) {
92
- const canon = stripAllWhitespace(line);
93
- const bucket = canonToOld.get(canon);
94
- if (bucket) bucket.count++;
95
- else canonToOld.set(canon, { line, count: 1 });
96
- }
97
-
98
- const candidates: { start: number; len: number; replacement: string; canon: string }[] = [];
99
- for (let start = 0; start < newLines.length; start++) {
100
- for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
101
- const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""));
102
- const old = canonToOld.get(canonSpan);
103
- if (old && old.count === 1 && canonSpan.length >= 6) {
104
- candidates.push({ start, len, replacement: old.line, canon: canonSpan });
105
- }
106
- }
107
- }
108
- if (candidates.length === 0) return newLines;
109
-
110
- // Keep only spans whose canonical match is unique in the new output.
111
- const canonCounts = new Map<string, number>();
112
- for (const c of candidates) {
113
- canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1);
114
- }
115
- const uniqueCandidates = candidates.filter(c => (canonCounts.get(c.canon) ?? 0) === 1);
116
- if (uniqueCandidates.length === 0) return newLines;
117
-
118
- // Apply replacements back-to-front so indices remain stable.
119
- uniqueCandidates.sort((a, b) => b.start - a.start);
120
- const out = [...newLines];
121
- for (const c of uniqueCandidates) {
122
- out.splice(c.start, c.len, c.replacement);
123
- }
124
- return out;
125
- }
126
-
127
- function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] {
128
- if (dstLines.length <= 1) return dstLines;
129
- if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) {
130
- return dstLines.slice(1);
131
- }
132
- return dstLines;
133
- }
134
-
135
- function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
136
- if (dstLines.length <= 1) return dstLines;
137
- if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
138
- return dstLines.slice(0, -1);
139
- }
140
- return dstLines;
141
- }
142
-
143
- function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
144
- let out = dstLines;
145
- if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
146
- out = out.slice(1);
147
- }
148
- if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
149
- out = out.slice(0, -1);
150
- }
151
- return out;
152
- }
153
-
154
- function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
155
- // Only strip when the model replaced with multiple lines and grew the edit.
156
- // This avoids turning a single-line replacement into a deletion.
157
- const count = endLine - startLine + 1;
158
- if (dstLines.length <= 1 || dstLines.length <= count) return dstLines;
159
-
160
- let out = dstLines;
161
- const beforeIdx = startLine - 2;
162
- if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
163
- out = out.slice(1);
164
- }
165
-
166
- const afterIdx = endLine;
167
- if (
168
- afterIdx < fileLines.length &&
169
- out.length > 0 &&
170
- equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
171
- ) {
172
- out = out.slice(0, -1);
173
- }
174
-
175
- return out;
176
- }
177
24
 
178
25
  const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
179
26
 
@@ -594,38 +441,6 @@ export function applyHashlineEdits(
594
441
  let firstChangedLine: number | undefined;
595
442
  const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
596
443
 
597
- const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
598
-
599
- function collectExplicitlyTouchedLines(): Set<number> {
600
- const touched = new Set<number>();
601
- for (const edit of edits) {
602
- switch (edit.op) {
603
- case "set":
604
- touched.add(edit.tag.line);
605
- break;
606
- case "replace":
607
- for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
608
- break;
609
- case "append":
610
- if (edit.after) {
611
- touched.add(edit.after.line);
612
- }
613
- break;
614
- case "prepend":
615
- if (edit.before) {
616
- touched.add(edit.before.line);
617
- }
618
- break;
619
- case "insert":
620
- touched.add(edit.after.line);
621
- touched.add(edit.before.line);
622
- break;
623
- }
624
- }
625
- return touched;
626
- }
627
-
628
- const explicitlyTouchedLines = collectExplicitlyTouchedLines();
629
444
  // Pre-validate: collect all hash mismatches before mutating
630
445
  const mismatches: HashMismatch[] = [];
631
446
  function validateRef(ref: { line: number; hash: string }): boolean {
@@ -765,35 +580,8 @@ export function applyHashlineEdits(
765
580
  for (const { edit, idx } of annotated) {
766
581
  switch (edit.op) {
767
582
  case "set": {
768
- const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
769
- if (merged) {
770
- const origLines = originalFileLines.slice(
771
- merged.startLine - 1,
772
- merged.startLine - 1 + merged.deleteCount,
773
- );
774
- let nextLines = merged.newLines;
775
- nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
776
-
777
- if (origLines.every((line, i) => line === nextLines[i])) {
778
- noopEdits.push({
779
- editIndex: idx,
780
- loc: `${edit.tag.line}#${edit.tag.hash}`,
781
- currentContent: origLines.join("\n"),
782
- });
783
- break;
784
- }
785
- fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
786
- trackFirstChanged(merged.startLine);
787
- break;
788
- }
789
-
790
- const count = 1;
791
583
  const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
792
- let stripped = autocorrect
793
- ? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
794
- : edit.content;
795
- stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
796
- const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
584
+ const newLines = edit.content;
797
585
  if (origLines.every((line, i) => line === newLines[i])) {
798
586
  noopEdits.push({
799
587
  editIndex: idx,
@@ -802,36 +590,19 @@ export function applyHashlineEdits(
802
590
  });
803
591
  break;
804
592
  }
805
- fileLines.splice(edit.tag.line - 1, count, ...newLines);
593
+ fileLines.splice(edit.tag.line - 1, 1, ...newLines);
806
594
  trackFirstChanged(edit.tag.line);
807
595
  break;
808
596
  }
809
597
  case "replace": {
810
598
  const count = edit.last.line - edit.first.line + 1;
811
- const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
812
- let stripped = autocorrect
813
- ? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
814
- : edit.content;
815
- stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
816
- const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
817
- if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
818
- noopEdits.push({
819
- editIndex: idx,
820
- loc: `${edit.first.line}#${edit.first.hash}`,
821
- currentContent: origLines.join("\n"),
822
- });
823
- break;
824
- }
599
+ const newLines = edit.content;
825
600
  fileLines.splice(edit.first.line - 1, count, ...newLines);
826
601
  trackFirstChanged(edit.first.line);
827
602
  break;
828
603
  }
829
604
  case "append": {
830
- const inserted = edit.after
831
- ? autocorrect
832
- ? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
833
- : edit.content
834
- : edit.content;
605
+ const inserted = edit.content;
835
606
  if (inserted.length === 0) {
836
607
  noopEdits.push({
837
608
  editIndex: idx,
@@ -855,11 +626,7 @@ export function applyHashlineEdits(
855
626
  break;
856
627
  }
857
628
  case "prepend": {
858
- const inserted = edit.before
859
- ? autocorrect
860
- ? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
861
- : edit.content
862
- : edit.content;
629
+ const inserted = edit.content;
863
630
  if (inserted.length === 0) {
864
631
  noopEdits.push({
865
632
  editIndex: idx,
@@ -884,7 +651,7 @@ export function applyHashlineEdits(
884
651
  case "insert": {
885
652
  const afterLine = originalFileLines[edit.after.line - 1];
886
653
  const beforeLine = originalFileLines[edit.before.line - 1];
887
- const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
654
+ const inserted = edit.content;
888
655
  if (inserted.length === 0) {
889
656
  noopEdits.push({
890
657
  editIndex: idx,
@@ -911,51 +678,4 @@ export function applyHashlineEdits(
911
678
  firstChangedLine = line;
912
679
  }
913
680
  }
914
-
915
- function maybeExpandSingleLineMerge(
916
- line: number,
917
- content: string[],
918
- ): { startLine: number; deleteCount: number; newLines: string[] } | null {
919
- if (content.length !== 1) return null;
920
- if (line < 1 || line > fileLines.length) return null;
921
-
922
- const newLine = content[0];
923
- const newCanon = stripAllWhitespace(newLine);
924
- const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
925
- if (newCanon.length === 0) return null;
926
-
927
- const orig = fileLines[line - 1];
928
- const origCanon = stripAllWhitespace(orig);
929
- const origCanonForMatch = stripTrailingContinuationTokens(origCanon);
930
- const origCanonForMergeOps = stripMergeOperatorChars(origCanon);
931
- const origLooksLikeContinuation = origCanonForMatch.length < origCanon.length;
932
- if (origCanon.length === 0) return null;
933
- const nextIdx = line;
934
- const prevIdx = line - 2;
935
- // Case A: dst absorbed the next continuation line.
936
- if (origLooksLikeContinuation && nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) {
937
- const next = fileLines[nextIdx];
938
- const nextCanon = stripAllWhitespace(next);
939
- const a = newCanon.indexOf(origCanonForMatch);
940
- const b = newCanon.indexOf(nextCanon);
941
- if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) {
942
- return { startLine: line, deleteCount: 2, newLines: [newLine] };
943
- }
944
- }
945
- // Case B: dst absorbed the previous declaration/continuation line.
946
- if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) {
947
- const prev = fileLines[prevIdx];
948
- const prevCanon = stripAllWhitespace(prev);
949
- const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon);
950
- const prevLooksLikeContinuation = prevCanonForMatch.length < prevCanon.length;
951
- if (!prevLooksLikeContinuation) return null;
952
- const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch));
953
- const b = newCanonForMergeOps.indexOf(origCanonForMergeOps);
954
- if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) {
955
- return { startLine: line - 1, deleteCount: 2, newLines: [newLine] };
956
- }
957
- }
958
-
959
- return null;
960
- }
961
681
  }
@@ -34,14 +34,7 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
34
34
  import { applyPatch } from "./applicator";
35
35
  import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
36
36
  import { findMatch } from "./fuzzy";
37
- import {
38
- applyHashlineEdits,
39
- computeLineHash,
40
- type HashlineEdit,
41
- type LineTag,
42
- parseTag,
43
- type ReplaceTextEdit,
44
- } from "./hashline";
37
+ import { applyHashlineEdits, computeLineHash, type HashlineEdit, type LineTag, parseTag } from "./hashline";
45
38
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
46
39
  import { buildNormativeUpdateInput } from "./normative";
47
40
  import { type EditToolDetails, getLspBatchRequest } from "./shared";
@@ -139,8 +132,8 @@ export type PatchParams = Static<typeof patchEditSchema>;
139
132
  /** Pattern matching hashline display format: `LINE#ID:CONTENT` */
140
133
  const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/;
141
134
 
142
- /** Pattern matching a unified-diff `+` prefix (but not `++`) */
143
- const DIFF_PLUS_RE = /^[+-](?![+-])/;
135
+ /** Pattern matching a unified-diff added-line `+` prefix (but not `++`). Does NOT match `-` to avoid corrupting Markdown list items. */
136
+ const DIFF_PLUS_RE = /^[+](?![+])/;
144
137
 
145
138
  /**
146
139
  * Strip hashline display prefixes and diff `+` markers from replacement lines.
@@ -149,7 +142,7 @@ const DIFF_PLUS_RE = /^[+-](?![+-])/;
149
142
  * replacement content, or include unified-diff `+` prefixes. Both corrupt the
150
143
  * output file. This strips them heuristically before application.
151
144
  */
152
- function stripNewLinePrefixes(lines: string[]): string[] {
145
+ export function stripNewLinePrefixes(lines: string[]): string[] {
153
146
  // Detect whether the *majority* of non-empty lines carry a prefix —
154
147
  // if only one line out of many has a match it's likely real content.
155
148
  let hashPrefixCount = 0;
@@ -193,7 +186,7 @@ const hashlineTagFormat = (what: string) =>
193
186
  description: `Tag identifying the ${what} in "LINE#ID" format`,
194
187
  });
195
188
 
196
- function hashlineParseContent(edit: string | string[] | null): string[] {
189
+ export function hashlineParseContent(edit: string | string[] | null): string[] {
197
190
  if (edit === null) return [];
198
191
  if (Array.isArray(edit)) return edit;
199
192
  const lines = stripNewLinePrefixes(edit.split("\n"));
@@ -201,13 +194,6 @@ function hashlineParseContent(edit: string | string[] | null): string[] {
201
194
  if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
202
195
  return lines;
203
196
  }
204
-
205
- function hashlineParseContentString(edit: string | string[] | null): string {
206
- if (edit === null) return "";
207
- if (Array.isArray(edit)) return edit.join("\n");
208
- return edit;
209
- }
210
-
211
197
  const hashlineTargetEditSchema = Type.Object(
212
198
  {
213
199
  op: Type.Literal("set"),
@@ -255,25 +241,12 @@ const hashlineInsertEditSchema = Type.Object(
255
241
  { additionalProperties: false },
256
242
  );
257
243
 
258
- const hashlineReplaceTextEditSchema = Type.Object(
259
- {
260
- op: Type.Literal("replaceText"),
261
- old_text: Type.String({ description: "Text to find", minLength: 1 }),
262
- new_text: hashlineReplaceContentFormat("Replacement"),
263
- all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
264
- },
265
- { additionalProperties: false },
266
- );
267
-
268
- const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
269
-
270
244
  const hashlineEditSpecSchema = Type.Union([
271
245
  hashlineTargetEditSchema,
272
246
  hashlineRangeEditSchema,
273
247
  hashlineAppendEditSchema,
274
248
  hashlinePrependEditSchema,
275
249
  hashlineInsertEditSchema,
276
- ...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
277
250
  ]);
278
251
 
279
252
  const hashlineEditSchema = Type.Object(
@@ -486,7 +459,7 @@ export class EditTool implements AgentTool<TInput> {
486
459
  case "patch":
487
460
  return renderPromptTemplate(patchDescription);
488
461
  case "hashline":
489
- return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
462
+ return renderPromptTemplate(hashlineDescription);
490
463
  default:
491
464
  return renderPromptTemplate(replaceDescription);
492
465
  }
@@ -581,7 +554,6 @@ export class EditTool implements AgentTool<TInput> {
581
554
  }
582
555
 
583
556
  const anchorEdits: HashlineEdit[] = [];
584
- const replaceEdits: ReplaceTextEdit[] = [];
585
557
  for (const edit of edits) {
586
558
  switch (edit.op) {
587
559
  case "set": {
@@ -643,16 +615,6 @@ export class EditTool implements AgentTool<TInput> {
643
615
  }
644
616
  break;
645
617
  }
646
- case "replaceText": {
647
- const { old_text, new_text, all } = edit;
648
- replaceEdits.push({
649
- op: "replaceText",
650
- old_text: old_text,
651
- new_text: hashlineParseContentString(new_text),
652
- all: all ?? false,
653
- });
654
- break;
655
- }
656
618
  default:
657
619
  throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
658
620
  }
@@ -668,19 +630,6 @@ export class EditTool implements AgentTool<TInput> {
668
630
  const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
669
631
  normalizedContent = anchorResult.content;
670
632
 
671
- // Apply content-replace edits (substr-style fuzzy replace)
672
- for (const r of replaceEdits) {
673
- if (r.old_text.length === 0) {
674
- throw new Error("old_text must not be empty.");
675
- }
676
- const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
677
- fuzzy: this.#allowFuzzy,
678
- all: r.all ?? false,
679
- threshold: this.#fuzzyThreshold,
680
- });
681
- normalizedContent = rep.content;
682
- }
683
-
684
633
  const result = {
685
634
  content: normalizedContent,
686
635
  firstChangedLine: anchorResult.firstChangedLine,
@@ -206,72 +206,29 @@ export function convertLeadingTabsToSpaces(text: string, spacesPerTab: number):
206
206
  // Unicode Normalization
207
207
  // ═══════════════════════════════════════════════════════════════════════════
208
208
 
209
- /**
210
- * Normalize common Unicode punctuation to ASCII equivalents.
211
- * Allows diffs with ASCII characters to match source files with typographic punctuation.
212
- */
213
- export function normalizeUnicode(s: string): string {
214
- return s
215
- .trim()
216
- .split("")
217
- .map(c => {
218
- const code = c.charCodeAt(0);
219
-
220
- // Various dash/hyphen code-pointsASCII '-'
221
- if (
222
- code === 0x2010 || // HYPHEN
223
- code === 0x2011 || // NON-BREAKING HYPHEN
224
- code === 0x2012 || // FIGURE DASH
225
- code === 0x2013 || // EN DASH
226
- code === 0x2014 || // EM DASH
227
- code === 0x2015 || // HORIZONTAL BAR
228
- code === 0x2212 // MINUS SIGN
229
- ) {
230
- return "-";
231
- }
209
+ const UNICODE_REPLACEMENTS: [RegExp, string][] = [
210
+ // Various dash/hyphen code-points ASCII '-'
211
+ [/[\u2010-\u2015\u2212]/g, "-"],
212
+ // Fancy single quotes → '
213
+ [/[\u2018-\u201B]/g, "'"],
214
+ // Fancy double quotes → "
215
+ [/[\u201C-\u201F]/g, '"'],
216
+ // Non-breaking space and other odd spaces → normal space
217
+ [/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "],
218
+ // Not-equal sign → !=
219
+ [/\u2260/g, "!="],
220
+ // Vulgar fraction ½1/2
221
+ [/\u00BD/g, "1/2"],
222
+ // Zero-width characters remove
223
+ [/[\u200B-\u200D\uFEFF]/g, ""],
224
+ ];
232
225
 
233
- // Fancy single quotes '
234
- if (
235
- code === 0x2018 || // LEFT SINGLE QUOTATION MARK
236
- code === 0x2019 || // RIGHT SINGLE QUOTATION MARK
237
- code === 0x201a || // SINGLE LOW-9 QUOTATION MARK
238
- code === 0x201b // SINGLE HIGH-REVERSED-9 QUOTATION MARK
239
- ) {
240
- return "'";
241
- }
242
-
243
- // Fancy double quotes → "
244
- if (
245
- code === 0x201c || // LEFT DOUBLE QUOTATION MARK
246
- code === 0x201d || // RIGHT DOUBLE QUOTATION MARK
247
- code === 0x201e || // DOUBLE LOW-9 QUOTATION MARK
248
- code === 0x201f // DOUBLE HIGH-REVERSED-9 QUOTATION MARK
249
- ) {
250
- return '"';
251
- }
252
-
253
- // Non-breaking space and other odd spaces → normal space
254
- if (
255
- code === 0x00a0 || // NO-BREAK SPACE
256
- code === 0x2002 || // EN SPACE
257
- code === 0x2003 || // EM SPACE
258
- code === 0x2004 || // THREE-PER-EM SPACE
259
- code === 0x2005 || // FOUR-PER-EM SPACE
260
- code === 0x2006 || // SIX-PER-EM SPACE
261
- code === 0x2007 || // FIGURE SPACE
262
- code === 0x2008 || // PUNCTUATION SPACE
263
- code === 0x2009 || // THIN SPACE
264
- code === 0x200a || // HAIR SPACE
265
- code === 0x202f || // NARROW NO-BREAK SPACE
266
- code === 0x205f || // MEDIUM MATHEMATICAL SPACE
267
- code === 0x3000 // IDEOGRAPHIC SPACE
268
- ) {
269
- return " ";
270
- }
271
-
272
- return c;
273
- })
274
- .join("");
226
+ export function normalizeUnicode(s: string): string {
227
+ let result = s.trim();
228
+ for (const [pattern, replacement] of UNICODE_REPLACEMENTS) {
229
+ result = result.replace(pattern, replacement);
230
+ }
231
+ return result.normalize("NFC");
275
232
  }
276
233
 
277
234
  /**