pi-spark 0.14.2 → 0.14.4
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/README.md +2 -2
- package/package.json +1 -1
- package/src/features/pi/actions/models.ts +10 -11
- package/src/features/pi/actions/name.ts +5 -13
- package/src/features/pi/actions/whoami.ts +1 -1
- package/src/features/pi/registry.ts +16 -105
- package/src/features/web/actions/fetch.ts +47 -0
- package/src/features/web/actions/search.ts +50 -0
- package/src/features/web/client.ts +2 -2
- package/src/features/web/config.ts +0 -2
- package/src/features/web/index.ts +9 -106
- package/src/features/web/registry.ts +22 -0
- package/src/features/web/render.ts +34 -0
- package/src/utils/format.ts +5 -0
- package/src/utils/tool.ts +209 -0
package/README.md
CHANGED
|
@@ -55,11 +55,11 @@ The `pi` tool lets the pi coding agent inspect and manipulate itself (~740 token
|
|
|
55
55
|
|
|
56
56
|
- `models` lists and searches the model catalog.
|
|
57
57
|
- `name` sets or updates the current session's name.
|
|
58
|
-
- `whoami` shows the current pi state
|
|
58
|
+
- `whoami` shows the current pi state, including session name, active model, and thinking level.
|
|
59
59
|
|
|
60
60
|
The `web` tool gives the agent live web access, backed by the free [Exa MCP](https://exa.ai/mcp) (~350 tokens). No separate MCP setup or API key is needed.
|
|
61
61
|
|
|
62
|
-
- `search` finds current information across the web and returns
|
|
62
|
+
- `search` finds current information across the web and returns ready-to-use content.
|
|
63
63
|
- `fetch` reads the full content of known URLs as clean markdown, and can batch several URLs in one call.
|
|
64
64
|
|
|
65
65
|

|
package/package.json
CHANGED
|
@@ -25,35 +25,34 @@ export const modelsAction = defineAction({
|
|
|
25
25
|
fields: {
|
|
26
26
|
query: Type.Optional(Type.String({
|
|
27
27
|
description:
|
|
28
|
-
"For \"models\":
|
|
28
|
+
"For \"models\": Filter models with a Liqe (Lucene-like) query. Bare terms match any " +
|
|
29
29
|
"field, and unquoted terms are case-insensitive substrings. The syntax supports " +
|
|
30
30
|
"AND/OR/NOT, grouping, wildcards, and numeric comparisons; quote values containing " +
|
|
31
31
|
"special characters (e.g., id:\"deepseek/deepseek-v4\"). Filterable fields are id, name, " +
|
|
32
32
|
"provider, api, reasoning (boolean), input (modalities), cost.input and cost.output " +
|
|
33
33
|
"(USD per 1M tokens), contextWindow, maxTokens, thinkingLevels, and available " +
|
|
34
|
-
"(boolean, true when auth is configured).
|
|
35
|
-
"
|
|
36
|
-
"\"provider:openrouter cost.input:<1\").",
|
|
34
|
+
"(boolean, true when auth is configured). Examples: \"claude available:true\", " +
|
|
35
|
+
"\"provider:openrouter cost.input:<1\".",
|
|
37
36
|
})),
|
|
38
|
-
offset: Type.Optional(Type.Number({ description: "For \"models\":
|
|
37
|
+
offset: Type.Optional(Type.Number({ description: "For \"models\": Start from this model number (1-indexed)." })),
|
|
39
38
|
limit: Type.Optional(Type.Number({ description: "For \"models\": Return at most this many models." })),
|
|
40
39
|
},
|
|
41
40
|
promptGuidelines: [
|
|
42
41
|
"Use the pi tool's \"models\" action when you need metadata of pi models; include available:true in the query unless unavailable models are needed.",
|
|
43
42
|
],
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
renderParams(args, theme) {
|
|
44
|
+
const params: string[] = [];
|
|
46
45
|
|
|
47
46
|
const query = args.query?.trim();
|
|
48
|
-
if (query)
|
|
47
|
+
if (query) params.push(theme.fg("muted", query));
|
|
49
48
|
|
|
50
49
|
if (args.offset !== undefined || args.limit !== undefined) {
|
|
51
50
|
const start = args.offset ?? 1;
|
|
52
51
|
const end = args.limit !== undefined ? start + args.limit - 1 : "end";
|
|
53
|
-
|
|
52
|
+
params.push(theme.fg("warning", `${start}-${end}`));
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
return
|
|
55
|
+
return params;
|
|
57
56
|
},
|
|
58
57
|
renderResult(result, { expanded }, theme, context) {
|
|
59
58
|
const details = result.details as ModelsDetails | undefined;
|
|
@@ -139,7 +138,7 @@ export const modelsAction = defineAction({
|
|
|
139
138
|
// Convert from 1-indexed offset to 0-indexed array access.
|
|
140
139
|
const startIndex = args.offset ? Math.max(0, args.offset - 1) : 0;
|
|
141
140
|
if (startIndex >= total) {
|
|
142
|
-
throw new Error(`Offset ${args.offset} is beyond the end of the list (${total} models total)
|
|
141
|
+
throw new Error(`Offset ${args.offset} is beyond the end of the list (${total} models total)`);
|
|
143
142
|
}
|
|
144
143
|
|
|
145
144
|
const endIndex = args.limit !== undefined ? Math.min(startIndex + args.limit, total) : total;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
4
|
import { sanitizeText } from "../../../utils/format";
|
|
@@ -34,19 +34,11 @@ export const nameAction = defineAction({
|
|
|
34
34
|
promptGuidelines: [
|
|
35
35
|
"Use the pi tool's \"name\" action to give the current session a concise, recognizable name, especially after a long, vague, or pasted opening prompt, or after a substantial shift in the conversation's focus.",
|
|
36
36
|
],
|
|
37
|
-
|
|
38
|
-
const name = sanitizeText(args.name ?? "");
|
|
37
|
+
renderParams(args, theme) {
|
|
38
|
+
const name = theme.fg("muted", sanitizeText(args.name ?? ""));
|
|
39
39
|
const reason = sanitizeText(args.reason ?? "");
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
container.addChild(new Text(`${theme.bold(theme.fg("toolTitle", "pi"))} ${theme.fg("accent", "name")} ${theme.fg("muted", name)}`, 0, 0));
|
|
43
|
-
|
|
44
|
-
if (reason) {
|
|
45
|
-
container.addChild(new Spacer(1));
|
|
46
|
-
container.addChild(new Text(theme.fg("muted", reason), 0, 0));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return container;
|
|
41
|
+
return [reason ? name + theme.fg("muted", "\n\n" + reason) : name];
|
|
50
42
|
},
|
|
51
43
|
renderResult() {
|
|
52
44
|
// "name" has no success UI; errors are rendered by the registry's fallback.
|
|
@@ -54,7 +46,7 @@ export const nameAction = defineAction({
|
|
|
54
46
|
},
|
|
55
47
|
async execute(args, { pi }) {
|
|
56
48
|
const name = sanitizeText(args.name);
|
|
57
|
-
if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase
|
|
49
|
+
if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase");
|
|
58
50
|
|
|
59
51
|
const previous = pi.getSessionName() ?? null;
|
|
60
52
|
if (previous === name) {
|
|
@@ -15,7 +15,7 @@ interface WhoamiDetails {
|
|
|
15
15
|
|
|
16
16
|
export const whoamiAction = defineAction({
|
|
17
17
|
name: "whoami",
|
|
18
|
-
summary: "shows the current pi state
|
|
18
|
+
summary: "shows the current pi state, including session name, active model, and thinking level",
|
|
19
19
|
fields: {},
|
|
20
20
|
promptGuidelines: [
|
|
21
21
|
"Use the pi tool's \"whoami\" action when you need the current session's name or the active model and thinking level.",
|
|
@@ -1,117 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
import { Type } from "typebox";
|
|
1
|
+
import { defineActionFor, registerComposedTool } from "../../utils/tool";
|
|
4
2
|
|
|
5
|
-
import {
|
|
3
|
+
import type { Action } from "../../utils/tool";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
import type { Static, TObject, TProperties, TSchema } from "typebox";
|
|
9
|
-
|
|
10
|
-
const DESCRIPTION_INTRO =
|
|
11
|
-
"Inspect and adjust the current pi session and model state. This tool groups self-management " +
|
|
12
|
-
"actions over the running pi instance:";
|
|
13
|
-
|
|
14
|
-
const DESCRIPTION_OUTRO = "Use this tool to read or change pi's own state instead of guessing.";
|
|
15
|
-
|
|
16
|
-
const GENERAL_GUIDELINE =
|
|
17
|
-
"The pi tool operates only on pi's own session and model state; it does not read or modify the " +
|
|
18
|
-
"user's project, files, or task.";
|
|
19
|
-
|
|
20
|
-
interface ActionDetails {
|
|
21
|
-
action: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface ActionContext {
|
|
6
|
+
interface PiActionContext {
|
|
25
7
|
pi: ExtensionAPI;
|
|
26
8
|
ctx: ExtensionContext;
|
|
27
9
|
}
|
|
28
10
|
|
|
29
|
-
|
|
30
|
-
* One self-management action of the pi tool, owning its fields, execution, and rendering. The
|
|
31
|
-
* registry merges every action's `fields` into one flat object schema (provider-safe, unlike a
|
|
32
|
-
* discriminated union; see the StringEnum note in pi's extension docs) and dispatches by `action`.
|
|
33
|
-
*/
|
|
34
|
-
interface Action<F extends TProperties = TProperties, D extends ActionDetails = ActionDetails> {
|
|
35
|
-
name: D["action"];
|
|
36
|
-
summary: string;
|
|
37
|
-
fields: F;
|
|
38
|
-
/** Fields required at runtime, since the flat schema makes every action's fields optional. */
|
|
39
|
-
required?: (keyof F & string)[];
|
|
40
|
-
/** Guideline bullets contributed to the tool's system-prompt guidelines. */
|
|
41
|
-
promptGuidelines?: string[];
|
|
42
|
-
renderCall?: NonNullable<ToolDefinition<TObject<F>, D>["renderCall"]>;
|
|
43
|
-
renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
|
|
44
|
-
execute(args: Static<TObject<F>>, context: ActionContext, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Identity helper that preserves per-action field and details inference at the definition site. */
|
|
48
|
-
export function defineAction<F extends TProperties, D extends ActionDetails>(action: Action<F, D>): Action<F, D> {
|
|
49
|
-
return action;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Compose the actions into a single flat-schema tool and register it as `pi`. Actions are
|
|
54
|
-
* heterogeneous, so the collection is typed loosely here; type safety lives at each `defineAction`.
|
|
55
|
-
*/
|
|
56
|
-
export function registerPiTool(pi: ExtensionAPI, actions: Action<any, any>[]): void {
|
|
57
|
-
const byName = new Map<string, Action<any, any>>();
|
|
58
|
-
const mergedFields: TProperties = {};
|
|
59
|
-
|
|
60
|
-
for (const action of actions) {
|
|
61
|
-
if (byName.has(action.name)) throw new Error(`Duplicate pi action "${action.name}".`);
|
|
62
|
-
byName.set(action.name, action);
|
|
11
|
+
export const defineAction = defineActionFor<PiActionContext>();
|
|
63
12
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (key in mergedFields) throw new Error(`pi action "${action.name}" redefines field "${key}"; field names must be unique across actions.`);
|
|
67
|
-
mergedFields[key] = Type.Optional(schema as TSchema);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const summaries = actions.map((action) => `"${action.name}" ${action.summary}`).join("; ");
|
|
72
|
-
|
|
73
|
-
const parameters = Type.Object({
|
|
74
|
-
action: StringEnum(actions.map((action) => action.name), { description: "The pi action to run." }),
|
|
75
|
-
...mergedFields,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
pi.registerTool({
|
|
13
|
+
export function registerPiTool(pi: ExtensionAPI, actions: Action<PiActionContext, any, any>[]): void {
|
|
14
|
+
registerComposedTool<PiActionContext>(pi, {
|
|
79
15
|
name: "pi",
|
|
80
16
|
label: "pi",
|
|
81
|
-
|
|
17
|
+
descriptionIntro:
|
|
18
|
+
"Inspect and adjust the current pi session and model state. This tool groups " +
|
|
19
|
+
"self-management actions over the running pi instance:",
|
|
20
|
+
descriptionOutro: "Use this tool to read or change pi's own state instead of guessing.",
|
|
82
21
|
promptSnippet: "Inspect and adjust the current pi session and model state",
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return new Text(`${theme.bold(theme.fg("toolTitle", "pi"))} ${theme.fg("accent", args.action)}`, 0, 0);
|
|
90
|
-
},
|
|
91
|
-
renderResult(result, options, theme, context) {
|
|
92
|
-
const details = result.details as ActionDetails | undefined;
|
|
93
|
-
|
|
94
|
-
if (context.isError || !details) {
|
|
95
|
-
const output = joinTextContent(result.content);
|
|
96
|
-
return new Text(context.isError && output ? theme.fg("error", "\n" + output) : "", 0, 0);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const action = byName.get(details.action);
|
|
100
|
-
if (action?.renderResult) return action.renderResult(result as AgentToolResult<ActionDetails>, options, theme, context);
|
|
101
|
-
|
|
102
|
-
return new Text(joinTextContent(result.content), 0, 0);
|
|
103
|
-
},
|
|
104
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
105
|
-
const action = byName.get(params.action);
|
|
106
|
-
if (!action) throw new Error(`Unknown pi action "${params.action}".`);
|
|
107
|
-
|
|
108
|
-
for (const field of action.required ?? []) {
|
|
109
|
-
if ((params as Record<string, unknown>)[field] === undefined) {
|
|
110
|
-
throw new Error(`The "${params.action}" action requires "${field}".`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return action.execute(params as never, { pi, ctx }, signal);
|
|
115
|
-
},
|
|
22
|
+
generalGuidelines: [
|
|
23
|
+
"The pi tool operates only on pi's own session and model state; it does not read or modify the user's project, files, or task.",
|
|
24
|
+
],
|
|
25
|
+
actions,
|
|
26
|
+
createContext: (ctx) => ({ pi, ctx }),
|
|
116
27
|
});
|
|
117
28
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
import { defineAction } from "../registry";
|
|
4
|
+
import { renderWebResult } from "../render";
|
|
5
|
+
|
|
6
|
+
interface FetchDetails {
|
|
7
|
+
action: "fetch";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const fetchAction = defineAction({
|
|
11
|
+
name: "fetch",
|
|
12
|
+
summary: "reads the full content of known URLs as clean markdown",
|
|
13
|
+
showTiming: true,
|
|
14
|
+
fields: {
|
|
15
|
+
urls: Type.Optional(Type.Array(Type.String(), {
|
|
16
|
+
description: "For \"fetch\": List the URLs to read. Batch multiple URLs in one call.",
|
|
17
|
+
})),
|
|
18
|
+
maxCharacters: Type.Optional(Type.Number({
|
|
19
|
+
description: "For \"fetch\": Extract at most this many characters per page (default 3000).",
|
|
20
|
+
})),
|
|
21
|
+
},
|
|
22
|
+
promptGuidelines: [
|
|
23
|
+
"Use the web tool's \"fetch\" action to read full content from known URLs, batching multiple URLs in one call, especially when search highlights are insufficient.",
|
|
24
|
+
],
|
|
25
|
+
renderParams(args, theme) {
|
|
26
|
+
const params: string[] = [];
|
|
27
|
+
|
|
28
|
+
if (args.urls && args.urls.length > 0) params.push(theme.fg("muted", args.urls.join(", ")));
|
|
29
|
+
if (args.maxCharacters !== undefined) params.push(theme.fg("warning", `<= ${args.maxCharacters} chars`));
|
|
30
|
+
|
|
31
|
+
return params;
|
|
32
|
+
},
|
|
33
|
+
renderResult(result, { expanded }, theme) {
|
|
34
|
+
return renderWebResult(result, expanded, theme);
|
|
35
|
+
},
|
|
36
|
+
async execute(args, { exa }, signal) {
|
|
37
|
+
if (!(args.urls && args.urls.length > 0)) {
|
|
38
|
+
throw new Error("The \"fetch\" action requires a non-empty \"urls\" array");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const requestArgs: Record<string, unknown> = { urls: args.urls };
|
|
42
|
+
if (args.maxCharacters !== undefined) requestArgs.maxCharacters = args.maxCharacters;
|
|
43
|
+
|
|
44
|
+
const content = await exa.call("web_fetch_exa", requestArgs, signal);
|
|
45
|
+
return { content, details: { action: "fetch" } satisfies FetchDetails };
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
import { defineAction } from "../registry";
|
|
4
|
+
import { renderWebResult } from "../render";
|
|
5
|
+
|
|
6
|
+
interface SearchDetails {
|
|
7
|
+
action: "search";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const searchAction = defineAction({
|
|
11
|
+
name: "search",
|
|
12
|
+
summary: "finds current information across the web and returns ready-to-use content",
|
|
13
|
+
showTiming: true,
|
|
14
|
+
fields: {
|
|
15
|
+
query: Type.Optional(Type.String({
|
|
16
|
+
description:
|
|
17
|
+
"For \"search\": Provide the search query. Use a semantically rich description of the " +
|
|
18
|
+
"ideal page, not just keywords. Optionally include category:<type> (company, people) " +
|
|
19
|
+
"to focus results.",
|
|
20
|
+
})),
|
|
21
|
+
numResults: Type.Optional(Type.Number({
|
|
22
|
+
description: "For \"search\": Return at most this many search results (default 10).",
|
|
23
|
+
})),
|
|
24
|
+
},
|
|
25
|
+
promptGuidelines: [
|
|
26
|
+
"Use the web tool's \"search\" action for current information, news, facts, people, or companies; describe the ideal page rather than keywords (e.g., \"blog post comparing React and Vue performance\").",
|
|
27
|
+
],
|
|
28
|
+
renderParams(args, theme) {
|
|
29
|
+
const params: string[] = [];
|
|
30
|
+
|
|
31
|
+
const query = args.query?.trim();
|
|
32
|
+
if (query) params.push(theme.fg("muted", query));
|
|
33
|
+
if (args.numResults !== undefined) params.push(theme.fg("warning", `${args.numResults} results`));
|
|
34
|
+
|
|
35
|
+
return params;
|
|
36
|
+
},
|
|
37
|
+
renderResult(result, { expanded }, theme) {
|
|
38
|
+
return renderWebResult(result, expanded, theme);
|
|
39
|
+
},
|
|
40
|
+
async execute(args, { exa }, signal) {
|
|
41
|
+
const query = args.query?.trim();
|
|
42
|
+
if (!query) throw new Error("The \"search\" action requires a non-empty \"query\"");
|
|
43
|
+
|
|
44
|
+
const requestArgs: Record<string, unknown> = { query };
|
|
45
|
+
if (args.numResults !== undefined) requestArgs.numResults = args.numResults;
|
|
46
|
+
|
|
47
|
+
const content = await exa.call("web_search_exa", requestArgs, signal);
|
|
48
|
+
return { content, details: { action: "search" } satisfies SearchDetails };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -38,8 +38,8 @@ export class ExaClient {
|
|
|
38
38
|
throw new Error(`Exa request failed: ${text}`);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
if (result.isError) throw new Error(`Exa request failed: no content returned
|
|
42
|
-
if (content.length === 0) throw new Error(`No content returned
|
|
41
|
+
if (result.isError) throw new Error(`Exa request failed: no content returned`);
|
|
42
|
+
if (content.length === 0) throw new Error(`No content returned`);
|
|
43
43
|
|
|
44
44
|
return content;
|
|
45
45
|
}
|
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import { Type } from "typebox";
|
|
5
|
-
|
|
6
1
|
import { ExaClient } from "./client";
|
|
2
|
+
import { fetchAction } from "./actions/fetch";
|
|
3
|
+
import { searchAction } from "./actions/search";
|
|
4
|
+
import { registerWebTool } from "./registry";
|
|
7
5
|
import { loadConfig } from "../../config";
|
|
8
|
-
import { joinTextContent } from "../../utils/format";
|
|
9
6
|
|
|
10
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const COLLAPSED_MAX_LINES = 10;
|
|
9
|
+
/** Web actions exposed by the web tool, ordered to match the description (search before fetch). */
|
|
10
|
+
const ACTIONS = [
|
|
11
|
+
searchAction,
|
|
12
|
+
fetchAction,
|
|
13
|
+
];
|
|
18
14
|
|
|
19
15
|
export function registerWeb(pi: ExtensionAPI): void {
|
|
20
16
|
const exa = new ExaClient();
|
|
@@ -23,100 +19,7 @@ export function registerWeb(pi: ExtensionAPI): void {
|
|
|
23
19
|
const config = loadConfig(ctx).web;
|
|
24
20
|
if (!config) return;
|
|
25
21
|
|
|
26
|
-
pi
|
|
27
|
-
name: "web",
|
|
28
|
-
label: "web",
|
|
29
|
-
description:
|
|
30
|
-
"Access the live web via Exa: \"search\" finds current information across the web and " +
|
|
31
|
-
"returns clean, ready-to-use content; \"fetch\" reads the full content of known URLs " +
|
|
32
|
-
"as clean markdown.",
|
|
33
|
-
promptSnippet: "Search the web and fetch page content via Exa",
|
|
34
|
-
promptGuidelines: [
|
|
35
|
-
"Use the web tool's \"search\" action for current information, news, facts, people, or companies; describe the ideal page rather than keywords (e.g., \"blog post comparing React and Vue performance\").",
|
|
36
|
-
"Use the web tool's \"fetch\" action to read full content from known URLs, batching multiple URLs in one call, especially when search highlights are insufficient.",
|
|
37
|
-
],
|
|
38
|
-
parameters: Type.Object({
|
|
39
|
-
action: StringEnum(["search", "fetch"], {
|
|
40
|
-
description: "The web action to run.",
|
|
41
|
-
}),
|
|
42
|
-
query: Type.Optional(Type.String({
|
|
43
|
-
description:
|
|
44
|
-
"For \"search\": the search query. Use a semantically rich description of the ideal " +
|
|
45
|
-
"page, not just keywords. Optionally include category:<type> (company, people) to " +
|
|
46
|
-
"focus results."
|
|
47
|
-
})),
|
|
48
|
-
numResults: Type.Optional(Type.Number({
|
|
49
|
-
description: "For \"search\": number of search results (default 10).",
|
|
50
|
-
})),
|
|
51
|
-
urls: Type.Optional(Type.Array(Type.String(), {
|
|
52
|
-
description: "For \"fetch\": URLs to read. Batch multiple URLs in one call.",
|
|
53
|
-
})),
|
|
54
|
-
maxCharacters: Type.Optional(Type.Number({
|
|
55
|
-
description: "For \"fetch\": maximum characters to extract per page (default 3000).",
|
|
56
|
-
})),
|
|
57
|
-
}),
|
|
58
|
-
renderCall(args, theme) {
|
|
59
|
-
let text = `${theme.bold(theme.fg("toolTitle", "web"))} ${theme.fg("success", args.action)}`;
|
|
60
|
-
|
|
61
|
-
if (args.action === "search") {
|
|
62
|
-
const query = args.query?.trim();
|
|
63
|
-
if (query) text += ` ${theme.fg("muted", query)}`;
|
|
64
|
-
if (args.numResults !== undefined) text += ` ${theme.fg("warning", `${args.numResults} results`)}`;
|
|
65
|
-
} else if (args.action === "fetch") {
|
|
66
|
-
if (args.urls && args.urls.length > 0) text += ` ${theme.fg("muted", `${args.urls.join(", ")}`)}`;
|
|
67
|
-
if (args.maxCharacters !== undefined) text += ` ${theme.fg("warning", `<= ${args.maxCharacters} chars`)}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return new Text(text, 0, 0);
|
|
71
|
-
},
|
|
72
|
-
renderResult(result, { expanded }, theme, context) {
|
|
73
|
-
const container = new Container();
|
|
74
|
-
container.addChild(new Spacer(1));
|
|
75
|
-
|
|
76
|
-
const text = joinTextContent(result.content);
|
|
77
|
-
|
|
78
|
-
if (context.isError) {
|
|
79
|
-
container.addChild(new Text(theme.fg("error", text || "Web request failed."), 0, 0));
|
|
80
|
-
return container;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const lines = text.length > 0 ? text.split("\n") : [];
|
|
84
|
-
if (lines.length === 0) {
|
|
85
|
-
container.addChild(new Text(theme.fg("muted", "(no content returned)"), 0, 0));
|
|
86
|
-
return container;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_MAX_LINES);
|
|
90
|
-
container.addChild(new Text(theme.fg("muted", lines.slice(0, maxLines).join("\n")), 0, 0));
|
|
91
|
-
|
|
92
|
-
const hidden = lines.length - maxLines;
|
|
93
|
-
if (hidden > 0) {
|
|
94
|
-
container.addChild(new Text(theme.fg("dim", `... (${hidden} more lines, ${keyText("app.tools.expand")} to expand)`), 0, 0));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return container;
|
|
98
|
-
},
|
|
99
|
-
async execute(_toolCallId, params, signal) {
|
|
100
|
-
const action = ACTIONS[params.action as keyof typeof ACTIONS];
|
|
101
|
-
if (!action) throw new Error(`Unknown web action "${params.action}".`);
|
|
102
|
-
|
|
103
|
-
if (params.action === "search" && !params.query?.trim()) {
|
|
104
|
-
throw new Error("The \"search\" action requires a non-empty \"query\".");
|
|
105
|
-
}
|
|
106
|
-
if (params.action === "fetch" && !(params.urls && params.urls.length > 0)) {
|
|
107
|
-
throw new Error("The \"fetch\" action requires a non-empty \"urls\" array.");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const args: Record<string, unknown> = {};
|
|
111
|
-
for (const field of action.fields) {
|
|
112
|
-
const value = (params as Record<string, unknown>)[field];
|
|
113
|
-
if (value !== undefined) args[field] = value;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const content = await exa.call(action.tool, args, signal);
|
|
117
|
-
return { content, details: { action: params.action } };
|
|
118
|
-
},
|
|
119
|
-
});
|
|
22
|
+
registerWebTool(pi, exa, ACTIONS);
|
|
120
23
|
});
|
|
121
24
|
|
|
122
25
|
pi.on("session_shutdown", () => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineActionFor, registerComposedTool } from "../../utils/tool";
|
|
2
|
+
|
|
3
|
+
import type { ExaClient } from "./client";
|
|
4
|
+
import type { Action } from "../../utils/tool";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
interface WebActionContext {
|
|
8
|
+
exa: ExaClient;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const defineAction = defineActionFor<WebActionContext>();
|
|
12
|
+
|
|
13
|
+
export function registerWebTool(pi: ExtensionAPI, exa: ExaClient, actions: Action<WebActionContext, any, any>[]): void {
|
|
14
|
+
registerComposedTool<WebActionContext>(pi, {
|
|
15
|
+
name: "web",
|
|
16
|
+
label: "web",
|
|
17
|
+
descriptionIntro: "Access the live web via Exa:",
|
|
18
|
+
promptSnippet: "Search the web and fetch page content via Exa",
|
|
19
|
+
actions,
|
|
20
|
+
createContext: () => ({ exa }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { joinTextContent } from "../../utils/format";
|
|
5
|
+
|
|
6
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const COLLAPSED_MAX_LINES = 10;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render a web result's text content with collapse-to-expand behavior. Shared by the `search` and
|
|
12
|
+
* `fetch` actions, which both return plain text content. Errors are handled by the registry's
|
|
13
|
+
* fallback renderer, so this only covers the success path.
|
|
14
|
+
*/
|
|
15
|
+
export function renderWebResult(result: AgentToolResult<unknown>, expanded: boolean, theme: Theme): Container {
|
|
16
|
+
const container = new Container();
|
|
17
|
+
container.addChild(new Spacer(1));
|
|
18
|
+
|
|
19
|
+
const text = joinTextContent(result.content);
|
|
20
|
+
const lines = text.length > 0 ? text.split("\n") : [];
|
|
21
|
+
|
|
22
|
+
if (lines.length === 0) {
|
|
23
|
+
container.addChild(new Text(theme.fg("muted", "No content returned."), 0, 0));
|
|
24
|
+
return container;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_MAX_LINES);
|
|
28
|
+
container.addChild(new Text(theme.fg("muted", lines.slice(0, maxLines).join("\n")), 0, 0));
|
|
29
|
+
|
|
30
|
+
const hidden = lines.length - maxLines;
|
|
31
|
+
if (hidden > 0) container.addChild(new Text(theme.fg("dim", `... (${hidden} more lines, ${keyText("app.tools.expand")} to expand)`), 0, 0));
|
|
32
|
+
|
|
33
|
+
return container;
|
|
34
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -28,6 +28,11 @@ export function formatCost(cost: number): string {
|
|
|
28
28
|
return `$${cost.toFixed(2)}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Format a duration in ms as `1.2s`, like the built-in bash tool. */
|
|
32
|
+
export function formatDuration(ms: number): string {
|
|
33
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export function formatCwd(cwd: string, home: string): string {
|
|
32
37
|
const resolvedCwd = resolve(cwd);
|
|
33
38
|
const resolvedHome = resolve(home);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
import { formatDuration, joinTextContent } from "./format";
|
|
6
|
+
|
|
7
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { Static, TObject, TProperties, TSchema } from "typebox";
|
|
10
|
+
|
|
11
|
+
interface ActionDetails {
|
|
12
|
+
action: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One action of a composed tool. The registry merges all actions' `fields` into one flat schema
|
|
17
|
+
* (provider-safe, unlike a discriminated union) and dispatches by `action`; `C` is the per-call
|
|
18
|
+
* context from `createContext`.
|
|
19
|
+
*/
|
|
20
|
+
export interface Action<C, F extends TProperties = TProperties, D extends ActionDetails = ActionDetails> {
|
|
21
|
+
name: D["action"];
|
|
22
|
+
summary: string;
|
|
23
|
+
fields: F;
|
|
24
|
+
/** Fields required at runtime (the flat schema makes all fields optional). */
|
|
25
|
+
required?: (keyof F & string)[];
|
|
26
|
+
promptGuidelines?: string[];
|
|
27
|
+
/** Show bash-style elapsed/took timing for this action. */
|
|
28
|
+
showTiming?: boolean;
|
|
29
|
+
/** Styled segments shown after the `<tool> <action>` prefix. */
|
|
30
|
+
renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
|
|
31
|
+
renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
|
|
32
|
+
execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
|
|
36
|
+
export function defineActionFor<C>() {
|
|
37
|
+
return <F extends TProperties, D extends ActionDetails>(action: Action<C, F, D>): Action<C, F, D> => action;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ComposedToolConfig<C> {
|
|
41
|
+
name: string;
|
|
42
|
+
label: string;
|
|
43
|
+
descriptionIntro: string;
|
|
44
|
+
descriptionOutro?: string;
|
|
45
|
+
promptSnippet: string;
|
|
46
|
+
generalGuidelines?: string[];
|
|
47
|
+
actions: Action<C, any, any>[];
|
|
48
|
+
createContext(ctx: ExtensionContext): C;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolConfig<C>): void {
|
|
52
|
+
const byName = new Map<string, Action<C, any, any>>();
|
|
53
|
+
const mergedFields: TProperties = {};
|
|
54
|
+
|
|
55
|
+
for (const action of config.actions) {
|
|
56
|
+
if (byName.has(action.name)) throw new Error(`Duplicate "${config.name}" action "${action.name}"`);
|
|
57
|
+
byName.set(action.name, action);
|
|
58
|
+
|
|
59
|
+
for (const [key, schema] of Object.entries(action.fields)) {
|
|
60
|
+
if (key === "action") throw new Error(`"${config.name}" action "${action.name}" must not define a field named "action"`);
|
|
61
|
+
if (key in mergedFields) throw new Error(`"${config.name}" action "${action.name}" redefines field "${key}"; field names must be unique across actions`);
|
|
62
|
+
mergedFields[key] = Type.Optional(schema as TSchema);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const summaries = config.actions.map((action) => `"${action.name}" ${action.summary}`).join("; ");
|
|
67
|
+
const description = config.descriptionOutro
|
|
68
|
+
? `${config.descriptionIntro} ${summaries}. ${config.descriptionOutro}`
|
|
69
|
+
: `${config.descriptionIntro} ${summaries}.`;
|
|
70
|
+
|
|
71
|
+
const parameters = Type.Object({
|
|
72
|
+
action: StringEnum(config.actions.map((action) => action.name), { description: `The ${config.name} action to run.` }),
|
|
73
|
+
...mergedFields,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const activeTiming = new ActiveTiming();
|
|
77
|
+
const noTiming = new NoTiming();
|
|
78
|
+
// Resolve per-action timing from the stable call args, so error results (no details) still settle.
|
|
79
|
+
const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
|
|
80
|
+
|
|
81
|
+
const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
|
|
82
|
+
const details = result.details as ActionDetails | undefined;
|
|
83
|
+
|
|
84
|
+
if (context.isError || !details) {
|
|
85
|
+
const output = joinTextContent(result.content);
|
|
86
|
+
return new Text(context.isError && output ? theme.fg("error", "\n" + output) : "", 0, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const action = byName.get(details.action);
|
|
90
|
+
if (action?.renderResult) return action.renderResult(result as AgentToolResult<ActionDetails>, options, theme, context as never);
|
|
91
|
+
|
|
92
|
+
return new Text(joinTextContent(result.content), 0, 0);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: config.name,
|
|
97
|
+
label: config.label,
|
|
98
|
+
description,
|
|
99
|
+
promptSnippet: config.promptSnippet,
|
|
100
|
+
promptGuidelines: [...(config.generalGuidelines ?? []), ...config.actions.flatMap((action) => action.promptGuidelines ?? [])],
|
|
101
|
+
parameters,
|
|
102
|
+
renderCall(args, theme, context) {
|
|
103
|
+
const segments = [theme.bold(theme.fg("toolTitle", config.name)), theme.fg("accent", args.action)];
|
|
104
|
+
const params = byName.get(args.action)?.renderParams?.(args, theme);
|
|
105
|
+
if (params) segments.push(...params);
|
|
106
|
+
|
|
107
|
+
return timingFor(context.args.action).renderCall(new Text(segments.join(" "), 0, 0), theme, context);
|
|
108
|
+
},
|
|
109
|
+
renderResult(result, options, theme, context) {
|
|
110
|
+
const inner = renderActionResult(result, options, theme, context);
|
|
111
|
+
|
|
112
|
+
return timingFor(context.args.action).renderResult(inner, options, theme, context);
|
|
113
|
+
},
|
|
114
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
115
|
+
const action = byName.get(params.action);
|
|
116
|
+
if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
|
|
117
|
+
|
|
118
|
+
for (const field of action.required ?? []) {
|
|
119
|
+
if ((params as Record<string, unknown>)[field] === undefined) {
|
|
120
|
+
throw new Error(`The "${params.action}" action requires "${field}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return action.execute(params as never, config.createContext(ctx), signal);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A tuple type without its first element. */
|
|
130
|
+
type DropFirst<T extends readonly unknown[]> = T extends readonly [unknown, ...infer R] ? R : never;
|
|
131
|
+
|
|
132
|
+
/** A renderer's params after the leading args/result. */
|
|
133
|
+
type RenderCallTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderCall"]>>>;
|
|
134
|
+
type RenderResultTail = DropFirst<Parameters<NonNullable<ToolDefinition["renderResult"]>>>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bash-style timing for a tool row: a live "Elapsed" ticker while running, a final "Took" once
|
|
138
|
+
* settled. `renderCall`/`renderResult` wrap the built component in place of the args/result.
|
|
139
|
+
*/
|
|
140
|
+
interface Timing {
|
|
141
|
+
renderCall(inner: Component, ...rest: RenderCallTail): Component;
|
|
142
|
+
renderResult(inner: Component, ...rest: RenderResultTail): Component;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Per-row timing state, kept in `ToolRenderContext.state` so the timing classes stay stateless. */
|
|
146
|
+
interface TimingState {
|
|
147
|
+
startedAt?: number;
|
|
148
|
+
endedAt?: number;
|
|
149
|
+
interval?: ReturnType<typeof setInterval>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class ActiveTiming implements Timing {
|
|
153
|
+
renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
|
|
154
|
+
const state = context.state as TimingState;
|
|
155
|
+
if (context.executionStarted && state.startedAt === undefined) {
|
|
156
|
+
state.startedAt = Date.now();
|
|
157
|
+
delete state.endedAt;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Result is in: renderResult owns the timing line, so just settle here.
|
|
161
|
+
if (!context.isPartial) {
|
|
162
|
+
this.settle(state);
|
|
163
|
+
return inner;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Still running, no result yet: show a live "Elapsed" line, ticking once a second.
|
|
167
|
+
if (state.startedAt === undefined) return inner;
|
|
168
|
+
|
|
169
|
+
state.interval ??= setInterval(() => context.invalidate(), 1000);
|
|
170
|
+
return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
|
|
174
|
+
const state = context.state as TimingState;
|
|
175
|
+
if (!options.isPartial || context.isError) this.settle(state);
|
|
176
|
+
if (state.startedAt === undefined) return inner;
|
|
177
|
+
|
|
178
|
+
const label = options.isPartial ? "Elapsed" : "Took";
|
|
179
|
+
const endTime = state.endedAt ?? Date.now();
|
|
180
|
+
return this.withTimingLine(inner, label, endTime - state.startedAt, theme);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private settle(state: TimingState): void {
|
|
184
|
+
if (state.startedAt !== undefined) state.endedAt ??= Date.now();
|
|
185
|
+
if (state.interval) {
|
|
186
|
+
clearInterval(state.interval);
|
|
187
|
+
delete state.interval;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private withTimingLine(content: Component, label: string, ms: number, theme: Theme): Container {
|
|
192
|
+
const container = new Container();
|
|
193
|
+
container.addChild(content);
|
|
194
|
+
|
|
195
|
+
container.addChild(new Spacer(1));
|
|
196
|
+
container.addChild(new Text(`${theme.fg("muted", `${label} ${formatDuration(ms)}`)}`, 0, 0));
|
|
197
|
+
|
|
198
|
+
return container;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
class NoTiming implements Timing {
|
|
203
|
+
renderCall(inner: Component): Component {
|
|
204
|
+
return inner;
|
|
205
|
+
}
|
|
206
|
+
renderResult(inner: Component): Component {
|
|
207
|
+
return inner;
|
|
208
|
+
}
|
|
209
|
+
}
|