@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -1,6 +1,4 @@
1
- import { spawnSync } from "node:child_process";
2
- import * as fs from "node:fs";
3
- import * as os from "node:os";
1
+ import { tmpdir } from "node:os";
4
2
  import * as path from "node:path";
5
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
6
4
  import { Type } from "@sinclair/typebox";
@@ -197,18 +195,15 @@ function exec(
197
195
  args: string[],
198
196
  options?: { timeout?: number; input?: string | Buffer },
199
197
  ): { stdout: string; stderr: string; ok: boolean } {
200
- const timeout = (options?.timeout ?? DEFAULT_TIMEOUT) * 1000;
201
- const result = spawnSync(cmd, args, {
202
- encoding: options?.input instanceof Buffer ? "buffer" : "utf-8",
203
- timeout,
204
- maxBuffer: MAX_BYTES,
205
- input: options?.input,
206
- shell: true,
198
+ const result = Bun.spawnSync([cmd, ...args], {
199
+ stdin: options?.input ? (options.input as any) : "ignore",
200
+ stdout: "pipe",
201
+ stderr: "pipe",
207
202
  });
208
203
  return {
209
204
  stdout: result.stdout?.toString() ?? "",
210
205
  stderr: result.stderr?.toString() ?? "",
211
- ok: result.status === 0,
206
+ ok: result.exitCode === 0,
212
207
  };
213
208
  }
214
209
 
@@ -217,8 +212,12 @@ function exec(
217
212
  */
218
213
  function hasCommand(cmd: string): boolean {
219
214
  const checkCmd = isWindows ? "where" : "which";
220
- const result = spawnSync(checkCmd, [cmd], { encoding: "utf-8", shell: true });
221
- return result.status === 0;
215
+ const result = Bun.spawnSync([checkCmd, cmd], {
216
+ stdin: "ignore",
217
+ stdout: "pipe",
218
+ stderr: "pipe",
219
+ });
220
+ return result.exitCode === 0;
222
221
  }
223
222
 
224
223
  /**
@@ -299,26 +298,27 @@ function looksLikeHtml(content: string): boolean {
299
298
  /**
300
299
  * Convert binary file to markdown using markitdown
301
300
  */
302
- function convertWithMarkitdown(
301
+ async function convertWithMarkitdown(
303
302
  content: Buffer,
304
303
  extensionHint: string,
305
304
  timeout: number,
306
- ): { content: string; ok: boolean } {
305
+ ): Promise<{ content: string; ok: boolean }> {
307
306
  if (!hasCommand("markitdown")) {
308
307
  return { content: "", ok: false };
309
308
  }
310
309
 
311
310
  // Write to temp file with extension hint
312
311
  const ext = extensionHint || ".bin";
313
- const tmpFile = path.join(os.tmpdir(), `omp-convert-${Date.now()}${ext}`);
312
+ const tmpDir = tmpdir();
313
+ const tmpFile = path.join(tmpDir, `omp-convert-${Date.now()}${ext}`);
314
314
 
315
315
  try {
316
- fs.writeFileSync(tmpFile, content);
316
+ await Bun.write(tmpFile, content);
317
317
  const result = exec("markitdown", [tmpFile], { timeout });
318
318
  return { content: result.stdout, ok: result.ok };
319
319
  } finally {
320
320
  try {
321
- fs.unlinkSync(tmpFile);
321
+ await Bun.$`rm ${tmpFile}`.quiet();
322
322
  } catch {}
323
323
  }
324
324
  }
@@ -531,10 +531,11 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
531
531
  /**
532
532
  * Render HTML to text using lynx
533
533
  */
534
- function renderWithLynx(html: string, timeout: number): { content: string; ok: boolean } {
535
- const tmpFile = path.join(os.tmpdir(), `omp-render-${Date.now()}.html`);
534
+ async function renderWithLynx(html: string, timeout: number): Promise<{ content: string; ok: boolean }> {
535
+ const tmpDir = tmpdir();
536
+ const tmpFile = path.join(tmpDir, `omp-render-${Date.now()}.html`);
536
537
  try {
537
- fs.writeFileSync(tmpFile, html);
538
+ await Bun.write(tmpFile, html);
538
539
  // Convert path to file URL (handles Windows paths correctly)
539
540
  const normalizedPath = tmpFile.replace(/\\/g, "/");
540
541
  const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`;
@@ -542,7 +543,7 @@ function renderWithLynx(html: string, timeout: number): { content: string; ok: b
542
543
  return { content: result.stdout, ok: result.ok };
543
544
  } finally {
544
545
  try {
545
- fs.unlinkSync(tmpFile);
546
+ await Bun.$`rm ${tmpFile}`.quiet();
546
547
  } catch {}
547
548
  }
548
549
  }
@@ -1752,7 +1753,7 @@ async function handleArxiv(url: string, timeout: number): Promise<RenderResult |
1752
1753
  notes.push("Fetching PDF for full content...");
1753
1754
  const pdfResult = await fetchBinary(pdfLink, timeout);
1754
1755
  if (pdfResult.ok) {
1755
- const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
1756
+ const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
1756
1757
  if (converted.ok && converted.content.length > 500) {
1757
1758
  md += `---\n\n## Full Paper\n\n${converted.content}\n`;
1758
1759
  notes.push("PDF converted via markitdown");
@@ -1835,7 +1836,7 @@ async function handleIacr(url: string, timeout: number): Promise<RenderResult |
1835
1836
  notes.push("Fetching PDF for full content...");
1836
1837
  const pdfResult = await fetchBinary(pdfUrl, timeout);
1837
1838
  if (pdfResult.ok) {
1838
- const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
1839
+ const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
1839
1840
  if (converted.ok && converted.content.length > 500) {
1840
1841
  md += `---\n\n## Full Paper\n\n${converted.content}\n`;
1841
1842
  notes.push("PDF converted via markitdown");
@@ -1992,7 +1993,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
1992
1993
  const binary = await fetchBinary(finalUrl, timeout);
1993
1994
  if (binary.ok) {
1994
1995
  const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
1995
- const converted = convertWithMarkitdown(binary.buffer, ext, timeout);
1996
+ const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
1996
1997
  if (converted.ok && converted.content.trim().length > 50) {
1997
1998
  notes.push(`Converted with markitdown`);
1998
1999
  const output = finalizeOutput(converted.content);
@@ -2174,7 +2175,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
2174
2175
  };
2175
2176
  }
2176
2177
 
2177
- const lynxResult = renderWithLynx(rawContent, timeout);
2178
+ const lynxResult = await renderWithLynx(rawContent, timeout);
2178
2179
  if (!lynxResult.ok) {
2179
2180
  notes.push("lynx failed");
2180
2181
  const output = finalizeOutput(rawContent);
@@ -2198,7 +2199,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
2198
2199
  const binary = await fetchBinary(docUrl, timeout);
2199
2200
  if (binary.ok) {
2200
2201
  const ext = getExtensionHint(docUrl, binary.contentDisposition);
2201
- const converted = convertWithMarkitdown(binary.buffer, ext, timeout);
2202
+ const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
2202
2203
  if (converted.ok && converted.content.trim().length > lynxResult.content.length) {
2203
2204
  notes.push(`Extracted and converted document: ${docUrl}`);
2204
2205
  const output = finalizeOutput(converted.content);
@@ -2384,7 +2385,7 @@ export function renderWebFetchResult(
2384
2385
  ? uiTheme.styledSymbol("status.warning", "warning")
2385
2386
  : uiTheme.styledSymbol("status.success", "success");
2386
2387
  const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O to expand)");
2387
- let text = `${statusIcon} ${uiTheme.fg("toolTitle", "Web Fetch")} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
2388
+ let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
2388
2389
 
2389
2390
  // Get content text
2390
2391
  const contentText = result.content[0]?.text ?? "";
@@ -20,10 +20,11 @@ import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../c
20
20
  import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client";
21
21
  import { renderExaCall, renderExaResult } from "../exa/render";
22
22
  import type { ExaRenderDetails } from "../exa/types";
23
+ import { formatAge } from "../render-utils";
23
24
  import { searchAnthropic } from "./providers/anthropic";
24
25
  import { searchExa } from "./providers/exa";
25
26
  import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
26
- import { formatAge, renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
27
+ import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
27
28
  import type { WebSearchProvider, WebSearchResponse } from "./types";
28
29
 
29
30
  /** Web search parameters schema */
@@ -31,8 +32,8 @@ export const webSearchSchema = Type.Object({
31
32
  // Common
32
33
  query: Type.String({ description: "Search query" }),
33
34
  provider: Type.Optional(
34
- Type.Union([Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], {
35
- description: "Search provider (auto-detected if omitted based on API keys)",
35
+ Type.Union([Type.Literal("auto"), Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], {
36
+ description: "Search provider (auto-detected if omitted or set to auto)",
36
37
  }),
37
38
  ),
38
39
  num_results: Type.Optional(Type.Number({ description: "Maximum number of results to return" })),
@@ -81,7 +82,7 @@ export const webSearchSchema = Type.Object({
81
82
 
82
83
  export type WebSearchParams = {
83
84
  query: string;
84
- provider?: "exa" | "anthropic" | "perplexity";
85
+ provider?: "auto" | "exa" | "anthropic" | "perplexity";
85
86
  num_results?: number;
86
87
  // Anthropic
87
88
  system_prompt?: string;
@@ -198,7 +199,7 @@ async function executeWebSearch(
198
199
  params: WebSearchParams,
199
200
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> {
200
201
  try {
201
- const provider = params.provider ?? (await detectProvider());
202
+ const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider();
202
203
 
203
204
  let response: WebSearchResponse;
204
205
  if (provider === "exa") {
@@ -5,6 +5,8 @@
5
5
  * Returns structured search results with optional content extraction.
6
6
  */
7
7
 
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
8
10
  import type { WebSearchResponse, WebSearchSource } from "../types";
9
11
 
10
12
  const EXA_API_URL = "https://api.exa.ai/search";
@@ -27,10 +29,9 @@ export interface ExaSearchParams {
27
29
  async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
28
30
  const result: Record<string, string> = {};
29
31
  try {
30
- const file = Bun.file(filePath);
31
- if (!(await file.exists())) return result;
32
+ if (!existsSync(filePath)) return result;
32
33
 
33
- const content = await file.text();
34
+ const content = readFileSync(filePath, "utf-8");
34
35
  for (const line of content.split("\n")) {
35
36
  let trimmed = line.trim();
36
37
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -57,8 +58,8 @@ async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
57
58
  return result;
58
59
  }
59
60
 
60
- function getHomeDir(): string | null {
61
- return process.env.HOME ?? process.env.USERPROFILE ?? null;
61
+ function getHomeDir(): string {
62
+ return homedir();
62
63
  }
63
64
 
64
65
  /** Find EXA_API_KEY from environment or .env files */
@@ -8,72 +8,39 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
9
  import type { Theme } from "../../../modes/interactive/theme/theme";
10
10
  import type { RenderResultOptions } from "../../custom-tools/types";
11
+ import {
12
+ formatAge,
13
+ formatCount,
14
+ formatExpandHint,
15
+ formatMoreItems,
16
+ getDomain,
17
+ getPreviewLines,
18
+ getStyledStatusIcon,
19
+ PREVIEW_LIMITS,
20
+ TRUNCATE_LENGTHS,
21
+ truncate,
22
+ } from "../render-utils";
11
23
  import type { WebSearchResponse } from "./types";
12
24
 
13
- /** Truncate text to max length with ellipsis */
14
- export function truncate(text: string, maxLen: number, ellipsis: string): string {
15
- if (text.length <= maxLen) return text;
16
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
17
- return `${text.slice(0, sliceLen)}${ellipsis}`;
18
- }
19
-
20
- /** Extract domain from URL */
21
- export function getDomain(url: string): string {
22
- try {
23
- const u = new URL(url);
24
- return u.hostname.replace(/^www\./, "");
25
- } catch {
26
- return url;
27
- }
28
- }
29
-
30
- /** Format age string from seconds */
31
- export function formatAge(ageSeconds: number | null | undefined): string {
32
- if (!ageSeconds) return "";
33
- const mins = Math.floor(ageSeconds / 60);
34
- const hours = Math.floor(mins / 60);
35
- const days = Math.floor(hours / 24);
36
- const weeks = Math.floor(days / 7);
37
- const months = Math.floor(days / 30);
38
-
39
- if (months > 0) return `${months}mo ago`;
40
- if (weeks > 0) return `${weeks}w ago`;
41
- if (days > 0) return `${days}d ago`;
42
- if (hours > 0) return `${hours}h ago`;
43
- if (mins > 0) return `${mins}m ago`;
44
- return "just now";
45
- }
46
-
47
- /** Get first N lines of text as preview */
48
- export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
49
- const lines = text.split("\n").filter((l) => l.trim());
50
- return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
51
- }
52
-
53
- const MAX_COLLAPSED_ANSWER_LINES = 3;
54
- const MAX_EXPANDED_ANSWER_LINES = 12;
55
- const MAX_ANSWER_LINE_LEN = 110;
25
+ const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
26
+ const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES;
27
+ const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE;
56
28
  const MAX_SNIPPET_LINES = 2;
57
- const MAX_SNIPPET_LINE_LEN = 110;
29
+ const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE;
58
30
  const MAX_RELATED_QUESTIONS = 6;
59
31
  const MAX_QUERY_PREVIEW = 2;
60
32
  const MAX_QUERY_LEN = 90;
61
33
  const MAX_REQUEST_ID_LEN = 36;
62
34
 
63
- function formatCount(label: string, count: number): string {
64
- const safeCount = Number.isFinite(count) ? count : 0;
65
- return `${safeCount} ${label}${safeCount === 1 ? "" : "s"}`;
66
- }
67
-
68
35
  function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component {
69
36
  const lines = contentText.split("\n").filter((line) => line.trim());
70
37
  const maxLines = expanded ? lines.length : 6;
71
38
  const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis));
72
39
  const remaining = lines.length - displayLines.length;
73
40
 
74
- const headerIcon = theme.fg("warning", theme.status.warning);
75
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
76
- let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")}${expandHint}`;
41
+ const headerIcon = getStyledStatusIcon("warning", theme);
42
+ const expandHint = formatExpandHint(expanded, remaining > 0, theme);
43
+ let text = `${headerIcon} ${theme.fg("dim", "Response")}${expandHint}`;
77
44
 
78
45
  if (displayLines.length === 0) {
79
46
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "No response data")}`;
@@ -87,10 +54,7 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme
87
54
  }
88
55
 
89
56
  if (!expanded && remaining > 0) {
90
- text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
91
- "muted",
92
- `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
93
- )}`;
57
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
94
58
  }
95
59
 
96
60
  return new Text(text, 0, 0);
@@ -134,6 +98,15 @@ export function renderWebSearchResult(
134
98
  : [];
135
99
  const provider = response.provider;
136
100
 
101
+ // Get answer text
102
+ const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
103
+ const contentText = answerText || rawText;
104
+ const totalAnswerLines = contentText ? contentText.split("\n").filter((l) => l.trim()).length : 0;
105
+ const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
106
+ const answerPreview = contentText
107
+ ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis)
108
+ : [];
109
+
137
110
  // Build header: status icon Web Search (provider) · counts
138
111
  const providerLabel =
139
112
  provider === "anthropic"
@@ -143,53 +116,48 @@ export function renderWebSearchResult(
143
116
  : provider === "exa"
144
117
  ? "Exa"
145
118
  : "Unknown";
146
- const headerIcon =
147
- sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning);
148
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
149
- let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
119
+ const headerIcon = getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme);
120
+ const hasMore =
121
+ totalAnswerLines > answerPreview.length ||
122
+ sourceCount > 0 ||
123
+ citationCount > 0 ||
124
+ relatedCount > 0 ||
125
+ searchQueries.length > 0;
126
+ const expandHint = formatExpandHint(expanded, hasMore, theme);
127
+ let text = `${headerIcon} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
150
128
  "dim",
151
129
  formatCount("source", sourceCount),
152
130
  )}${expandHint}`;
153
131
 
154
- // Get answer text
155
- const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
156
- const contentText = answerText || rawText;
157
- const totalAnswerLines = contentText ? contentText.split("\n").filter((l) => l.trim()).length : 0;
158
- const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
159
- const answerPreview = contentText
160
- ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis)
161
- : [];
162
-
163
132
  if (!expanded) {
164
133
  const answerTitle = `${theme.fg("accent", theme.status.info)} ${theme.fg("accent", "Answer")}`;
165
- text += `\n ${theme.fg("dim", theme.tree.vertical)} ${answerTitle}`;
134
+ text += `\n ${theme.fg("dim", theme.tree.branch)} ${answerTitle}`;
135
+
136
+ const remaining = totalAnswerLines - answerPreview.length;
137
+ const allLines: Array<{ text: string; style: "dim" | "muted" }> = [];
166
138
 
167
139
  if (answerPreview.length === 0) {
168
- text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
169
- "muted",
170
- "No answer text returned",
171
- )}`;
140
+ allLines.push({ text: "No answer text returned", style: "muted" });
172
141
  } else {
173
142
  for (const line of answerPreview) {
174
- text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
175
- "dim",
176
- line,
177
- )}`;
143
+ allLines.push({ text: line, style: "dim" });
178
144
  }
179
145
  }
180
-
181
- const remaining = totalAnswerLines - answerPreview.length;
182
146
  if (remaining > 0) {
183
- text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
184
- "muted",
185
- `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
186
- )}`;
147
+ allLines.push({ text: formatMoreItems(remaining, "line", theme), style: "muted" });
148
+ }
149
+
150
+ for (let i = 0; i < allLines.length; i++) {
151
+ const { text: lineText, style } = allLines[i];
152
+ const isLastLine = i === allLines.length - 1;
153
+ const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch;
154
+ text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", lineBranch)} ${theme.fg(style, lineText)}`;
187
155
  }
188
156
 
189
157
  const summary = [
190
158
  formatCount("source", sourceCount),
191
159
  formatCount("citation", citationCount),
192
- formatCount("related", relatedCount),
160
+ formatCount("related question", relatedCount),
193
161
  ].join(theme.sep.dot);
194
162
  text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", summary)}`;
195
163
  return new Text(text, 0, 0);
@@ -201,9 +169,7 @@ export function renderWebSearchResult(
201
169
  );
202
170
  const remainingAnswer = totalAnswerLines - answerPreview.length;
203
171
  if (remainingAnswer > 0) {
204
- answerSectionLines.push(
205
- theme.fg("muted", `${theme.format.ellipsis} ${remainingAnswer} more line${remainingAnswer === 1 ? "" : "s"}`),
206
- );
172
+ answerSectionLines.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme)));
207
173
  }
208
174
 
209
175
  const sourceLines: string[] = [];
@@ -263,14 +229,7 @@ export function renderWebSearchResult(
263
229
  relatedLines.push(theme.fg("muted", `${theme.format.dash} ${related[i]}`));
264
230
  }
265
231
  if (relatedCount > maxRelated) {
266
- relatedLines.push(
267
- theme.fg(
268
- "muted",
269
- `${theme.format.ellipsis} ${relatedCount - maxRelated} more question${
270
- relatedCount - maxRelated === 1 ? "" : "s"
271
- }`,
272
- ),
273
- );
232
+ relatedLines.push(theme.fg("muted", formatMoreItems(relatedCount - maxRelated, "question", theme)));
274
233
  }
275
234
  }
276
235
 
@@ -311,36 +270,29 @@ export function renderWebSearchResult(
311
270
  metaLines.push(theme.fg("muted", `${theme.format.dash} ${truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)}`));
312
271
  }
313
272
  if (searchQueries.length > MAX_QUERY_PREVIEW) {
314
- metaLines.push(
315
- theme.fg(
316
- "muted",
317
- `${theme.format.ellipsis} ${searchQueries.length - MAX_QUERY_PREVIEW} more query${
318
- searchQueries.length - MAX_QUERY_PREVIEW === 1 ? "" : "s"
319
- }`,
320
- ),
321
- );
273
+ metaLines.push(theme.fg("muted", formatMoreItems(searchQueries.length - MAX_QUERY_PREVIEW, "query", theme)));
322
274
  }
323
275
  }
324
276
 
325
277
  const sections: Array<{ title: string; icon: string; lines: string[] }> = [
326
278
  {
327
279
  title: "Answer",
328
- icon: theme.fg("accent", theme.status.info),
280
+ icon: getStyledStatusIcon("info", theme),
329
281
  lines: answerSectionLines,
330
282
  },
331
283
  {
332
284
  title: "Sources",
333
- icon: sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning),
285
+ icon: getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme),
334
286
  lines: sourceLines,
335
287
  },
336
288
  {
337
289
  title: "Related",
338
- icon: relatedCount > 0 ? theme.fg("accent", theme.status.info) : theme.fg("warning", theme.status.warning),
290
+ icon: getStyledStatusIcon(relatedCount > 0 ? "info" : "warning", theme),
339
291
  lines: relatedLines,
340
292
  },
341
293
  {
342
294
  title: "Meta",
343
- icon: theme.fg("accent", theme.status.info),
295
+ icon: getStyledStatusIcon("info", theme),
344
296
  lines: metaLines,
345
297
  },
346
298
  ];
@@ -349,11 +301,14 @@ export function renderWebSearchResult(
349
301
  const section = sections[i];
350
302
  const isLast = i === sections.length - 1;
351
303
  const branch = isLast ? theme.tree.last : theme.tree.branch;
352
- const indent = isLast ? " " : theme.tree.vertical;
304
+ const indent = isLast ? " " : `${theme.tree.vertical} `;
353
305
 
354
306
  text += `\n ${theme.fg("dim", branch)} ${section.icon} ${theme.fg("accent", section.title)}`;
355
- for (const line of section.lines) {
356
- text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${theme.tree.hook} `)}${line}`;
307
+ for (let j = 0; j < section.lines.length; j++) {
308
+ const line = section.lines[j];
309
+ const isLastLine = j === section.lines.length - 1;
310
+ const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch;
311
+ text += `\n ${theme.fg("dim", indent)}${theme.fg("dim", lineBranch)} ${line}`;
357
312
  }
358
313
  }
359
314
 
@@ -0,0 +1,135 @@
1
+ import { Agent, run, setDefaultOpenAIKey } from "@openai/agents";
2
+ import { z } from "zod";
3
+ import { logger } from "./logger";
4
+ import type { ModelRegistry } from "./model-registry";
5
+
6
+ const DEFAULT_CONTROLLER_MODEL = process.env.OMP_VOICE_CONTROLLER_MODEL ?? "gpt-4o-mini";
7
+ const DEFAULT_SUMMARY_MODEL = process.env.OMP_VOICE_SUMMARY_MODEL ?? DEFAULT_CONTROLLER_MODEL;
8
+ const MAX_INPUT_CHARS = 8000;
9
+
10
+ export type VoiceSteeringDecision = { action: "pass" | "ask"; text: string };
11
+ export type VoicePresentationDecision = { action: "skip" | "speak"; text?: string };
12
+ type VoiceSummaryOutput = { text: string };
13
+
14
+ const steeringSchema: z.ZodType<VoiceSteeringDecision> = z.object({
15
+ action: z.enum(["pass", "ask"]),
16
+ text: z.string().min(1),
17
+ });
18
+
19
+ const presentationSchema: z.ZodType<VoicePresentationDecision> = z.object({
20
+ action: z.enum(["skip", "speak"]),
21
+ text: z.string().min(1).optional(),
22
+ });
23
+
24
+ const summarySchema: z.ZodType<VoiceSummaryOutput> = z.object({
25
+ text: z.string().min(1),
26
+ });
27
+
28
+ function normalizeText(text: string): string {
29
+ return text.replace(/\s+/g, " ").trim();
30
+ }
31
+
32
+ function truncateText(text: string, maxChars: number): string {
33
+ if (text.length <= maxChars) return text;
34
+ return `${text.slice(0, maxChars)}...`;
35
+ }
36
+
37
+ export class VoiceController {
38
+ private lastApiKey: string | undefined;
39
+
40
+ constructor(private registry: ModelRegistry) {}
41
+
42
+ private async ensureApiKey(): Promise<string | null> {
43
+ const apiKey = await this.registry.getApiKeyForProvider("openai");
44
+ if (!apiKey) {
45
+ logger.debug("voice-controller: no OpenAI API key available");
46
+ return null;
47
+ }
48
+ if (apiKey !== this.lastApiKey) {
49
+ setDefaultOpenAIKey(apiKey);
50
+ this.lastApiKey = apiKey;
51
+ }
52
+ return apiKey;
53
+ }
54
+
55
+ async steerUserInput(text: string): Promise<VoiceSteeringDecision | null> {
56
+ if (!(await this.ensureApiKey())) return null;
57
+
58
+ const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS);
59
+ const agent = new Agent({
60
+ name: "Voice Input Steering",
61
+ instructions:
62
+ "You are a voice-input controller for a coding agent. " +
63
+ "Given a user's speech transcript, decide if it is clear enough to send to the agent. " +
64
+ "If unclear or missing key details, ask exactly one short question. " +
65
+ "If clear, rewrite it as a concise instruction for the agent. " +
66
+ "Keep it short and preserve intent.",
67
+ model: DEFAULT_CONTROLLER_MODEL,
68
+ outputType: steeringSchema,
69
+ });
70
+
71
+ try {
72
+ const result = await run(agent, normalized);
73
+ return result.finalOutput ?? null;
74
+ } catch (error) {
75
+ logger.debug("voice-controller: steering error", {
76
+ error: error instanceof Error ? error.message : String(error),
77
+ });
78
+ return null;
79
+ }
80
+ }
81
+
82
+ async decidePresentation(text: string): Promise<VoicePresentationDecision | null> {
83
+ if (!(await this.ensureApiKey())) return null;
84
+
85
+ const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS);
86
+ const agent = new Agent({
87
+ name: "Voice Presentation Gate",
88
+ instructions:
89
+ "You are a voice presentation gate for a coding agent. " +
90
+ "Decide whether to speak the assistant response to the user. " +
91
+ "Speak when there is a decision, summary, or a question for the user. " +
92
+ "Skip if it is mostly tool output, verbose logs, or not useful to speak. " +
93
+ "When speaking, respond in 1-3 short sentences (<=45 words) in a casual, concise tone. " +
94
+ "If user input is needed, ask exactly one short question.",
95
+ model: DEFAULT_CONTROLLER_MODEL,
96
+ outputType: presentationSchema,
97
+ });
98
+
99
+ try {
100
+ const result = await run(agent, normalized);
101
+ return result.finalOutput ?? null;
102
+ } catch (error) {
103
+ logger.debug("voice-controller: presentation error", {
104
+ error: error instanceof Error ? error.message : String(error),
105
+ });
106
+ return null;
107
+ }
108
+ }
109
+
110
+ async summarizeForVoice(text: string): Promise<string | null> {
111
+ if (!(await this.ensureApiKey())) return null;
112
+
113
+ const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS);
114
+ const agent = new Agent({
115
+ name: "Voice Summary",
116
+ instructions:
117
+ "Summarize the assistant response for voice playback. " +
118
+ "Use 1-2 short sentences. " +
119
+ "If a question is required from the user, ask one short question.",
120
+ model: DEFAULT_SUMMARY_MODEL,
121
+ outputType: summarySchema,
122
+ });
123
+
124
+ try {
125
+ const result = await run(agent, normalized);
126
+ const output = result.finalOutput?.text ?? "";
127
+ return output.trim() || null;
128
+ } catch (error) {
129
+ logger.debug("voice-controller: summary error", {
130
+ error: error instanceof Error ? error.message : String(error),
131
+ });
132
+ return null;
133
+ }
134
+ }
135
+ }