@oh-my-pi/pi-coding-agent 13.3.6 → 13.3.8
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 +115 -0
- package/package.json +9 -18
- package/scripts/format-prompts.ts +7 -172
- package/src/capability/mcp.ts +5 -0
- package/src/cli/args.ts +1 -0
- package/src/config/prompt-templates.ts +9 -55
- package/src/config/settings-schema.ts +24 -0
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/codex.ts +1 -2
- package/src/discovery/helpers.ts +0 -5
- package/src/discovery/mcp-json.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/client.ts +8 -0
- package/src/lsp/config.ts +2 -3
- package/src/lsp/index.ts +379 -99
- package/src/lsp/render.ts +21 -31
- package/src/lsp/types.ts +21 -8
- package/src/lsp/utils.ts +193 -1
- package/src/mcp/config-writer.ts +3 -0
- package/src/mcp/config.ts +1 -0
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/mcp/types.ts +5 -0
- package/src/modes/components/settings-defs.ts +9 -0
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/controllers/mcp-command-controller.ts +6 -2
- package/src/modes/interactive-mode.ts +8 -1
- package/src/modes/theme/mermaid-cache.ts +4 -4
- package/src/modes/theme/theme.ts +33 -0
- package/src/prompts/system/custom-system-prompt.md +0 -10
- package/src/prompts/system/subagent-user-prompt.md +2 -0
- package/src/prompts/system/system-prompt.md +12 -9
- package/src/prompts/tools/ast-find.md +20 -0
- package/src/prompts/tools/ast-replace.md +21 -0
- package/src/prompts/tools/bash.md +2 -0
- package/src/prompts/tools/hashline.md +26 -8
- package/src/prompts/tools/lsp.md +22 -5
- package/src/prompts/tools/task.md +0 -1
- package/src/sdk.ts +11 -5
- package/src/session/agent-session.ts +293 -83
- package/src/system-prompt.ts +3 -34
- package/src/task/executor.ts +8 -7
- package/src/task/index.ts +8 -55
- package/src/task/template.ts +2 -4
- package/src/task/types.ts +0 -5
- package/src/task/worktree.ts +6 -2
- package/src/tools/ast-find.ts +316 -0
- package/src/tools/ast-replace.ts +294 -0
- package/src/tools/bash.ts +2 -1
- package/src/tools/browser.ts +2 -8
- package/src/tools/fetch.ts +55 -18
- package/src/tools/index.ts +8 -0
- package/src/tools/jtd-to-json-schema.ts +29 -13
- package/src/tools/path-utils.ts +34 -0
- package/src/tools/python.ts +2 -1
- package/src/tools/renderers.ts +4 -0
- package/src/tools/ssh.ts +2 -1
- package/src/tools/submit-result.ts +143 -44
- package/src/tools/todo-write.ts +34 -0
- package/src/tools/tool-timeouts.ts +29 -0
- package/src/utils/mime.ts +37 -14
- package/src/utils/prompt-format.ts +172 -0
- package/src/web/scrapers/arxiv.ts +12 -12
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/iacr.ts +17 -9
- package/src/web/scrapers/readthedocs.ts +3 -3
- package/src/web/scrapers/twitter.ts +11 -11
- package/src/web/scrapers/wikipedia.ts +4 -5
- package/src/utils/ignore-files.ts +0 -119
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import { astReplace } from "@oh-my-pi/pi-natives";
|
|
3
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
5
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
7
|
+
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
8
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
9
|
+
import type { Theme } from "../modes/theme/theme";
|
|
10
|
+
import astReplaceDescription from "../prompts/tools/ast-replace.md" with { type: "text" };
|
|
11
|
+
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
|
12
|
+
import type { ToolSession } from ".";
|
|
13
|
+
import type { OutputMeta } from "./output-meta";
|
|
14
|
+
import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
|
|
15
|
+
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
|
16
|
+
import { ToolError } from "./tool-errors";
|
|
17
|
+
import { toolResult } from "./tool-result";
|
|
18
|
+
|
|
19
|
+
const astReplaceSchema = Type.Object({
|
|
20
|
+
pattern: Type.String({ description: "AST pattern to match" }),
|
|
21
|
+
rewrite: Type.String({ description: "Rewrite template" }),
|
|
22
|
+
lang: Type.Optional(Type.String({ description: "Language override" })),
|
|
23
|
+
path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
|
|
24
|
+
selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
|
|
25
|
+
dry_run: Type.Optional(Type.Boolean({ description: "Preview only (default: true)" })),
|
|
26
|
+
max_replacements: Type.Optional(Type.Number({ description: "Safety cap on total replacements" })),
|
|
27
|
+
max_files: Type.Optional(Type.Number({ description: "Safety cap on touched files" })),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface AstReplaceToolDetails {
|
|
31
|
+
totalReplacements: number;
|
|
32
|
+
filesTouched: number;
|
|
33
|
+
filesSearched: number;
|
|
34
|
+
applied: boolean;
|
|
35
|
+
limitReached: boolean;
|
|
36
|
+
parseErrors?: string[];
|
|
37
|
+
meta?: OutputMeta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AstReplaceTool implements AgentTool<typeof astReplaceSchema, AstReplaceToolDetails> {
|
|
41
|
+
readonly name = "ast_replace";
|
|
42
|
+
readonly label = "AST Replace";
|
|
43
|
+
readonly description: string;
|
|
44
|
+
readonly parameters = astReplaceSchema;
|
|
45
|
+
readonly strict = true;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly session: ToolSession) {
|
|
48
|
+
this.description = renderPromptTemplate(astReplaceDescription);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async execute(
|
|
52
|
+
_toolCallId: string,
|
|
53
|
+
params: Static<typeof astReplaceSchema>,
|
|
54
|
+
signal?: AbortSignal,
|
|
55
|
+
_onUpdate?: AgentToolUpdateCallback<AstReplaceToolDetails>,
|
|
56
|
+
_context?: AgentToolContext,
|
|
57
|
+
): Promise<AgentToolResult<AstReplaceToolDetails>> {
|
|
58
|
+
return untilAborted(signal, async () => {
|
|
59
|
+
const pattern = params.pattern?.trim();
|
|
60
|
+
if (!pattern) {
|
|
61
|
+
throw new ToolError("`pattern` is required");
|
|
62
|
+
}
|
|
63
|
+
if (!params.rewrite?.trim()) {
|
|
64
|
+
throw new ToolError("`rewrite` is required");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const maxReplacements =
|
|
68
|
+
params.max_replacements === undefined ? undefined : Math.floor(params.max_replacements);
|
|
69
|
+
if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
|
|
70
|
+
throw new ToolError("max_replacements must be a positive number");
|
|
71
|
+
}
|
|
72
|
+
const maxFiles = params.max_files === undefined ? undefined : Math.floor(params.max_files);
|
|
73
|
+
if (maxFiles !== undefined && (!Number.isFinite(maxFiles) || maxFiles < 1)) {
|
|
74
|
+
throw new ToolError("max_files must be a positive number");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let searchPath: string | undefined;
|
|
78
|
+
let globFilter: string | undefined;
|
|
79
|
+
const rawPath = params.path?.trim();
|
|
80
|
+
if (rawPath) {
|
|
81
|
+
const internalRouter = this.session.internalRouter;
|
|
82
|
+
if (internalRouter?.canHandle(rawPath)) {
|
|
83
|
+
if (hasGlobPathChars(rawPath)) {
|
|
84
|
+
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
|
85
|
+
}
|
|
86
|
+
const resource = await internalRouter.resolve(rawPath);
|
|
87
|
+
if (!resource.sourcePath) {
|
|
88
|
+
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
|
|
89
|
+
}
|
|
90
|
+
searchPath = resource.sourcePath;
|
|
91
|
+
} else {
|
|
92
|
+
const parsedPath = parseSearchPath(rawPath);
|
|
93
|
+
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
|
94
|
+
globFilter = parsedPath.glob;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await astReplace({
|
|
99
|
+
pattern,
|
|
100
|
+
rewrite: params.rewrite?.trim(),
|
|
101
|
+
lang: params.lang?.trim(),
|
|
102
|
+
path: searchPath,
|
|
103
|
+
glob: globFilter,
|
|
104
|
+
selector: params.selector?.trim(),
|
|
105
|
+
dryRun: params.dry_run,
|
|
106
|
+
maxReplacements,
|
|
107
|
+
maxFiles,
|
|
108
|
+
failOnParseError: false,
|
|
109
|
+
signal,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const details: AstReplaceToolDetails = {
|
|
113
|
+
totalReplacements: result.totalReplacements,
|
|
114
|
+
filesTouched: result.filesTouched,
|
|
115
|
+
filesSearched: result.filesSearched,
|
|
116
|
+
applied: result.applied,
|
|
117
|
+
limitReached: result.limitReached,
|
|
118
|
+
parseErrors: result.parseErrors,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const action = result.applied ? "Applied" : "Would apply";
|
|
122
|
+
const lines = [
|
|
123
|
+
`${action} ${result.totalReplacements} replacements across ${result.filesTouched} files (searched ${result.filesSearched})`,
|
|
124
|
+
];
|
|
125
|
+
if (result.fileChanges.length > 0) {
|
|
126
|
+
lines.push("", "File changes:");
|
|
127
|
+
for (const file of result.fileChanges) {
|
|
128
|
+
lines.push(`- ${file.path}: ${file.count}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (result.changes.length > 0) {
|
|
132
|
+
lines.push("", "Preview:");
|
|
133
|
+
for (const change of result.changes.slice(0, 30)) {
|
|
134
|
+
const before = (change.before.split("\n", 1)[0] ?? "").slice(0, 80);
|
|
135
|
+
const after = (change.after.split("\n", 1)[0] ?? "").slice(0, 80);
|
|
136
|
+
lines.push(`${change.path}:${change.startLine}:${change.startColumn} ${before} -> ${after}`);
|
|
137
|
+
}
|
|
138
|
+
if (result.changes.length > 30) {
|
|
139
|
+
lines.push(`... ${result.changes.length - 30} more changes`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (result.limitReached) {
|
|
143
|
+
lines.push("", "Safety cap reached; narrow path pattern or increase max_files/max_replacements.");
|
|
144
|
+
}
|
|
145
|
+
if (result.parseErrors?.length) {
|
|
146
|
+
lines.push("", "Parse issues:", ...result.parseErrors.map(err => `- ${err}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return toolResult(details).text(lines.join("\n")).done();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// TUI Renderer
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
interface AstReplaceRenderArgs {
|
|
159
|
+
pattern?: string;
|
|
160
|
+
rewrite?: string;
|
|
161
|
+
lang?: string;
|
|
162
|
+
path?: string;
|
|
163
|
+
selector?: string;
|
|
164
|
+
dry_run?: boolean;
|
|
165
|
+
max_replacements?: number;
|
|
166
|
+
max_files?: number;
|
|
167
|
+
fail_on_parse_error?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
|
171
|
+
|
|
172
|
+
export const astReplaceToolRenderer = {
|
|
173
|
+
inline: true,
|
|
174
|
+
renderCall(args: AstReplaceRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
175
|
+
const meta: string[] = [];
|
|
176
|
+
if (args.lang) meta.push(`lang:${args.lang}`);
|
|
177
|
+
if (args.path) meta.push(`in ${args.path}`);
|
|
178
|
+
if (args.dry_run !== false) meta.push("dry run");
|
|
179
|
+
if (args.max_replacements !== undefined) meta.push(`max:${args.max_replacements}`);
|
|
180
|
+
if (args.max_files !== undefined) meta.push(`max files:${args.max_files}`);
|
|
181
|
+
|
|
182
|
+
const description = args.pattern || "?";
|
|
183
|
+
const text = renderStatusLine({ icon: "pending", title: "AST Replace", description, meta }, uiTheme);
|
|
184
|
+
return new Text(text, 0, 0);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
renderResult(
|
|
188
|
+
result: { content: Array<{ type: string; text?: string }>; details?: AstReplaceToolDetails; isError?: boolean },
|
|
189
|
+
options: RenderResultOptions,
|
|
190
|
+
uiTheme: Theme,
|
|
191
|
+
args?: AstReplaceRenderArgs,
|
|
192
|
+
): Component {
|
|
193
|
+
const details = result.details;
|
|
194
|
+
|
|
195
|
+
if (result.isError) {
|
|
196
|
+
const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
|
|
197
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const totalReplacements = details?.totalReplacements ?? 0;
|
|
201
|
+
const filesTouched = details?.filesTouched ?? 0;
|
|
202
|
+
const filesSearched = details?.filesSearched ?? 0;
|
|
203
|
+
const applied = details?.applied ?? false;
|
|
204
|
+
const limitReached = details?.limitReached ?? false;
|
|
205
|
+
|
|
206
|
+
if (totalReplacements === 0) {
|
|
207
|
+
const description = args?.pattern;
|
|
208
|
+
const meta = ["0 replacements"];
|
|
209
|
+
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
|
|
210
|
+
const header = renderStatusLine({ icon: "warning", title: "AST Replace", description, meta }, uiTheme);
|
|
211
|
+
return new Text([header, formatEmptyMessage("No replacements made", uiTheme)].join("\n"), 0, 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const summaryParts = [
|
|
215
|
+
formatCount("replacement", totalReplacements),
|
|
216
|
+
formatCount("file", filesTouched),
|
|
217
|
+
`searched ${filesSearched}`,
|
|
218
|
+
];
|
|
219
|
+
const meta = [...summaryParts];
|
|
220
|
+
if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
|
|
221
|
+
const description = args?.pattern;
|
|
222
|
+
const badge = applied
|
|
223
|
+
? { label: "applied", color: "success" as const }
|
|
224
|
+
: { label: "dry run", color: "warning" as const };
|
|
225
|
+
const header = renderStatusLine(
|
|
226
|
+
{ icon: limitReached ? "warning" : "success", title: "AST Replace", description, badge, meta },
|
|
227
|
+
uiTheme,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Parse text content into display groups
|
|
231
|
+
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
232
|
+
const rawLines = textContent.split("\n");
|
|
233
|
+
// Skip the summary line and group by blank-line separators
|
|
234
|
+
const contentLines = rawLines.slice(1);
|
|
235
|
+
const allGroups: string[][] = [];
|
|
236
|
+
let current: string[] = [];
|
|
237
|
+
for (const line of contentLines) {
|
|
238
|
+
if (line.trim().length === 0) {
|
|
239
|
+
if (current.length > 0) {
|
|
240
|
+
allGroups.push(current);
|
|
241
|
+
current = [];
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
current.push(line);
|
|
246
|
+
}
|
|
247
|
+
if (current.length > 0) allGroups.push(current);
|
|
248
|
+
|
|
249
|
+
// Filter out trailing metadata groups (safety cap / parse issues) — shown via details
|
|
250
|
+
const displayGroups = allGroups.filter(
|
|
251
|
+
group => !group[0]?.startsWith("Safety cap") && !group[0]?.startsWith("Parse issues"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const extraLines: string[] = [];
|
|
255
|
+
if (limitReached) {
|
|
256
|
+
extraLines.push(uiTheme.fg("warning", "safety cap reached; narrow scope or increase limits"));
|
|
257
|
+
}
|
|
258
|
+
if (details?.parseErrors?.length) {
|
|
259
|
+
extraLines.push(uiTheme.fg("warning", `${details.parseErrors.length} parse issue(s)`));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let cached: RenderCache | undefined;
|
|
263
|
+
return {
|
|
264
|
+
render(width: number): string[] {
|
|
265
|
+
const { expanded } = options;
|
|
266
|
+
const key = new Hasher().bool(expanded).u32(width).digest();
|
|
267
|
+
if (cached?.key === key) return cached.lines;
|
|
268
|
+
const matchLines = renderTreeList(
|
|
269
|
+
{
|
|
270
|
+
items: displayGroups,
|
|
271
|
+
expanded,
|
|
272
|
+
maxCollapsed: expanded ? displayGroups.length : COLLAPSED_CHANGE_LIMIT,
|
|
273
|
+
itemType: "section",
|
|
274
|
+
renderItem: group =>
|
|
275
|
+
group.map(line => {
|
|
276
|
+
if (line === "File changes:" || line === "Preview:") return uiTheme.fg("accent", line);
|
|
277
|
+
if (line.startsWith("- ")) return uiTheme.fg("toolOutput", line);
|
|
278
|
+
if (line.startsWith("...")) return uiTheme.fg("dim", line);
|
|
279
|
+
return uiTheme.fg("toolOutput", line);
|
|
280
|
+
}),
|
|
281
|
+
},
|
|
282
|
+
uiTheme,
|
|
283
|
+
);
|
|
284
|
+
const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
|
|
285
|
+
cached = { key, lines: result };
|
|
286
|
+
return result;
|
|
287
|
+
},
|
|
288
|
+
invalidate() {
|
|
289
|
+
cached = undefined;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
mergeCallAndResult: true,
|
|
294
|
+
};
|
package/src/tools/bash.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { resolveToCwd } from "./path-utils";
|
|
|
25
25
|
import { replaceTabs } from "./render-utils";
|
|
26
26
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
27
27
|
import { toolResult } from "./tool-result";
|
|
28
|
+
import { clampTimeout } from "./tool-timeouts";
|
|
28
29
|
|
|
29
30
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
30
31
|
|
|
@@ -200,7 +201,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
// Clamp to reasonable range: 1s - 3600s (1 hour)
|
|
203
|
-
const timeoutSec =
|
|
204
|
+
const timeoutSec = clampTimeout("bash", rawTimeout);
|
|
204
205
|
const timeoutMs = timeoutSec * 1000;
|
|
205
206
|
|
|
206
207
|
if (asyncRequested) {
|
package/src/tools/browser.ts
CHANGED
|
@@ -37,6 +37,7 @@ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type:
|
|
|
37
37
|
import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
|
|
38
38
|
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
|
39
39
|
import { toolResult } from "./tool-result";
|
|
40
|
+
import { clampTimeout } from "./tool-timeouts";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke
|
|
@@ -57,8 +58,6 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
61
|
-
const MAX_TIMEOUT_SECONDS = 120;
|
|
62
61
|
const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
|
|
63
62
|
const STEALTH_IGNORE_DEFAULT_ARGS = [
|
|
64
63
|
"--disable-extensions",
|
|
@@ -452,11 +451,6 @@ export interface ReadableResult {
|
|
|
452
451
|
markdown?: string;
|
|
453
452
|
}
|
|
454
453
|
|
|
455
|
-
function clampTimeout(timeoutSeconds?: number): number {
|
|
456
|
-
if (timeoutSeconds === undefined) return DEFAULT_TIMEOUT_SECONDS;
|
|
457
|
-
return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
454
|
function ensureParam<T>(value: T | undefined, name: string, action: string): T {
|
|
461
455
|
if (value === undefined || value === null || value === "") {
|
|
462
456
|
throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
|
|
@@ -956,7 +950,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
956
950
|
): Promise<AgentToolResult<BrowserToolDetails>> {
|
|
957
951
|
try {
|
|
958
952
|
throwIfAborted(signal);
|
|
959
|
-
const timeoutSeconds = clampTimeout(params.timeout);
|
|
953
|
+
const timeoutSeconds = clampTimeout("browser", params.timeout);
|
|
960
954
|
const timeoutMs = timeoutSeconds * 1000;
|
|
961
955
|
const details: BrowserToolDetails = { action: params.action };
|
|
962
956
|
|
package/src/tools/fetch.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
5
5
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { ptree, truncate } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import { type Static, Type } from "@sinclair/typebox";
|
|
8
|
-
import {
|
|
8
|
+
import { parseHTML } from "linkedom";
|
|
9
9
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
10
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
11
11
|
import { type Theme, theme } from "../modes/theme/theme";
|
|
@@ -24,6 +24,7 @@ import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
|
|
|
24
24
|
import { formatExpandHint, getDomain } from "./render-utils";
|
|
25
25
|
import { ToolAbortError } from "./tool-errors";
|
|
26
26
|
import { toolResult } from "./tool-result";
|
|
27
|
+
import { clampTimeout } from "./tool-timeouts";
|
|
27
28
|
|
|
28
29
|
// =============================================================================
|
|
29
30
|
// Types and Constants
|
|
@@ -248,6 +249,36 @@ async function tryContentNegotiation(
|
|
|
248
249
|
return null;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Read a single HTML attribute from a tag string
|
|
254
|
+
*/
|
|
255
|
+
function getHtmlAttribute(tag: string, attribute: string): string | null {
|
|
256
|
+
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, "i");
|
|
257
|
+
const match = tag.match(pattern);
|
|
258
|
+
if (!match) return null;
|
|
259
|
+
return (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract bounded <head> markup to avoid expensive whole-page parsing
|
|
264
|
+
*/
|
|
265
|
+
function extractHeadHtml(html: string): string {
|
|
266
|
+
const lower = html.toLowerCase();
|
|
267
|
+
const headStart = lower.indexOf("<head");
|
|
268
|
+
if (headStart === -1) {
|
|
269
|
+
return html.slice(0, 32 * 1024);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const headTagEnd = html.indexOf(">", headStart);
|
|
273
|
+
if (headTagEnd === -1) {
|
|
274
|
+
return html.slice(headStart, headStart + 32 * 1024);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const headEnd = lower.indexOf("</head>", headTagEnd + 1);
|
|
278
|
+
const fallbackEnd = Math.min(html.length, headTagEnd + 1 + 32 * 1024);
|
|
279
|
+
return html.slice(headStart, headEnd === -1 ? fallbackEnd : headEnd + 7);
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
/**
|
|
252
283
|
* Parse alternate links from HTML head
|
|
253
284
|
*/
|
|
@@ -255,13 +286,17 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
|
|
|
255
286
|
const links: string[] = [];
|
|
256
287
|
|
|
257
288
|
try {
|
|
258
|
-
const
|
|
259
|
-
const
|
|
289
|
+
const pagePath = new URL(pageUrl).pathname;
|
|
290
|
+
const headHtml = extractHeadHtml(html);
|
|
291
|
+
const linkTags = headHtml.match(/<link\b[^>]*>/gi) ?? [];
|
|
260
292
|
|
|
261
|
-
for (const
|
|
262
|
-
const
|
|
263
|
-
const
|
|
293
|
+
for (const tag of linkTags) {
|
|
294
|
+
const rel = getHtmlAttribute(tag, "rel")?.toLowerCase() ?? "";
|
|
295
|
+
const relTokens = rel.split(/\s+/).filter(Boolean);
|
|
296
|
+
if (!relTokens.includes("alternate")) continue;
|
|
264
297
|
|
|
298
|
+
const href = getHtmlAttribute(tag, "href");
|
|
299
|
+
const type = getHtmlAttribute(tag, "type")?.toLowerCase() ?? "";
|
|
265
300
|
if (!href) continue;
|
|
266
301
|
|
|
267
302
|
// Skip site-wide feeds
|
|
@@ -278,7 +313,7 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
|
|
|
278
313
|
links.push(href);
|
|
279
314
|
} else if (
|
|
280
315
|
(type.includes("rss") || type.includes("atom") || type.includes("feed")) &&
|
|
281
|
-
(href.includes(
|
|
316
|
+
(href.includes(pagePath) || href.includes("comments"))
|
|
282
317
|
) {
|
|
283
318
|
links.push(href);
|
|
284
319
|
}
|
|
@@ -293,20 +328,22 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
|
|
|
293
328
|
*/
|
|
294
329
|
function extractDocumentLinks(html: string, baseUrl: string): string[] {
|
|
295
330
|
const links: string[] = [];
|
|
331
|
+
const seen = new Set<string>();
|
|
296
332
|
|
|
297
333
|
try {
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
for (const anchor of anchors) {
|
|
302
|
-
const href = anchor.getAttribute("href");
|
|
334
|
+
const anchorTags = html.slice(0, 512 * 1024).match(/<a\b[^>]*>/gi) ?? [];
|
|
335
|
+
for (const tag of anchorTags) {
|
|
336
|
+
const href = getHtmlAttribute(tag, "href");
|
|
303
337
|
if (!href) continue;
|
|
304
338
|
|
|
305
339
|
const ext = path.extname(href).toLowerCase();
|
|
306
|
-
if (CONVERTIBLE_EXTENSIONS.has(ext))
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
340
|
+
if (!CONVERTIBLE_EXTENSIONS.has(ext)) continue;
|
|
341
|
+
|
|
342
|
+
const resolved = href.startsWith("http") ? href : new URL(href, baseUrl).href;
|
|
343
|
+
if (seen.has(resolved)) continue;
|
|
344
|
+
seen.add(resolved);
|
|
345
|
+
links.push(resolved);
|
|
346
|
+
if (links.length >= 20) break;
|
|
310
347
|
}
|
|
311
348
|
} catch {}
|
|
312
349
|
|
|
@@ -333,7 +370,7 @@ function cleanFeedText(text: string): string {
|
|
|
333
370
|
*/
|
|
334
371
|
function parseFeedToMarkdown(content: string, maxItems = 10): string {
|
|
335
372
|
try {
|
|
336
|
-
const doc =
|
|
373
|
+
const doc = parseHTML(content).document;
|
|
337
374
|
|
|
338
375
|
// Try RSS
|
|
339
376
|
const channel = doc.querySelector("channel");
|
|
@@ -872,7 +909,7 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
|
|
|
872
909
|
const { url, timeout: rawTimeout = 20, raw = false } = params;
|
|
873
910
|
|
|
874
911
|
// Clamp to valid range (seconds)
|
|
875
|
-
const effectiveTimeout =
|
|
912
|
+
const effectiveTimeout = clampTimeout("fetch", rawTimeout);
|
|
876
913
|
|
|
877
914
|
if (signal?.aborted) {
|
|
878
915
|
throw new ToolAbortError();
|
package/src/tools/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { AgentOutputManager } from "../task/output-manager";
|
|
|
15
15
|
import type { EventBus } from "../utils/event-bus";
|
|
16
16
|
import { SearchTool } from "../web/search";
|
|
17
17
|
import { AskTool } from "./ask";
|
|
18
|
+
import { AstFindTool } from "./ast-find";
|
|
19
|
+
import { AstReplaceTool } from "./ast-replace";
|
|
18
20
|
import { AwaitTool } from "./await-tool";
|
|
19
21
|
import { BashTool } from "./bash";
|
|
20
22
|
import { BrowserTool } from "./browser";
|
|
@@ -54,6 +56,8 @@ export * from "../session/streaming-output";
|
|
|
54
56
|
export { BUNDLED_AGENTS, TaskTool } from "../task";
|
|
55
57
|
export * from "../web/search";
|
|
56
58
|
export { AskTool, type AskToolDetails } from "./ask";
|
|
59
|
+
export { AstFindTool, type AstFindToolDetails } from "./ast-find";
|
|
60
|
+
export { AstReplaceTool, type AstReplaceToolDetails } from "./ast-replace";
|
|
57
61
|
export { AwaitTool, type AwaitToolDetails } from "./await-tool";
|
|
58
62
|
export { BashTool, type BashToolDetails, type BashToolInput, type BashToolOptions } from "./bash";
|
|
59
63
|
export { BrowserTool, type BrowserToolDetails } from "./browser";
|
|
@@ -155,6 +159,8 @@ export interface ToolSession {
|
|
|
155
159
|
type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
|
|
156
160
|
|
|
157
161
|
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
162
|
+
ast_find: s => new AstFindTool(s),
|
|
163
|
+
ast_replace: s => new AstReplaceTool(s),
|
|
158
164
|
ask: AskTool.createIf,
|
|
159
165
|
bash: s => new BashTool(s),
|
|
160
166
|
python: s => new PythonTool(s),
|
|
@@ -283,6 +289,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
283
289
|
if (name === "todo_write") return !includeSubmitResult && session.settings.get("todo.enabled");
|
|
284
290
|
if (name === "find") return session.settings.get("find.enabled");
|
|
285
291
|
if (name === "grep") return session.settings.get("grep.enabled");
|
|
292
|
+
if (name === "ast_find") return session.settings.get("astFind.enabled");
|
|
293
|
+
if (name === "ast_replace") return session.settings.get("astReplace.enabled");
|
|
286
294
|
if (name === "notebook") return session.settings.get("notebook.enabled");
|
|
287
295
|
if (name === "fetch") return session.settings.get("fetch.enabled");
|
|
288
296
|
if (name === "web_search") return session.settings.get("web_search.enabled");
|
|
@@ -38,15 +38,6 @@ function convertSchema(schema: unknown): unknown {
|
|
|
38
38
|
return {};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// Type form: { type: "string" } → { type: "string" }
|
|
42
|
-
if (isJTDType(schema)) {
|
|
43
|
-
const jsonType = primitiveMap[schema.type as JTDPrimitive];
|
|
44
|
-
if (!jsonType) {
|
|
45
|
-
return { type: schema.type };
|
|
46
|
-
}
|
|
47
|
-
return { type: jsonType };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
41
|
// Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
|
|
51
42
|
if (isJTDEnum(schema)) {
|
|
52
43
|
return { enum: schema.enum };
|
|
@@ -60,6 +51,14 @@ function convertSchema(schema: unknown): unknown {
|
|
|
60
51
|
};
|
|
61
52
|
}
|
|
62
53
|
|
|
54
|
+
// Type form: { type: "string" } → { type: "string" }
|
|
55
|
+
if (isJTDType(schema)) {
|
|
56
|
+
const jsonType = primitiveMap[schema.type as JTDPrimitive];
|
|
57
|
+
if (!jsonType) {
|
|
58
|
+
return { type: schema.type };
|
|
59
|
+
}
|
|
60
|
+
return { type: jsonType };
|
|
61
|
+
}
|
|
63
62
|
// Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... }
|
|
64
63
|
if (isJTDValues(schema)) {
|
|
65
64
|
return {
|
|
@@ -171,13 +170,30 @@ export function isJTDSchema(schema: unknown): boolean {
|
|
|
171
170
|
return false;
|
|
172
171
|
}
|
|
173
172
|
|
|
173
|
+
function normalizeMixedSchemaNode(schema: unknown): unknown {
|
|
174
|
+
if (schema === null || typeof schema !== "object") {
|
|
175
|
+
return schema;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (Array.isArray(schema)) {
|
|
179
|
+
return schema.map(item => normalizeMixedSchemaNode(item));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (isJTDSchema(schema)) {
|
|
183
|
+
return normalizeMixedSchemaNode(convertSchema(schema));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const normalized: Record<string, unknown> = {};
|
|
187
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
188
|
+
normalized[key] = normalizeMixedSchemaNode(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
174
193
|
/**
|
|
175
194
|
* Convert JTD schema to JSON Schema.
|
|
176
195
|
* If already JSON Schema, returns as-is.
|
|
177
196
|
*/
|
|
178
197
|
export function jtdToJsonSchema(schema: unknown): unknown {
|
|
179
|
-
|
|
180
|
-
return schema;
|
|
181
|
-
}
|
|
182
|
-
return convertSchema(schema);
|
|
198
|
+
return normalizeMixedSchemaNode(schema);
|
|
183
199
|
}
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -88,6 +88,40 @@ export function resolveToCwd(filePath: string, cwd: string): string {
|
|
|
88
88
|
return path.resolve(cwd, expanded);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
|
|
92
|
+
|
|
93
|
+
export function hasGlobPathChars(filePath: string): boolean {
|
|
94
|
+
return GLOB_PATH_CHARS.some(char => filePath.includes(char));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ParsedSearchPath {
|
|
98
|
+
basePath: string;
|
|
99
|
+
glob?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split a user path into a base path + glob pattern for tools that delegate to
|
|
104
|
+
* APIs accepting separate `path` and `glob` arguments.
|
|
105
|
+
*/
|
|
106
|
+
export function parseSearchPath(filePath: string): ParsedSearchPath {
|
|
107
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
108
|
+
if (!hasGlobPathChars(normalizedPath)) {
|
|
109
|
+
return { basePath: filePath };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const segments = normalizedPath.split("/");
|
|
113
|
+
const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
|
|
114
|
+
|
|
115
|
+
if (firstGlobIndex <= 0) {
|
|
116
|
+
return { basePath: ".", glob: normalizedPath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
basePath: segments.slice(0, firstGlobIndex).join("/"),
|
|
121
|
+
glob: segments.slice(firstGlobIndex).join("/"),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
export function resolveReadPath(filePath: string, cwd: string): string {
|
|
92
126
|
const resolved = resolveToCwd(filePath, cwd);
|
|
93
127
|
|
package/src/tools/python.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { resolveToCwd } from "./path-utils";
|
|
|
21
21
|
import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
|
|
22
22
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
23
23
|
import { toolResult } from "./tool-result";
|
|
24
|
+
import { clampTimeout } from "./tool-timeouts";
|
|
24
25
|
|
|
25
26
|
export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
|
|
26
27
|
|
|
@@ -177,7 +178,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
|
|
177
178
|
|
|
178
179
|
const { cells, timeout: rawTimeout = 30, cwd, reset } = params;
|
|
179
180
|
// Clamp to reasonable range: 1s - 600s (10 min)
|
|
180
|
-
const timeoutSec =
|
|
181
|
+
const timeoutSec = clampTimeout("python", rawTimeout);
|
|
181
182
|
const timeoutMs = timeoutSec * 1000;
|
|
182
183
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
183
184
|
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
package/src/tools/renderers.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { editToolRenderer } from "../patch";
|
|
|
11
11
|
import { taskToolRenderer } from "../task/render";
|
|
12
12
|
import { webSearchToolRenderer } from "../web/search/render";
|
|
13
13
|
import { askToolRenderer } from "./ask";
|
|
14
|
+
import { astFindToolRenderer } from "./ast-find";
|
|
15
|
+
import { astReplaceToolRenderer } from "./ast-replace";
|
|
14
16
|
import { bashToolRenderer } from "./bash";
|
|
15
17
|
import { calculatorToolRenderer } from "./calculator";
|
|
16
18
|
import { fetchToolRenderer } from "./fetch";
|
|
@@ -38,6 +40,8 @@ type ToolRenderer = {
|
|
|
38
40
|
|
|
39
41
|
export const toolRenderers: Record<string, ToolRenderer> = {
|
|
40
42
|
ask: askToolRenderer as ToolRenderer,
|
|
43
|
+
ast_find: astFindToolRenderer as ToolRenderer,
|
|
44
|
+
ast_replace: astReplaceToolRenderer as ToolRenderer,
|
|
41
45
|
bash: bashToolRenderer as ToolRenderer,
|
|
42
46
|
python: pythonToolRenderer as ToolRenderer,
|
|
43
47
|
calc: calculatorToolRenderer as ToolRenderer,
|
package/src/tools/ssh.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { ToolSession } from ".";
|
|
|
19
19
|
import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
|
|
20
20
|
import { ToolError } from "./tool-errors";
|
|
21
21
|
import { toolResult } from "./tool-result";
|
|
22
|
+
import { clampTimeout } from "./tool-timeouts";
|
|
22
23
|
|
|
23
24
|
const sshSchema = Type.Object({
|
|
24
25
|
host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }),
|
|
@@ -155,7 +156,7 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
|
|
|
155
156
|
const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
|
|
156
157
|
|
|
157
158
|
// Clamp to reasonable range: 1s - 3600s (1 hour)
|
|
158
|
-
const timeoutSec =
|
|
159
|
+
const timeoutSec = clampTimeout("ssh", rawTimeout);
|
|
159
160
|
const timeoutMs = timeoutSec * 1000;
|
|
160
161
|
|
|
161
162
|
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
|