@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.
- package/CHANGELOG.md +30 -0
- package/examples/hooks/snake.ts +5 -5
- package/package.json +7 -7
- package/src/discovery/helpers.ts +6 -1
- package/src/ipy/gateway-coordinator.ts +0 -16
- package/src/modes/components/armin.ts +7 -7
- package/src/modes/components/extensions/extension-dashboard.ts +11 -2
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/footer.ts +5 -5
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +3 -3
- package/src/modes/components/status-line.ts +2 -2
- package/src/modes/components/welcome.ts +3 -3
- package/src/modes/controllers/command-controller.ts +2 -2
- package/src/patch/normalize.ts +3 -1
- package/src/prompts/system/plan-mode-active.md +5 -4
- package/src/prompts/system/subagent-system-prompt.md +14 -7
- package/src/prompts/tools/task-summary.md +0 -7
- package/src/prompts/tools/task.md +6 -6
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +63 -0
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +11 -3
- package/src/tools/gemini-image.ts +7 -8
- package/src/tools/index.ts +2 -0
- package/src/tools/python.ts +27 -2
- package/src/tui/output-block.ts +2 -2
- package/src/tui/utils.ts +2 -2
- package/src/web/search/auth.ts +6 -58
- package/src/web/search/index.ts +2 -6
- package/src/web/search/providers/anthropic.ts +6 -6
- package/src/web/search/providers/exa.ts +2 -62
- 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
|
|
package/examples/hooks/snake.ts
CHANGED
|
@@ -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
|
|
231
|
-
return dim(" │") + content +
|
|
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
|
|
295
|
-
return line +
|
|
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
|
+
"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.
|
|
83
|
-
"@oh-my-pi/pi-agent-core": "9.
|
|
84
|
-
"@oh-my-pi/pi-ai": "9.
|
|
85
|
-
"@oh-my-pi/pi-natives": "9.
|
|
86
|
-
"@oh-my-pi/pi-tui": "9.
|
|
87
|
-
"@oh-my-pi/pi-utils": "9.
|
|
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",
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -189,7 +189,12 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
|
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
|
|
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
|
|
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
|
|
91
|
-
const availableWidth = width -
|
|
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 -
|
|
97
|
-
return ` ${theme.fg("accent", clipped)}${
|
|
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 -
|
|
103
|
-
this.cachedLines.push(` ${theme.fg("accent", message)}${
|
|
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 {
|
|
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 +
|
|
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 +
|
|
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
|
|
287
|
-
statsLine = statsLeft +
|
|
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
|
|
297
|
-
statsLine = statsLeft +
|
|
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) :
|
|
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}${
|
|
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) :
|
|
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
|
|
355
|
-
return text +
|
|
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 +
|
|
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
|
|
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 +
|
|
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}${
|
|
776
|
+
return `${text}${padding(width - visible)}`;
|
|
777
777
|
}
|
|
778
778
|
|
|
779
779
|
function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
|
package/src/patch/normalize.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
59
|
-
|
|
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:
|
|
90
|
-
|
|
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
|
-
```
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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.
|
|
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
|
// =========================================================================
|
package/src/task/executor.ts
CHANGED
|
@@ -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 =
|
|
410
|
+
const geminiKey = getEnvApiKey("google");
|
|
412
411
|
if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
|
|
413
|
-
const googleKey =
|
|
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 =
|
|
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 =
|
|
427
|
+
const openRouterKey = getEnvApiKey("openrouter");
|
|
429
428
|
if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
|
|
430
429
|
|
|
431
|
-
const geminiKey =
|
|
430
|
+
const geminiKey = getEnvApiKey("google");
|
|
432
431
|
if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
|
|
433
432
|
|
|
434
|
-
const googleKey =
|
|
433
|
+
const googleKey = getEnv("GOOGLE_API_KEY");
|
|
435
434
|
if (googleKey) return { provider: "gemini", apiKey: googleKey };
|
|
436
435
|
|
|
437
436
|
return null;
|
package/src/tools/index.ts
CHANGED
|
@@ -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>;
|
package/src/tools/python.ts
CHANGED
|
@@ -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
|
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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 =
|
|
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 +
|
|
27
|
+
const padded = paddingNeeded > 0 ? text + padding(paddingNeeded) : text;
|
|
28
28
|
return bgFn ? bgFn(padded) : padded;
|
|
29
29
|
}
|
|
30
30
|
|
package/src/web/search/auth.ts
CHANGED
|
@@ -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 =
|
|
177
|
-
const searchBaseUrl =
|
|
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 =
|
|
232
|
-
const baseUrl =
|
|
179
|
+
const apiKey = getEnvApiKey("anthropic");
|
|
180
|
+
const baseUrl = getEnv("ANTHROPIC_BASE_URL");
|
|
233
181
|
if (apiKey) {
|
|
234
182
|
return {
|
|
235
183
|
apiKey,
|
package/src/web/search/index.ts
CHANGED
|
@@ -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).
|
|
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 =
|
|
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
|
|
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
|
-
|
|
45
|
-
return
|
|
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:
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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
|
}
|