pi-spark 0.9.5 → 0.10.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
@@ -80,8 +80,8 @@ Example:
80
80
  ### Models
81
81
 
82
82
  - The agent can call the `model` tool with two actions:
83
- - `list`: lists models with their metadata, with optional `scope`, `provider` and `model` substring filters, and `offset`/`limit` paging.
84
- - `current`: gets the active provider, model, and thinking level.
83
+ - `active`: gets the active provider, model, and thinking level.
84
+ - `list`: lists models with their metadata, with an optional [Liqe](https://github.com/gajus/liqe) (Lucene-like) `query` over model fields and `offset`/`limit` paging.
85
85
 
86
86
  ### Name
87
87
 
@@ -1,10 +1,11 @@
1
1
  import { clampThinkingLevel, getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
2
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, keyText, truncateHead } from "@earendil-works/pi-coding-agent";
2
+ import { keyText, truncateHead } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import { filter, parse } from "liqe";
4
5
  import { Type } from "typebox";
5
6
 
6
7
  import { loadConfig } from "../shared/config";
7
- import { formatModel } from "../shared/format";
8
+ import { formatModel, formatTokens } from "../shared/format";
8
9
 
9
10
  import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
11
  import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
@@ -12,6 +13,9 @@ import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-a
12
13
  /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
13
14
  const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
14
15
 
16
+ /** Maximum number of models returned per list call; byte size is unrestricted. */
17
+ const LIST_MAX_LINES = 200;
18
+
15
19
  type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
16
20
  thinkingLevels: ModelThinkingLevel[];
17
21
  defaultThinkingLevel: ModelThinkingLevel;
@@ -19,9 +23,17 @@ type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
19
23
  };
20
24
 
21
25
  type ModelToolDetails =
22
- | { action: "current"; current: { model: ModelMetadata; thinkingLevel: ModelThinkingLevel } }
26
+ | { action: "active"; active: { model: ModelMetadata; thinkingLevel: ModelThinkingLevel } }
23
27
  | { action: "list"; models: ModelMetadata[]; total: number; truncation?: TruncationResult }
24
28
 
29
+ /** One display row of model metadata: label, cost per 1M tokens, and context window. */
30
+ type ModelRow = {
31
+ label: string;
32
+ cost: string;
33
+ context: string;
34
+ available: boolean;
35
+ };
36
+
25
37
  function toMetadata(model: Model<Api>, available: boolean): ModelMetadata {
26
38
  const { headers: _headers, compat: _compat, ...metadata } = model;
27
39
 
@@ -33,6 +45,20 @@ function toMetadata(model: Model<Api>, available: boolean): ModelMetadata {
33
45
  };
34
46
  }
35
47
 
48
+ function toModelRow(model: ModelMetadata, thinkingLevel?: ModelThinkingLevel): ModelRow {
49
+ return {
50
+ label: formatModel(model.provider, model.id, thinkingLevel),
51
+ cost: `$${formatPrice(model.cost.input)}/$${formatPrice(model.cost.output)}`,
52
+ context: formatTokens(model.contextWindow),
53
+ available: model.available,
54
+ };
55
+ }
56
+
57
+ /** Round to at most 2 decimals and trim float noise: 0.7999... -> 0.8, 0.0983 -> 0.1, 15 -> 15. */
58
+ function formatPrice(value: number): string {
59
+ return String(parseFloat(value.toFixed(2)));
60
+ }
61
+
36
62
  export default function (pi: ExtensionAPI) {
37
63
  pi.on("session_start", (_event, ctx) => {
38
64
  const config = loadConfig(ctx, "models");
@@ -42,48 +68,52 @@ export default function (pi: ExtensionAPI) {
42
68
  name: "model",
43
69
  label: "model",
44
70
  description:
45
- "Inspect pi's model state. The \"current\" action returns the active model with metadata " +
46
- "and thinking level. The \"list\" action returns models with metadata such as provider, " +
47
- "id, name, API type, reasoning support, input modalities, cost, context window, and max " +
48
- "output tokens. Lists can be filtered with scope/provider/model queries and paged with " +
49
- `offset/limit. List output is one JSON object per line, truncated to ${DEFAULT_MAX_LINES} ` +
50
- `models or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
51
- promptSnippet: "List models or show the current model in use",
71
+ "Show the active model or list pi models. \"active\" returns the active model with " +
72
+ "metadata and thinking level. \"list\" returns models with metadata such as provider, id, name, " +
73
+ "API type, reasoning support, cost, context window, thinking levels, and availability, " +
74
+ "filterable with a Liqe (Lucene-like) query and paged with offset/limit. List output is " +
75
+ `one JSON object per line, truncated to ${LIST_MAX_LINES} models.`,
76
+ promptSnippet: "Show the active model or list pi models",
52
77
  promptGuidelines: [
53
- "Use model when you need to know pi models, the active provider or model, or the current thinking level.",
78
+ "Use the model tool when you need pi model metadata, the active model, or the thinking level, rather than guessing.",
79
+ "When listing models with the model tool, include available:true in the query unless unavailable models are explicitly needed.",
54
80
  ],
55
81
  parameters: Type.Object({
56
- action: StringEnum(["list", "current"] as const, {
57
- description:
58
- "\"current\" returns the active model with metadata and thinking level; " +
59
- "\"list\" returns models with metadata.",
82
+ action: StringEnum(["active", "list"] as const, {
83
+ description: "\"active\" shows the active model; \"list\" lists the model catalog.",
60
84
  }),
61
- scope: Type.Optional(StringEnum(["all", "available"] as const, {
85
+ query: Type.Optional(Type.String({
62
86
  description:
63
- "For list: \"all\" lists every pi model; \"available\" (default) lists only " +
64
- "models with auth configured.",
65
- })),
66
- provider: Type.Optional(Type.String({
67
- description: "For list: filter by provider, case-insensitive substring (e.g., \"vercel\", \"moonshot\")",
68
- })),
69
- model: Type.Optional(Type.String({
70
- description: "For list: filter by model id or display name, case-insensitive substring (e.g., \"claude\", \"deepseek\")",
87
+ "For list: filter models with a Liqe (Lucene-like) query. Bare terms match any " +
88
+ "field, and unquoted terms are case-insensitive substrings. The syntax supports " +
89
+ "AND/OR/NOT, grouping, wildcards, and numeric comparisons; quote values containing " +
90
+ "special characters (e.g., id:\"deepseek/deepseek-v4\"). Queryable fields are id, " +
91
+ "name, provider, api, reasoning (boolean), input (modalities), cost.input and " +
92
+ "cost.output (USD per 1M tokens), contextWindow, maxTokens, thinkingLevels, and " +
93
+ "available (boolean, true when auth is configured). The catalog is large, so " +
94
+ "include available:true unless every model is needed (e.g., \"claude " +
95
+ "available:true\", \"provider:openrouter cost.input:<1\").",
71
96
  })),
72
97
  offset: Type.Optional(Type.Number({
73
- description: "For list: model number to start from (1-indexed)"
98
+ description: "For list: start from this model number (1-indexed)."
74
99
  })),
75
100
  limit: Type.Optional(Type.Number({
76
- description: "For list: maximum number of models to return"
101
+ description: "For list: return at most this many models."
77
102
  })),
78
103
  }),
79
104
  renderCall(args, theme) {
80
105
  let text = `${theme.bold(theme.fg("toolTitle", "model"))} ${theme.fg("accent", args.action)}`;
81
106
 
82
- if (args.action === "list") text += theme.fg("muted", ` ${args.scope ?? "available"}`);
83
- if (args.provider) text += theme.fg("muted", ` provider:${args.provider}`);
84
- if (args.model) text += theme.fg("muted", ` model:${args.model}`);
85
- if (args.offset !== undefined || args.limit !== undefined) text += theme.fg("warning", ` from ${args.offset ?? 1}`);
86
- if (args.limit !== undefined) text += theme.fg("warning", ` to ${(args.offset ?? 1) + args.limit - 1}`);
107
+ if (args.action === "list") {
108
+ const query = args.query?.trim();
109
+ if (query) text += theme.fg("muted", ` ${query}`);
110
+
111
+ if (args.offset !== undefined || args.limit !== undefined) {
112
+ const start = args.offset ?? 1;
113
+ const end = args.limit !== undefined ? start + args.limit - 1 : "end";
114
+ text += theme.fg("warning", ` ${start}-${end}`);
115
+ }
116
+ }
87
117
 
88
118
  return new Text(text, 0, 0);
89
119
  },
@@ -103,28 +133,41 @@ export default function (pi: ExtensionAPI) {
103
133
  return container;
104
134
  }
105
135
 
106
- if (details.action === "current") {
107
- const { model, thinkingLevel } = details.current;
108
- container.addChild(new Text(theme.fg("muted", formatModel(model.provider, model.id, thinkingLevel)), 0, 0));
136
+ const addRows = (rows: ModelRow[]) => {
137
+ const widths = {
138
+ label: Math.max(...rows.map((row) => row.label.length)),
139
+ cost: Math.max(...rows.map((row) => row.cost.length)),
140
+ context: Math.max(...rows.map((row) => row.context.length)),
141
+ };
142
+
143
+ for (const row of rows) {
144
+ const cells = [
145
+ row.label.padEnd(widths.label),
146
+ row.cost.padStart(widths.cost),
147
+ row.context.padStart(widths.context),
148
+ ].join(" ");
149
+ const noAuthHint = row.available ? "" : theme.fg("dim", " (no auth)");
150
+ container.addChild(new Text(theme.fg("muted", cells) + noAuthHint, 0, 0));
151
+ }
152
+ };
153
+
154
+ if (details.action === "active") {
155
+ const { model, thinkingLevel } = details.active;
156
+ addRows([toModelRow(model, thinkingLevel)]);
109
157
 
110
158
  return container;
111
159
  }
112
160
 
113
- const filtered = context.args.provider !== undefined || context.args.model !== undefined;
161
+ const filtered = Boolean(context.args.query?.trim());
114
162
  const models = details.models ?? [];
115
163
  const total = details.total ?? models.length;
116
164
 
117
165
  if (models.length > 0 && expanded) {
118
- models.forEach((model) => {
119
- const label = formatModel(model.provider, model.id);
120
- const noAuthHint = !model.available ? theme.fg("dim", " (unavailable)") : "";
121
- container.addChild(new Text(theme.fg("muted", label) + noAuthHint, 0, 0));
122
- });
123
-
166
+ addRows(models.map((model) => toModelRow(model)));
124
167
  container.addChild(new Spacer(1));
125
168
  }
126
169
 
127
- const summary = total > 0 ? `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed` : `0 models${filtered ? " matched" : ""}`;
170
+ const summary = total > 0 ? `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched " : ""}model${total === 1 ? "" : "s"} listed.` : `No models ${filtered ? "matched" : "found"}.`;
128
171
  const truncatedHint = details.truncation?.truncated ? theme.fg("warning", " (truncated)") : "";
129
172
  const expandHint = models.length > 0 && !expanded ? theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`) : "";
130
173
  container.addChild(new Text(theme.fg("muted", summary) + truncatedHint + expandHint, 0, 0));
@@ -132,36 +175,39 @@ export default function (pi: ExtensionAPI) {
132
175
  return container;
133
176
  },
134
177
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
135
- if (params.action === "current") {
178
+ if (params.action === "active") {
136
179
  const model = ctx.model;
137
- if (!model) throw new Error("No model is currently selected");
180
+ if (!model) throw new Error("No model is active.");
138
181
 
139
182
  const thinkingLevel = pi.getThinkingLevel();
140
- const current = { model: toMetadata(model, true), thinkingLevel };
183
+ const active = { model: toMetadata(model, true), thinkingLevel };
141
184
 
142
185
  return {
143
- content: [{ type: "text", text: JSON.stringify(current) }],
144
- details: { action: "current", current } satisfies ModelToolDetails,
186
+ content: [{ type: "text", text: JSON.stringify(active) }],
187
+ details: { action: "active", active } satisfies ModelToolDetails,
145
188
  };
146
189
  }
147
190
 
148
- const providerQuery = params.provider?.trim().toLowerCase();
149
- const modelQuery = params.model?.trim().toLowerCase();
150
- const filtered = providerQuery !== undefined || modelQuery !== undefined;
191
+ const queryText = params.query?.trim();
192
+ const filtered = Boolean(queryText);
151
193
 
152
- const scope = params.scope ?? "available";
153
- const source = scope === "all" ? ctx.modelRegistry.getAll() : ctx.modelRegistry.getAvailable();
194
+ let listQuery = null;
195
+ if (queryText) {
196
+ try {
197
+ listQuery = parse(queryText);
198
+ } catch (error) {
199
+ throw new Error(`Invalid Liqe query ${JSON.stringify(queryText)}: ${error instanceof Error ? error.message : String(error)}`);
200
+ }
201
+ }
154
202
 
155
- const matched = source.filter((model) => {
156
- if (providerQuery && !model.provider.toLowerCase().includes(providerQuery)) return false;
157
- if (modelQuery && !model.id.toLowerCase().includes(modelQuery) && !model.name.toLowerCase().includes(modelQuery)) return false;
158
- return true;
159
- }).map((model) => toMetadata(model, scope === "all" ? ctx.modelRegistry.hasConfiguredAuth(model) : true));
203
+ const models = ctx.modelRegistry.getAll()
204
+ .map((model) => toMetadata(model, ctx.modelRegistry.hasConfiguredAuth(model)));
205
+ const matched = listQuery ? filter(listQuery, models) : models;
160
206
  const total = matched.length;
161
207
 
162
208
  if (total === 0) {
163
209
  return {
164
- content: [{ type: "text", text: `0 models${filtered ? " matched" : ""}` }],
210
+ content: [{ type: "text", text: `No models ${filtered ? "matched" : "found"}.` }],
165
211
  details: { action: "list", models: [], total } satisfies ModelToolDetails,
166
212
  };
167
213
  }
@@ -169,7 +215,7 @@ export default function (pi: ExtensionAPI) {
169
215
  // Convert from 1-indexed offset to 0-indexed array access.
170
216
  const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
171
217
  if (startIndex >= total) {
172
- throw new Error(`Offset ${params.offset} is beyond end of list (${total} models total)`);
218
+ throw new Error(`Offset ${params.offset} is beyond the end of the list (${total} models total).`);
173
219
  }
174
220
 
175
221
  const endIndex = params.limit !== undefined ? Math.min(startIndex + params.limit, total) : total;
@@ -177,8 +223,8 @@ export default function (pi: ExtensionAPI) {
177
223
 
178
224
  // JSONL: one compact object per line, so truncation cuts at record boundaries.
179
225
  const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
180
- maxLines: DEFAULT_MAX_LINES,
181
- maxBytes: DEFAULT_MAX_BYTES,
226
+ maxLines: LIST_MAX_LINES,
227
+ maxBytes: Infinity,
182
228
  });
183
229
 
184
230
  const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
@@ -187,9 +233,9 @@ export default function (pi: ExtensionAPI) {
187
233
 
188
234
  let text = truncation.content;
189
235
  if (truncation.truncated) {
190
- text += `\n\n[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}; use offset=${nextOffset} to continue]`;
236
+ text += `\n\n[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}. Use offset=${nextOffset} to continue.]`;
191
237
  } else if (endDisplay < total) {
192
- text += `\n\n[${total - endDisplay} more models in list; use offset=${nextOffset} to continue]`;
238
+ text += `\n\n[${total - endDisplay} more models in list. Use offset=${nextOffset} to continue.]`;
193
239
  }
194
240
 
195
241
  return {
@@ -17,33 +17,29 @@ export default function (pi: ExtensionAPI) {
17
17
  name: "name",
18
18
  label: "name",
19
19
  description:
20
- "Set or refresh the current session's concise name, shown in the session selector " +
21
- "instead of the first-message preview.",
22
- promptSnippet: "Set or refresh the current session's concise name",
20
+ "Name or rename the current session. The name is a concise label shown in the session " +
21
+ "selector instead of the first-message preview.",
22
+ promptSnippet: "Name or rename the current session",
23
23
  promptGuidelines: [
24
- "Use name when the session would benefit from a concise, recognizable name, especially after a long, vague, or pasted opening prompt.",
25
- "Use name to refresh the session's name only after a substantial shift in the conversation's focus; do not rename for minor follow-ups.",
26
- "Include a concise reason for name when it would help explain why the name identifies the session.",
24
+ "Use the name tool when the session needs a concise, recognizable label, especially after a long, vague, or pasted opening prompt.",
25
+ "Use the name tool to rename the session only after a substantial shift in the conversation's focus, not for minor follow-ups.",
26
+ "Use the name tool with a reason when it helps explain why the name identifies the session.",
27
27
  ],
28
28
  parameters: Type.Object({
29
29
  name: Type.String({
30
30
  minLength: 1,
31
31
  maxLength: 120,
32
32
  description:
33
- "Concise session name. Use a short, recognizable phrase in sentence case, " +
34
- "ideally <= 72 characters. Do not use surrounding quotes, trailing punctuation, or " +
35
- "generic prefixes like \"Chat about\". Examples: \"Refactor auth module\", " +
36
- "\"Debug flaky CI pipeline\", \"Draft Q3 planning doc\".",
33
+ "Use a short, recognizable phrase in sentence case, ideally <= 72 characters " +
34
+ "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
35
+ "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
37
36
  }),
38
37
  reason: Type.Optional(Type.String({
39
38
  maxLength: 240,
40
39
  description:
41
- "Optional concise reason for naming or renaming the current session. Explain what " +
42
- "made the name useful, such as a long pasted prompt, ambiguous first message, task " +
43
- "handoff, or substantial topic shift. Write a complete sentence, and keep it brief " +
44
- "and user-facing. Examples: \"The long pasted prompt needed a stable label.\", " +
45
- "\"The focus shifted from debugging to README updates.\", " +
46
- "\"The task migrated from a previous session.\"",
40
+ "Explain briefly why the session was named or renamed, such as a long pasted " +
41
+ "prompt, an ambiguous first message, or a topic shift. Write one user-facing " +
42
+ "sentence (e.g., \"The focus shifted from debugging to README updates.\").",
47
43
  })),
48
44
  }),
49
45
  renderCall(args, theme) {
@@ -70,12 +66,12 @@ export default function (pi: ExtensionAPI) {
70
66
  },
71
67
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
72
68
  const name = sanitizeText(params.name);
73
- if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase");
69
+ if (!name) throw new Error("Session name was empty after normalization; provide a short, non-empty phrase.");
74
70
 
75
71
  const previous = pi.getSessionName() ?? null;
76
72
  if (previous === name) {
77
73
  return {
78
- content: [{ type: "text", text: `Session is already named "${name}"; no change` }],
74
+ content: [{ type: "text", text: `Session is already named "${name}". Nothing changed.` }],
79
75
  details: { changed: false, previous },
80
76
  };
81
77
  }
@@ -83,7 +79,7 @@ export default function (pi: ExtensionAPI) {
83
79
  pi.setSessionName(name);
84
80
 
85
81
  return {
86
- content: [{ type: "text", text: `${previous ? `Renamed session from "${previous}" to "${name}"` : `Named session "${name}"`}` }],
82
+ content: [{ type: "text", text: `${previous ? `Renamed session from "${previous}" to "${name}".` : `Named session "${name}".`}` }],
87
83
  details: { changed: true, previous },
88
84
  };
89
85
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -28,6 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
+ "liqe": "^3.8.7",
31
32
  "ms": "4.0.0-nightly.202508271359",
32
33
  "zod": "^4.4.3"
33
34
  },