pi-spark 0.8.0 → 0.9.1

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
@@ -9,6 +9,7 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
9
9
  - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
10
10
  - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
11
11
  - **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
12
+ - **Models:** exposes a `model` tool so the agent can list the models the user can use currently and inspect the provider, model, and thinking level in use.
12
13
  - **Name:** exposes a `name` tool so the agent can give the current session a concise, recognizable name in the session selector.
13
14
  - **Presets:** switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
14
15
  - **Recap:** generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
@@ -76,6 +77,12 @@ Example:
76
77
 
77
78
  - pi-spark clears the screen and scrollback at session start and exit, pins the editor and footer to the bottom, and enables pi's `clearOnShrink` behavior programmatically so pinned UI stays aligned after taller components close.
78
79
 
80
+ ### Models
81
+
82
+ - The agent can call the `model` tool with two actions:
83
+ - `list`: gets all currently usable models with their metadata, with optional `provider` and `model` substring filters and `offset`/`limit` paging.
84
+ - `current`: gets the active provider, model, and thinking level.
85
+
79
86
  ### Name
80
87
 
81
88
  - The agent can set or refresh the current session's name and optionally give a reason.
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const modelsConfigSchema = z.object({});
@@ -0,0 +1,188 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
2
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+
6
+ import { loadConfig } from "../shared/config";
7
+ import { formatModel } from "../shared/format";
8
+
9
+ import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
11
+
12
+ /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
13
+ const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
14
+
15
+ type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
16
+ thinkingLevels: ModelThinkingLevel[];
17
+ defaultThinkingLevel: ModelThinkingLevel;
18
+ };
19
+
20
+ type ModelToolDetails =
21
+ | { action: "current"; current: { model: ModelMetadata; thinkingLevel: ModelThinkingLevel } }
22
+ | { action: "list"; models: ModelMetadata[]; total: number; truncation?: TruncationResult }
23
+
24
+ function toMetadata(model: Model<Api>): ModelMetadata {
25
+ const { headers: _headers, compat: _compat, ...metadata } = model;
26
+
27
+ return {
28
+ ...metadata,
29
+ thinkingLevels: getSupportedThinkingLevels(model),
30
+ defaultThinkingLevel: clampThinkingLevel(model, DEFAULT_THINKING_LEVEL),
31
+ };
32
+ }
33
+
34
+ export default function (pi: ExtensionAPI) {
35
+ pi.on("session_start", (_event, ctx) => {
36
+ const config = loadConfig(ctx, "models");
37
+ if (!config) return;
38
+
39
+ pi.registerTool({
40
+ name: "model",
41
+ label: "model",
42
+ description:
43
+ "Inspect pi's model state. The \"current\" action returns the active model with metadata " +
44
+ "and thinking level. The \"list\" action returns all models the user can use " +
45
+ "currently (auth configured), including metadata such as provider, id, name, API type, " +
46
+ "reasoning support, input modalities, cost, context window, and max output tokens. " +
47
+ "Lists can be filtered with provider/model queries and paged with offset/limit. " +
48
+ `List output is one JSON object per line, truncated to ${DEFAULT_MAX_LINES} models or ` +
49
+ `${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
50
+ promptSnippet: "List available models or show the current model in use",
51
+ promptGuidelines: [
52
+ "Use model when you need to know available pi models, the active provider or model, or the current thinking level.",
53
+ ],
54
+ parameters: Type.Object({
55
+ action: StringEnum(["list", "current"] as const, {
56
+ description:
57
+ "\"list\" returns all currently usable models with metadata; " +
58
+ "\"current\" returns the active model with metadata and thinking level.",
59
+ }),
60
+ provider: Type.Optional(Type.String({
61
+ description: "For list: filter by provider, case-insensitive substring (e.g., \"vercel\", \"moonshot\")",
62
+ })),
63
+ model: Type.Optional(Type.String({
64
+ description: "For list: filter by model id or display name, case-insensitive substring (e.g., \"claude\", \"deepseek\")",
65
+ })),
66
+ offset: Type.Optional(Type.Number({
67
+ description: "For list: model number to start from (1-indexed)"
68
+ })),
69
+ limit: Type.Optional(Type.Number({
70
+ description: "For list: maximum number of models to return"
71
+ })),
72
+ }),
73
+ renderCall(args, theme) {
74
+ let text = `${theme.bold(theme.fg("toolTitle", "model"))} ${theme.fg("accent", args.action)}`;
75
+
76
+ if (args.provider) text += theme.fg("muted", ` provider:${args.provider}`);
77
+ if (args.model) text += theme.fg("muted", ` model:${args.model}`);
78
+ if (args.offset !== undefined || args.limit !== undefined) text += theme.fg("warning", ` from:${args.offset ?? 1}`);
79
+ if (args.limit !== undefined) text += theme.fg("warning", ` to:${(args.offset ?? 1) + args.limit - 1}`);
80
+
81
+ return new Text(text, 0, 0);
82
+ },
83
+ renderResult(result, { expanded }, theme, context) {
84
+ const details = result.details as ModelToolDetails | undefined;
85
+
86
+ const container = new Container();
87
+ container.addChild(new Spacer(1));
88
+
89
+ if (context.isError || !details) {
90
+ const output = result.content
91
+ .filter((content) => content.type === "text")
92
+ .map((content) => content.text)
93
+ .join("\n");
94
+ container.addChild(new Text(theme.fg("error", output), 0, 0));
95
+
96
+ return container;
97
+ }
98
+
99
+ if (details.action === "current") {
100
+ const { model, thinkingLevel } = details.current;
101
+ container.addChild(new Text(theme.fg("muted", formatModel(model.provider, model.id, thinkingLevel)), 0, 0));
102
+
103
+ return container;
104
+ }
105
+
106
+ const filtered = context.args.provider !== undefined || context.args.model !== undefined;
107
+ const models = details.models ?? [];
108
+ const total = details.total ?? models.length;
109
+
110
+ if (expanded) {
111
+ models.forEach((model) => {
112
+ container.addChild(new Text(theme.fg("muted", formatModel(model.provider, model.id)), 0, 0));
113
+ });
114
+
115
+ container.addChild(new Spacer(1));
116
+ }
117
+
118
+ const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched" : "available"} model${total === 1 ? "" : "s"} listed`;
119
+ container.addChild(new Text(theme.fg("muted", summary) + (details.truncation?.truncated ? theme.fg("warning", " (truncated)") : ""), 0, 0));
120
+
121
+ return container;
122
+ },
123
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124
+ if (params.action === "current") {
125
+ const model = ctx.model;
126
+ if (!model) throw new Error("No model is currently selected.");
127
+
128
+ const thinkingLevel = pi.getThinkingLevel();
129
+ const current = { model: toMetadata(model), thinkingLevel };
130
+
131
+ return {
132
+ content: [{ type: "text", text: JSON.stringify(current) }],
133
+ details: { action: "current", current } satisfies ModelToolDetails,
134
+ };
135
+ }
136
+
137
+ const providerQuery = params.provider?.trim().toLowerCase();
138
+ const modelQuery = params.model?.trim().toLowerCase();
139
+ const filtered = providerQuery !== undefined || modelQuery !== undefined;
140
+
141
+ const matched = ctx.modelRegistry.getAvailable().filter((model) => {
142
+ if (providerQuery && !model.provider.toLowerCase().includes(providerQuery)) return false;
143
+ if (modelQuery && !model.id.toLowerCase().includes(modelQuery) && !model.name.toLowerCase().includes(modelQuery)) return false;
144
+ return true;
145
+ }).map(toMetadata);
146
+ const total = matched.length;
147
+
148
+ if (total === 0) {
149
+ return {
150
+ content: [{ type: "text", text: `0 models ${filtered ? "matched" : "available"}.` }],
151
+ details: { action: "list", models: [], total } satisfies ModelToolDetails,
152
+ };
153
+ }
154
+
155
+ // Convert from 1-indexed offset to 0-indexed array access.
156
+ const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
157
+ if (startIndex >= total) {
158
+ throw new Error(`Offset ${params.offset} is beyond end of list (${total} models total).`);
159
+ }
160
+
161
+ const endIndex = params.limit !== undefined ? Math.min(startIndex + params.limit, total) : total;
162
+ const selected = matched.slice(startIndex, endIndex);
163
+
164
+ // JSONL: one compact object per line, so truncation cuts at record boundaries.
165
+ const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
166
+ maxLines: DEFAULT_MAX_LINES,
167
+ maxBytes: DEFAULT_MAX_BYTES,
168
+ });
169
+
170
+ const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
171
+ const endDisplay = startIndex + delivered.length;
172
+ const nextOffset = endDisplay + 1;
173
+
174
+ let text = truncation.content;
175
+ if (truncation.truncated) {
176
+ text += `\n\n[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}; use offset=${nextOffset} to continue]`;
177
+ } else if (endDisplay < total) {
178
+ text += `\n\n[${total - endDisplay} more models in list; use offset=${nextOffset} to continue]`;
179
+ }
180
+
181
+ return {
182
+ content: [{ type: "text", text }],
183
+ details: { action: "list", models: delivered, total, truncation } satisfies ModelToolDetails,
184
+ };
185
+ },
186
+ });
187
+ });
188
+ }
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { editorConfigSchema } from "../../editor/config";
4
4
  import { footerConfigSchema } from "../../footer/config";
5
5
  import { fullscreenConfigSchema } from "../../fullscreen/config";
6
+ import { modelsConfigSchema } from "../../models/config";
6
7
  import { nameConfigSchema } from "../../name/config";
7
8
  import { presetsConfigSchema } from "../../presets/config";
8
9
  import { recapConfigSchema } from "../../recap/config";
@@ -11,6 +12,7 @@ export const configSchemas = {
11
12
  editor: editorConfigSchema,
12
13
  footer: footerConfigSchema,
13
14
  fullscreen: fullscreenConfigSchema,
15
+ models: modelsConfigSchema,
14
16
  name: nameConfigSchema,
15
17
  presets: presetsConfigSchema,
16
18
  recap: recapConfigSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",