pi-spark 0.14.6 → 0.15.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.
@@ -1,165 +0,0 @@
1
- import { keyText, truncateHead } from "@earendil-works/pi-coding-agent";
2
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { filter, parse } from "liqe";
4
- import { Type } from "typebox";
5
-
6
- import { defineAction } from "../registry";
7
- import { formatListNotice, toMetadata, toModelRow } from "../model";
8
-
9
- import type { TruncationResult } from "@earendil-works/pi-coding-agent";
10
- import type { ModelMetadata, ModelRow } from "../model";
11
-
12
- const LIST_MAX_LINES = 200;
13
- const COLLAPSED_MAX_LINES = 10;
14
-
15
- interface ModelsDetails {
16
- action: "models";
17
- models: ModelMetadata[];
18
- total: number;
19
- truncation?: TruncationResult;
20
- }
21
-
22
- export const modelsAction = defineAction({
23
- name: "models",
24
- summary: `lists and searches the model catalog (one JSON object per line, truncated to ${LIST_MAX_LINES} models)`,
25
- fields: {
26
- query: Type.Optional(Type.String({
27
- description:
28
- "For \"models\": Filter models with a Liqe (Lucene-like) query. Bare terms match any " +
29
- "field, and unquoted terms are case-insensitive substrings. The syntax supports " +
30
- "AND/OR/NOT, grouping, wildcards, and numeric comparisons; quote values containing " +
31
- "special characters (e.g., id:\"deepseek/deepseek-v4\"). Filterable fields are id, name, " +
32
- "provider, api, reasoning (boolean), input (modalities), cost.input and cost.output " +
33
- "(USD per 1M tokens), contextWindow, maxTokens, thinkingLevels, and available " +
34
- "(boolean, true when auth is configured). Examples: \"claude available:true\", " +
35
- "\"provider:openrouter cost.input:<1\".",
36
- })),
37
- offset: Type.Optional(Type.Number({ description: "For \"models\": Start from this model number (1-indexed)." })),
38
- limit: Type.Optional(Type.Number({ description: "For \"models\": Return at most this many models." })),
39
- },
40
- promptGuidelines: [
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.",
42
- ],
43
- renderParams(args, theme) {
44
- const params: string[] = [];
45
-
46
- const query = args.query?.trim();
47
- if (query) params.push(theme.fg("muted", query));
48
-
49
- if (args.offset !== undefined || args.limit !== undefined) {
50
- const start = args.offset ?? 1;
51
- const end = args.limit !== undefined ? start + args.limit - 1 : "end";
52
- params.push(theme.fg("warning", `${start}-${end}`));
53
- }
54
-
55
- return params;
56
- },
57
- renderResult(result, { expanded }, theme, context) {
58
- const details = result.details as ModelsDetails | undefined;
59
-
60
- const container = new Container();
61
- container.addChild(new Spacer(1));
62
-
63
- if (!details) {
64
- container.addChild(new Text(theme.fg("muted", "No models found."), 0, 0));
65
- return container;
66
- }
67
-
68
- const addRows = (rows: ModelRow[]) => {
69
- const widths = {
70
- label: Math.max(...rows.map((row) => row.label.length)),
71
- cost: Math.max(...rows.map((row) => row.cost.length)),
72
- context: Math.max(...rows.map((row) => row.context.length)),
73
- };
74
-
75
- for (const row of rows) {
76
- const cells = [
77
- row.label.padEnd(widths.label),
78
- row.cost.padStart(widths.cost),
79
- row.context.padStart(widths.context),
80
- ].join(" ");
81
- const noAuthHint = row.available ? "" : theme.fg("dim", " (no auth)");
82
- container.addChild(new Text(theme.fg("muted", cells) + noAuthHint, 0, 0));
83
- }
84
- };
85
-
86
- const filtered = Boolean(context.args.query?.trim());
87
- const models = details.models ?? [];
88
- const total = details.total ?? models.length;
89
-
90
- if (models.length > 0) {
91
- const maxRows = expanded ? models.length : Math.min(models.length, COLLAPSED_MAX_LINES);
92
- addRows(models.slice(0, maxRows).map((model) => toModelRow(model)));
93
-
94
- const hiddenRows = models.length - maxRows;
95
- if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, ${keyText("app.tools.expand")} to expand)`), 0, 0));
96
- container.addChild(new Spacer(1));
97
- }
98
-
99
- if (details.truncation?.truncated) {
100
- const startIndex = context.args.offset ? Math.max(0, context.args.offset - 1) : 0;
101
- const endDisplay = startIndex + models.length;
102
- const notice = formatListNotice(true, startIndex, endDisplay, total);
103
- if (notice) {
104
- container.addChild(new Text(theme.fg("warning", notice), 0, 0));
105
- container.addChild(new Spacer(1));
106
- }
107
- }
108
-
109
- const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed.`;
110
- container.addChild(new Text(theme.fg("muted", total > 0 ? summary : `No models ${filtered ? "matched" : "found"}.`), 0, 0));
111
-
112
- return container;
113
- },
114
- async execute(args, { ctx }) {
115
- const queryText = args.query?.trim();
116
- const filtered = Boolean(queryText);
117
-
118
- let listQuery = null;
119
- if (queryText) {
120
- try {
121
- listQuery = parse(queryText);
122
- } catch (error) {
123
- throw new Error(`Invalid Liqe query ${JSON.stringify(queryText)}: ${error instanceof Error ? error.message : String(error)}`);
124
- }
125
- }
126
-
127
- const models = ctx.modelRegistry.getAll().map((model) => toMetadata(model, ctx.modelRegistry.hasConfiguredAuth(model)));
128
- const matched = listQuery ? filter(listQuery, models) : models;
129
- const total = matched.length;
130
-
131
- if (total === 0) {
132
- return {
133
- content: [{ type: "text", text: `No models ${filtered ? "matched" : "found"}.` }],
134
- details: { action: "models", models: [], total } satisfies ModelsDetails,
135
- };
136
- }
137
-
138
- // Convert from 1-indexed offset to 0-indexed array access.
139
- const startIndex = args.offset ? Math.max(0, args.offset - 1) : 0;
140
- if (startIndex >= total) {
141
- throw new Error(`Offset ${args.offset} is beyond the end of the list (${total} models total)`);
142
- }
143
-
144
- const endIndex = args.limit !== undefined ? Math.min(startIndex + args.limit, total) : total;
145
- const selected = matched.slice(startIndex, endIndex);
146
-
147
- // JSONL: one compact object per line, so truncation cuts at record boundaries.
148
- const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
149
- maxLines: LIST_MAX_LINES,
150
- maxBytes: Infinity,
151
- });
152
-
153
- const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
154
- const endDisplay = startIndex + delivered.length;
155
-
156
- let text = truncation.content;
157
- const notice = formatListNotice(truncation.truncated, startIndex, endDisplay, total);
158
- if (notice) text += `\n\n${notice}`;
159
-
160
- return {
161
- content: [{ type: "text", text }],
162
- details: { action: "models", models: delivered, total, truncation } satisfies ModelsDetails,
163
- };
164
- },
165
- });
@@ -1,58 +0,0 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import { Type } from "typebox";
3
-
4
- import { sanitizeText } from "../../../utils/format";
5
- import { defineAction } from "../registry";
6
-
7
- interface NameDetails {
8
- action: "name";
9
- changed: boolean;
10
- previous: string | null;
11
- }
12
-
13
- export const nameAction = defineAction({
14
- name: "name",
15
- summary: "sets or updates the current session's name",
16
- fields: {
17
- name: Type.String({
18
- minLength: 1,
19
- maxLength: 120,
20
- description:
21
- "For \"name\": Use a short, recognizable phrase in sentence case, ideally <= 72 characters " +
22
- "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
23
- "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
24
- }),
25
- },
26
- required: ["name"],
27
- promptGuidelines: [
28
- "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.",
29
- ],
30
- renderParams(args, theme) {
31
- return [theme.fg("muted", sanitizeText(args.name ?? ""))];
32
- },
33
- renderResult() {
34
- // "name" has no success UI; errors are rendered by the registry's fallback.
35
- return new Text("", 0, 0);
36
- },
37
- async execute(args, { pi }) {
38
- const name = sanitizeText(args.name);
39
- if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase");
40
-
41
- const previous = pi.getSessionName() ?? null;
42
- if (previous === name) {
43
- const details: NameDetails = { action: "name", changed: false, previous };
44
- return {
45
- content: [{ type: "text", text: `Session is already named "${name}". Nothing changed.` }],
46
- details,
47
- };
48
- }
49
-
50
- pi.setSessionName(name);
51
-
52
- const details: NameDetails = { action: "name", changed: true, previous };
53
- return {
54
- content: [{ type: "text", text: previous ? `Renamed session from "${previous}" to "${name}".` : `Named session "${name}".` }],
55
- details,
56
- };
57
- },
58
- });
@@ -1,61 +0,0 @@
1
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
-
3
- import { defineAction } from "../registry";
4
- import { toMetadata, toModelRow } from "../model";
5
-
6
- import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
7
- import type { ModelMetadata } from "../model";
8
-
9
- interface WhoamiDetails {
10
- action: "whoami";
11
- sessionName: string | null;
12
- model: ModelMetadata | null;
13
- thinkingLevel: ModelThinkingLevel;
14
- }
15
-
16
- export const whoamiAction = defineAction({
17
- name: "whoami",
18
- summary: "shows the current pi state, including session name, active model, and thinking level",
19
- fields: {},
20
- promptGuidelines: [
21
- "Use the pi tool's \"whoami\" action when you need the current session's name or the active model and thinking level.",
22
- ],
23
- renderResult(result, _options, theme) {
24
- const details = result.details as WhoamiDetails | undefined;
25
-
26
- const container = new Container();
27
- container.addChild(new Spacer(1));
28
-
29
- if (!details) {
30
- container.addChild(new Text(theme.fg("muted", "No pi state available."), 0, 0));
31
- return container;
32
- }
33
-
34
- const formatLine = (label: string, value: string) => `${label.padEnd(7)} ${value}`;
35
-
36
- if (details.sessionName) {
37
- container.addChild(new Text(theme.fg("muted", formatLine("session", details.sessionName)), 0, 0));
38
- }
39
-
40
- if (details.model) {
41
- const row = toModelRow(details.model, details.thinkingLevel);
42
- const cells = [row.label, row.cost, row.context].join(" ");
43
- container.addChild(new Text(theme.fg("muted", formatLine("model", cells)), 0, 0));
44
- }
45
-
46
- return container;
47
- },
48
- async execute(_args, { pi, ctx }) {
49
- const state: WhoamiDetails = {
50
- action: "whoami",
51
- sessionName: pi.getSessionName() ?? null,
52
- model: ctx.model ? toMetadata(ctx.model, true) : null,
53
- thinkingLevel: pi.getThinkingLevel(),
54
- };
55
-
56
- return {
57
- content: [{ type: "text", text: JSON.stringify(state) }],
58
- details: state,
59
- };
60
- },
61
- });
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const piConfigSchema = z.object({});
@@ -1,23 +0,0 @@
1
- import { nameAction } from "./actions/name";
2
- import { modelsAction } from "./actions/models";
3
- import { whoamiAction } from "./actions/whoami";
4
- import { registerPiTool } from "./registry";
5
- import { loadConfig } from "../../config";
6
-
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
-
9
- /** Self-management actions exposed by the pi tool, ordered alphabetically by action name. */
10
- const ACTIONS = [
11
- modelsAction,
12
- nameAction,
13
- whoamiAction,
14
- ];
15
-
16
- export function registerPi(pi: ExtensionAPI): void {
17
- pi.on("session_start", (_event, ctx) => {
18
- const config = loadConfig(ctx).pi;
19
- if (!config) return;
20
-
21
- registerPiTool(pi, ACTIONS);
22
- });
23
- }
@@ -1,56 +0,0 @@
1
- import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
-
3
- import { formatModel, formatTokens } from "../../utils/format";
4
-
5
- import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
6
-
7
- /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
8
- const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
9
-
10
- export type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
11
- thinkingLevels: ModelThinkingLevel[];
12
- defaultThinkingLevel: ModelThinkingLevel;
13
- available: boolean;
14
- };
15
-
16
- export type ModelRow = {
17
- label: string;
18
- cost: string;
19
- context: string;
20
- available: boolean;
21
- };
22
-
23
- export function toMetadata(model: Model<Api>, available: boolean): ModelMetadata {
24
- const { headers: _headers, compat: _compat, ...metadata } = model;
25
-
26
- return {
27
- ...metadata,
28
- thinkingLevels: getSupportedThinkingLevels(model),
29
- defaultThinkingLevel: clampThinkingLevel(model, DEFAULT_THINKING_LEVEL),
30
- available,
31
- };
32
- }
33
-
34
- export function toModelRow(model: ModelMetadata, thinkingLevel?: ModelThinkingLevel): ModelRow {
35
- return {
36
- label: formatModel(model.provider, model.id, thinkingLevel),
37
- cost: `$${formatPrice(model.cost.input)}/$${formatPrice(model.cost.output)}`,
38
- context: formatTokens(model.contextWindow),
39
- available: model.available,
40
- };
41
- }
42
-
43
- /** Round to at most 2 decimals and trim float noise: 0.7999... -> 0.8, 0.0983 -> 0.1, 15 -> 15. */
44
- function formatPrice(value: number): string {
45
- return String(parseFloat(value.toFixed(2)));
46
- }
47
-
48
- /** Notice line describing truncation or remaining pages, shared by the text result and the TUI. */
49
- export function formatListNotice(truncated: boolean, startIndex: number, endDisplay: number, total: number): string | undefined {
50
- const nextOffset = endDisplay + 1;
51
-
52
- if (truncated) return `[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}. Use offset=${nextOffset} to continue.]`;
53
- if (endDisplay < total) return `[${total - endDisplay} more models in list. Use offset=${nextOffset} to continue.]`;
54
-
55
- return undefined;
56
- }
@@ -1,28 +0,0 @@
1
- import { defineActionFor, registerComposedTool } from "../../utils/tool";
2
-
3
- import type { Action } from "../../utils/tool";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
-
6
- interface PiActionContext {
7
- pi: ExtensionAPI;
8
- ctx: ExtensionContext;
9
- }
10
-
11
- export const defineAction = defineActionFor<PiActionContext>();
12
-
13
- export function registerPiTool(pi: ExtensionAPI, actions: Action<PiActionContext, any, any>[]): void {
14
- registerComposedTool<PiActionContext>(pi, {
15
- name: "pi",
16
- label: "pi",
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.",
21
- promptSnippet: "Inspect and adjust the current pi session and model state",
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 }),
27
- });
28
- }
@@ -1,47 +0,0 @@
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
- });
@@ -1,50 +0,0 @@
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
- });
@@ -1,77 +0,0 @@
1
- import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import type { TextContent } from "@earendil-works/pi-ai";
3
-
4
- /** Exa's hosted MCP endpoint (Streamable HTTP). No API key required for the free tier. */
5
- const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
6
-
7
- /** Collapse an MCP tool call result's content into plain text content for the model. */
8
- function sanitizeContent(content: unknown): TextContent[] {
9
- const blocks = (Array.isArray(content) ? content : []) as { type: string; text?: string}[];
10
- const result: TextContent[] = blocks.map((block) =>
11
- block.type === "text" && typeof block.text === "string"
12
- ? { type: "text", text: block.text }
13
- : { type: "text", text: JSON.stringify(block) },
14
- );
15
-
16
- return result;
17
- }
18
-
19
- /**
20
- * Thin, session-scoped wrapper over Exa's remote MCP server using the official MCP client.
21
- *
22
- * The MCP connection (one `initialize` handshake) is established lazily on the first call and
23
- * reused for the rest of the session; `close()` tears it down on `session_shutdown`.
24
- */
25
- export class ExaClient {
26
- private client: Client | undefined;
27
- private connecting: Promise<Client> | undefined;
28
-
29
- async call(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<TextContent[]> {
30
- const client = await this.getClient();
31
- const result = await client.callTool({ name: toolName, arguments: args }, undefined, signal ? { signal } : undefined);
32
- const content = sanitizeContent(result.content);
33
-
34
- if (result.isError && content.length > 0) {
35
- const text = content.map((block) => block.text).join("\n");
36
- throw new Error(`Exa request failed: ${text}`);
37
- }
38
-
39
- if (result.isError) throw new Error(`Exa request failed: no content returned`);
40
- if (content.length === 0) throw new Error(`No content returned`);
41
-
42
- return content;
43
- }
44
-
45
- async close(): Promise<void> {
46
- const current = this.client;
47
- this.client = undefined;
48
- this.connecting = undefined;
49
- await current?.close().catch(() => {});
50
- }
51
-
52
- private getClient(): Promise<Client> {
53
- if (this.client) return Promise.resolve(this.client);
54
-
55
- if (!this.connecting) {
56
- this.connecting = (async () => {
57
- // The MCP SDK is heavy to import (~40 ms cold); load it lazily so startup never pays for
58
- // it unless a web action actually runs.
59
- const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
60
- import("@modelcontextprotocol/sdk/client/index.js"),
61
- import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
62
- ]);
63
-
64
- const next = new Client({ name: "pi-spark", version: "0" });
65
- const transport = new StreamableHTTPClientTransport(new URL(EXA_MCP_URL));
66
- await next.connect(transport as Parameters<Client["connect"]>[0]);
67
- this.client = next;
68
- return next;
69
- })().catch((error) => {
70
- this.connecting = undefined;
71
- throw error;
72
- });
73
- }
74
-
75
- return this.connecting;
76
- }
77
- }
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const webConfigSchema = z.object({});
@@ -1,28 +0,0 @@
1
- import { ExaClient } from "./client";
2
- import { fetchAction } from "./actions/fetch";
3
- import { searchAction } from "./actions/search";
4
- import { registerWebTool } from "./registry";
5
- import { loadConfig } from "../../config";
6
-
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
-
9
- /** Web actions exposed by the web tool, ordered to match the description (search before fetch). */
10
- const ACTIONS = [
11
- searchAction,
12
- fetchAction,
13
- ];
14
-
15
- export function registerWeb(pi: ExtensionAPI): void {
16
- const exa = new ExaClient();
17
-
18
- pi.on("session_start", (_event, ctx) => {
19
- const config = loadConfig(ctx).web;
20
- if (!config) return;
21
-
22
- registerWebTool(pi, exa, ACTIONS);
23
- });
24
-
25
- pi.on("session_shutdown", () => {
26
- exa.close();
27
- });
28
- }
@@ -1,22 +0,0 @@
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
- }
@@ -1,34 +0,0 @@
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
- }