pi-spark 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  [Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
6
6
 
7
+ ![Overview](./assets/screenshot-overview.png)
8
+
7
9
  ## Features
8
10
 
9
11
  ### Compact TUI: editor, footer, and fullscreen
@@ -14,6 +16,8 @@ pi-spark ships with custom editor, footer, and fullscreen rendering, replacing t
14
16
  - The footer shows session information, extension statuses, cost, and context usage on one line.
15
17
  - The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
16
18
 
19
+ ![Compact TUI](./assets/screenshot-tui.png)
20
+
17
21
  ### Credits
18
22
 
19
23
  pi-spark shows the active provider's credit balance or rate-limit usage in the status line, so you can keep an eye on what's left without leaving the terminal.
@@ -21,6 +25,8 @@ pi-spark shows the active provider's credit balance or rate-limit usage in the s
21
25
  - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
22
26
  - Most provider fetching follows [CodexBar](https://github.com/steipete/codexbar). Fireworks is the exception: its balance sits behind an internal gRPC API, reverse-engineered from the `firectl` binary (see [docs/fireworks.md](./docs/fireworks.md)).
23
27
 
28
+ ![Credits](./assets/screenshot-credits.png)
29
+
24
30
  ### Presets
25
31
 
26
32
  pi-spark lets you define named model presets in `spark.json` (see [Configuration](#configuration)), so you can switch between models and thinking levels without retyping provider details. The active preset is shown on the editor's top border.
@@ -29,6 +35,8 @@ pi-spark lets you define named model presets in `spark.json` (see [Configuration
29
35
  - Start pi on a given preset with `pi --preset <name>`.
30
36
  - Cycle presets with `ctrl+super+p` (forward) and `ctrl+shift+super+p` (backward); `super` is `command` on macOS and needs a terminal that forwards it.
31
37
 
38
+ ![Presets](./assets/screenshot-presets.png)
39
+
32
40
  ### Recap
33
41
 
34
42
  pi-spark generates a short recap of the current session after it goes idle, or on demand, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
@@ -37,12 +45,24 @@ pi-spark generates a short recap of the current session after it goes idle, or o
37
45
  - Run `/recap` to generate one manually at any time.
38
46
  - The recap can use its own model, configured separately from your working model.
39
47
 
40
- ### Agent tools: `name` and `model`
48
+ ![Recap](./assets/screenshot-recap.png)
49
+
50
+ ### Agent tools: `pi` and `web`
41
51
 
42
- pi-spark provides the pi coding agent with tools to inspect and manipulate itself. Currently, `name` and `model` tools are included.
52
+ pi-spark adds two agent tools:
43
53
 
54
+ The `pi` tool lets the pi coding agent inspect and manipulate itself (~740 tokens).
55
+
56
+ - `models` lists and searches the model catalog.
44
57
  - `name` sets or updates the current session's name.
45
- - `model` shows the active model or lists pi models.
58
+ - `whoami` shows the current pi state with session name, active model with metadata, and thinking level.
59
+
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
+
62
+ - `search` finds current information across the web and returns clean, ready-to-use content.
63
+ - `fetch` reads the full content of known URLs as clean markdown, and can batch several URLs in one call.
64
+
65
+ ![Agent tools](./assets/screenshot-tools.png)
46
66
 
47
67
  ## Configuration
48
68
 
@@ -87,10 +107,10 @@ All fields are optional. Each top-level feature runs with the defaults below unl
87
107
  | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
88
108
  | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
89
109
  | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
90
- | `model` | `{}` | Exposes the `model` agent tool. |
91
- | `name` | `{}` | Exposes the `name` agent tool. |
110
+ | `pi` | `{}` | Exposes the `pi` agent tool (`models`, `name`, `whoami` actions). |
92
111
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
93
112
  | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
113
+ | `web` | `{}` | Exposes the `web` agent tool (`search`, `fetch` actions) via Exa's hosted MCP server. |
94
114
 
95
115
  #### `EditorConfig`
96
116
 
@@ -109,8 +129,8 @@ Each preset must set all three fields.
109
129
 
110
130
  | Field | Value | Description |
111
131
  | --- | --- | --- |
112
- | `provider` | string | Provider id, e.g. `anthropic`. |
113
- | `model` | string | Model id, e.g. `claude-opus-4-8`. |
132
+ | `provider` | string | Provider ID, e.g., `anthropic`. |
133
+ | `model` | string | Model ID, e.g., `claude-opus-4-8`. |
114
134
  | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
115
135
 
116
136
  #### `RecapConfig`
@@ -119,9 +139,9 @@ All fields are optional, including `thinkingLevel`.
119
139
 
120
140
  | Field | Value | Description |
121
141
  | --- | --- | --- |
122
- | `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g. `"3m"`); minimum 5000 ms, defaults to 3 minutes. |
123
- | `provider` | string | Provider id for the recap model. |
124
- | `model` | string | Model id for the recap model. |
142
+ | `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g., `"3m"`); minimum 5000 ms, defaults to 3 minutes. |
143
+ | `provider` | string | Provider ID for the recap model. |
144
+ | `model` | string | Model ID for the recap model. |
125
145
  | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
126
146
 
127
147
  ### Turn off the features you don't like
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/index.ts CHANGED
@@ -3,10 +3,10 @@ import { registerCredits } from "./src/features/credits";
3
3
  import { registerEditor } from "./src/features/editor";
4
4
  import { registerFooter } from "./src/features/footer";
5
5
  import { registerFullscreen } from "./src/features/fullscreen";
6
- import { registerModel } from "./src/features/model";
7
- import { registerName } from "./src/features/name";
6
+ import { registerPi } from "./src/features/pi";
8
7
  import { registerPresets } from "./src/features/presets";
9
8
  import { registerRecap } from "./src/features/recap";
9
+ import { registerWeb } from "./src/features/web";
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
 
@@ -24,8 +24,8 @@ export default function (pi: ExtensionAPI) {
24
24
  registerEditor(pi, events);
25
25
  registerFooter(pi);
26
26
  registerFullscreen(pi);
27
- registerModel(pi);
28
- registerName(pi);
27
+ registerPi(pi);
29
28
  registerPresets(pi);
30
29
  registerRecap(pi);
30
+ registerWeb(pi);
31
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@grpc/grpc-js": "^1.14.4",
33
33
  "@grpc/proto-loader": "^0.8.1",
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
35
  "liqe": "^3.8.7",
35
36
  "ms": "4.0.0-nightly.202508271359",
36
37
  "zod": "^4.4.3"
@@ -16,10 +16,10 @@ const DISABLED_CONFIG: SparkConfig = Object.freeze({
16
16
  editor: false,
17
17
  footer: false,
18
18
  fullscreen: false,
19
- model: false,
20
- name: false,
19
+ pi: false,
21
20
  presets: false,
22
21
  recap: false,
22
+ web: false,
23
23
  });
24
24
 
25
25
  const cache = new Map<string, SparkConfig>();
@@ -4,10 +4,10 @@ import { creditsConfigSchema } from "../features/credits/config";
4
4
  import { editorConfigSchema } from "../features/editor/config";
5
5
  import { footerConfigSchema } from "../features/footer/config";
6
6
  import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
- import { modelConfigSchema } from "../features/model/config";
8
- import { nameConfigSchema } from "../features/name/config";
7
+ import { piConfigSchema } from "../features/pi/config";
9
8
  import { presetsConfigSchema } from "../features/presets/config";
10
9
  import { recapConfigSchema } from "../features/recap/config";
10
+ import { webConfigSchema } from "../features/web/config";
11
11
 
12
12
  const disabled = z.literal(false);
13
13
 
@@ -20,10 +20,10 @@ export const sparkConfigSchema = z.object({
20
20
  editor: editorConfigSchema.or(disabled).default({}),
21
21
  footer: footerConfigSchema.or(disabled).default({}),
22
22
  fullscreen: fullscreenConfigSchema.or(disabled).default({}),
23
- model: modelConfigSchema.or(disabled).default({}),
24
- name: nameConfigSchema.or(disabled).default({}),
23
+ pi: piConfigSchema.or(disabled).default({}),
25
24
  presets: presetsConfigSchema.or(disabled).default({}),
26
25
  recap: recapConfigSchema.or(disabled).default({}),
26
+ web: webConfigSchema.or(disabled).default({}),
27
27
  });
28
28
 
29
29
  /** Resolved config for every feature; `false` means the feature is disabled. */
@@ -0,0 +1,166 @@
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). The catalog is large, so include " +
35
+ "available:true unless every model is needed (e.g., \"claude available:true\", " +
36
+ "\"provider:openrouter cost.input:<1\").",
37
+ })),
38
+ offset: Type.Optional(Type.Number({ description: "For \"models\": start from this model number (1-indexed)." })),
39
+ limit: Type.Optional(Type.Number({ description: "For \"models\": Return at most this many models." })),
40
+ },
41
+ promptGuidelines: [
42
+ "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
+ ],
44
+ renderCall(args, theme) {
45
+ let text = `${theme.bold(theme.fg("toolTitle", "pi"))} ${theme.fg("accent", "models")}`;
46
+
47
+ const query = args.query?.trim();
48
+ if (query) text += theme.fg("muted", ` ${query}`);
49
+
50
+ if (args.offset !== undefined || args.limit !== undefined) {
51
+ const start = args.offset ?? 1;
52
+ const end = args.limit !== undefined ? start + args.limit - 1 : "end";
53
+ text += theme.fg("warning", ` ${start}-${end}`);
54
+ }
55
+
56
+ return new Text(text, 0, 0);
57
+ },
58
+ renderResult(result, { expanded }, theme, context) {
59
+ const details = result.details as ModelsDetails | undefined;
60
+
61
+ const container = new Container();
62
+ container.addChild(new Spacer(1));
63
+
64
+ if (!details) {
65
+ container.addChild(new Text(theme.fg("muted", "No models found."), 0, 0));
66
+ return container;
67
+ }
68
+
69
+ const addRows = (rows: ModelRow[]) => {
70
+ const widths = {
71
+ label: Math.max(...rows.map((row) => row.label.length)),
72
+ cost: Math.max(...rows.map((row) => row.cost.length)),
73
+ context: Math.max(...rows.map((row) => row.context.length)),
74
+ };
75
+
76
+ for (const row of rows) {
77
+ const cells = [
78
+ row.label.padEnd(widths.label),
79
+ row.cost.padStart(widths.cost),
80
+ row.context.padStart(widths.context),
81
+ ].join(" ");
82
+ const noAuthHint = row.available ? "" : theme.fg("dim", " (no auth)");
83
+ container.addChild(new Text(theme.fg("muted", cells) + noAuthHint, 0, 0));
84
+ }
85
+ };
86
+
87
+ const filtered = Boolean(context.args.query?.trim());
88
+ const models = details.models ?? [];
89
+ const total = details.total ?? models.length;
90
+
91
+ if (models.length > 0) {
92
+ const maxRows = expanded ? models.length : Math.min(models.length, COLLAPSED_MAX_LINES);
93
+ addRows(models.slice(0, maxRows).map((model) => toModelRow(model)));
94
+
95
+ const hiddenRows = models.length - maxRows;
96
+ if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, ${keyText("app.tools.expand")} to expand)`), 0, 0));
97
+ container.addChild(new Spacer(1));
98
+ }
99
+
100
+ if (details.truncation?.truncated) {
101
+ const startIndex = context.args.offset ? Math.max(0, context.args.offset - 1) : 0;
102
+ const endDisplay = startIndex + models.length;
103
+ const notice = formatListNotice(true, startIndex, endDisplay, total);
104
+ if (notice) {
105
+ container.addChild(new Text(theme.fg("warning", notice), 0, 0));
106
+ container.addChild(new Spacer(1));
107
+ }
108
+ }
109
+
110
+ const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed.`;
111
+ container.addChild(new Text(theme.fg("muted", total > 0 ? summary : `No models ${filtered ? "matched" : "found"}.`), 0, 0));
112
+
113
+ return container;
114
+ },
115
+ async execute(args, { ctx }) {
116
+ const queryText = args.query?.trim();
117
+ const filtered = Boolean(queryText);
118
+
119
+ let listQuery = null;
120
+ if (queryText) {
121
+ try {
122
+ listQuery = parse(queryText);
123
+ } catch (error) {
124
+ throw new Error(`Invalid Liqe query ${JSON.stringify(queryText)}: ${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ }
127
+
128
+ const models = ctx.modelRegistry.getAll().map((model) => toMetadata(model, ctx.modelRegistry.hasConfiguredAuth(model)));
129
+ const matched = listQuery ? filter(listQuery, models) : models;
130
+ const total = matched.length;
131
+
132
+ if (total === 0) {
133
+ return {
134
+ content: [{ type: "text", text: `No models ${filtered ? "matched" : "found"}.` }],
135
+ details: { action: "models", models: [], total } satisfies ModelsDetails,
136
+ };
137
+ }
138
+
139
+ // Convert from 1-indexed offset to 0-indexed array access.
140
+ const startIndex = args.offset ? Math.max(0, args.offset - 1) : 0;
141
+ if (startIndex >= total) {
142
+ throw new Error(`Offset ${args.offset} is beyond the end of the list (${total} models total).`);
143
+ }
144
+
145
+ const endIndex = args.limit !== undefined ? Math.min(startIndex + args.limit, total) : total;
146
+ const selected = matched.slice(startIndex, endIndex);
147
+
148
+ // JSONL: one compact object per line, so truncation cuts at record boundaries.
149
+ const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
150
+ maxLines: LIST_MAX_LINES,
151
+ maxBytes: Infinity,
152
+ });
153
+
154
+ const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
155
+ const endDisplay = startIndex + delivered.length;
156
+
157
+ let text = truncation.content;
158
+ const notice = formatListNotice(truncation.truncated, startIndex, endDisplay, total);
159
+ if (notice) text += `\n\n${notice}`;
160
+
161
+ return {
162
+ content: [{ type: "text", text }],
163
+ details: { action: "models", models: delivered, total, truncation } satisfies ModelsDetails,
164
+ };
165
+ },
166
+ });
@@ -0,0 +1,76 @@
1
+ import { Container, Spacer, 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
+ reason: Type.Optional(Type.String({
26
+ maxLength: 240,
27
+ description:
28
+ "For \"name\": Explain briefly why the session was named or renamed, such as a long pasted " +
29
+ "prompt, an ambiguous first message, or a topic shift. Write one user-facing " +
30
+ "sentence (e.g., \"The focus shifted from debugging to README updates.\").",
31
+ })),
32
+ },
33
+ required: ["name"],
34
+ promptGuidelines: [
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
+ ],
37
+ renderCall(args, theme) {
38
+ const name = sanitizeText(args.name ?? "");
39
+ const reason = sanitizeText(args.reason ?? "");
40
+
41
+ const container = new Container();
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;
50
+ },
51
+ renderResult() {
52
+ // "name" has no success UI; errors are rendered by the registry's fallback.
53
+ return new Text("", 0, 0);
54
+ },
55
+ async execute(args, { pi }) {
56
+ const name = sanitizeText(args.name);
57
+ if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase.");
58
+
59
+ const previous = pi.getSessionName() ?? null;
60
+ if (previous === name) {
61
+ const details: NameDetails = { action: "name", changed: false, previous };
62
+ return {
63
+ content: [{ type: "text", text: `Session is already named "${name}". Nothing changed.` }],
64
+ details,
65
+ };
66
+ }
67
+
68
+ pi.setSessionName(name);
69
+
70
+ const details: NameDetails = { action: "name", changed: true, previous };
71
+ return {
72
+ content: [{ type: "text", text: previous ? `Renamed session from "${previous}" to "${name}".` : `Named session "${name}".` }],
73
+ details,
74
+ };
75
+ },
76
+ });
@@ -0,0 +1,60 @@
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 with session name, active model with metadata, 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 sessionName = details.sessionName ?? theme.fg("dim", "(unnamed)");
35
+ container.addChild(new Text(`${theme.fg("muted", "session")} ${theme.fg("accent", sessionName)}`, 0, 0));
36
+
37
+ if (details.model) {
38
+ const row = toModelRow(details.model, details.thinkingLevel);
39
+ const cells = [row.label, row.cost, row.context].join(" ");
40
+ container.addChild(new Text(`${theme.fg("muted", "model")} ${theme.fg("muted", cells)}`, 0, 0));
41
+ } else {
42
+ container.addChild(new Text(`${theme.fg("muted", "model")} ${theme.fg("dim", "(no-model)")}`, 0, 0));
43
+ }
44
+
45
+ return container;
46
+ },
47
+ async execute(_args, { pi, ctx }) {
48
+ const state: WhoamiDetails = {
49
+ action: "whoami",
50
+ sessionName: pi.getSessionName() ?? null,
51
+ model: ctx.model ? toMetadata(ctx.model, true) : null,
52
+ thinkingLevel: pi.getThinkingLevel(),
53
+ };
54
+
55
+ return {
56
+ content: [{ type: "text", text: JSON.stringify(state) }],
57
+ details: state,
58
+ };
59
+ },
60
+ });
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const piConfigSchema = z.object({});
@@ -0,0 +1,23 @@
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
+ }
@@ -0,0 +1,56 @@
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
+ }
@@ -0,0 +1,117 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+
5
+ import { joinTextContent } from "../../utils/format";
6
+
7
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
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 {
25
+ pi: ExtensionAPI;
26
+ ctx: ExtensionContext;
27
+ }
28
+
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);
63
+
64
+ for (const [key, schema] of Object.entries(action.fields)) {
65
+ if (key === "action") throw new Error(`pi action "${action.name}" must not define a field named "action".`);
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({
79
+ name: "pi",
80
+ label: "pi",
81
+ description: `${DESCRIPTION_INTRO} ${summaries}. ${DESCRIPTION_OUTRO}`,
82
+ promptSnippet: "Inspect and adjust the current pi session and model state",
83
+ promptGuidelines: [GENERAL_GUIDELINE, ...actions.flatMap((action) => action.promptGuidelines ?? [])],
84
+ parameters,
85
+ renderCall(args, theme, context) {
86
+ const action = byName.get(args.action);
87
+ if (action?.renderCall) return action.renderCall(args, theme, context);
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
+ },
116
+ });
117
+ }
@@ -0,0 +1,72 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+
4
+ import type { TextContent } from "@earendil-works/pi-ai";
5
+
6
+ /** Exa's hosted MCP endpoint (Streamable HTTP). No API key required for the free tier. */
7
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
8
+
9
+ /** Collapse an MCP tool call result's content into plain text content for the model. */
10
+ function sanitizeContent(content: unknown): TextContent[] {
11
+ const blocks = (Array.isArray(content) ? content : []) as { type: string; text?: string}[];
12
+ const result: TextContent[] = blocks.map((block) =>
13
+ block.type === "text" && typeof block.text === "string"
14
+ ? { type: "text", text: block.text }
15
+ : { type: "text", text: JSON.stringify(block) },
16
+ );
17
+
18
+ return result;
19
+ }
20
+
21
+ /**
22
+ * Thin, session-scoped wrapper over Exa's remote MCP server using the official MCP client.
23
+ *
24
+ * The MCP connection (one `initialize` handshake) is established lazily on the first call and
25
+ * reused for the rest of the session; `close()` tears it down on `session_shutdown`.
26
+ */
27
+ export class ExaClient {
28
+ private client: Client | undefined;
29
+ private connecting: Promise<Client> | undefined;
30
+
31
+ async call(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<TextContent[]> {
32
+ const client = await this.getClient();
33
+ const result = await client.callTool({ name: toolName, arguments: args }, undefined, signal ? { signal } : undefined);
34
+ const content = sanitizeContent(result.content);
35
+
36
+ if (result.isError && content.length > 0) {
37
+ const text = content.map((block) => block.text).join("\n");
38
+ throw new Error(`Exa request failed: ${text}`);
39
+ }
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.`);
43
+
44
+ return content;
45
+ }
46
+
47
+ async close(): Promise<void> {
48
+ const current = this.client;
49
+ this.client = undefined;
50
+ this.connecting = undefined;
51
+ await current?.close().catch(() => {});
52
+ }
53
+
54
+ private getClient(): Promise<Client> {
55
+ if (this.client) return Promise.resolve(this.client);
56
+
57
+ if (!this.connecting) {
58
+ this.connecting = (async () => {
59
+ const next = new Client({ name: "pi-spark", version: "0" });
60
+ const transport = new StreamableHTTPClientTransport(new URL(EXA_MCP_URL));
61
+ await next.connect(transport as Parameters<Client["connect"]>[0]);
62
+ this.client = next;
63
+ return next;
64
+ })().catch((error) => {
65
+ this.connecting = undefined;
66
+ throw error;
67
+ });
68
+ }
69
+
70
+ return this.connecting;
71
+ }
72
+ }
@@ -0,0 +1,5 @@
1
+ import * as z from "zod";
2
+
3
+ export const webConfigSchema = z.object({});
4
+
5
+ export type WebConfig = z.infer<typeof webConfigSchema>;
@@ -0,0 +1,125 @@
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
+ import { ExaClient } from "./client";
7
+ import { loadConfig } from "../../config";
8
+ import { joinTextContent } from "../../utils/format";
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+
12
+ const ACTIONS = {
13
+ search: { tool: "web_search_exa", fields: ["query", "numResults"] as const },
14
+ fetch: { tool: "web_fetch_exa", fields: ["urls", "maxCharacters"] as const },
15
+ } as const;
16
+
17
+ const COLLAPSED_MAX_LINES = 10;
18
+
19
+ export function registerWeb(pi: ExtensionAPI): void {
20
+ const exa = new ExaClient();
21
+
22
+ pi.on("session_start", (_event, ctx) => {
23
+ const config = loadConfig(ctx).web;
24
+ if (!config) return;
25
+
26
+ pi.registerTool({
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
+ });
120
+ });
121
+
122
+ pi.on("session_shutdown", () => {
123
+ exa.close();
124
+ });
125
+ }
@@ -1,10 +1,10 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
 
3
- import type { Provider } from "@earendil-works/pi-ai";
3
+ import type { ImageContent, Provider, TextContent } from "@earendil-works/pi-ai";
4
4
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export function formatModel(provider?: Provider, model?: string, thinkingLevel?: string): string {
7
- return provider && model ? `${provider}/${model}${thinkingLevel ? `:${thinkingLevel}` : ""}` : "no-model";
7
+ return provider && model ? `${provider}/${model}${thinkingLevel ? `:${thinkingLevel}` : ""}` : "(no-model)";
8
8
  }
9
9
 
10
10
  export function formatTokens(count: number): string {
@@ -62,6 +62,14 @@ export function sanitizeText(text: string): string {
62
62
  .trim();
63
63
  }
64
64
 
65
+ /** Join the text blocks of tool result content into a single newline-separated string. */
66
+ export function joinTextContent(content: (TextContent | ImageContent)[]): string {
67
+ return content
68
+ .filter((block) => block.type === "text")
69
+ .map((block) => block.text)
70
+ .join("\n");
71
+ }
72
+
65
73
  /** Coerce a possibly-stringified numeric value to a finite number, or `undefined`. */
66
74
  export function toNumber(value?: string | number | null): number | undefined {
67
75
  if (value === undefined || value === null) return undefined;
Binary file
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const modelConfigSchema = z.object({});
@@ -1,268 +0,0 @@
1
- import { clampThinkingLevel, getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
2
- import { keyText, truncateHead } from "@earendil-works/pi-coding-agent";
3
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
4
- import { filter, parse } from "liqe";
5
- import { Type } from "typebox";
6
-
7
- import { loadConfig } from "../../config";
8
- import { formatModel, formatTokens } from "../../utils/format";
9
-
10
- import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
- import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
12
-
13
- /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
14
- const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
15
-
16
- /** Maximum number of models returned per list call; byte size is unrestricted. */
17
- const LIST_MAX_LINES = 200;
18
-
19
- /** Maximum number of model rows shown when the result is collapsed. */
20
- const COLLAPSED_MAX_ROWS = 10;
21
-
22
- type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
23
- thinkingLevels: ModelThinkingLevel[];
24
- defaultThinkingLevel: ModelThinkingLevel;
25
- available: boolean;
26
- };
27
-
28
- type ModelToolDetails =
29
- | { action: "active"; active: { model: ModelMetadata; thinkingLevel: ModelThinkingLevel } }
30
- | { action: "list"; models: ModelMetadata[]; total: number; truncation?: TruncationResult }
31
-
32
- /** One display row of model metadata: label, cost per 1M tokens, and context window. */
33
- type ModelRow = {
34
- label: string;
35
- cost: string;
36
- context: string;
37
- available: boolean;
38
- };
39
-
40
- function toMetadata(model: Model<Api>, available: boolean): ModelMetadata {
41
- const { headers: _headers, compat: _compat, ...metadata } = model;
42
-
43
- return {
44
- ...metadata,
45
- thinkingLevels: getSupportedThinkingLevels(model),
46
- defaultThinkingLevel: clampThinkingLevel(model, DEFAULT_THINKING_LEVEL),
47
- available,
48
- };
49
- }
50
-
51
- function toModelRow(model: ModelMetadata, thinkingLevel?: ModelThinkingLevel): ModelRow {
52
- return {
53
- label: formatModel(model.provider, model.id, thinkingLevel),
54
- cost: `$${formatPrice(model.cost.input)}/$${formatPrice(model.cost.output)}`,
55
- context: formatTokens(model.contextWindow),
56
- available: model.available,
57
- };
58
- }
59
-
60
- /** Round to at most 2 decimals and trim float noise: 0.7999... -> 0.8, 0.0983 -> 0.1, 15 -> 15. */
61
- function formatPrice(value: number): string {
62
- return String(parseFloat(value.toFixed(2)));
63
- }
64
-
65
- /** Notice line describing truncation or remaining pages, shared by the text result and the TUI. */
66
- function formatListNotice(truncated: boolean, startIndex: number, endDisplay: number, total: number): string | undefined {
67
- const nextOffset = endDisplay + 1;
68
-
69
- if (truncated) return `[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}. Use offset=${nextOffset} to continue.]`;
70
- if (endDisplay < total) return `[${total - endDisplay} more models in list. Use offset=${nextOffset} to continue.]`;
71
-
72
- return undefined;
73
- }
74
-
75
- export function registerModel(pi: ExtensionAPI): void {
76
- pi.on("session_start", (_event, ctx) => {
77
- const config = loadConfig(ctx).model;
78
- if (!config) return;
79
-
80
- pi.registerTool({
81
- name: "model",
82
- label: "model",
83
- description:
84
- "Show the active model or list pi models. \"active\" returns the active model with " +
85
- "metadata and thinking level. \"list\" returns models with metadata such as provider, id, name, " +
86
- "API type, reasoning support, cost, context window, thinking levels, and availability, " +
87
- "filterable with a Liqe (Lucene-like) query and paged with offset/limit. List output is " +
88
- `one JSON object per line, truncated to ${LIST_MAX_LINES} models.`,
89
- promptSnippet: "Show the active model or list pi models",
90
- promptGuidelines: [
91
- "Use the model tool when you need pi model metadata, the active model, or the thinking level, rather than guessing.",
92
- "Use the model tool with `available:true` in the query unless unavailable models are explicitly needed.",
93
- ],
94
- parameters: Type.Object({
95
- action: StringEnum(["active", "list"] as const, {
96
- description: "\"active\" shows the active model; \"list\" lists the model catalog.",
97
- }),
98
- query: Type.Optional(Type.String({
99
- description:
100
- "For list: filter models with a Liqe (Lucene-like) query. Bare terms match any " +
101
- "field, and unquoted terms are case-insensitive substrings. The syntax supports " +
102
- "AND/OR/NOT, grouping, wildcards, and numeric comparisons; quote values containing " +
103
- "special characters (e.g., id:\"deepseek/deepseek-v4\"). Queryable fields are id, " +
104
- "name, provider, api, reasoning (boolean), input (modalities), cost.input and " +
105
- "cost.output (USD per 1M tokens), contextWindow, maxTokens, thinkingLevels, and " +
106
- "available (boolean, true when auth is configured). The catalog is large, so " +
107
- "include available:true unless every model is needed (e.g., \"claude " +
108
- "available:true\", \"provider:openrouter cost.input:<1\").",
109
- })),
110
- offset: Type.Optional(Type.Number({
111
- description: "For list: start from this model number (1-indexed)."
112
- })),
113
- limit: Type.Optional(Type.Number({
114
- description: "For list: return at most this many models."
115
- })),
116
- }),
117
- renderCall(args, theme) {
118
- let text = `${theme.bold(theme.fg("toolTitle", "model"))} ${theme.fg("accent", args.action)}`;
119
-
120
- if (args.action === "list") {
121
- const query = args.query?.trim();
122
- if (query) text += theme.fg("muted", ` ${query}`);
123
-
124
- if (args.offset !== undefined || args.limit !== undefined) {
125
- const start = args.offset ?? 1;
126
- const end = args.limit !== undefined ? start + args.limit - 1 : "end";
127
- text += theme.fg("warning", ` ${start}-${end}`);
128
- }
129
- }
130
-
131
- return new Text(text, 0, 0);
132
- },
133
- renderResult(result, { expanded }, theme, context) {
134
- const details = result.details as ModelToolDetails | undefined;
135
-
136
- const container = new Container();
137
- container.addChild(new Spacer(1));
138
-
139
- if (context.isError || !details) {
140
- const output = result.content
141
- .filter((content) => content.type === "text")
142
- .map((content) => content.text)
143
- .join("\n");
144
- container.addChild(new Text(theme.fg("error", output), 0, 0));
145
-
146
- return container;
147
- }
148
-
149
- const addRows = (rows: ModelRow[]) => {
150
- const widths = {
151
- label: Math.max(...rows.map((row) => row.label.length)),
152
- cost: Math.max(...rows.map((row) => row.cost.length)),
153
- context: Math.max(...rows.map((row) => row.context.length)),
154
- };
155
-
156
- for (const row of rows) {
157
- const cells = [
158
- row.label.padEnd(widths.label),
159
- row.cost.padStart(widths.cost),
160
- row.context.padStart(widths.context),
161
- ].join(" ");
162
- const noAuthHint = row.available ? "" : theme.fg("dim", " (no auth)");
163
- container.addChild(new Text(theme.fg("muted", cells) + noAuthHint, 0, 0));
164
- }
165
- };
166
-
167
- if (details.action === "active") {
168
- const { model, thinkingLevel } = details.active;
169
- addRows([toModelRow(model, thinkingLevel)]);
170
-
171
- return container;
172
- }
173
-
174
- const filtered = Boolean(context.args.query?.trim());
175
- const models = details.models ?? [];
176
- const total = details.total ?? models.length;
177
-
178
- if (models.length > 0) {
179
- const maxRows = expanded ? models.length : Math.min(models.length, COLLAPSED_MAX_ROWS);
180
- addRows(models.slice(0, maxRows).map((model) => toModelRow(model)));
181
-
182
- const hiddenRows = models.length - maxRows;
183
- if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, ${keyText("app.tools.expand")} to expand)`), 0, 0));
184
- container.addChild(new Spacer(1));
185
- }
186
-
187
- if (details.truncation?.truncated) {
188
- const startIndex = context.args.offset ? Math.max(0, context.args.offset - 1) : 0;
189
- const endDisplay = startIndex + models.length;
190
- const notice = formatListNotice(true, startIndex, endDisplay, total);
191
- if (notice) {
192
- container.addChild(new Text(theme.fg("warning", notice), 0, 0));
193
- container.addChild(new Spacer(1));
194
- }
195
- }
196
-
197
- const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed.`;
198
- container.addChild(new Text(theme.fg("muted", total > 0 ? summary : `No models ${filtered ? "matched" : "found"}.`), 0, 0));
199
-
200
- return container;
201
- },
202
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
203
- if (params.action === "active") {
204
- const model = ctx.model;
205
- if (!model) throw new Error("No model is active.");
206
-
207
- const thinkingLevel = pi.getThinkingLevel();
208
- const active = { model: toMetadata(model, true), thinkingLevel };
209
-
210
- return {
211
- content: [{ type: "text", text: JSON.stringify(active) }],
212
- details: { action: "active", active } satisfies ModelToolDetails,
213
- };
214
- }
215
-
216
- const queryText = params.query?.trim();
217
- const filtered = Boolean(queryText);
218
-
219
- let listQuery = null;
220
- if (queryText) {
221
- try {
222
- listQuery = parse(queryText);
223
- } catch (error) {
224
- throw new Error(`Invalid Liqe query ${JSON.stringify(queryText)}: ${error instanceof Error ? error.message : String(error)}`);
225
- }
226
- }
227
-
228
- const models = ctx.modelRegistry.getAll().map((model) => toMetadata(model, ctx.modelRegistry.hasConfiguredAuth(model)));
229
- const matched = listQuery ? filter(listQuery, models) : models;
230
- const total = matched.length;
231
-
232
- if (total === 0) {
233
- return {
234
- content: [{ type: "text", text: `No models ${filtered ? "matched" : "found"}.` }],
235
- details: { action: "list", models: [], total } satisfies ModelToolDetails,
236
- };
237
- }
238
-
239
- // Convert from 1-indexed offset to 0-indexed array access.
240
- const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
241
- if (startIndex >= total) {
242
- throw new Error(`Offset ${params.offset} is beyond the end of the list (${total} models total).`);
243
- }
244
-
245
- const endIndex = params.limit !== undefined ? Math.min(startIndex + params.limit, total) : total;
246
- const selected = matched.slice(startIndex, endIndex);
247
-
248
- // JSONL: one compact object per line, so truncation cuts at record boundaries.
249
- const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
250
- maxLines: LIST_MAX_LINES,
251
- maxBytes: Infinity,
252
- });
253
-
254
- const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
255
- const endDisplay = startIndex + delivered.length;
256
-
257
- let text = truncation.content;
258
- const notice = formatListNotice(truncation.truncated, startIndex, endDisplay, total);
259
- if (notice) text += `\n\n${notice}`;
260
-
261
- return {
262
- content: [{ type: "text", text }],
263
- details: { action: "list", models: delivered, total, truncation } satisfies ModelToolDetails,
264
- };
265
- },
266
- });
267
- });
268
- }
@@ -1,3 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const nameConfigSchema = z.object({});
@@ -1,86 +0,0 @@
1
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
- import { Type } from "typebox";
3
-
4
- import { loadConfig } from "../../config";
5
- import { sanitizeText } from "../../utils/format";
6
-
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
-
9
- export function registerName(pi: ExtensionAPI): void {
10
- pi.on("session_start", (_event, ctx) => {
11
- const config = loadConfig(ctx).name;
12
- if (!config) return;
13
-
14
- pi.registerTool({
15
- name: "name",
16
- label: "name",
17
- description:
18
- "Set or update the current session's name. The name is a concise label shown in the session " +
19
- "selector instead of the first-message preview.",
20
- promptSnippet: "Set or update the current session's name",
21
- promptGuidelines: [
22
- "Use the name tool when the session needs a concise, recognizable label, especially after a long, vague, or pasted opening prompt.",
23
- "Use the name tool to rename the session only after a substantial shift in the conversation's focus, not for minor follow-ups.",
24
- "Use the name tool with a reason when it helps explain why the name identifies the session.",
25
- ],
26
- parameters: Type.Object({
27
- name: Type.String({
28
- minLength: 1,
29
- maxLength: 120,
30
- description:
31
- "Use a short, recognizable phrase in sentence case, ideally <= 72 characters " +
32
- "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
33
- "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
34
- }),
35
- reason: Type.Optional(Type.String({
36
- maxLength: 240,
37
- description:
38
- "Explain briefly why the session was named or renamed, such as a long pasted " +
39
- "prompt, an ambiguous first message, or a topic shift. Write one user-facing " +
40
- "sentence (e.g., \"The focus shifted from debugging to README updates.\").",
41
- })),
42
- }),
43
- renderCall(args, theme) {
44
- const name = sanitizeText(args.name);
45
- const reason = sanitizeText(args.reason ?? "");
46
-
47
- const container = new Container();
48
- container.addChild(new Text(`${theme.bold(theme.fg("toolTitle", "name"))} ${theme.fg("accent", name)}`, 0, 0));
49
-
50
- if (reason) {
51
- container.addChild(new Spacer(1));
52
- container.addChild(new Text(theme.fg("muted", reason), 0, 0));
53
- }
54
-
55
- return container;
56
- },
57
- renderResult(result, _options, theme, context) {
58
- const output = result.content
59
- .filter((content) => content.type === "text")
60
- .map((content) => content.text)
61
- .join("\n");
62
-
63
- return new Text(context.isError ? theme.fg("error", "\n" + output) : "", 0, 0);
64
- },
65
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
66
- const name = sanitizeText(params.name);
67
- if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase.");
68
-
69
- const previous = pi.getSessionName() ?? null;
70
- if (previous === name) {
71
- return {
72
- content: [{ type: "text", text: `Session is already named "${name}". Nothing changed.` }],
73
- details: { changed: false, previous },
74
- };
75
- }
76
-
77
- pi.setSessionName(name);
78
-
79
- return {
80
- content: [{ type: "text", text: `${previous ? `Renamed session from "${previous}" to "${name}".` : `Named session "${name}".`}` }],
81
- details: { changed: true, previous },
82
- };
83
- },
84
- });
85
- });
86
- }