pi-spark 0.14.7 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +26 -20
  2. package/assets/screenshot-credits.png +0 -0
  3. package/index.ts +2 -4
  4. package/package.json +3 -4
  5. package/src/config/index.ts +11 -45
  6. package/src/config/schema.ts +2 -4
  7. package/src/features/credits/providers/moonshot.ts +1 -1
  8. package/src/features/credits/status.ts +1 -1
  9. package/src/features/credits/types.ts +3 -3
  10. package/src/features/footer/index.ts +9 -2
  11. package/src/features/fullscreen/filler.ts +1 -1
  12. package/src/features/presets/manager.ts +1 -1
  13. package/src/features/presets/selector.ts +2 -2
  14. package/src/features/recap/manager.ts +6 -6
  15. package/src/features/title/config.ts +7 -0
  16. package/src/features/title/index.ts +26 -0
  17. package/src/features/title/manager.ts +117 -0
  18. package/src/utils/format.ts +2 -20
  19. package/src/{features/recap → utils}/model.ts +25 -10
  20. package/src/utils/usage.ts +1 -1
  21. package/assets/screenshot-tools.png +0 -0
  22. package/src/features/pi/actions/models.ts +0 -165
  23. package/src/features/pi/actions/name.ts +0 -58
  24. package/src/features/pi/actions/whoami.ts +0 -61
  25. package/src/features/pi/config.ts +0 -3
  26. package/src/features/pi/index.ts +0 -23
  27. package/src/features/pi/model.ts +0 -56
  28. package/src/features/pi/registry.ts +0 -28
  29. package/src/features/web/actions/fetch.ts +0 -47
  30. package/src/features/web/actions/search.ts +0 -50
  31. package/src/features/web/client.ts +0 -77
  32. package/src/features/web/config.ts +0 -3
  33. package/src/features/web/index.ts +0 -28
  34. package/src/features/web/registry.ts +0 -22
  35. package/src/features/web/render.ts +0 -34
  36. package/src/utils/tool.ts +0 -221
@@ -1,14 +1,14 @@
1
1
  import { clampThinkingLevel } from "@earendil-works/pi-ai";
2
2
 
3
- import { formatModel } from "../../utils/format";
3
+ import { formatModel } from "./format";
4
4
 
5
5
  import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
6
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import type { OptionalModelConfig } from "../../config/model";
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { OptionalModelConfig } from "../config/model";
8
8
 
9
9
  const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
10
10
 
11
- type RecapModelSettings = {
11
+ export type ModelSettings = {
12
12
  model: Model<Api>;
13
13
  thinkingLevel: ModelThinkingLevel;
14
14
  warning: string | undefined;
@@ -24,15 +24,30 @@ type ThinkingLevelSelection = {
24
24
  warning: string | undefined;
25
25
  };
26
26
 
27
- export async function resolveRecapModelSettings(pi: ExtensionAPI, ctx: ExtensionContext, config: OptionalModelConfig): Promise<RecapModelSettings | undefined> {
27
+ /** Check if a model has zero costs for all categories. */
28
+ export function isFreeModel(model: Model<Api>): boolean {
29
+ return model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0 && model.cost.cacheWrite === 0;
30
+ }
31
+
32
+ /**
33
+ * Resolve the model and thinking level for a background feature (recap, title, ...).
34
+ *
35
+ * `feature` names the config section so warnings can point at the offending fields. When the
36
+ * feature's model config is incomplete or unavailable, this falls back to the session's main
37
+ * model and reports why via `warning`. Pass `notifyOnMissingModel: false` for silent features.
38
+ *
39
+ * The thinking level defaults to "off" (clamped to the model) when not set in config, so these
40
+ * background features stay cheap regardless of the working model's thinking level.
41
+ */
42
+ export async function resolveModelSettings(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, options: { notifyOnMissingModel?: boolean } = {}): Promise<ModelSettings | undefined> {
28
43
  const fallbackModel = ctx.model;
29
44
  if (!fallbackModel) {
30
- ctx.ui.notify("No model selected for recap", "warning");
45
+ if (options.notifyOnMissingModel ?? true) ctx.ui.notify(`No model selected for ${feature}`, "warning");
31
46
  return;
32
47
  }
33
48
 
34
- const { model, warning: modelWarning } = await resolveModelSelection(ctx, config, fallbackModel);
35
- const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? pi.getThinkingLevel());
49
+ const { model, warning: modelWarning } = await resolveModelSelection(ctx, config, feature, fallbackModel);
50
+ const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? DEFAULT_THINKING_LEVEL);
36
51
 
37
52
  return {
38
53
  model,
@@ -41,7 +56,7 @@ export async function resolveRecapModelSettings(pi: ExtensionAPI, ctx: Extension
41
56
  };
42
57
  }
43
58
 
44
- async function resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, fallbackModel: Model<Api>): Promise<ModelSelection> {
59
+ async function resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, fallbackModel: Model<Api>): Promise<ModelSelection> {
45
60
  if (!config.provider && !config.model) {
46
61
  return {
47
62
  model: fallbackModel,
@@ -52,7 +67,7 @@ async function resolveModelSelection(ctx: ExtensionContext, config: OptionalMode
52
67
  if (!config.provider || !config.model) {
53
68
  return {
54
69
  model: fallbackModel,
55
- warning: "Both recap.provider and recap.model are required; using the current model.",
70
+ warning: `Both ${feature}.provider and ${feature}.model are required; using the current model.`,
56
71
  };
57
72
  }
58
73
 
@@ -1,7 +1,7 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
 
4
- /** Structural type guard for the pi `Usage` shape. */
4
+ /** Structural type guard for the Pi `Usage` shape. */
5
5
  export function isUsage(value: unknown): value is Usage {
6
6
  if (typeof value !== "object" || value === null) return false;
7
7
 
Binary file
@@ -1,165 +0,0 @@
1
- import { keyHint, 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, `) + keyHint("app.tools.expand", "to expand") + theme.fg("dim", ")"), 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({});