@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.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 (157) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -1,28 +1,16 @@
1
- import { existsSync, type Stats, statSync } from "node:fs";
2
1
  import path from "node:path";
3
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
5
4
  import { Text } from "@oh-my-pi/pi-tui";
6
5
  import { Type } from "@sinclair/typebox";
7
- import { globSync } from "glob";
8
6
  import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
9
7
  import findDescription from "../../prompts/tools/find.md" with { type: "text" };
10
8
  import { ensureTool } from "../../utils/tools-manager";
11
9
  import type { RenderResultOptions } from "../custom-tools/types";
12
- import { untilAborted } from "../utils";
10
+ import { ScopeSignal, untilAborted } from "../utils";
13
11
  import type { ToolSession } from "./index";
14
12
  import { resolveToCwd } from "./path-utils";
15
- import {
16
- formatCount,
17
- formatEmptyMessage,
18
- formatErrorMessage,
19
- formatExpandHint,
20
- formatMeta,
21
- formatMoreItems,
22
- formatScope,
23
- formatTruncationSuffix,
24
- PREVIEW_LIMITS,
25
- } from "./render-utils";
13
+ import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils";
26
14
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
27
15
 
28
16
  const findSchema = Type.Object({
@@ -56,6 +44,53 @@ export interface FindToolDetails {
56
44
  error?: string;
57
45
  }
58
46
 
47
+ async function captureCommandOutput(
48
+ command: string,
49
+ args: string[],
50
+ signal?: AbortSignal,
51
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> {
52
+ const child = Bun.spawn([command, ...args], {
53
+ stdin: "ignore",
54
+ stdout: "pipe",
55
+ stderr: "pipe",
56
+ });
57
+
58
+ using scope = new ScopeSignal(signal ? { signal } : undefined);
59
+ scope.catch(() => {
60
+ child.kill();
61
+ });
62
+
63
+ const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
64
+ const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
65
+ const stdoutDecoder = new TextDecoder();
66
+ const stderrDecoder = new TextDecoder();
67
+ let stdout = "";
68
+ let stderr = "";
69
+
70
+ await Promise.all([
71
+ (async () => {
72
+ while (true) {
73
+ const { done, value } = await stdoutReader.read();
74
+ if (done) break;
75
+ stdout += stdoutDecoder.decode(value, { stream: true });
76
+ }
77
+ stdout += stdoutDecoder.decode();
78
+ })(),
79
+ (async () => {
80
+ while (true) {
81
+ const { done, value } = await stderrReader.read();
82
+ if (done) break;
83
+ stderr += stderrDecoder.decode(value, { stream: true });
84
+ }
85
+ stderr += stderrDecoder.decode();
86
+ })(),
87
+ ]);
88
+
89
+ const exitCode = await child.exited;
90
+
91
+ return { stdout, stderr, exitCode, aborted: scope.aborted };
92
+ }
93
+
59
94
  export function createFindTool(session: ToolSession): AgentTool<typeof findSchema> {
60
95
  return {
61
96
  name: "find",
@@ -126,22 +161,43 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
126
161
  // Include .gitignore files (root + nested) so fd respects them even outside git repos
127
162
  const gitignoreFiles = new Set<string>();
128
163
  const rootGitignore = path.join(searchPath, ".gitignore");
129
- if (existsSync(rootGitignore)) {
164
+ if (await Bun.file(rootGitignore).exists()) {
130
165
  gitignoreFiles.add(rootGitignore);
131
166
  }
132
167
 
133
168
  try {
134
- const nestedGitignores = globSync("**/.gitignore", {
135
- cwd: searchPath,
136
- dot: true,
137
- absolute: true,
138
- ignore: ["**/node_modules/**", "**/.git/**"],
139
- });
140
- for (const file of nestedGitignores) {
169
+ const gitignoreArgs = [
170
+ "--hidden",
171
+ "--no-ignore",
172
+ "--type",
173
+ "f",
174
+ "--name",
175
+ ".gitignore",
176
+ "--exclude",
177
+ ".git",
178
+ "--exclude",
179
+ "node_modules",
180
+ "--absolute-path",
181
+ searchPath,
182
+ ];
183
+ const { stdout: gitignoreStdout, aborted: gitignoreAborted } = await captureCommandOutput(
184
+ fdPath,
185
+ gitignoreArgs,
186
+ signal,
187
+ );
188
+ if (gitignoreAborted) {
189
+ throw new Error("Operation aborted");
190
+ }
191
+ for (const rawLine of gitignoreStdout.split("\n")) {
192
+ const file = rawLine.trim();
193
+ if (!file) continue;
141
194
  gitignoreFiles.add(file);
142
195
  }
143
- } catch {
144
- // Ignore glob errors
196
+ } catch (err) {
197
+ if (signal?.aborted) {
198
+ throw err instanceof Error ? err : new Error("Operation aborted");
199
+ }
200
+ // Ignore lookup errors
145
201
  }
146
202
 
147
203
  for (const gitignorePath of gitignoreFiles) {
@@ -152,16 +208,16 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
152
208
  args.push(effectivePattern, searchPath);
153
209
 
154
210
  // Run fd
155
- const result = Bun.spawnSync([fdPath, ...args], {
156
- stdin: "ignore",
157
- stdout: "pipe",
158
- stderr: "pipe",
159
- });
211
+ const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal);
212
+
213
+ if (aborted) {
214
+ throw new Error("Operation aborted");
215
+ }
160
216
 
161
- const output = result.stdout.toString().trim();
217
+ const output = stdout.trim();
162
218
 
163
- if (result.exitCode !== 0) {
164
- const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
219
+ if (exitCode !== 0) {
220
+ const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`;
165
221
  // fd returns non-zero for some errors but may still have partial output
166
222
  if (!output) {
167
223
  throw new Error(errorMsg);
@@ -180,6 +236,7 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
180
236
  const mtimes: number[] = [];
181
237
 
182
238
  for (const rawLine of lines) {
239
+ signal?.throwIfAborted();
183
240
  const line = rawLine.replace(/\r$/, "").trim();
184
241
  if (!line) {
185
242
  continue;
@@ -197,23 +254,25 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
197
254
  relativePath += "/";
198
255
  }
199
256
 
200
- relativized.push(relativePath);
201
-
202
- // Collect mtime if sorting is requested
257
+ // When sorting by mtime, keep files that fail to stat with mtime 0
203
258
  if (shouldSortByMtime) {
204
259
  try {
205
260
  const fullPath = path.join(searchPath, relativePath);
206
- const stat: Stats = statSync(fullPath);
261
+ const stat = await Bun.file(fullPath).stat();
262
+ relativized.push(relativePath);
207
263
  mtimes.push(stat.mtimeMs);
208
264
  } catch {
265
+ relativized.push(relativePath);
209
266
  mtimes.push(0);
210
267
  }
268
+ } else {
269
+ relativized.push(relativePath);
211
270
  }
212
271
  }
213
272
 
214
273
  // Sort by mtime if requested (most recent first)
215
274
  if (shouldSortByMtime && relativized.length > 0) {
216
- const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 }));
275
+ const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] }));
217
276
  indexed.sort((a, b) => b.mtime - a.mtime);
218
277
  relativized.length = 0;
219
278
  relativized.push(...indexed.map((item) => item.path));
@@ -279,7 +338,8 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
279
338
 
280
339
  export const findToolRenderer = {
281
340
  renderCall(args: FindRenderArgs, uiTheme: Theme): Component {
282
- const label = uiTheme.fg("toolTitle", uiTheme.bold("Find"));
341
+ const ui = createToolUIKit(uiTheme);
342
+ const label = ui.title("Find");
283
343
  let text = `${label} ${uiTheme.fg("accent", args.pattern || "*")}`;
284
344
 
285
345
  const meta: string[] = [];
@@ -289,7 +349,7 @@ export const findToolRenderer = {
289
349
  if (args.sortByMtime) meta.push("sort:mtime");
290
350
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
291
351
 
292
- text += formatMeta(meta, uiTheme);
352
+ text += ui.meta(meta);
293
353
 
294
354
  return new Text(text, 0, 0);
295
355
  },
@@ -299,10 +359,11 @@ export const findToolRenderer = {
299
359
  { expanded }: RenderResultOptions,
300
360
  uiTheme: Theme,
301
361
  ): Component {
362
+ const ui = createToolUIKit(uiTheme);
302
363
  const details = result.details;
303
364
 
304
365
  if (details?.error) {
305
- return new Text(formatErrorMessage(details.error, uiTheme), 0, 0);
366
+ return new Text(ui.errorMessage(details.error), 0, 0);
306
367
  }
307
368
 
308
369
  const hasDetailedData = details?.fileCount !== undefined;
@@ -310,7 +371,7 @@ export const findToolRenderer = {
310
371
 
311
372
  if (!hasDetailedData) {
312
373
  if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
313
- return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
374
+ return new Text(ui.emptyMessage("No files found"), 0, 0);
314
375
  }
315
376
 
316
377
  const lines = textContent.split("\n").filter((l) => l.trim());
@@ -320,8 +381,8 @@ export const findToolRenderer = {
320
381
  const hasMore = remaining > 0;
321
382
 
322
383
  const icon = uiTheme.styledSymbol("status.success", "success");
323
- const summary = formatCount("file", lines.length);
324
- const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
384
+ const summary = ui.count("file", lines.length);
385
+ const expandHint = ui.expandHint(expanded, hasMore);
325
386
  let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
326
387
 
327
388
  for (let i = 0; i < displayLines.length; i++) {
@@ -330,7 +391,7 @@ export const findToolRenderer = {
330
391
  text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`;
331
392
  }
332
393
  if (remaining > 0) {
333
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "file", uiTheme))}`;
394
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "file"))}`;
334
395
  }
335
396
  return new Text(text, 0, 0);
336
397
  }
@@ -340,17 +401,17 @@ export const findToolRenderer = {
340
401
  const files = details?.files ?? [];
341
402
 
342
403
  if (fileCount === 0) {
343
- return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
404
+ return new Text(ui.emptyMessage("No files found"), 0, 0);
344
405
  }
345
406
 
346
407
  const icon = uiTheme.styledSymbol("status.success", "success");
347
- const summaryText = formatCount("file", fileCount);
348
- const scopeLabel = formatScope(details?.scopePath, uiTheme);
408
+ const summaryText = ui.count("file", fileCount);
409
+ const scopeLabel = ui.scope(details?.scopePath);
349
410
  const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
350
411
  const hasMoreFiles = files.length > maxFiles;
351
- const expandHint = formatExpandHint(expanded, hasMoreFiles, uiTheme);
412
+ const expandHint = ui.expandHint(expanded, hasMoreFiles);
352
413
 
353
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${scopeLabel}${expandHint}`;
414
+ let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
354
415
 
355
416
  const truncationReasons: string[] = [];
356
417
  if (details?.resultLimitReached) {
@@ -380,7 +441,7 @@ export const findToolRenderer = {
380
441
  const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
381
442
  text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
382
443
  "muted",
383
- formatMoreItems(files.length - maxFiles, "file", uiTheme),
444
+ ui.moreItems(files.length - maxFiles, "file"),
384
445
  )}`;
385
446
  }
386
447
  }
@@ -1,8 +1,7 @@
1
- import * as crypto from "node:crypto";
2
- import * as fs from "node:fs";
3
1
  import { tmpdir } from "node:os";
4
2
  import { join } from "node:path";
5
3
  import { type Static, Type } from "@sinclair/typebox";
4
+ import { nanoid } from "nanoid";
6
5
  import geminiImageDescription from "../../prompts/tools/gemini-image.md" with { type: "text" };
7
6
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
8
7
  import type { CustomTool } from "../custom-tools/types";
@@ -311,14 +310,18 @@ function getExtensionForMime(mimeType: string): string {
311
310
  return map[mimeType] ?? "png";
312
311
  }
313
312
 
314
- function saveImageToTemp(image: InlineImageData): string {
313
+ async function saveImageToTemp(image: InlineImageData): Promise<string> {
315
314
  const ext = getExtensionForMime(image.mimeType);
316
- const filename = `omp-image-${crypto.randomUUID()}.${ext}`;
315
+ const filename = `omp-image-${nanoid()}.${ext}`;
317
316
  const filepath = join(tmpdir(), filename);
318
- fs.writeFileSync(filepath, Buffer.from(image.data, "base64"));
317
+ await Bun.write(filepath, Buffer.from(image.data, "base64"));
319
318
  return filepath;
320
319
  }
321
320
 
321
+ async function saveImagesToTemp(images: InlineImageData[]): Promise<string[]> {
322
+ return Promise.all(images.map(saveImageToTemp));
323
+ }
324
+
322
325
  function buildResponseSummary(model: string, imagePaths: string[], responseText: string | undefined): string {
323
326
  const lines = [`Model: ${model}`, `Generated ${imagePaths.length} image(s):`];
324
327
  for (const p of imagePaths) {
@@ -356,27 +359,9 @@ function combineParts(response: GeminiGenerateContentResponse): GeminiPart[] {
356
359
  return parts;
357
360
  }
358
361
 
359
- function createAbortController(
360
- signal: AbortSignal | undefined,
361
- timeoutSeconds: number,
362
- ): { controller: AbortController; cleanup: () => void } {
363
- const controller = new AbortController();
364
- const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
365
-
366
- let abortListener: (() => void) | undefined;
367
- if (signal) {
368
- abortListener = () => controller.abort(signal.reason);
369
- signal.addEventListener("abort", abortListener, { once: true });
370
- }
371
-
372
- const cleanup = () => {
373
- clearTimeout(timeout);
374
- if (abortListener && signal) {
375
- signal.removeEventListener("abort", abortListener);
376
- }
377
- };
378
-
379
- return { controller, cleanup };
362
+ function createRequestSignal(signal: AbortSignal | undefined, timeoutSeconds: number): AbortSignal {
363
+ const timeoutSignal = AbortSignal.timeout(timeoutSeconds * 1000);
364
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
380
365
  }
381
366
 
382
367
  export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageToolDetails> = {
@@ -404,118 +389,28 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
404
389
  }
405
390
 
406
391
  const timeoutSeconds = params.timeout_seconds ?? DEFAULT_TIMEOUT_SECONDS;
407
- const { controller, cleanup } = createAbortController(signal, timeoutSeconds);
408
-
409
- try {
410
- if (provider === "openrouter") {
411
- const contentParts: OpenRouterContentPart[] = [{ type: "text", text: params.prompt }];
412
- for (const image of resolvedImages) {
413
- contentParts.push({ type: "image_url", image_url: { url: toDataUrl(image) } });
414
- }
415
-
416
- const requestBody = {
417
- model: resolvedModel,
418
- messages: [{ role: "user" as const, content: contentParts }],
419
- };
420
-
421
- const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
422
- method: "POST",
423
- headers: {
424
- "Content-Type": "application/json",
425
- Authorization: `Bearer ${apiKey.apiKey}`,
426
- },
427
- body: JSON.stringify(requestBody),
428
- signal: controller.signal,
429
- });
430
-
431
- const rawText = await response.text();
432
- if (!response.ok) {
433
- let message = rawText;
434
- try {
435
- const parsed = JSON.parse(rawText) as { error?: { message?: string } };
436
- message = parsed.error?.message ?? message;
437
- } catch {
438
- // Keep raw text.
439
- }
440
- throw new Error(`OpenRouter image request failed (${response.status}): ${message}`);
441
- }
442
-
443
- const data = JSON.parse(rawText) as OpenRouterResponse;
444
- const message = data.choices?.[0]?.message;
445
- const responseText = collectOpenRouterResponseText(message);
446
- const imageUrls = extractOpenRouterImageUrls(message);
447
- const inlineImages: InlineImageData[] = [];
448
- for (const imageUrl of imageUrls) {
449
- inlineImages.push(await loadImageFromUrl(imageUrl, controller.signal));
450
- }
451
-
452
- if (inlineImages.length === 0) {
453
- const messageText = responseText ? `\n\n${responseText}` : "";
454
- return {
455
- content: [{ type: "text", text: `No image data returned.${messageText}` }],
456
- details: {
457
- provider,
458
- model: resolvedModel,
459
- imageCount: 0,
460
- imagePaths: [],
461
- images: [],
462
- responseText,
463
- },
464
- };
465
- }
466
-
467
- const imagePaths = inlineImages.map(saveImageToTemp);
468
-
469
- return {
470
- content: [{ type: "text", text: buildResponseSummary(resolvedModel, imagePaths, responseText) }],
471
- details: {
472
- provider,
473
- model: resolvedModel,
474
- imageCount: inlineImages.length,
475
- imagePaths,
476
- images: inlineImages,
477
- responseText,
478
- },
479
- };
480
- }
392
+ const requestSignal = createRequestSignal(signal, timeoutSeconds);
481
393
 
482
- const parts = [] as Array<{ text?: string; inlineData?: InlineImageData }>;
394
+ if (provider === "openrouter") {
395
+ const contentParts: OpenRouterContentPart[] = [{ type: "text", text: params.prompt }];
483
396
  for (const image of resolvedImages) {
484
- parts.push({ inlineData: image });
485
- }
486
- parts.push({ text: params.prompt });
487
-
488
- const generationConfig: {
489
- responseModalities: GeminiResponseModality[];
490
- imageConfig?: { aspectRatio?: string; imageSize?: string };
491
- } = {
492
- responseModalities: ["Image"],
493
- };
494
-
495
- if (params.aspect_ratio || params.image_size) {
496
- generationConfig.imageConfig = {
497
- aspectRatio: params.aspect_ratio,
498
- imageSize: params.image_size,
499
- };
397
+ contentParts.push({ type: "image_url", image_url: { url: toDataUrl(image) } });
500
398
  }
501
399
 
502
400
  const requestBody = {
503
- contents: [{ role: "user" as const, parts }],
504
- generationConfig,
401
+ model: resolvedModel,
402
+ messages: [{ role: "user" as const, content: contentParts }],
505
403
  };
506
404
 
507
- const response = await fetch(
508
- `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
509
- {
510
- method: "POST",
511
- headers: {
512
- "Content-Type": "application/json",
513
- "x-goog-api-key": apiKey.apiKey,
514
- },
515
- body: JSON.stringify(requestBody),
516
- signal: controller.signal,
405
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
406
+ method: "POST",
407
+ headers: {
408
+ "Content-Type": "application/json",
409
+ Authorization: `Bearer ${apiKey.apiKey}`,
517
410
  },
518
- );
411
+ body: JSON.stringify(requestBody),
412
+ signal: requestSignal,
413
+ });
519
414
 
520
415
  const rawText = await response.text();
521
416
  if (!response.ok) {
@@ -526,51 +421,137 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
526
421
  } catch {
527
422
  // Keep raw text.
528
423
  }
529
- throw new Error(`Gemini image request failed (${response.status}): ${message}`);
424
+ throw new Error(`OpenRouter image request failed (${response.status}): ${message}`);
530
425
  }
531
426
 
532
- const data = JSON.parse(rawText) as GeminiGenerateContentResponse;
533
- const responseParts = combineParts(data);
534
- const responseText = collectResponseText(responseParts);
535
- const inlineImages = collectInlineImages(responseParts);
427
+ const data = JSON.parse(rawText) as OpenRouterResponse;
428
+ const message = data.choices?.[0]?.message;
429
+ const responseText = collectOpenRouterResponseText(message);
430
+ const imageUrls = extractOpenRouterImageUrls(message);
431
+ const inlineImages: InlineImageData[] = [];
432
+ for (const imageUrl of imageUrls) {
433
+ inlineImages.push(await loadImageFromUrl(imageUrl, requestSignal));
434
+ }
536
435
 
537
436
  if (inlineImages.length === 0) {
538
- const blocked = data.promptFeedback?.blockReason
539
- ? `Blocked: ${data.promptFeedback.blockReason}`
540
- : "No image data returned.";
437
+ const messageText = responseText ? `\n\n${responseText}` : "";
541
438
  return {
542
- content: [{ type: "text", text: `${blocked}${responseText ? `\n\n${responseText}` : ""}` }],
439
+ content: [{ type: "text", text: `No image data returned.${messageText}` }],
543
440
  details: {
544
441
  provider,
545
- model,
442
+ model: resolvedModel,
546
443
  imageCount: 0,
547
444
  imagePaths: [],
548
445
  images: [],
549
446
  responseText,
550
- promptFeedback: data.promptFeedback,
551
- usage: data.usageMetadata,
552
447
  },
553
448
  };
554
449
  }
555
450
 
556
- const imagePaths = inlineImages.map(saveImageToTemp);
451
+ const imagePaths = await saveImagesToTemp(inlineImages);
557
452
 
558
453
  return {
559
- content: [{ type: "text", text: buildResponseSummary(model, imagePaths, responseText) }],
454
+ content: [{ type: "text", text: buildResponseSummary(resolvedModel, imagePaths, responseText) }],
560
455
  details: {
561
456
  provider,
562
- model,
457
+ model: resolvedModel,
563
458
  imageCount: inlineImages.length,
564
459
  imagePaths,
565
460
  images: inlineImages,
566
461
  responseText,
462
+ },
463
+ };
464
+ }
465
+
466
+ const parts = [] as Array<{ text?: string; inlineData?: InlineImageData }>;
467
+ for (const image of resolvedImages) {
468
+ parts.push({ inlineData: image });
469
+ }
470
+ parts.push({ text: params.prompt });
471
+
472
+ const generationConfig: {
473
+ responseModalities: GeminiResponseModality[];
474
+ imageConfig?: { aspectRatio?: string; imageSize?: string };
475
+ } = {
476
+ responseModalities: ["Image"],
477
+ };
478
+
479
+ if (params.aspect_ratio || params.image_size) {
480
+ generationConfig.imageConfig = {
481
+ aspectRatio: params.aspect_ratio,
482
+ imageSize: params.image_size,
483
+ };
484
+ }
485
+
486
+ const requestBody = {
487
+ contents: [{ role: "user" as const, parts }],
488
+ generationConfig,
489
+ };
490
+
491
+ const response = await fetch(
492
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
493
+ {
494
+ method: "POST",
495
+ headers: {
496
+ "Content-Type": "application/json",
497
+ "x-goog-api-key": apiKey.apiKey,
498
+ },
499
+ body: JSON.stringify(requestBody),
500
+ signal: requestSignal,
501
+ },
502
+ );
503
+
504
+ const rawText = await response.text();
505
+ if (!response.ok) {
506
+ let message = rawText;
507
+ try {
508
+ const parsed = JSON.parse(rawText) as { error?: { message?: string } };
509
+ message = parsed.error?.message ?? message;
510
+ } catch {
511
+ // Keep raw text.
512
+ }
513
+ throw new Error(`Gemini image request failed (${response.status}): ${message}`);
514
+ }
515
+
516
+ const data = JSON.parse(rawText) as GeminiGenerateContentResponse;
517
+ const responseParts = combineParts(data);
518
+ const responseText = collectResponseText(responseParts);
519
+ const inlineImages = collectInlineImages(responseParts);
520
+
521
+ if (inlineImages.length === 0) {
522
+ const blocked = data.promptFeedback?.blockReason
523
+ ? `Blocked: ${data.promptFeedback.blockReason}`
524
+ : "No image data returned.";
525
+ return {
526
+ content: [{ type: "text", text: `${blocked}${responseText ? `\n\n${responseText}` : ""}` }],
527
+ details: {
528
+ provider,
529
+ model,
530
+ imageCount: 0,
531
+ imagePaths: [],
532
+ images: [],
533
+ responseText,
567
534
  promptFeedback: data.promptFeedback,
568
535
  usage: data.usageMetadata,
569
536
  },
570
537
  };
571
- } finally {
572
- cleanup();
573
538
  }
539
+
540
+ const imagePaths = await saveImagesToTemp(inlineImages);
541
+
542
+ return {
543
+ content: [{ type: "text", text: buildResponseSummary(model, imagePaths, responseText) }],
544
+ details: {
545
+ provider,
546
+ model,
547
+ imageCount: inlineImages.length,
548
+ imagePaths,
549
+ images: inlineImages,
550
+ responseText,
551
+ promptFeedback: data.promptFeedback,
552
+ usage: data.usageMetadata,
553
+ },
554
+ };
574
555
  });
575
556
  },
576
557
  };