@oh-my-pi/pi-coding-agent 8.12.1 → 8.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools/read.ts CHANGED
@@ -2,9 +2,10 @@ import * as os from "node:os";
2
2
  import path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
+ import { find as wasmFind } from "@oh-my-pi/pi-natives";
5
6
  import type { Component } from "@oh-my-pi/pi-tui";
6
7
  import { Text } from "@oh-my-pi/pi-tui";
7
- import { ptree } from "@oh-my-pi/pi-utils";
8
+ import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
8
9
  import { Type } from "@sinclair/typebox";
9
10
  import { CONFIG_DIR_NAME } from "../config";
10
11
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -16,7 +17,6 @@ import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui";
16
17
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
18
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
18
19
  import { ensureTool } from "../utils/tools-manager";
19
- import { runRg } from "./grep";
20
20
  import { applyListLimit } from "./list-limit";
21
21
  import { LsTool } from "./ls";
22
22
  import type { OutputMeta } from "./output-meta";
@@ -49,6 +49,7 @@ const MAX_FUZZY_RESULTS = 5;
49
49
  const MAX_FUZZY_CANDIDATES = 20000;
50
50
  const MIN_BASE_SIMILARITY = 0.5;
51
51
  const MIN_FULL_SIMILARITY = 0.6;
52
+ const GLOB_TIMEOUT_MS = 5000;
52
53
 
53
54
  function normalizePathForMatch(value: string): string {
54
55
  return value
@@ -162,95 +163,36 @@ function similarityScore(a: string, b: string): number {
162
163
  async function listCandidateFiles(
163
164
  searchRoot: string,
164
165
  signal?: AbortSignal,
165
- notify?: (message: string) => void,
166
+ _notify?: (message: string) => void,
166
167
  ): Promise<{ files: string[]; truncated: boolean; error?: string }> {
167
- let rgPath: string | undefined;
168
- try {
169
- rgPath = await ensureTool("rg", { silent: true, notify });
170
- } catch {
171
- return { files: [], truncated: false, error: "rg not available" };
172
- }
173
-
174
- if (!rgPath) {
175
- return { files: [], truncated: false, error: "rg not available" };
176
- }
177
-
178
- const args: string[] = [
179
- "--files",
180
- "--color=never",
181
- "--hidden",
182
- "--glob",
183
- "!**/.git/**",
184
- "--glob",
185
- "!**/node_modules/**",
186
- ];
187
-
188
- const gitignoreFiles = new Set<string>();
189
- const rootGitignore = path.join(searchRoot, ".gitignore");
190
- if (await Bun.file(rootGitignore).exists()) {
191
- gitignoreFiles.add(rootGitignore);
192
- }
193
-
168
+ let files: string[];
169
+ const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
170
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
194
171
  try {
195
- const gitignoreArgs = [
196
- "--files",
197
- "--color=never",
198
- "--hidden",
199
- "--no-ignore",
200
- "--glob",
201
- "!**/.git/**",
202
- "--glob",
203
- "!**/node_modules/**",
204
- "--glob",
205
- ".gitignore",
206
- searchRoot,
207
- ];
208
- const { stdout } = await runRg(rgPath, gitignoreArgs, signal);
209
- const output = stdout.trim();
210
- if (output) {
211
- const nestedGitignores = output
212
- .split("\n")
213
- .map(line => line.replace(/\r$/, "").trim())
214
- .filter(line => line.length > 0);
215
- for (const file of nestedGitignores) {
216
- const normalized = file.replace(/\\/g, "/");
217
- if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) {
218
- continue;
219
- }
220
- gitignoreFiles.add(file);
221
- }
222
- }
172
+ const result = await untilAborted(combinedSignal, () =>
173
+ wasmFind({
174
+ pattern: "**/*",
175
+ path: searchRoot,
176
+ fileType: "file",
177
+ hidden: true,
178
+ }),
179
+ );
180
+ files = result.matches.map(match => match.path);
223
181
  } catch (error) {
224
- if (error instanceof ToolAbortError) {
225
- throw error;
226
- }
227
- // Ignore gitignore scan errors.
228
- }
229
-
230
- for (const gitignorePath of gitignoreFiles) {
231
- args.push("--ignore-file", gitignorePath);
232
- }
233
-
234
- args.push(searchRoot);
235
-
236
- const { stdout, stderr, exitCode } = await runRg(rgPath, args, signal);
237
- const output = stdout.trim();
238
-
239
- if (!output) {
240
- // rg exit codes: 0 = ok, 1 = no matches, other = error
241
- if (exitCode !== 0 && exitCode !== 1) {
242
- return { files: [], truncated: false, error: stderr.trim() || `rg failed (exit ${exitCode})` };
182
+ if (error instanceof Error && error.name === "AbortError") {
183
+ if (timeoutSignal.aborted && !signal?.aborted) {
184
+ const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
185
+ return { files: [], truncated: false, error: `find timed out after ${timeoutSeconds}s` };
186
+ }
187
+ throw new ToolAbortError();
243
188
  }
244
- return { files: [], truncated: false };
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ return { files: [], truncated: false, error: message };
245
191
  }
246
192
 
247
- const files = output
248
- .split("\n")
249
- .map(line => line.replace(/\r$/, "").trim())
250
- .filter(line => line.length > 0);
251
-
252
- const truncated = files.length > MAX_FUZZY_CANDIDATES;
253
- const limited = truncated ? files.slice(0, MAX_FUZZY_CANDIDATES) : files;
193
+ const normalizedFiles = files.filter(line => line.length > 0);
194
+ const truncated = normalizedFiles.length > MAX_FUZZY_CANDIDATES;
195
+ const limited = truncated ? normalizedFiles.slice(0, MAX_FUZZY_CANDIDATES) : normalizedFiles;
254
196
 
255
197
  return { files: limited, truncated };
256
198
  }
@@ -304,11 +246,7 @@ async function findReadPathSuggestions(
304
246
  const cleaned = file.replace(/\r$/, "").trim();
305
247
  if (!cleaned) continue;
306
248
 
307
- const relativePath = path.isAbsolute(cleaned)
308
- ? cleaned.startsWith(searchRoot)
309
- ? cleaned.slice(searchRoot.length + 1)
310
- : path.relative(searchRoot, cleaned)
311
- : cleaned;
249
+ const relativePath = cleaned;
312
250
 
313
251
  if (!relativePath || relativePath.startsWith("..")) {
314
252
  continue;
@@ -363,22 +301,23 @@ async function convertWithMarkitdown(
363
301
  return { content: "", ok: false, error: "markitdown not found (uv/pip unavailable)" };
364
302
  }
365
303
 
366
- const child = ptree.cspawn([cmd, filePath], { signal });
367
- let stdout: string;
368
- try {
369
- stdout = await child.nothrow().text();
370
- } catch (err) {
371
- if (err instanceof ptree.Exception && err.aborted) {
372
- throw new ToolAbortError();
373
- }
374
- throw err;
304
+ const result = await ptree.exec([cmd, filePath], {
305
+ signal,
306
+ allowNonZero: true,
307
+ allowAbort: true,
308
+ stderr: "buffer",
309
+ detached: true,
310
+ });
311
+
312
+ if (result.exitError?.aborted) {
313
+ throw new ToolAbortError();
375
314
  }
376
315
 
377
- if (child.exitCode === 0 && stdout.length > 0) {
378
- return { content: stdout, ok: true };
316
+ if (result.exitCode === 0 && result.stdout.length > 0) {
317
+ return { content: result.stdout, ok: true };
379
318
  }
380
319
 
381
- return { content: "", ok: false, error: child.peekStderr().trim() || "Conversion failed" };
320
+ return { content: "", ok: false, error: result.stderr.trim() || "Conversion failed" };
382
321
  }
383
322
 
384
323
  const readSchema = Type.Object({
@@ -1,4 +1,4 @@
1
- import * as photon from "../vendor/photon";
1
+ import { PhotonImage } from "@oh-my-pi/pi-natives";
2
2
 
3
3
  /**
4
4
  * Convert image to PNG format for terminal display.
@@ -14,16 +14,12 @@ export async function convertToPng(
14
14
  }
15
15
 
16
16
  try {
17
- const image = photon.PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64")));
18
- try {
19
- const pngBuffer = image.get_bytes();
20
- return {
21
- data: Buffer.from(pngBuffer).toString("base64"),
22
- mimeType: "image/png",
23
- };
24
- } finally {
25
- image.free();
26
- }
17
+ using image = await PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64")));
18
+ const pngBuffer = await image.get_bytes();
19
+ return {
20
+ data: Buffer.from(pngBuffer).toString("base64"),
21
+ mimeType: "image/png",
22
+ };
27
23
  } catch {
28
24
  // Conversion failed
29
25
  return null;
@@ -1,5 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import * as photon from "../vendor/photon";
2
+ import { PhotonImage, resize, SamplingFilter } from "@oh-my-pi/pi-natives";
3
3
 
4
4
  export interface ImageResizeOptions {
5
5
  maxWidth?: number; // Default: 2000
@@ -53,9 +53,8 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
53
53
  const opts = { ...DEFAULT_OPTIONS, ...options };
54
54
  const inputBuffer = Buffer.from(img.data, "base64");
55
55
 
56
- let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
57
56
  try {
58
- image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
57
+ using image = await PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
59
58
 
60
59
  const originalWidth = image.get_width();
61
60
  const originalHeight = image.get_height();
@@ -89,24 +88,19 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
89
88
  }
90
89
 
91
90
  // Helper to resize and encode in both formats, returning the smaller one
92
- function tryBothFormats(
91
+ async function tryBothFormats(
93
92
  width: number,
94
93
  height: number,
95
94
  jpegQuality: number,
96
- ): { buffer: Uint8Array; mimeType: string } {
97
- const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
98
-
99
- try {
100
- const pngBuffer = resized.get_bytes();
101
- const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
102
-
103
- return pickSmaller(
104
- { buffer: pngBuffer, mimeType: "image/png" },
105
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
106
- );
107
- } finally {
108
- resized.free();
109
- }
95
+ ): Promise<{ buffer: Uint8Array; mimeType: string }> {
96
+ using resized = await resize(image!, width, height, SamplingFilter.Lanczos3);
97
+
98
+ const [pngBuffer, jpegBuffer] = await Promise.all([resized.get_bytes(), resized.get_bytes_jpeg(jpegQuality)]);
99
+
100
+ return pickSmaller(
101
+ { buffer: pngBuffer, mimeType: "image/png" },
102
+ { buffer: jpegBuffer, mimeType: "image/jpeg" },
103
+ );
110
104
  }
111
105
 
112
106
  // Try to produce an image under maxBytes
@@ -118,7 +112,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
118
112
  let finalHeight = targetHeight;
119
113
 
120
114
  // First attempt: resize to target dimensions, try both formats
121
- best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
115
+ best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
122
116
 
123
117
  if (best.buffer.length <= opts.maxBytes) {
124
118
  return {
@@ -134,7 +128,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
134
128
 
135
129
  // Still too large - try JPEG with decreasing quality
136
130
  for (const quality of qualitySteps) {
137
- best = tryBothFormats(targetWidth, targetHeight, quality);
131
+ best = await tryBothFormats(targetWidth, targetHeight, quality);
138
132
 
139
133
  if (best.buffer.length <= opts.maxBytes) {
140
134
  return {
@@ -159,7 +153,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
159
153
  }
160
154
 
161
155
  for (const quality of qualitySteps) {
162
- best = tryBothFormats(finalWidth, finalHeight, quality);
156
+ best = await tryBothFormats(finalWidth, finalHeight, quality);
163
157
 
164
158
  if (best.buffer.length <= opts.maxBytes) {
165
159
  return {
@@ -196,10 +190,6 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
196
190
  height: 0,
197
191
  wasResized: false,
198
192
  };
199
- } finally {
200
- if (image) {
201
- image.free();
202
- }
203
193
  }
204
194
  }
205
195
 
@@ -3,9 +3,9 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { logger, TempDir } from "@oh-my-pi/pi-utils";
5
5
  import { $ } from "bun";
6
- import { APP_NAME, getBinDir } from "../config";
6
+ import { APP_NAME, getToolsDir } from "../config";
7
7
 
8
- const TOOLS_DIR = getBinDir();
8
+ const TOOLS_DIR = getToolsDir();
9
9
  const TOOL_DOWNLOAD_TIMEOUT_MS = 15000;
10
10
 
11
11
  interface ToolConfig {
@@ -18,46 +18,6 @@ interface ToolConfig {
18
18
  }
19
19
 
20
20
  const TOOLS: Record<string, ToolConfig> = {
21
- fd: {
22
- name: "fd",
23
- repo: "sharkdp/fd",
24
- binaryName: "fd",
25
- tagPrefix: "v",
26
- getAssetName: (version, plat, architecture) => {
27
- if (plat === "darwin") {
28
- const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
29
- return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
30
- } else if (plat === "linux") {
31
- const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
32
- return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;
33
- } else if (plat === "win32") {
34
- const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
35
- return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
36
- }
37
- return null;
38
- },
39
- },
40
- rg: {
41
- name: "ripgrep",
42
- repo: "BurntSushi/ripgrep",
43
- binaryName: "rg",
44
- tagPrefix: "",
45
- getAssetName: (version, plat, architecture) => {
46
- if (plat === "darwin") {
47
- const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
48
- return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
49
- } else if (plat === "linux") {
50
- if (architecture === "arm64") {
51
- return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
52
- }
53
- return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
54
- } else if (plat === "win32") {
55
- const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
56
- return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
57
- }
58
- return null;
59
- },
60
- },
61
21
  sd: {
62
22
  name: "sd",
63
23
  repo: "chmln/sd",
@@ -135,7 +95,7 @@ const PYTHON_TOOLS: Record<string, PythonToolConfig> = {
135
95
  },
136
96
  };
137
97
 
138
- export type ToolName = "fd" | "rg" | "sd" | "sg" | "yt-dlp" | "markitdown" | "html2text";
98
+ export type ToolName = "sd" | "sg" | "yt-dlp" | "markitdown" | "html2text";
139
99
 
140
100
  // Get the path to a tool (system-wide or in our tools dir)
141
101
  export async function getToolPath(tool: ToolName): Promise<string | null> {
@@ -49,16 +49,21 @@ export async function convertWithMarkitdown(
49
49
 
50
50
  try {
51
51
  await Bun.write(tmpFile, content);
52
- const result = await ptree.cspawn([markitdown, tmpFile], { timeout });
53
- const [stdout, stderr, exitCode] = await Promise.all([result.stdout.text(), result.stderr.text(), result.exited]);
54
- if (exitCode !== 0) {
52
+ const result = await ptree.exec([markitdown, tmpFile], {
53
+ timeout,
54
+ allowNonZero: true,
55
+ stderr: "full",
56
+ detached: true,
57
+ });
58
+ if (!result.ok) {
55
59
  return {
56
- content: stdout,
60
+ content: result.stdout,
57
61
  ok: false,
58
- error: stderr.length > 0 ? stderr : `markitdown failed (exit ${exitCode})`,
62
+ error:
63
+ result.stderr.length > 0 ? result.stderr : `markitdown failed (exit ${result.exitCode ?? "unknown"})`,
59
64
  };
60
65
  }
61
- return { content: stdout, ok: true };
66
+ return { content: result.stdout, ok: true };
62
67
  } finally {
63
68
  try {
64
69
  await fs.rm(tmpFile, { force: true });
@@ -1,41 +1,13 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import path from "node:path";
4
- import { cspawn } from "@oh-my-pi/pi-utils";
4
+ import { ptree } from "@oh-my-pi/pi-utils";
5
5
  import { nanoid } from "nanoid";
6
6
  import { throwIfAborted } from "../../tools/tool-errors";
7
7
  import { ensureTool } from "../../utils/tools-manager";
8
8
  import type { RenderResult, SpecialHandler } from "./types";
9
9
  import { finalizeOutput } from "./types";
10
10
 
11
- /**
12
- * Execute a command and return stdout
13
- */
14
- async function exec(
15
- cmd: string,
16
- args: string[],
17
- options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
18
- ): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
19
- const proc = cspawn([cmd, ...args], {
20
- signal: options?.signal,
21
- timeout: options?.timeout,
22
- stdin: options?.input ? Buffer.from(options.input) : undefined,
23
- });
24
-
25
- const [stdout, stderr, exitResult] = await Promise.all([
26
- proc.stdout.text(),
27
- proc.stderr.text(),
28
- proc.exited.then(() => proc.exitCode ?? 0),
29
- ]);
30
-
31
- return {
32
- stdout,
33
- stderr,
34
- ok: exitResult === 0,
35
- exitCode: exitResult,
36
- };
37
- }
38
-
39
11
  interface YouTubeUrl {
40
12
  videoId: string;
41
13
  playlistId?: string;
@@ -163,16 +135,20 @@ export const handleYouTube: SpecialHandler = async (
163
135
  const fetchedAt = new Date().toISOString();
164
136
  const notes: string[] = [];
165
137
  const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
138
+ const execOptions = {
139
+ mode: "group" as const,
140
+ signal,
141
+ timeout: timeout * 1000,
142
+ allowNonZero: true,
143
+ allowAbort: true,
144
+ stderr: "full" as const,
145
+ };
166
146
 
167
147
  // Fetch video metadata
168
148
  throwIfAborted(signal);
169
- const metaResult = await exec(
170
- ytdlp,
171
- ["--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
172
- {
173
- timeout: timeout * 1000,
174
- signal,
175
- },
149
+ const metaResult = await ptree.exec(
150
+ [ytdlp, "--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
151
+ execOptions,
176
152
  );
177
153
  throwIfAborted(signal);
178
154
 
@@ -215,13 +191,9 @@ export const handleYouTube: SpecialHandler = async (
215
191
 
216
192
  // First, list available subtitles
217
193
  throwIfAborted(signal);
218
- const listResult = await exec(
219
- ytdlp,
220
- ["--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
221
- {
222
- timeout: timeout * 1000,
223
- signal,
224
- },
194
+ const listResult = await ptree.exec(
195
+ [ytdlp, "--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl],
196
+ execOptions,
225
197
  );
226
198
  throwIfAborted(signal);
227
199
 
@@ -236,9 +208,9 @@ export const handleYouTube: SpecialHandler = async (
236
208
  // Try manual subtitles first (English preferred)
237
209
  if (hasManualSubs) {
238
210
  throwIfAborted(signal);
239
- const subResult = await exec(
240
- ytdlp,
211
+ const subResult = await ptree.exec(
241
212
  [
213
+ ytdlp,
242
214
  "--write-sub",
243
215
  "--sub-lang",
244
216
  "en,en-US,en-GB",
@@ -251,7 +223,7 @@ export const handleYouTube: SpecialHandler = async (
251
223
  tmpBase,
252
224
  videoUrl,
253
225
  ],
254
- { timeout: timeout * 1000, signal },
226
+ execOptions,
255
227
  );
256
228
 
257
229
  if (subResult.ok) {
@@ -271,9 +243,9 @@ export const handleYouTube: SpecialHandler = async (
271
243
  // Fall back to auto-generated captions
272
244
  if (!transcript && hasAutoSubs) {
273
245
  throwIfAborted(signal);
274
- const autoResult = await exec(
275
- ytdlp,
246
+ const autoResult = await ptree.exec(
276
247
  [
248
+ ytdlp,
277
249
  "--write-auto-sub",
278
250
  "--sub-lang",
279
251
  "en,en-US,en-GB",
@@ -286,7 +258,7 @@ export const handleYouTube: SpecialHandler = async (
286
258
  tmpBase,
287
259
  videoUrl,
288
260
  ],
289
- { timeout: timeout * 1000, signal },
261
+ execOptions,
290
262
  );
291
263
 
292
264
  if (autoResult.ok) {
@@ -1 +0,0 @@
1
- export { abortableSleep, once, untilAborted } from "@oh-my-pi/pi-utils";