@oh-my-pi/pi-coding-agent 9.3.0 → 9.4.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 (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/examples/hooks/snake.ts +5 -5
  3. package/package.json +7 -7
  4. package/src/discovery/helpers.ts +6 -1
  5. package/src/ipy/gateway-coordinator.ts +0 -16
  6. package/src/modes/components/armin.ts +7 -7
  7. package/src/modes/components/extensions/extension-dashboard.ts +11 -2
  8. package/src/modes/components/extensions/extension-list.ts +2 -2
  9. package/src/modes/components/footer.ts +5 -5
  10. package/src/modes/components/history-search.ts +2 -1
  11. package/src/modes/components/hook-selector.ts +2 -2
  12. package/src/modes/components/session-selector.ts +2 -1
  13. package/src/modes/components/status-line-segment-editor.ts +3 -3
  14. package/src/modes/components/status-line.ts +2 -2
  15. package/src/modes/components/welcome.ts +3 -3
  16. package/src/modes/controllers/command-controller.ts +2 -2
  17. package/src/patch/normalize.ts +3 -1
  18. package/src/prompts/system/plan-mode-active.md +5 -4
  19. package/src/prompts/system/subagent-system-prompt.md +14 -7
  20. package/src/prompts/tools/task-summary.md +0 -7
  21. package/src/prompts/tools/task.md +6 -6
  22. package/src/sdk.ts +1 -0
  23. package/src/session/agent-session.ts +63 -0
  24. package/src/task/executor.ts +3 -0
  25. package/src/task/index.ts +11 -3
  26. package/src/tools/gemini-image.ts +7 -8
  27. package/src/tools/index.ts +2 -0
  28. package/src/tools/python.ts +27 -2
  29. package/src/tui/output-block.ts +2 -2
  30. package/src/tui/utils.ts +2 -2
  31. package/src/web/search/auth.ts +6 -58
  32. package/src/web/search/index.ts +2 -6
  33. package/src/web/search/providers/anthropic.ts +6 -6
  34. package/src/web/search/providers/exa.ts +2 -62
  35. package/src/web/search/providers/perplexity.ts +6 -52
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [9.4.0] - 2026-01-31
6
+ ### Changed
7
+
8
+ - Migrated environment variable handling to use centralized `getEnv()` and `getEnvApiKey()` utilities from pi-ai package for consistent API key resolution across web search providers and image tools
9
+ - Simplified web search error messages to remove provider-specific configuration hints
10
+ - Replaced manual space padding with `padding()` utility function from pi-tui across UI components for consistent whitespace handling
11
+ - Improved rendering performance for Python cell output by implementing caching in the table and cell results renderers
12
+ - Updated task tool documentation to clarify that subagents can access parent conversation context via a searchable file, reducing need to repeat information in context parameter
13
+ - Updated plan mode prompt to guide model toward using `edit` tool for incremental plan updates instead of defaulting to `write`
14
+
15
+ ### Removed
16
+
17
+ - Removed environment variable denylist that blocked API keys from being passed to subprocesses; API keys are now controlled via allowlist only
18
+
19
+ ## [9.3.1] - 2026-01-31
20
+ ### Added
21
+
22
+ - Added `getCompactContext()` API to retrieve parent conversation context for subagents, excluding system prompts and tool results
23
+ - Added automatic `submit_result` tool injection for subagents with explicit tool lists
24
+ - Added `contextFile` parameter to pass parent conversation context to subagent sessions
25
+
26
+ ### Changed
27
+
28
+ - Updated subagent system prompt to reference parent conversation context file when available
29
+ - Enhanced subagent system prompt formatting with clearer backtick notation for tool and parameter names
30
+
31
+ ### Removed
32
+
33
+ - Removed schema override notification from task summary prompt
34
+
5
35
  ## [9.2.5] - 2026-01-31
6
36
  ### Changed
7
37
 
@@ -2,7 +2,7 @@
2
2
  * Snake game hook - play snake with /snake command
3
3
  */
4
4
  import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
5
- import { matchesKey, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import { matchesKey, padding, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
 
7
7
  const GAME_WIDTH = 40;
8
8
  const GAME_HEIGHT = 15;
@@ -227,8 +227,8 @@ class SnakeComponent {
227
227
  // Helper to pad content inside box
228
228
  const boxLine = (content: string) => {
229
229
  const contentLen = visibleWidth(content);
230
- const padding = Math.max(0, boxWidth - contentLen);
231
- return dim(" │") + content + " ".repeat(padding) + dim("│");
230
+ const pad = Math.max(0, boxWidth - contentLen);
231
+ return dim(" │") + content + padding(pad) + dim("│");
232
232
  };
233
233
 
234
234
  // Top border
@@ -291,8 +291,8 @@ class SnakeComponent {
291
291
  private padLine(line: string, width: number): string {
292
292
  // Calculate visible length (strip ANSI codes)
293
293
  const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
294
- const padding = Math.max(0, width - visibleLen);
295
- return line + " ".repeat(padding);
294
+ const pad = Math.max(0, width - visibleLen);
295
+ return line + padding(pad);
296
296
  }
297
297
 
298
298
  dispose(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "9.3.0",
3
+ "version": "9.4.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -79,12 +79,12 @@
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "9.3.0",
83
- "@oh-my-pi/pi-agent-core": "9.3.0",
84
- "@oh-my-pi/pi-ai": "9.3.0",
85
- "@oh-my-pi/pi-natives": "9.3.0",
86
- "@oh-my-pi/pi-tui": "9.3.0",
87
- "@oh-my-pi/pi-utils": "9.3.0",
82
+ "@oh-my-pi/omp-stats": "9.4.0",
83
+ "@oh-my-pi/pi-agent-core": "9.4.0",
84
+ "@oh-my-pi/pi-ai": "9.4.0",
85
+ "@oh-my-pi/pi-natives": "9.4.0",
86
+ "@oh-my-pi/pi-tui": "9.4.0",
87
+ "@oh-my-pi/pi-utils": "9.4.0",
88
88
  "@openai/agents": "^0.4.4",
89
89
  "@sinclair/typebox": "^0.34.48",
90
90
  "ajv": "^8.17.1",
@@ -189,7 +189,12 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
189
189
  return null;
190
190
  }
191
191
 
192
- const tools = parseArrayOrCSV(frontmatter.tools);
192
+ let tools = parseArrayOrCSV(frontmatter.tools);
193
+
194
+ // Subagents with explicit tool lists always need submit_result
195
+ if (tools && !tools.includes("submit_result")) {
196
+ tools = [...tools, "submit_result"];
197
+ }
193
198
 
194
199
  // Parse spawns field (array, "*", or CSV)
195
200
  let spawns: string[] | "*" | undefined;
@@ -88,27 +88,12 @@ const WINDOWS_ENV_ALLOWLIST = new Set([
88
88
 
89
89
  const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
90
90
 
91
- const DEFAULT_ENV_DENYLIST = new Set([
92
- "OPENAI_API_KEY",
93
- "ANTHROPIC_API_KEY",
94
- "GOOGLE_API_KEY",
95
- "GEMINI_API_KEY",
96
- "OPENROUTER_API_KEY",
97
- "PERPLEXITY_API_KEY",
98
- "EXA_API_KEY",
99
- "AZURE_OPENAI_API_KEY",
100
- "MISTRAL_API_KEY",
101
- ]);
102
-
103
91
  const CASE_INSENSITIVE_ENV = process.platform === "win32";
104
92
  const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
105
93
 
106
94
  const NORMALIZED_ALLOWLIST = new Map(
107
95
  Array.from(ACTIVE_ENV_ALLOWLIST, key => [CASE_INSENSITIVE_ENV ? key.toUpperCase() : key, key] as const),
108
96
  );
109
- const NORMALIZED_DENYLIST = new Set(
110
- Array.from(DEFAULT_ENV_DENYLIST, key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
111
- );
112
97
  const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
113
98
  ? DEFAULT_ENV_ALLOW_PREFIXES.map(prefix => prefix.toUpperCase())
114
99
  : DEFAULT_ENV_ALLOW_PREFIXES;
@@ -150,7 +135,6 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
150
135
  for (const [key, value] of Object.entries(env)) {
151
136
  if (value === undefined) continue;
152
137
  const normalizedKey = normalizeEnvKey(key);
153
- if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
154
138
  const canonicalKey = NORMALIZED_ALLOWLIST.get(normalizedKey);
155
139
  if (canonicalKey !== undefined) {
156
140
  filtered[canonicalKey] = value;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Armin says hi! A fun easter egg with animated XBM art.
3
3
  */
4
- import type { Component, TUI } from "@oh-my-pi/pi-tui";
4
+ import { type Component, padding, type TUI } from "@oh-my-pi/pi-tui";
5
5
  import { theme } from "../../modes/theme/theme";
6
6
 
7
7
  // XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground
@@ -87,20 +87,20 @@ export class ArminComponent implements Component {
87
87
  return this.cachedLines;
88
88
  }
89
89
 
90
- const padding = 1;
91
- const availableWidth = width - padding;
90
+ const indent = 1;
91
+ const availableWidth = width - indent;
92
92
 
93
93
  this.cachedLines = this.currentGrid.map(row => {
94
94
  // Clip row to available width before applying color
95
95
  const clipped = row.slice(0, availableWidth).join("");
96
- const padRight = Math.max(0, width - padding - clipped.length);
97
- return ` ${theme.fg("accent", clipped)}${" ".repeat(padRight)}`;
96
+ const padRight = Math.max(0, width - indent - clipped.length);
97
+ return ` ${theme.fg("accent", clipped)}${padding(padRight)}`;
98
98
  });
99
99
 
100
100
  // Add "ARMIN SAYS HI" at the end
101
101
  const message = "ARMIN SAYS HI";
102
- const msgPadRight = Math.max(0, width - padding - message.length);
103
- this.cachedLines.push(` ${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`);
102
+ const msgPadRight = Math.max(0, width - indent - message.length);
103
+ this.cachedLines.push(` ${theme.fg("accent", message)}${padding(msgPadRight)}`);
104
104
 
105
105
  this.cachedWidth = width;
106
106
  this.cachedVersion = this.gridVersion;
@@ -11,7 +11,16 @@
11
11
  * - Space: Toggle selected item (or master switch)
12
12
  * - Esc: Close dashboard (clears search first if active)
13
13
  */
14
- import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
14
+ import {
15
+ type Component,
16
+ Container,
17
+ matchesKey,
18
+ padding,
19
+ Spacer,
20
+ Text,
21
+ truncateToWidth,
22
+ visibleWidth,
23
+ } from "@oh-my-pi/pi-tui";
15
24
  import type { SettingsManager } from "../../../config/settings-manager";
16
25
  import { DynamicBorder } from "../../../modes/components/dynamic-border";
17
26
  import { theme } from "../../../modes/theme/theme";
@@ -296,7 +305,7 @@ class TwoColumnBody implements Component {
296
305
 
297
306
  for (let i = 0; i < numLines; i++) {
298
307
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
299
- const leftPadded = left + " ".repeat(Math.max(0, leftWidth - visibleWidth(left)));
308
+ const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
300
309
  const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
301
310
  combined.push(leftPadded + separator + right);
302
311
  }
@@ -5,7 +5,7 @@
5
5
  * that toggles the entire provider. All items below are dimmed when the
6
6
  * master switch is off.
7
7
  */
8
- import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
8
+ import { type Component, matchesKey, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
9
9
  import { isProviderEnabled } from "../../../discovery";
10
10
  import { theme } from "../../../modes/theme/theme";
11
11
  import { applyFilter } from "./state-manager";
@@ -272,7 +272,7 @@ export class ExtensionList implements Component {
272
272
  if (width >= targetWidth) {
273
273
  return truncateToWidth(text, targetWidth);
274
274
  }
275
- return text + " ".repeat(targetWidth - width);
275
+ return text + padding(targetWidth - width);
276
276
  }
277
277
 
278
278
  private rebuildList(): void {
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
- import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
  import type { AgentSession } from "../../session/agent-session";
@@ -283,8 +283,8 @@ export class FooterComponent implements Component {
283
283
  let statsLine: string;
284
284
  if (totalNeeded <= width) {
285
285
  // Both fit - add padding to right-align model
286
- const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
287
- statsLine = statsLeft + padding + rightSide;
286
+ const pad = padding(width - statsLeftWidth - rightSideWidth);
287
+ statsLine = statsLeft + pad + rightSide;
288
288
  } else {
289
289
  // Need to truncate right side
290
290
  const availableForRight = width - statsLeftWidth - minPadding;
@@ -293,8 +293,8 @@ export class FooterComponent implements Component {
293
293
  const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
294
294
  const truncatedPlain = plainRightSide.substring(0, availableForRight);
295
295
  // For simplicity, just use plain truncated version (loses color, but fits)
296
- const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
297
- statsLine = statsLeft + padding + truncatedPlain;
296
+ const pad = padding(width - statsLeftWidth - truncatedPlain.length);
297
+ statsLine = statsLeft + pad + truncatedPlain;
298
298
  } else {
299
299
  // Not enough space for right side at all
300
300
  statsLine = statsLeft;
@@ -3,6 +3,7 @@ import {
3
3
  Container,
4
4
  Input,
5
5
  matchesKey,
6
+ padding,
6
7
  Spacer,
7
8
  Text,
8
9
  truncateToWidth,
@@ -50,7 +51,7 @@ class HistoryResultsList implements Component {
50
51
 
51
52
  const cursorSymbol = `${theme.nav.cursor} `;
52
53
  const cursorWidth = visibleWidth(cursorSymbol);
53
- const cursor = isSelected ? theme.fg("accent", cursorSymbol) : " ".repeat(cursorWidth);
54
+ const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth);
54
55
  const maxWidth = width - cursorWidth;
55
56
 
56
57
  const normalized = entry.prompt.replace(/\s+/g, " ").trim();
@@ -2,7 +2,7 @@
2
2
  * Generic selector component for hooks.
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
- import { Container, matchesKey, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import { Container, matchesKey, padding, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
  import { CountdownTimer } from "./countdown-timer";
8
8
  import { DynamicBorder } from "./dynamic-border";
@@ -29,7 +29,7 @@ class OutlinedList extends Container {
29
29
  const innerWidth = Math.max(1, width - 2);
30
30
  const content = this.lines.map(line => {
31
31
  const pad = Math.max(0, innerWidth - visibleWidth(line));
32
- return `${borderColor(theme.boxSharp.vertical)}${line}${" ".repeat(pad)}${borderColor(theme.boxSharp.vertical)}`;
32
+ return `${borderColor(theme.boxSharp.vertical)}${line}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
33
33
  });
34
34
  return [horizontal, ...content, horizontal];
35
35
  }
@@ -3,6 +3,7 @@ import {
3
3
  Container,
4
4
  Input,
5
5
  matchesKey,
6
+ padding,
6
7
  Spacer,
7
8
  Text,
8
9
  truncateToWidth,
@@ -122,7 +123,7 @@ class SessionList implements Component {
122
123
  // First line: cursor + title (or first message if no title)
123
124
  const cursorSymbol = `${theme.nav.cursor} `;
124
125
  const cursorWidth = visibleWidth(cursorSymbol);
125
- const cursor = isSelected ? theme.fg("accent", cursorSymbol) : " ".repeat(cursorWidth);
126
+ const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth);
126
127
  const maxWidth = width - cursorWidth; // Account for cursor width
127
128
 
128
129
  if (session.title) {
@@ -8,7 +8,7 @@
8
8
  * - Shift+J/K: Reorder segment within column
9
9
  * - Live preview shown in the actual status line above
10
10
  */
11
- import { Container, matchesKey } from "@oh-my-pi/pi-tui";
11
+ import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
12
12
  import type { StatusLineSegmentId } from "../../config/settings-manager";
13
13
  import { theme } from "../../modes/theme/theme";
14
14
  import { ALL_SEGMENT_IDS } from "./status-line/segments";
@@ -351,7 +351,7 @@ export class StatusLineSegmentEditorComponent extends Container {
351
351
  }
352
352
 
353
353
  // Pad to column width (accounting for ANSI codes)
354
- const padding = colWidth - label.length - 1;
355
- return text + " ".repeat(Math.max(0, padding));
354
+ const padSize = colWidth - label.length - 1;
355
+ return text + padding(Math.max(0, padSize));
356
356
  }
357
357
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
- import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import { $ } from "bun";
6
6
  import type { StatusLineSegmentOptions, StatusLineSettings } from "../../config/settings-manager";
7
7
  import { theme } from "../../modes/theme/theme";
@@ -378,7 +378,7 @@ export class StatusLineComponent implements Component {
378
378
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
379
379
  rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
380
380
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
381
- return leftGroup + " ".repeat(gapWidth) + rightGroup;
381
+ return leftGroup + padding(gapWidth) + rightGroup;
382
382
  }
383
383
 
384
384
  getTopBorder(width: number): { content: string; width: number } {
@@ -1,4 +1,4 @@
1
- import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
2
  import { APP_NAME } from "../../config";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
 
@@ -169,7 +169,7 @@ export class WelcomeComponent implements Component {
169
169
  }
170
170
  const leftPad = Math.floor((width - visLen) / 2);
171
171
  const rightPad = width - visLen - leftPad;
172
- return " ".repeat(leftPad) + text + " ".repeat(rightPad);
172
+ return padding(leftPad) + text + padding(rightPad);
173
173
  }
174
174
 
175
175
  /** Apply magenta→cyan gradient to a string */
@@ -224,6 +224,6 @@ export class WelcomeComponent implements Component {
224
224
  }
225
225
  return `${truncated}${ellipsis}`;
226
226
  }
227
- return str + " ".repeat(width - visLen);
227
+ return str + padding(width - visLen);
228
228
  }
229
229
  }
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
5
- import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { $ } from "bun";
7
7
  import { nanoid } from "nanoid";
8
8
  import { loadCustomShare } from "../../export/custom-share";
@@ -773,7 +773,7 @@ function formatAccountHeader(limit: UsageLimit, report: UsageReport, index: numb
773
773
  function padColumn(text: string, width: number): string {
774
774
  const visible = visibleWidth(text);
775
775
  if (visible >= width) return text;
776
- return `${text}${" ".repeat(width - visible)}`;
776
+ return `${text}${padding(width - visible)}`;
777
777
  }
778
778
 
779
779
  function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
@@ -4,6 +4,8 @@
4
4
  * Handles line endings, BOM, whitespace, and Unicode normalization.
5
5
  */
6
6
 
7
+ import { padding } from "@oh-my-pi/pi-tui";
8
+
7
9
  // ═══════════════════════════════════════════════════════════════════════════
8
10
  // Line Ending Utilities
9
11
  // ═══════════════════════════════════════════════════════════════════════════
@@ -194,7 +196,7 @@ export function convertLeadingTabsToSpaces(text: string, spacesPerTab: number):
194
196
  if (trimmed.length === 0) return line;
195
197
  const leading = getLeadingWhitespace(line);
196
198
  if (!leading.includes("\t") || leading.includes(" ")) return line;
197
- const converted = " ".repeat(leading.length * spacesPerTab);
199
+ const converted = padding(leading.length * spacesPerTab);
198
200
  return converted + trimmed;
199
201
  })
200
202
  .join("\n");
@@ -18,6 +18,7 @@ Create your plan at `{{planFilePath}}`.
18
18
  {{/if}}
19
19
 
20
20
  The plan file is the ONLY file you may write or edit.
21
+ Use `{{editToolName}}` for incremental updates; use `{{writeToolName}}` only when creating or fully replacing the plan.
21
22
 
22
23
  <important>
23
24
  Plan execution runs in a fresh context (session cleared). Make the plan file self-contained: include any requirements, decisions, key findings, and remaining todos needed to continue without prior session history.
@@ -55,8 +56,8 @@ Use `ask` to clarify:
55
56
  - Preferences for UI/UX, performance, edge cases
56
57
 
57
58
  Batch questions. Do not ask what you can answer by exploring.
58
- ### 3. Write Incrementally
59
- Update the plan file as you learn. Do not wait until the end.
59
+ ### 3. Update Incrementally
60
+ Use `{{editToolName}}` to update the plan file as you learn. Do not wait until the end.
60
61
  ### 4. Calibrate
61
62
  - Large unspecified task → multiple interview rounds
62
63
  - Smaller task → fewer or no questions
@@ -86,8 +87,8 @@ Draft approach based on exploration. Consider trade-offs briefly, then choose.
86
87
  ### Phase 3: Review
87
88
  Read critical files. Verify plan matches original request. Use `ask` to clarify remaining questions.
88
89
 
89
- ### Phase 4: Write Plan
90
- Write to `{{planFilePath}}`:
90
+ ### Phase 4: Update Plan
91
+ Update `{{planFilePath}}` (use `{{editToolName}}` for changes, `{{writeToolName}}` only if creating from scratch):
91
92
  - Recommended approach only
92
93
  - Paths of critical files to modify
93
94
  - Verification section
@@ -1,24 +1,31 @@
1
1
  {{base}}
2
- -----------------------------------
2
+
3
+ ====================================================
3
4
 
4
5
  {{agent}}
5
6
 
7
+ {{#if contextFile}}
8
+ <context>
9
+ If you need additional context about the parent conversation, check {{contextFile}} (e.g., `tail -100` or `grep` for relevant terms).
10
+ </context>
11
+ {{/if}}
12
+
6
13
  <critical>
7
14
  {{#if worktree}}
8
15
  - You MUST work under this working tree: {{worktree}}. Do not modify anything under the original repository.
9
16
  {{/if}}
10
- - You MUST call the submit_result tool exactly once when finished. Do not output JSON in text. Do not end with a plain-text summary. Call submit_result with your result as the data parameter.
17
+ - You MUST call the `submit_result` tool exactly once when finished. Do not output JSON in text. Do not end with a plain-text summary. Call `submit_result` with your result as the `data` parameter.
11
18
  {{#if outputSchema}}
12
- - If you cannot complete the task, call submit_result with status="aborted" and an error message. Do not provide a success result or pretend completion.
19
+ - If you cannot complete the task, call `submit_result` with `status="aborted"` and an error message. Do not provide a success result or pretend completion.
13
20
  {{else}}
14
- - If you cannot complete the task, call submit_result with status="aborted" and an error message. Do not claim success.
21
+ - If you cannot complete the task, call `submit_result` with `status="aborted"` and an error message. Do not claim success.
15
22
  {{/if}}
16
23
  {{#if outputSchema}}
17
- - The data parameter MUST be valid JSON matching this TypeScript interface:
18
- ```typescript
24
+ - The `data` parameter MUST be valid JSON matching this TypeScript interface:
25
+ ```ts
19
26
  {{jtdToTypeScript outputSchema}}
20
27
  ```
21
28
  {{/if}}
22
- - If you cannot complete the task, call submit_result exactly once with a result that explicitly indicates failure or abort status (use a failure/notes field if available). Do not claim success.
29
+ - If you cannot complete the task, call `submit_result` exactly once with a result that explicitly indicates failure or abort status (use a failure/notes field if available). Do not claim success.
23
30
  - Keep going until request is fully fulfilled. This matters.
24
31
  </critical>
@@ -20,13 +20,6 @@
20
20
  {{/unless}}
21
21
  {{/each}}
22
22
 
23
- {{#if schemaOverridden}}
24
- <schema-note>
25
- Note: Agent '{{agentName}}' has a fixed output schema:
26
- {{requiredSchema}}
27
- </schema-note>
28
- {{/if}}
29
-
30
23
  {{#if patchApplySummary}}
31
24
  <patch-summary>
32
25
  {{patchApplySummary}}
@@ -5,10 +5,10 @@ Launch a new agent to handle complex, multi-step tasks autonomously. Each agent
5
5
  <critical>
6
6
  This matters. Get it right.
7
7
 
8
- Subagents have NO access to conversation history. They only see:
9
- 1. Their agent-specific system prompt
10
- 2. The `context` string you provide
11
- 3. The `task` string you provide
8
+ Subagents can access parent conversation context via a file—they can grep or tail it for details you don't include. Don't repeat information unnecessarily; focus `context` on:
9
+ - Task-specific constraints and decisions
10
+ - Critical requirements that must not be missed
11
+ - Information not easily found in the codebase
12
12
 
13
13
  Use a single Task call with multiple `tasks` entries when parallelizing. Multiple concurrent Task calls bypass coordination.
14
14
 
@@ -35,12 +35,12 @@ This matters. Be thorough.
35
35
  4. **Always provide a `schema`** with typed properties. Avoid `{ "type": "string" }`—if data has any structure (list, fields, categories), model it. Plain text is almost never the right choice.
36
36
  5. Assign distinct file scopes per task to avoid conflicts.
37
37
  6. Trust the returned data, then verify with tools when correctness matters.
38
- 7. The `context` must be self-contained. Paste relevant file contents, quote user requirements verbatim, include data from prior tool results. "The output user showed" means nothing to a subagent.
38
+ 7. For critical constraints, be explicit in `context`. For general background, subagents can search the parent context file themselves.
39
39
  </instruction>
40
40
 
41
41
  <parameters>
42
42
  - `agent`: Agent type to use for all tasks
43
- - `context`: Template with `\{{placeholders}}` for multi-task. Must be self-contained—include all information the subagent needs. Subagents cannot see conversation history, images, or prior tool results. Reproduce relevant content directly: paste file snippets, quote user requirements, embed data. Each placeholder is filled from task args. `\{{id}}` and `\{{description}}` are always available.
43
+ - `context`: Template with `\{{placeholders}}` for multi-task. Include critical constraints and task-specific decisions. Subagents have access to parent conversation context via a searchable file, so don't repeat everything—focus on what matters. `\{{id}}` and `\{{description}}` are always available.
44
44
  - `isolated`: (optional) Run each task in its own git worktree and return patches; patches are applied only if all apply cleanly.
45
45
  - `tasks`: Array of `{id, description, args}` - tasks to run in parallel
46
46
  - `id`: Short CamelCase identifier (max 32 chars, e.g., "SessionStore", "LspRefactor")
package/src/sdk.ts CHANGED
@@ -760,6 +760,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
760
760
  return undefined;
761
761
  },
762
762
  getPlanModeState: () => session.getPlanModeState(),
763
+ getCompactContext: () => session.formatCompactContext(),
763
764
  settings: settingsManager,
764
765
  settingsManager,
765
766
  authStorage,
@@ -3916,6 +3916,69 @@ Be thorough - include exact file paths, function names, error messages, and tech
3916
3916
  return lines.join("\n").trim();
3917
3917
  }
3918
3918
 
3919
+ /**
3920
+ * Format the conversation as compact context for subagents.
3921
+ * Includes only user messages and assistant text responses.
3922
+ * Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
3923
+ */
3924
+ formatCompactContext(): string {
3925
+ const lines: string[] = [];
3926
+ lines.push("# Conversation Context");
3927
+ lines.push("");
3928
+ lines.push(
3929
+ "This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
3930
+ );
3931
+ lines.push("");
3932
+
3933
+ for (const msg of this.messages) {
3934
+ if (msg.role === "user") {
3935
+ lines.push("## User");
3936
+ lines.push("");
3937
+ if (typeof msg.content === "string") {
3938
+ lines.push(msg.content);
3939
+ } else {
3940
+ for (const c of msg.content) {
3941
+ if (c.type === "text") {
3942
+ lines.push(c.text);
3943
+ } else if (c.type === "image") {
3944
+ lines.push("[Image attached]");
3945
+ }
3946
+ }
3947
+ }
3948
+ lines.push("");
3949
+ } else if (msg.role === "assistant") {
3950
+ const assistantMsg = msg as AssistantMessage;
3951
+ // Only include text content, skip tool calls and thinking
3952
+ const textParts: string[] = [];
3953
+ for (const c of assistantMsg.content) {
3954
+ if (c.type === "text" && c.text.trim()) {
3955
+ textParts.push(c.text);
3956
+ }
3957
+ }
3958
+ if (textParts.length > 0) {
3959
+ lines.push("## Assistant");
3960
+ lines.push("");
3961
+ lines.push(textParts.join("\n\n"));
3962
+ lines.push("");
3963
+ }
3964
+ } else if (msg.role === "fileMention") {
3965
+ const fileMsg = msg as FileMentionMessage;
3966
+ const paths = fileMsg.files.map(f => f.path).join(", ");
3967
+ lines.push(`[Files referenced: ${paths}]`);
3968
+ lines.push("");
3969
+ } else if (msg.role === "compactionSummary") {
3970
+ const compactMsg = msg as CompactionSummaryMessage;
3971
+ lines.push("## Earlier Context (Summarized)");
3972
+ lines.push("");
3973
+ lines.push(compactMsg.summary);
3974
+ lines.push("");
3975
+ }
3976
+ // Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
3977
+ }
3978
+
3979
+ return lines.join("\n").trim();
3980
+ }
3981
+
3919
3982
  // =========================================================================
3920
3983
  // Extension System
3921
3984
  // =========================================================================
@@ -196,6 +196,8 @@ export interface ExecutorOptions {
196
196
  sessionFile?: string | null;
197
197
  persistArtifacts?: boolean;
198
198
  artifactsDir?: string;
199
+ /** Path to parent conversation context file */
200
+ contextFile?: string;
199
201
  eventBus?: EventBus;
200
202
  contextFiles?: ContextFileEntry[];
201
203
  skills?: Skill[];
@@ -948,6 +950,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
948
950
  agent: agent.systemPrompt,
949
951
  worktree: worktree ?? "",
950
952
  outputSchema: normalizedOutputSchema,
953
+ contextFile: options.contextFile,
951
954
  }),
952
955
  sessionManager,
953
956
  hasUI: false,
package/src/task/index.ts CHANGED
@@ -211,7 +211,6 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
211
211
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
212
212
 
213
213
  // Output schema priority: agent frontmatter > params > inherited from parent session
214
- const schemaOverridden = outputSchema !== undefined && effectiveAgent.output !== undefined;
215
214
  const effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema;
216
215
 
217
216
  // Handle empty or missing tasks
@@ -382,6 +381,15 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
382
381
  };
383
382
  }
384
383
 
384
+ // Write parent conversation context for subagents
385
+ await fs.mkdir(effectiveArtifactsDir, { recursive: true });
386
+ const compactContext = this.session.getCompactContext?.();
387
+ let contextFilePath: string | undefined;
388
+ if (compactContext) {
389
+ contextFilePath = path.join(effectiveArtifactsDir, "context.md");
390
+ await Bun.write(contextFilePath, compactContext);
391
+ }
392
+
385
393
  // Build full prompts with context prepended
386
394
  // Allocate unique IDs across the session to prevent artifact collisions
387
395
  const outputManager =
@@ -478,6 +486,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
478
486
  sessionFile,
479
487
  persistArtifacts: !!artifactsDir,
480
488
  artifactsDir: effectiveArtifactsDir,
489
+ contextFile: contextFilePath,
481
490
  enableLsp: false,
482
491
  signal,
483
492
  eventBus: undefined,
@@ -522,6 +531,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
522
531
  sessionFile,
523
532
  persistArtifacts: !!artifactsDir,
524
533
  artifactsDir: effectiveArtifactsDir,
534
+ contextFile: contextFilePath,
525
535
  enableLsp: false,
526
536
  signal,
527
537
  eventBus: undefined,
@@ -735,9 +745,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
735
745
  duration: formatDuration(totalDuration),
736
746
  summaries,
737
747
  outputIds,
738
- schemaOverridden,
739
748
  agentName,
740
- requiredSchema: agent.output ? JSON.stringify(agent.output) : "",
741
749
  patchApplySummary,
742
750
  });
743
751
 
@@ -1,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { StringEnum } from "@oh-my-pi/pi-ai";
3
+ import { getEnv, getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
6
  import { nanoid } from "nanoid";
@@ -9,7 +9,6 @@ import { renderPromptTemplate } from "../config/prompt-templates";
9
9
  import type { CustomTool } from "../extensibility/custom-tools/types";
10
10
  import geminiImageDescription from "../prompts/tools/gemini-image.md" with { type: "text" };
11
11
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
12
- import { getEnv } from "../web/search/auth";
13
12
  import { resolveReadPath } from "./path-utils";
14
13
 
15
14
  const DEFAULT_MODEL = "gemini-3-pro-image-preview";
@@ -408,13 +407,13 @@ async function findImageApiKey(modelRegistry?: ModelRegistry): Promise<ImageApiK
408
407
  // Fall through to auto-detect if preferred provider key not found
409
408
  }
410
409
  if (preferredImageProvider === "gemini") {
411
- const geminiKey = await getEnv("GEMINI_API_KEY");
410
+ const geminiKey = getEnvApiKey("google");
412
411
  if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
413
- const googleKey = await getEnv("GOOGLE_API_KEY");
412
+ const googleKey = getEnv("GOOGLE_API_KEY");
414
413
  if (googleKey) return { provider: "gemini", apiKey: googleKey };
415
414
  // Fall through to auto-detect if preferred provider key not found
416
415
  } else if (preferredImageProvider === "openrouter") {
417
- const openRouterKey = await getEnv("OPENROUTER_API_KEY");
416
+ const openRouterKey = getEnvApiKey("openrouter");
418
417
  if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
419
418
  // Fall through to auto-detect if preferred provider key not found
420
419
  }
@@ -425,13 +424,13 @@ async function findImageApiKey(modelRegistry?: ModelRegistry): Promise<ImageApiK
425
424
  if (antigravity) return antigravity;
426
425
  }
427
426
 
428
- const openRouterKey = await getEnv("OPENROUTER_API_KEY");
427
+ const openRouterKey = getEnvApiKey("openrouter");
429
428
  if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
430
429
 
431
- const geminiKey = await getEnv("GEMINI_API_KEY");
430
+ const geminiKey = getEnvApiKey("google");
432
431
  if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
433
432
 
434
- const googleKey = await getEnv("GOOGLE_API_KEY");
433
+ const googleKey = getEnv("GOOGLE_API_KEY");
435
434
  if (googleKey) return { provider: "gemini", apiKey: googleKey };
436
435
 
437
436
  return null;
@@ -173,6 +173,8 @@ export interface ToolSession {
173
173
  };
174
174
  /** Plan mode state (if active) */
175
175
  getPlanModeState?: () => PlanModeState | undefined;
176
+ /** Get compact conversation context for subagents (excludes tool results, system prompts) */
177
+ getCompactContext?: () => string;
176
178
  }
177
179
 
178
180
  type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
@@ -785,8 +785,15 @@ export const pythonToolRenderer = {
785
785
  return new Text(text, 0, 0);
786
786
  }
787
787
 
788
+ // Cache state - cells don't change, only width varies
789
+ let cached: { width: number; result: string[] } | undefined;
790
+
788
791
  return {
789
792
  render: (width: number): string[] => {
793
+ if (cached && cached.width === width) {
794
+ return cached.result;
795
+ }
796
+
790
797
  const lines: string[] = [];
791
798
  for (let i = 0; i < cells.length; i++) {
792
799
  const cell = cells[i];
@@ -812,9 +819,12 @@ export const pythonToolRenderer = {
812
819
  lines.push("");
813
820
  }
814
821
  }
822
+ cached = { width, result: lines };
815
823
  return lines;
816
824
  },
817
- invalidate: () => {},
825
+ invalidate: () => {
826
+ cached = undefined;
827
+ },
818
828
  };
819
829
  },
820
830
 
@@ -864,8 +874,20 @@ export const pythonToolRenderer = {
864
874
 
865
875
  const cellResults = details?.cells;
866
876
  if (cellResults && cellResults.length > 0) {
877
+ // Cache state following Box pattern
878
+ let cached: { key: string; width: number; result: string[] } | undefined;
879
+
880
+ const buildCacheKey = (spinnerFrame: number | undefined): string => {
881
+ return `${expanded}|${previewLines}|${spinnerFrame}`;
882
+ };
883
+
867
884
  return {
868
885
  render: (width: number): string[] => {
886
+ const key = buildCacheKey(options.spinnerFrame);
887
+ if (cached && cached.key === key && cached.width === width) {
888
+ return cached.result;
889
+ }
890
+
869
891
  const lines: string[] = [];
870
892
  for (let i = 0; i < cellResults.length; i++) {
871
893
  const cell = cellResults[i];
@@ -921,9 +943,12 @@ export const pythonToolRenderer = {
921
943
  if (warningLine) {
922
944
  lines.push(warningLine);
923
945
  }
946
+ cached = { key, width, result: lines };
924
947
  return lines;
925
948
  },
926
- invalidate: () => {},
949
+ invalidate: () => {
950
+ cached = undefined;
951
+ },
927
952
  };
928
953
  }
929
954
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bordered output container with optional header and sections.
3
3
  */
4
- import { visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { padding, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme } from "../modes/theme/theme";
6
6
  import type { State } from "./types";
7
7
  import { getStateBgColor, padToWidth, truncateToWidth } from "./utils";
@@ -70,7 +70,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
70
70
  const allLines = section.lines.flatMap(l => l.split("\n"));
71
71
  for (const line of allLines) {
72
72
  const text = truncateToWidth(line, contentWidth, theme.format.ellipsis);
73
- const innerPadding = " ".repeat(Math.max(0, contentWidth - visibleWidth(text)));
73
+ const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(text)));
74
74
  const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`;
75
75
  lines.push(padToWidth(fullLine, lineWidth, bgFn));
76
76
  }
package/src/tui/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared helpers for tool-rendered UI components.
3
3
  */
4
- import { truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { padding, truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme, ThemeBg } from "../modes/theme/theme";
6
6
  import type { IconType, State } from "./types";
7
7
 
@@ -24,7 +24,7 @@ export function truncateToWidth(text: string, width: number, ellipsis: string):
24
24
  export function padToWidth(text: string, width: number, bgFn?: (s: string) => string): string {
25
25
  if (width <= 0) return bgFn ? bgFn(text) : text;
26
26
  const paddingNeeded = Math.max(0, width - visibleWidth(text));
27
- const padded = paddingNeeded > 0 ? text + " ".repeat(paddingNeeded) : text;
27
+ const padded = paddingNeeded > 0 ? text + padding(paddingNeeded) : text;
28
28
  return bgFn ? bgFn(padded) : padded;
29
29
  }
30
30
 
@@ -7,9 +7,8 @@
7
7
  * 3. OAuth credentials in ~/.omp/agent/agent.db (with expiry check)
8
8
  * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback
9
9
  */
10
- import * as os from "node:os";
11
10
  import * as path from "node:path";
12
- import { buildAnthropicHeaders as buildProviderAnthropicHeaders } from "@oh-my-pi/pi-ai";
11
+ import { buildAnthropicHeaders as buildProviderAnthropicHeaders, getEnv, getEnvApiKey } from "@oh-my-pi/pi-ai";
13
12
  import { logger } from "@oh-my-pi/pi-utils";
14
13
  import { getAgentDbPath, getConfigDirPaths } from "../../config";
15
14
  import { AgentStorage } from "../../session/agent-storage";
@@ -18,58 +17,7 @@ import { migrateJsonStorage } from "../../session/storage-migration";
18
17
  import type { AnthropicAuthConfig, AnthropicOAuthCredential, ModelsJson } from "./types";
19
18
 
20
19
  const DEFAULT_BASE_URL = "https://api.anthropic.com";
21
-
22
- /**
23
- * Parses a .env file and extracts key-value pairs.
24
- * @param filePath - Path to the .env file
25
- * @returns Object containing parsed environment variables
26
- */
27
- async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
28
- const result: Record<string, string> = {};
29
- try {
30
- const file = Bun.file(filePath);
31
- if (!(await file.exists())) return result;
32
-
33
- const content = await file.text();
34
- for (const line of content.split("\n")) {
35
- const trimmed = line.trim();
36
- if (!trimmed || trimmed.startsWith("#")) continue;
37
-
38
- const eqIndex = trimmed.indexOf("=");
39
- if (eqIndex === -1) continue;
40
-
41
- const key = trimmed.slice(0, eqIndex).trim();
42
- let value = trimmed.slice(eqIndex + 1).trim();
43
-
44
- // Remove surrounding quotes
45
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
46
- value = value.slice(1, -1);
47
- }
48
-
49
- result[key] = value;
50
- }
51
- } catch (error) {
52
- logger.warn("Failed to read .env file", { path: filePath, error: String(error) });
53
- }
54
- return result;
55
- }
56
-
57
- /**
58
- * Gets an environment variable from process.env or .env files.
59
- * @param key - The environment variable name to look up
60
- * @returns The value if found, undefined otherwise
61
- */
62
- export async function getEnv(key: string): Promise<string | undefined> {
63
- if (process.env[key]) return process.env[key];
64
-
65
- const localEnv = await parseEnvFile(`${process.cwd()}/.env`);
66
- if (localEnv[key]) return localEnv[key];
67
-
68
- const homeEnv = await parseEnvFile(`${os.homedir()}/.env`);
69
- if (homeEnv[key]) return homeEnv[key];
70
-
71
- return undefined;
72
- }
20
+ export { getEnv };
73
21
 
74
22
  /**
75
23
  * Reads and parses a JSON file safely.
@@ -173,8 +121,8 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
173
121
  const configDirs = getConfigDirPaths("", { project: false });
174
122
 
175
123
  // 1. Explicit search-specific env vars
176
- const searchApiKey = await getEnv("ANTHROPIC_SEARCH_API_KEY");
177
- const searchBaseUrl = await getEnv("ANTHROPIC_SEARCH_BASE_URL");
124
+ const searchApiKey = getEnv("ANTHROPIC_SEARCH_API_KEY");
125
+ const searchBaseUrl = getEnv("ANTHROPIC_SEARCH_BASE_URL");
178
126
  if (searchApiKey) {
179
127
  return {
180
128
  apiKey: searchApiKey,
@@ -228,8 +176,8 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
228
176
  }
229
177
 
230
178
  // 4. Generic ANTHROPIC_API_KEY fallback
231
- const apiKey = await getEnv("ANTHROPIC_API_KEY");
232
- const baseUrl = await getEnv("ANTHROPIC_BASE_URL");
179
+ const apiKey = getEnvApiKey("anthropic");
180
+ const baseUrl = getEnv("ANTHROPIC_BASE_URL");
233
181
  if (apiKey) {
234
182
  return {
235
183
  apiKey,
@@ -96,14 +96,10 @@ function formatProviderList(providers: WebSearchProvider[]): string {
96
96
  return providers.map(provider => formatProviderLabel(provider)).join(", ");
97
97
  }
98
98
 
99
- function buildNoProviderError(): string {
100
- return "No web search provider configured. Set EXA_API_KEY, PERPLEXITY_API_KEY, ANTHROPIC_SEARCH_API_KEY, or ANTHROPIC_API_KEY.";
101
- }
102
-
103
99
  function formatProviderError(error: unknown, provider: WebSearchProvider): string {
104
100
  if (error instanceof WebSearchProviderError) {
105
101
  if (error.provider === "anthropic" && error.status === 404) {
106
- return "Anthropic web search returned 404 (model or endpoint not found). Set ANTHROPIC_SEARCH_MODEL/ANTHROPIC_SEARCH_BASE_URL, or configure EXA_API_KEY or PERPLEXITY_API_KEY.";
102
+ return "Anthropic web search returned 404 (model or endpoint not found).";
107
103
  }
108
104
  if (error.status === 401 || error.status === 403) {
109
105
  return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
@@ -221,7 +217,7 @@ async function executeWebSearch(
221
217
  const { providers, allowFallback } = await resolveProviderChain(params.provider);
222
218
 
223
219
  if (providers.length === 0) {
224
- const message = buildNoProviderError();
220
+ const message = "No web search provider configured.";
225
221
  const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider;
226
222
  return {
227
223
  content: [{ type: "text" as const, text: `Error: ${message}` }],
@@ -4,8 +4,8 @@
4
4
  * Uses Claude's built-in web_search_20250305 tool to search the web.
5
5
  * Returns synthesized answers with citations and source metadata.
6
6
  */
7
- import { applyClaudeToolPrefix, buildAnthropicSystemBlocks, stripClaudeToolPrefix } from "@oh-my-pi/pi-ai";
8
- import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth, getEnv } from "../../../web/search/auth";
7
+ import { applyClaudeToolPrefix, buildAnthropicSystemBlocks, getEnv, stripClaudeToolPrefix } from "@oh-my-pi/pi-ai";
8
+ import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth } from "../../../web/search/auth";
9
9
  import type {
10
10
  AnthropicApiResponse,
11
11
  AnthropicAuthConfig,
@@ -41,8 +41,8 @@ export interface AnthropicSearchParams {
41
41
  * Gets the model to use for web search from environment or default.
42
42
  * @returns Model identifier string
43
43
  */
44
- async function getModel(): Promise<string> {
45
- return (await getEnv("ANTHROPIC_SEARCH_MODEL")) ?? DEFAULT_MODEL;
44
+ function getModel(): string {
45
+ return getEnv("ANTHROPIC_SEARCH_MODEL") ?? DEFAULT_MODEL;
46
46
  }
47
47
 
48
48
  /**
@@ -184,7 +184,7 @@ function parseResponse(response: AnthropicApiResponse): WebSearchResponse {
184
184
  sources.push({
185
185
  title: result.title,
186
186
  url: result.url,
187
- snippet: result.encrypted_content,
187
+ snippet: undefined,
188
188
  publishedDate: result.page_age ?? undefined,
189
189
  ageSeconds: parsePageAge(result.page_age),
190
190
  });
@@ -235,7 +235,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise<We
235
235
  );
236
236
  }
237
237
 
238
- const model = await getModel();
238
+ const model = getModel();
239
239
  const response = await callWebSearch(auth, model, params.query, params.system_prompt);
240
240
 
241
241
  const result = parseResponse(response);
@@ -4,7 +4,7 @@
4
4
  * High-quality neural search via Exa Search API.
5
5
  * Returns structured search results with optional content extraction.
6
6
  */
7
- import * as os from "node:os";
7
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
8
  import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types";
9
9
  import { WebSearchProviderError } from "../../../web/search/types";
10
10
 
@@ -24,66 +24,6 @@ export interface ExaSearchParams {
24
24
  end_published_date?: string;
25
25
  }
26
26
 
27
- /** Parse a .env file and return key-value pairs */
28
- async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
29
- const result: Record<string, string> = {};
30
- try {
31
- const content = await Bun.file(filePath).text();
32
- for (const line of content.split("\n")) {
33
- let trimmed = line.trim();
34
- if (!trimmed || trimmed.startsWith("#")) continue;
35
-
36
- if (trimmed.startsWith("export ")) {
37
- trimmed = trimmed.slice("export ".length).trim();
38
- }
39
-
40
- const eqIndex = trimmed.indexOf("=");
41
- if (eqIndex === -1) continue;
42
-
43
- const key = trimmed.slice(0, eqIndex).trim();
44
- let value = trimmed.slice(eqIndex + 1).trim();
45
-
46
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
47
- value = value.slice(1, -1);
48
- }
49
-
50
- result[key] = value;
51
- }
52
- } catch {
53
- // Ignore read errors (including ENOENT for missing files)
54
- }
55
- return result;
56
- }
57
-
58
- function getHomeDir(): string {
59
- return os.homedir();
60
- }
61
-
62
- /** Find EXA_API_KEY from environment or .env files */
63
- export async function findApiKey(): Promise<string | null> {
64
- // 1. Check environment variable
65
- if (process.env.EXA_API_KEY) {
66
- return process.env.EXA_API_KEY;
67
- }
68
-
69
- // 2. Check .env in current directory
70
- const localEnv = await parseEnvFile(`${process.cwd()}/.env`);
71
- if (localEnv.EXA_API_KEY) {
72
- return localEnv.EXA_API_KEY;
73
- }
74
-
75
- // 3. Check ~/.env
76
- const homeDir = getHomeDir();
77
- if (homeDir) {
78
- const homeEnv = await parseEnvFile(`${homeDir}/.env`);
79
- if (homeEnv.EXA_API_KEY) {
80
- return homeEnv.EXA_API_KEY;
81
- }
82
- }
83
-
84
- return null;
85
- }
86
-
87
27
  interface ExaSearchResult {
88
28
  title?: string | null;
89
29
  url?: string | null;
@@ -159,7 +99,7 @@ function dateToAgeSeconds(dateStr: string | null | undefined): number | undefine
159
99
 
160
100
  /** Execute Exa web search */
161
101
  export async function searchExa(params: ExaSearchParams): Promise<WebSearchResponse> {
162
- const apiKey = await findApiKey();
102
+ const apiKey = getEnvApiKey("exa");
163
103
  if (!apiKey) {
164
104
  throw new Error("EXA_API_KEY not found. Set it in environment or .env file.");
165
105
  }
@@ -4,7 +4,8 @@
4
4
  * Supports both sonar (fast) and sonar-pro (comprehensive) models.
5
5
  * Returns synthesized answers with citations and related questions.
6
6
  */
7
- import * as os from "node:os";
7
+
8
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
9
  import type {
9
10
  PerplexityRequest,
10
11
  PerplexityResponse,
@@ -23,56 +24,9 @@ export interface PerplexitySearchParams {
23
24
  num_results?: number;
24
25
  }
25
26
 
26
- /** Parse a .env file and return key-value pairs */
27
- async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
28
- const result: Record<string, string> = {};
29
- try {
30
- const file = Bun.file(filePath);
31
- if (!(await file.exists())) return result;
32
-
33
- const content = await file.text();
34
- for (const line of content.split("\n")) {
35
- const trimmed = line.trim();
36
- if (!trimmed || trimmed.startsWith("#")) continue;
37
-
38
- const eqIndex = trimmed.indexOf("=");
39
- if (eqIndex === -1) continue;
40
-
41
- const key = trimmed.slice(0, eqIndex).trim();
42
- let value = trimmed.slice(eqIndex + 1).trim();
43
-
44
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
45
- value = value.slice(1, -1);
46
- }
47
-
48
- result[key] = value;
49
- }
50
- } catch {
51
- // Ignore read errors
52
- }
53
- return result;
54
- }
55
-
56
- /** Find PERPLEXITY_API_KEY from environment or .env files */
57
- export async function findApiKey(): Promise<string | null> {
58
- // 1. Check environment variable
59
- if (process.env.PERPLEXITY_API_KEY) {
60
- return process.env.PERPLEXITY_API_KEY;
61
- }
62
-
63
- // 2. Check .env in current directory
64
- const localEnv = await parseEnvFile(`${process.cwd()}/.env`);
65
- if (localEnv.PERPLEXITY_API_KEY) {
66
- return localEnv.PERPLEXITY_API_KEY;
67
- }
68
-
69
- // 3. Check ~/.env
70
- const homeEnv = await parseEnvFile(`${os.homedir()}/.env`);
71
- if (homeEnv.PERPLEXITY_API_KEY) {
72
- return homeEnv.PERPLEXITY_API_KEY;
73
- }
74
-
75
- return null;
27
+ /** Find PERPLEXITY_API_KEY from environment or .env files (also checks PPLX_API_KEY) */
28
+ export function findApiKey(): string | null {
29
+ return getEnvApiKey("perplexity") ?? null;
76
30
  }
77
31
 
78
32
  /** Call Perplexity API */
@@ -154,7 +108,7 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse {
154
108
 
155
109
  /** Execute Perplexity web search */
156
110
  export async function searchPerplexity(params: PerplexitySearchParams): Promise<WebSearchResponse> {
157
- const apiKey = await findApiKey();
111
+ const apiKey = findApiKey();
158
112
  if (!apiKey) {
159
113
  throw new Error("PERPLEXITY_API_KEY not found. Set it in environment or .env file.");
160
114
  }