@oh-my-pi/pi-coding-agent 3.6.1337 → 3.9.1337

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.
@@ -1,18 +1,16 @@
1
- import { constants } from "node:fs";
2
- import { access, readFile, writeFile } from "node:fs/promises";
3
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
2
  import { Type } from "@sinclair/typebox";
5
3
  import {
6
4
  DEFAULT_FUZZY_THRESHOLD,
7
5
  detectLineEnding,
6
+ EditMatchError,
8
7
  findEditMatch,
9
- formatEditMatchError,
10
8
  generateDiffString,
11
9
  normalizeToLF,
12
10
  restoreLineEndings,
13
11
  stripBom,
14
12
  } from "./edit-diff";
15
- import type { FileDiagnosticsResult } from "./lsp/index";
13
+ import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
16
14
  import { resolveToCwd } from "./path-utils";
17
15
 
18
16
  const editSchema = Type.Object({
@@ -28,8 +26,6 @@ export interface EditToolDetails {
28
26
  diff: string;
29
27
  /** Line number of the first change in the new file (for editor navigation) */
30
28
  firstChangedLine?: number;
31
- /** Whether LSP diagnostics were retrieved */
32
- hasDiagnostics?: boolean;
33
29
  /** Diagnostic result (if available) */
34
30
  diagnostics?: FileDiagnosticsResult;
35
31
  }
@@ -37,12 +33,13 @@ export interface EditToolDetails {
37
33
  export interface EditToolOptions {
38
34
  /** Whether to accept high-confidence fuzzy matches for whitespace/indentation (default: true) */
39
35
  fuzzyMatch?: boolean;
40
- /** Callback to get LSP diagnostics after editing a file */
41
- getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
36
+ /** Writethrough callback to get LSP diagnostics after editing a file */
37
+ writethrough?: WritethroughCallback;
42
38
  }
43
39
 
44
40
  export function createEditTool(cwd: string, options: EditToolOptions = {}): AgentTool<typeof editSchema> {
45
41
  const allowFuzzy = options.fuzzyMatch ?? true;
42
+ const writethrough = options.writethrough ?? writethroughNoop;
46
43
  return {
47
44
  name: "edit",
48
45
  label: "Edit",
@@ -61,196 +58,87 @@ Usage:
61
58
  { path, oldText, newText }: { path: string; oldText: string; newText: string },
62
59
  signal?: AbortSignal,
63
60
  ) => {
64
- const absolutePath = resolveToCwd(path, cwd);
65
-
66
61
  // Reject .ipynb files - use NotebookEdit tool instead
67
- if (absolutePath.endsWith(".ipynb")) {
68
- return {
69
- content: [
70
- {
71
- type: "text",
72
- text: "Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.",
73
- },
74
- ],
75
- details: undefined,
76
- };
62
+ if (path.endsWith(".ipynb")) {
63
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
77
64
  }
78
65
 
79
- return new Promise<{
80
- content: Array<{ type: "text"; text: string }>;
81
- details: EditToolDetails | undefined;
82
- }>((resolve, reject) => {
83
- // Check if already aborted
84
- if (signal?.aborted) {
85
- reject(new Error("Operation aborted"));
86
- return;
87
- }
88
-
89
- let aborted = false;
90
-
91
- // Set up abort handler
92
- const onAbort = () => {
93
- aborted = true;
94
- reject(new Error("Operation aborted"));
95
- };
96
-
97
- if (signal) {
98
- signal.addEventListener("abort", onAbort, { once: true });
99
- }
100
-
101
- // Perform the edit operation
102
- (async () => {
103
- try {
104
- // Check if file exists
105
- try {
106
- await access(absolutePath, constants.R_OK | constants.W_OK);
107
- } catch {
108
- if (signal) {
109
- signal.removeEventListener("abort", onAbort);
110
- }
111
- reject(new Error(`File not found: ${path}`));
112
- return;
113
- }
114
-
115
- // Check if aborted before reading
116
- if (aborted) {
117
- return;
118
- }
119
-
120
- // Read the file
121
- const rawContent = await readFile(absolutePath, "utf-8");
122
-
123
- // Check if aborted after reading
124
- if (aborted) {
125
- return;
126
- }
127
-
128
- // Strip BOM before matching (LLM won't include invisible BOM in oldText)
129
- const { bom, text: content } = stripBom(rawContent);
130
-
131
- const originalEnding = detectLineEnding(content);
132
- const normalizedContent = normalizeToLF(content);
133
- const normalizedOldText = normalizeToLF(oldText);
134
- const normalizedNewText = normalizeToLF(newText);
135
-
136
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
137
- allowFuzzy,
138
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
139
- });
140
-
141
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
142
- if (signal) {
143
- signal.removeEventListener("abort", onAbort);
144
- }
145
- reject(
146
- new Error(
147
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
148
- ),
149
- );
150
- return;
151
- }
152
-
153
- if (!matchOutcome.match) {
154
- if (signal) {
155
- signal.removeEventListener("abort", onAbort);
156
- }
157
- reject(
158
- new Error(
159
- formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
160
- allowFuzzy,
161
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
162
- fuzzyMatches: matchOutcome.fuzzyMatches,
163
- }),
164
- ),
165
- );
166
- return;
167
- }
66
+ const absolutePath = resolveToCwd(path, cwd);
168
67
 
169
- const match = matchOutcome.match;
68
+ const file = Bun.file(absolutePath);
69
+ if (!(await file.exists())) {
70
+ throw new Error(`File not found: ${path}`);
71
+ }
170
72
 
171
- // Check if aborted before writing
172
- if (aborted) {
173
- return;
174
- }
73
+ const rawContent = await file.text();
175
74
 
176
- const normalizedNewContent =
177
- normalizedContent.substring(0, match.startIndex) +
178
- normalizedNewText +
179
- normalizedContent.substring(match.startIndex + match.actualText.length);
75
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText)
76
+ const { bom, text: content } = stripBom(rawContent);
180
77
 
181
- // Verify the replacement actually changed something
182
- if (normalizedContent === normalizedNewContent) {
183
- if (signal) {
184
- signal.removeEventListener("abort", onAbort);
185
- }
186
- reject(
187
- new Error(
188
- `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.`,
189
- ),
190
- );
191
- return;
192
- }
78
+ const originalEnding = detectLineEnding(content);
79
+ const normalizedContent = normalizeToLF(content);
80
+ const normalizedOldText = normalizeToLF(oldText);
81
+ const normalizedNewText = normalizeToLF(newText);
193
82
 
194
- const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
195
- await writeFile(absolutePath, finalContent, "utf-8");
83
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
84
+ allowFuzzy,
85
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
86
+ });
196
87
 
197
- // Check if aborted after writing
198
- if (aborted) {
199
- return;
200
- }
88
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
89
+ throw new Error(
90
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
91
+ );
92
+ }
201
93
 
202
- // Clean up abort handler
203
- if (signal) {
204
- signal.removeEventListener("abort", onAbort);
205
- }
94
+ if (!matchOutcome.match) {
95
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
96
+ allowFuzzy,
97
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
98
+ fuzzyMatches: matchOutcome.fuzzyMatches,
99
+ });
100
+ }
206
101
 
207
- const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
102
+ const match = matchOutcome.match;
103
+ const normalizedNewContent =
104
+ normalizedContent.substring(0, match.startIndex) +
105
+ normalizedNewText +
106
+ normalizedContent.substring(match.startIndex + match.actualText.length);
107
+
108
+ // Verify the replacement actually changed something
109
+ if (normalizedContent === normalizedNewContent) {
110
+ throw new Error(
111
+ `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.`,
112
+ );
113
+ }
208
114
 
209
- // Get LSP diagnostics if callback provided
210
- let diagnosticsResult: FileDiagnosticsResult | undefined;
211
- if (options.getDiagnostics) {
212
- try {
213
- diagnosticsResult = await options.getDiagnostics(absolutePath);
214
- } catch {
215
- // Ignore diagnostics errors - don't fail the edit
216
- }
217
- }
115
+ const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
116
+ const diagnostics = await writethrough(absolutePath, finalContent, signal, file);
218
117
 
219
- // Build result text
220
- let resultText = `Successfully replaced text in ${path}.`;
118
+ const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
221
119
 
222
- // Append diagnostics if available and there are issues
223
- if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
224
- resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
225
- resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
226
- }
120
+ // Build result text
121
+ let resultText = `Successfully replaced text in ${path}.`;
227
122
 
228
- resolve({
229
- content: [
230
- {
231
- type: "text",
232
- text: resultText,
233
- },
234
- ],
235
- details: {
236
- diff: diffResult.diff,
237
- firstChangedLine: diffResult.firstChangedLine,
238
- hasDiagnostics: diagnosticsResult?.available ?? false,
239
- diagnostics: diagnosticsResult,
240
- },
241
- });
242
- } catch (error: any) {
243
- // Clean up abort handler
244
- if (signal) {
245
- signal.removeEventListener("abort", onAbort);
246
- }
123
+ const messages = diagnostics?.messages;
124
+ if (messages && messages.length > 0) {
125
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
126
+ resultText += messages.map((d) => ` ${d}`).join("\n");
127
+ }
247
128
 
248
- if (!aborted) {
249
- reject(error);
250
- }
251
- }
252
- })();
253
- });
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: resultText,
134
+ },
135
+ ],
136
+ details: {
137
+ diff: diffResult.diff,
138
+ firstChangedLine: diffResult.firstChangedLine,
139
+ diagnostics: diagnostics,
140
+ },
141
+ };
254
142
  },
255
143
  };
256
144
  }
@@ -4,6 +4,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { globSync } from "glob";
6
6
  import { ensureTool } from "../../utils/tools-manager";
7
+ import { untilAborted } from "../utils";
7
8
  import { resolveToCwd } from "./path-utils";
8
9
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
9
10
 
@@ -67,191 +68,171 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
67
68
  },
68
69
  signal?: AbortSignal,
69
70
  ) => {
70
- return new Promise((resolve, reject) => {
71
- if (signal?.aborted) {
72
- reject(new Error("Operation aborted"));
73
- return;
71
+ return untilAborted(signal, async () => {
72
+ // Ensure fd is available
73
+ const fdPath = await ensureTool("fd", true);
74
+ if (!fdPath) {
75
+ throw new Error("fd is not available and could not be downloaded");
74
76
  }
75
77
 
76
- const onAbort = () => reject(new Error("Operation aborted"));
77
- signal?.addEventListener("abort", onAbort, { once: true });
78
+ const searchPath = resolveToCwd(searchDir || ".", cwd);
79
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
80
+ const effectiveType = type ?? "all";
81
+ const includeHidden = hidden ?? false;
82
+ const shouldSortByMtime = sortByMtime ?? false;
83
+
84
+ // Build fd arguments
85
+ const args: string[] = [
86
+ "--glob", // Use glob pattern
87
+ "--color=never", // No ANSI colors
88
+ "--max-results",
89
+ String(effectiveLimit),
90
+ ];
91
+
92
+ if (includeHidden) {
93
+ args.push("--hidden");
94
+ }
78
95
 
79
- (async () => {
80
- try {
81
- // Ensure fd is available
82
- const fdPath = await ensureTool("fd", true);
83
- if (!fdPath) {
84
- reject(new Error("fd is not available and could not be downloaded"));
85
- return;
86
- }
96
+ // Add type filter
97
+ if (effectiveType === "file") {
98
+ args.push("--type", "f");
99
+ } else if (effectiveType === "dir") {
100
+ args.push("--type", "d");
101
+ }
87
102
 
88
- const searchPath = resolveToCwd(searchDir || ".", cwd);
89
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
90
- const effectiveType = type ?? "all";
91
- const includeHidden = hidden ?? false;
92
- const shouldSortByMtime = sortByMtime ?? false;
93
-
94
- // Build fd arguments
95
- const args: string[] = [
96
- "--glob", // Use glob pattern
97
- "--color=never", // No ANSI colors
98
- "--max-results",
99
- String(effectiveLimit),
100
- ];
101
-
102
- if (includeHidden) {
103
- args.push("--hidden");
104
- }
103
+ // Include .gitignore files (root + nested) so fd respects them even outside git repos
104
+ const gitignoreFiles = new Set<string>();
105
+ const rootGitignore = path.join(searchPath, ".gitignore");
106
+ if (existsSync(rootGitignore)) {
107
+ gitignoreFiles.add(rootGitignore);
108
+ }
105
109
 
106
- // Add type filter
107
- if (effectiveType === "file") {
108
- args.push("--type", "f");
109
- } else if (effectiveType === "dir") {
110
- args.push("--type", "d");
111
- }
110
+ try {
111
+ const nestedGitignores = globSync("**/.gitignore", {
112
+ cwd: searchPath,
113
+ dot: true,
114
+ absolute: true,
115
+ ignore: ["**/node_modules/**", "**/.git/**"],
116
+ });
117
+ for (const file of nestedGitignores) {
118
+ gitignoreFiles.add(file);
119
+ }
120
+ } catch {
121
+ // Ignore glob errors
122
+ }
112
123
 
113
- // Include .gitignore files (root + nested) so fd respects them even outside git repos
114
- const gitignoreFiles = new Set<string>();
115
- const rootGitignore = path.join(searchPath, ".gitignore");
116
- if (existsSync(rootGitignore)) {
117
- gitignoreFiles.add(rootGitignore);
118
- }
124
+ for (const gitignorePath of gitignoreFiles) {
125
+ args.push("--ignore-file", gitignorePath);
126
+ }
119
127
 
120
- try {
121
- const nestedGitignores = globSync("**/.gitignore", {
122
- cwd: searchPath,
123
- dot: true,
124
- absolute: true,
125
- ignore: ["**/node_modules/**", "**/.git/**"],
126
- });
127
- for (const file of nestedGitignores) {
128
- gitignoreFiles.add(file);
129
- }
130
- } catch {
131
- // Ignore glob errors
132
- }
128
+ // Pattern and path
129
+ args.push(pattern, searchPath);
133
130
 
134
- for (const gitignorePath of gitignoreFiles) {
135
- args.push("--ignore-file", gitignorePath);
136
- }
131
+ // Run fd
132
+ const result = Bun.spawnSync([fdPath, ...args], {
133
+ stdin: "ignore",
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ });
137
137
 
138
- // Pattern and path
139
- args.push(pattern, searchPath);
138
+ const output = result.stdout.toString().trim();
140
139
 
141
- // Run fd
142
- const result = Bun.spawnSync([fdPath, ...args], {
143
- stdin: "ignore",
144
- stdout: "pipe",
145
- stderr: "pipe",
146
- });
140
+ if (result.exitCode !== 0) {
141
+ const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
142
+ // fd returns non-zero for some errors but may still have partial output
143
+ if (!output) {
144
+ throw new Error(errorMsg);
145
+ }
146
+ }
147
147
 
148
- signal?.removeEventListener("abort", onAbort);
148
+ if (!output) {
149
+ return {
150
+ content: [{ type: "text", text: "No files found matching pattern" }],
151
+ details: { fileCount: 0, files: [], truncated: false },
152
+ };
153
+ }
149
154
 
150
- const output = result.stdout.toString().trim();
155
+ const lines = output.split("\n");
156
+ const relativized: string[] = [];
157
+ const mtimes: number[] = [];
151
158
 
152
- if (result.exitCode !== 0) {
153
- const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
154
- // fd returns non-zero for some errors but may still have partial output
155
- if (!output) {
156
- reject(new Error(errorMsg));
157
- return;
158
- }
159
- }
159
+ for (const rawLine of lines) {
160
+ const line = rawLine.replace(/\r$/, "").trim();
161
+ if (!line) {
162
+ continue;
163
+ }
160
164
 
161
- if (!output) {
162
- resolve({
163
- content: [{ type: "text", text: "No files found matching pattern" }],
164
- details: { fileCount: 0, files: [], truncated: false },
165
- });
166
- return;
167
- }
165
+ const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
166
+ let relativePath = line;
167
+ if (line.startsWith(searchPath)) {
168
+ relativePath = line.slice(searchPath.length + 1); // +1 for the /
169
+ } else {
170
+ relativePath = path.relative(searchPath, line);
171
+ }
168
172
 
169
- const lines = output.split("\n");
170
- const relativized: string[] = [];
171
- const mtimes: number[] = [];
172
-
173
- for (const rawLine of lines) {
174
- const line = rawLine.replace(/\r$/, "").trim();
175
- if (!line) {
176
- continue;
177
- }
178
-
179
- const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
180
- let relativePath = line;
181
- if (line.startsWith(searchPath)) {
182
- relativePath = line.slice(searchPath.length + 1); // +1 for the /
183
- } else {
184
- relativePath = path.relative(searchPath, line);
185
- }
186
-
187
- if (hadTrailingSlash && !relativePath.endsWith("/")) {
188
- relativePath += "/";
189
- }
190
-
191
- relativized.push(relativePath);
192
-
193
- // Collect mtime if sorting is requested
194
- if (shouldSortByMtime) {
195
- try {
196
- const fullPath = path.join(searchPath, relativePath);
197
- const stat: Stats = statSync(fullPath);
198
- mtimes.push(stat.mtimeMs);
199
- } catch {
200
- mtimes.push(0);
201
- }
202
- }
203
- }
173
+ if (hadTrailingSlash && !relativePath.endsWith("/")) {
174
+ relativePath += "/";
175
+ }
176
+
177
+ relativized.push(relativePath);
204
178
 
205
- // Sort by mtime if requested (most recent first)
206
- if (shouldSortByMtime && relativized.length > 0) {
207
- const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 }));
208
- indexed.sort((a, b) => b.mtime - a.mtime);
209
- relativized.length = 0;
210
- relativized.push(...indexed.map((item) => item.path));
179
+ // Collect mtime if sorting is requested
180
+ if (shouldSortByMtime) {
181
+ try {
182
+ const fullPath = path.join(searchPath, relativePath);
183
+ const stat: Stats = statSync(fullPath);
184
+ mtimes.push(stat.mtimeMs);
185
+ } catch {
186
+ mtimes.push(0);
211
187
  }
188
+ }
189
+ }
212
190
 
213
- // Check if we hit the result limit
214
- const resultLimitReached = relativized.length >= effectiveLimit;
191
+ // Sort by mtime if requested (most recent first)
192
+ if (shouldSortByMtime && relativized.length > 0) {
193
+ const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 }));
194
+ indexed.sort((a, b) => b.mtime - a.mtime);
195
+ relativized.length = 0;
196
+ relativized.push(...indexed.map((item) => item.path));
197
+ }
215
198
 
216
- // Apply byte truncation (no line limit since we already have result limit)
217
- const rawOutput = relativized.join("\n");
218
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
199
+ // Check if we hit the result limit
200
+ const resultLimitReached = relativized.length >= effectiveLimit;
219
201
 
220
- let resultOutput = truncation.content;
221
- const details: FindToolDetails = {
222
- fileCount: relativized.length,
223
- files: relativized.slice(0, 50),
224
- truncated: resultLimitReached || truncation.truncated,
225
- };
202
+ // Apply byte truncation (no line limit since we already have result limit)
203
+ const rawOutput = relativized.join("\n");
204
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
226
205
 
227
- // Build notices
228
- const notices: string[] = [];
206
+ let resultOutput = truncation.content;
207
+ const details: FindToolDetails = {
208
+ fileCount: relativized.length,
209
+ files: relativized.slice(0, 50),
210
+ truncated: resultLimitReached || truncation.truncated,
211
+ };
229
212
 
230
- if (resultLimitReached) {
231
- notices.push(
232
- `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
233
- );
234
- details.resultLimitReached = effectiveLimit;
235
- }
213
+ // Build notices
214
+ const notices: string[] = [];
236
215
 
237
- if (truncation.truncated) {
238
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
239
- details.truncation = truncation;
240
- }
216
+ if (resultLimitReached) {
217
+ notices.push(
218
+ `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
219
+ );
220
+ details.resultLimitReached = effectiveLimit;
221
+ }
241
222
 
242
- if (notices.length > 0) {
243
- resultOutput += `\n\n[${notices.join(". ")}]`;
244
- }
223
+ if (truncation.truncated) {
224
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
225
+ details.truncation = truncation;
226
+ }
245
227
 
246
- resolve({
247
- content: [{ type: "text", text: resultOutput }],
248
- details: Object.keys(details).length > 0 ? details : undefined,
249
- });
250
- } catch (e: any) {
251
- signal?.removeEventListener("abort", onAbort);
252
- reject(e);
253
- }
254
- })();
228
+ if (notices.length > 0) {
229
+ resultOutput += `\n\n[${notices.join(". ")}]`;
230
+ }
231
+
232
+ return {
233
+ content: [{ type: "text", text: resultOutput }],
234
+ details: Object.keys(details).length > 0 ? details : undefined,
235
+ };
255
236
  });
256
237
  },
257
238
  };