@oh-my-pi/pi-coding-agent 1.337.0

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 (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Shared diff computation utilities for the edit tool.
3
+ * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
4
+ */
5
+
6
+ import * as Diff from "diff";
7
+ import { constants } from "fs";
8
+ import { access, readFile } from "fs/promises";
9
+ import { resolveToCwd } from "./path-utils.js";
10
+
11
+ export function detectLineEnding(content: string): "\r\n" | "\n" {
12
+ const crlfIdx = content.indexOf("\r\n");
13
+ const lfIdx = content.indexOf("\n");
14
+ if (lfIdx === -1) return "\n";
15
+ if (crlfIdx === -1) return "\n";
16
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
17
+ }
18
+
19
+ export function normalizeToLF(text: string): string {
20
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
21
+ }
22
+
23
+ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
24
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
25
+ }
26
+
27
+ /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
28
+ export function stripBom(content: string): { bom: string; text: string } {
29
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
30
+ }
31
+
32
+ export const DEFAULT_FUZZY_THRESHOLD = 0.95;
33
+
34
+ export interface EditMatch {
35
+ actualText: string;
36
+ startIndex: number;
37
+ startLine: number;
38
+ confidence: number;
39
+ }
40
+
41
+ export interface EditMatchOutcome {
42
+ match?: EditMatch;
43
+ closest?: EditMatch;
44
+ occurrences?: number;
45
+ fuzzyMatches?: number;
46
+ }
47
+
48
+ function countLeadingWhitespace(line: string): number {
49
+ let count = 0;
50
+ for (let i = 0; i < line.length; i++) {
51
+ const char = line[i];
52
+ if (char === " " || char === "\t") {
53
+ count++;
54
+ continue;
55
+ }
56
+ break;
57
+ }
58
+ return count;
59
+ }
60
+
61
+ function computeRelativeIndentDepths(lines: string[]): number[] {
62
+ const indents = lines.map(countLeadingWhitespace);
63
+ const nonEmptyIndents: number[] = [];
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (lines[i].trim().length > 0) {
66
+ nonEmptyIndents.push(indents[i]);
67
+ }
68
+ }
69
+ const minIndent = nonEmptyIndents.length > 0 ? Math.min(...nonEmptyIndents) : 0;
70
+ const indentSteps = nonEmptyIndents.map((indent) => indent - minIndent).filter((step) => step > 0);
71
+ const indentUnit = indentSteps.length > 0 ? Math.min(...indentSteps) : 1;
72
+
73
+ return lines.map((line, index) => {
74
+ if (line.trim().length === 0) {
75
+ return 0;
76
+ }
77
+ if (indentUnit <= 0) {
78
+ return 0;
79
+ }
80
+ const relativeIndent = indents[index] - minIndent;
81
+ return Math.round(relativeIndent / indentUnit);
82
+ });
83
+ }
84
+
85
+ function normalizeLinesForMatch(lines: string[]): string[] {
86
+ const indentDepths = computeRelativeIndentDepths(lines);
87
+ return lines.map((line, index) => {
88
+ const trimmed = line.trim();
89
+ if (trimmed.length === 0) {
90
+ return `${indentDepths[index]}|`;
91
+ }
92
+ const collapsed = trimmed.replace(/[ \t]+/g, " ");
93
+ return `${indentDepths[index]}|${collapsed}`;
94
+ });
95
+ }
96
+
97
+ function levenshteinDistance(a: string, b: string): number {
98
+ if (a === b) return 0;
99
+ const aLen = a.length;
100
+ const bLen = b.length;
101
+ if (aLen === 0) return bLen;
102
+ if (bLen === 0) return aLen;
103
+
104
+ let prev = new Array<number>(bLen + 1);
105
+ let curr = new Array<number>(bLen + 1);
106
+ for (let j = 0; j <= bLen; j++) {
107
+ prev[j] = j;
108
+ }
109
+
110
+ for (let i = 1; i <= aLen; i++) {
111
+ curr[0] = i;
112
+ const aCode = a.charCodeAt(i - 1);
113
+ for (let j = 1; j <= bLen; j++) {
114
+ const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1;
115
+ const deletion = prev[j] + 1;
116
+ const insertion = curr[j - 1] + 1;
117
+ const substitution = prev[j - 1] + cost;
118
+ curr[j] = Math.min(deletion, insertion, substitution);
119
+ }
120
+ const tmp = prev;
121
+ prev = curr;
122
+ curr = tmp;
123
+ }
124
+
125
+ return prev[bLen];
126
+ }
127
+
128
+ function similarityScore(a: string, b: string): number {
129
+ if (a.length === 0 && b.length === 0) {
130
+ return 1;
131
+ }
132
+ const maxLen = Math.max(a.length, b.length);
133
+ if (maxLen === 0) {
134
+ return 1;
135
+ }
136
+ const distance = levenshteinDistance(a, b);
137
+ return 1 - distance / maxLen;
138
+ }
139
+
140
+ function computeLineOffsets(lines: string[]): number[] {
141
+ const offsets: number[] = [];
142
+ let offset = 0;
143
+ for (let i = 0; i < lines.length; i++) {
144
+ offsets.push(offset);
145
+ offset += lines[i].length;
146
+ if (i < lines.length - 1) {
147
+ offset += 1;
148
+ }
149
+ }
150
+ return offsets;
151
+ }
152
+
153
+ function findBestFuzzyMatch(
154
+ content: string,
155
+ target: string,
156
+ threshold: number,
157
+ ): { best?: EditMatch; aboveThresholdCount: number } {
158
+ const contentLines = content.split("\n");
159
+ const targetLines = target.split("\n");
160
+ if (targetLines.length === 0 || target.length === 0) {
161
+ return { aboveThresholdCount: 0 };
162
+ }
163
+ if (targetLines.length > contentLines.length) {
164
+ return { aboveThresholdCount: 0 };
165
+ }
166
+
167
+ const targetNormalized = normalizeLinesForMatch(targetLines);
168
+ const offsets = computeLineOffsets(contentLines);
169
+
170
+ let best: EditMatch | undefined;
171
+ let bestScore = -1;
172
+ let aboveThresholdCount = 0;
173
+
174
+ for (let start = 0; start <= contentLines.length - targetLines.length; start++) {
175
+ const windowLines = contentLines.slice(start, start + targetLines.length);
176
+ const windowNormalized = normalizeLinesForMatch(windowLines);
177
+ let score = 0;
178
+ for (let i = 0; i < targetLines.length; i++) {
179
+ score += similarityScore(targetNormalized[i], windowNormalized[i]);
180
+ }
181
+ score = score / targetLines.length;
182
+
183
+ if (score >= threshold) {
184
+ aboveThresholdCount++;
185
+ }
186
+
187
+ if (score > bestScore) {
188
+ bestScore = score;
189
+ best = {
190
+ actualText: windowLines.join("\n"),
191
+ startIndex: offsets[start],
192
+ startLine: start + 1,
193
+ confidence: score,
194
+ };
195
+ }
196
+ }
197
+
198
+ return { best, aboveThresholdCount };
199
+ }
200
+
201
+ export function findEditMatch(
202
+ content: string,
203
+ target: string,
204
+ options: { allowFuzzy: boolean; similarityThreshold?: number },
205
+ ): EditMatchOutcome {
206
+ if (target.length === 0) {
207
+ return {};
208
+ }
209
+
210
+ const exactIndex = content.indexOf(target);
211
+ if (exactIndex !== -1) {
212
+ const occurrences = content.split(target).length - 1;
213
+ if (occurrences > 1) {
214
+ return { occurrences };
215
+ }
216
+ const startLine = content.slice(0, exactIndex).split("\n").length;
217
+ return {
218
+ match: {
219
+ actualText: target,
220
+ startIndex: exactIndex,
221
+ startLine,
222
+ confidence: 1,
223
+ },
224
+ };
225
+ }
226
+
227
+ const threshold = options.similarityThreshold ?? DEFAULT_FUZZY_THRESHOLD;
228
+ const { best, aboveThresholdCount } = findBestFuzzyMatch(content, target, threshold);
229
+ if (!best) {
230
+ return {};
231
+ }
232
+
233
+ if (options.allowFuzzy && best.confidence >= threshold && aboveThresholdCount === 1) {
234
+ return { match: best, closest: best };
235
+ }
236
+
237
+ return { closest: best, fuzzyMatches: aboveThresholdCount };
238
+ }
239
+
240
+ function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } {
241
+ const max = Math.max(oldLines.length, newLines.length);
242
+ for (let i = 0; i < max; i++) {
243
+ const oldLine = oldLines[i] ?? "";
244
+ const newLine = newLines[i] ?? "";
245
+ if (oldLine !== newLine) {
246
+ return { oldLine, newLine };
247
+ }
248
+ }
249
+ return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" };
250
+ }
251
+
252
+ export function formatEditMatchError(
253
+ path: string,
254
+ normalizedOldText: string,
255
+ closest: EditMatch | undefined,
256
+ options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
257
+ ): string {
258
+ if (!closest) {
259
+ return options.allowFuzzy
260
+ ? `Could not find a close enough match in ${path}.`
261
+ : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
262
+ }
263
+
264
+ const similarity = Math.round(closest.confidence * 100);
265
+ const oldLines = normalizedOldText.split("\n");
266
+ const actualLines = closest.actualText.split("\n");
267
+ const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines);
268
+ const thresholdPercent = Math.round(options.similarityThreshold * 100);
269
+
270
+ const hint = options.allowFuzzy
271
+ ? options.fuzzyMatches && options.fuzzyMatches > 1
272
+ ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
273
+ : `Closest match was below the ${thresholdPercent}% similarity threshold.`
274
+ : "Hint: Use fuzzy=true to accept high-confidence matches.";
275
+
276
+ return [
277
+ options.allowFuzzy
278
+ ? `Could not find a close enough match in ${path}.`
279
+ : `Could not find the exact text in ${path}.`,
280
+ ``,
281
+ `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
282
+ ` - ${oldLine}`,
283
+ ` + ${newLine}`,
284
+ hint,
285
+ ].join("\n");
286
+ }
287
+
288
+ /**
289
+ * Generate a unified diff string with line numbers and context.
290
+ * Returns both the diff string and the first changed line number (in the new file).
291
+ */
292
+ export function generateDiffString(
293
+ oldContent: string,
294
+ newContent: string,
295
+ contextLines = 4,
296
+ ): { diff: string; firstChangedLine: number | undefined } {
297
+ const parts = Diff.diffLines(oldContent, newContent);
298
+ const output: string[] = [];
299
+
300
+ const oldLines = oldContent.split("\n");
301
+ const newLines = newContent.split("\n");
302
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
303
+ const lineNumWidth = String(maxLineNum).length;
304
+
305
+ let oldLineNum = 1;
306
+ let newLineNum = 1;
307
+ let lastWasChange = false;
308
+ let firstChangedLine: number | undefined;
309
+
310
+ for (let i = 0; i < parts.length; i++) {
311
+ const part = parts[i];
312
+ const raw = part.value.split("\n");
313
+ if (raw[raw.length - 1] === "") {
314
+ raw.pop();
315
+ }
316
+
317
+ if (part.added || part.removed) {
318
+ // Capture the first changed line (in the new file)
319
+ if (firstChangedLine === undefined) {
320
+ firstChangedLine = newLineNum;
321
+ }
322
+
323
+ // Show the change
324
+ for (const line of raw) {
325
+ if (part.added) {
326
+ const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
327
+ output.push(`+${lineNum} ${line}`);
328
+ newLineNum++;
329
+ } else {
330
+ // removed
331
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
332
+ output.push(`-${lineNum} ${line}`);
333
+ oldLineNum++;
334
+ }
335
+ }
336
+ lastWasChange = true;
337
+ } else {
338
+ // Context lines - only show a few before/after changes
339
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
340
+
341
+ if (lastWasChange || nextPartIsChange) {
342
+ // Show context
343
+ let linesToShow = raw;
344
+ let skipStart = 0;
345
+ let skipEnd = 0;
346
+
347
+ if (!lastWasChange) {
348
+ // Show only last N lines as leading context
349
+ skipStart = Math.max(0, raw.length - contextLines);
350
+ linesToShow = raw.slice(skipStart);
351
+ }
352
+
353
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
354
+ // Show only first N lines as trailing context
355
+ skipEnd = linesToShow.length - contextLines;
356
+ linesToShow = linesToShow.slice(0, contextLines);
357
+ }
358
+
359
+ // Add ellipsis if we skipped lines at start
360
+ if (skipStart > 0) {
361
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
362
+ // Update line numbers for the skipped leading context
363
+ oldLineNum += skipStart;
364
+ newLineNum += skipStart;
365
+ }
366
+
367
+ for (const line of linesToShow) {
368
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
369
+ output.push(` ${lineNum} ${line}`);
370
+ oldLineNum++;
371
+ newLineNum++;
372
+ }
373
+
374
+ // Add ellipsis if we skipped lines at end
375
+ if (skipEnd > 0) {
376
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
377
+ // Update line numbers for the skipped trailing context
378
+ oldLineNum += skipEnd;
379
+ newLineNum += skipEnd;
380
+ }
381
+ } else {
382
+ // Skip these context lines entirely
383
+ oldLineNum += raw.length;
384
+ newLineNum += raw.length;
385
+ }
386
+
387
+ lastWasChange = false;
388
+ }
389
+ }
390
+
391
+ return { diff: output.join("\n"), firstChangedLine };
392
+ }
393
+
394
+ export interface EditDiffResult {
395
+ diff: string;
396
+ firstChangedLine: number | undefined;
397
+ }
398
+
399
+ export interface EditDiffError {
400
+ error: string;
401
+ }
402
+
403
+ /**
404
+ * Compute the diff for an edit operation without applying it.
405
+ * Used for preview rendering in the TUI before the tool executes.
406
+ */
407
+ export async function computeEditDiff(
408
+ path: string,
409
+ oldText: string,
410
+ newText: string,
411
+ cwd: string,
412
+ fuzzy = false,
413
+ ): Promise<EditDiffResult | EditDiffError> {
414
+ const absolutePath = resolveToCwd(path, cwd);
415
+
416
+ try {
417
+ // Check if file exists and is readable
418
+ try {
419
+ await access(absolutePath, constants.R_OK);
420
+ } catch {
421
+ return { error: `File not found: ${path}` };
422
+ }
423
+
424
+ // Read the file
425
+ const rawContent = await readFile(absolutePath, "utf-8");
426
+
427
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText)
428
+ const { text: content } = stripBom(rawContent);
429
+
430
+ const normalizedContent = normalizeToLF(content);
431
+ const normalizedOldText = normalizeToLF(oldText);
432
+ const normalizedNewText = normalizeToLF(newText);
433
+
434
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
435
+ allowFuzzy: fuzzy,
436
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
437
+ });
438
+
439
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
440
+ return {
441
+ error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
442
+ };
443
+ }
444
+
445
+ if (!matchOutcome.match) {
446
+ return {
447
+ error: formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
448
+ allowFuzzy: fuzzy,
449
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
450
+ fuzzyMatches: matchOutcome.fuzzyMatches,
451
+ }),
452
+ };
453
+ }
454
+
455
+ const match = matchOutcome.match;
456
+
457
+ // Compute the new content
458
+ const normalizedNewContent =
459
+ normalizedContent.substring(0, match.startIndex) +
460
+ normalizedNewText +
461
+ normalizedContent.substring(match.startIndex + match.actualText.length);
462
+
463
+ // Check if it would actually change anything
464
+ if (normalizedContent === normalizedNewContent) {
465
+ return {
466
+ error: `No changes would be made to ${path}. The replacement produces identical content.`,
467
+ };
468
+ }
469
+
470
+ // Generate the diff
471
+ return generateDiffString(normalizedContent, normalizedNewContent);
472
+ } catch (err) {
473
+ return { error: err instanceof Error ? err.message : String(err) };
474
+ }
475
+ }
@@ -0,0 +1,208 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { constants } from "fs";
4
+ import { access, readFile, writeFile } from "fs/promises";
5
+ import {
6
+ DEFAULT_FUZZY_THRESHOLD,
7
+ detectLineEnding,
8
+ findEditMatch,
9
+ formatEditMatchError,
10
+ generateDiffString,
11
+ normalizeToLF,
12
+ restoreLineEndings,
13
+ stripBom,
14
+ } from "./edit-diff.js";
15
+ import { resolveToCwd } from "./path-utils.js";
16
+
17
+ const editSchema = Type.Object({
18
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
19
+ oldText: Type.String({
20
+ description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
21
+ }),
22
+ newText: Type.String({ description: "New text to replace the old text with" }),
23
+ });
24
+
25
+ export interface EditToolDetails {
26
+ /** Unified diff of the changes made */
27
+ diff: string;
28
+ /** Line number of the first change in the new file (for editor navigation) */
29
+ firstChangedLine?: number;
30
+ }
31
+
32
+ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
33
+ return {
34
+ name: "edit",
35
+ label: "Edit",
36
+ description: `Performs string replacements in files with fuzzy whitespace matching.
37
+
38
+ Usage:
39
+ - You must use your read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
40
+ - Fuzzy matching handles minor whitespace/indentation differences automatically - you don't need to match indentation exactly.
41
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
42
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
43
+ - The edit will FAIL if old_string is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all to change every instance of old_string.
44
+ - Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
45
+ parameters: editSchema,
46
+ execute: async (
47
+ _toolCallId: string,
48
+ { path, oldText, newText }: { path: string; oldText: string; newText: string },
49
+ signal?: AbortSignal,
50
+ ) => {
51
+ const absolutePath = resolveToCwd(path, cwd);
52
+
53
+ return new Promise<{
54
+ content: Array<{ type: "text"; text: string }>;
55
+ details: EditToolDetails | undefined;
56
+ }>((resolve, reject) => {
57
+ // Check if already aborted
58
+ if (signal?.aborted) {
59
+ reject(new Error("Operation aborted"));
60
+ return;
61
+ }
62
+
63
+ let aborted = false;
64
+
65
+ // Set up abort handler
66
+ const onAbort = () => {
67
+ aborted = true;
68
+ reject(new Error("Operation aborted"));
69
+ };
70
+
71
+ if (signal) {
72
+ signal.addEventListener("abort", onAbort, { once: true });
73
+ }
74
+
75
+ // Perform the edit operation
76
+ (async () => {
77
+ try {
78
+ // Check if file exists
79
+ try {
80
+ await access(absolutePath, constants.R_OK | constants.W_OK);
81
+ } catch {
82
+ if (signal) {
83
+ signal.removeEventListener("abort", onAbort);
84
+ }
85
+ reject(new Error(`File not found: ${path}`));
86
+ return;
87
+ }
88
+
89
+ // Check if aborted before reading
90
+ if (aborted) {
91
+ return;
92
+ }
93
+
94
+ // Read the file
95
+ const rawContent = await readFile(absolutePath, "utf-8");
96
+
97
+ // Check if aborted after reading
98
+ if (aborted) {
99
+ return;
100
+ }
101
+
102
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText)
103
+ const { bom, text: content } = stripBom(rawContent);
104
+
105
+ const originalEnding = detectLineEnding(content);
106
+ const normalizedContent = normalizeToLF(content);
107
+ const normalizedOldText = normalizeToLF(oldText);
108
+ const normalizedNewText = normalizeToLF(newText);
109
+
110
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
111
+ allowFuzzy: true,
112
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
113
+ });
114
+
115
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
116
+ if (signal) {
117
+ signal.removeEventListener("abort", onAbort);
118
+ }
119
+ reject(
120
+ new Error(
121
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
122
+ ),
123
+ );
124
+ return;
125
+ }
126
+
127
+ if (!matchOutcome.match) {
128
+ if (signal) {
129
+ signal.removeEventListener("abort", onAbort);
130
+ }
131
+ reject(
132
+ new Error(
133
+ formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
134
+ allowFuzzy: true,
135
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
136
+ fuzzyMatches: matchOutcome.fuzzyMatches,
137
+ }),
138
+ ),
139
+ );
140
+ return;
141
+ }
142
+
143
+ const match = matchOutcome.match;
144
+
145
+ // Check if aborted before writing
146
+ if (aborted) {
147
+ return;
148
+ }
149
+
150
+ const normalizedNewContent =
151
+ normalizedContent.substring(0, match.startIndex) +
152
+ normalizedNewText +
153
+ normalizedContent.substring(match.startIndex + match.actualText.length);
154
+
155
+ // Verify the replacement actually changed something
156
+ if (normalizedContent === normalizedNewContent) {
157
+ if (signal) {
158
+ signal.removeEventListener("abort", onAbort);
159
+ }
160
+ reject(
161
+ new Error(
162
+ `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
163
+ ),
164
+ );
165
+ return;
166
+ }
167
+
168
+ const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
169
+ await writeFile(absolutePath, finalContent, "utf-8");
170
+
171
+ // Check if aborted after writing
172
+ if (aborted) {
173
+ return;
174
+ }
175
+
176
+ // Clean up abort handler
177
+ if (signal) {
178
+ signal.removeEventListener("abort", onAbort);
179
+ }
180
+
181
+ const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
182
+ resolve({
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: `Successfully replaced text in ${path}.`,
187
+ },
188
+ ],
189
+ details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
190
+ });
191
+ } catch (error: any) {
192
+ // Clean up abort handler
193
+ if (signal) {
194
+ signal.removeEventListener("abort", onAbort);
195
+ }
196
+
197
+ if (!aborted) {
198
+ reject(error);
199
+ }
200
+ }
201
+ })();
202
+ });
203
+ },
204
+ };
205
+ }
206
+
207
+ /** Default edit tool using process.cwd() - for backwards compatibility */
208
+ export const editTool = createEditTool(process.cwd());