pi-spark 0.14.4 → 0.14.6

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
@@ -6,6 +6,20 @@
6
6
 
7
7
  ![Overview](./assets/screenshot-overview.png)
8
8
 
9
+ ## Install
10
+
11
+ Install from npm:
12
+
13
+ ```bash
14
+ pi install npm:pi-spark
15
+ ```
16
+
17
+ Install from git:
18
+
19
+ ```bash
20
+ pi install git:github.com/zlliang/pi-spark
21
+ ```
22
+
9
23
  ## Features
10
24
 
11
25
  ### Compact TUI: editor, footer, and fullscreen
@@ -131,18 +145,22 @@ Each preset must set all three fields.
131
145
  | --- | --- | --- |
132
146
  | `provider` | string | Provider ID, e.g., `anthropic`. |
133
147
  | `model` | string | Model ID, e.g., `claude-opus-4-8`. |
134
- | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
148
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the preset. |
135
149
 
136
150
  #### `RecapConfig`
137
151
 
138
- All fields are optional, including `thinkingLevel`.
152
+ All fields are optional, including `thinkingLevel`. If the recap model configuration is incomplete, pi-spark falls back to the session's main model.
139
153
 
140
154
  | Field | Value | Description |
141
155
  | --- | --- | --- |
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. |
156
+ | `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., `"5m"`); minimum 5000 ms, defaults to 5 minutes. |
143
157
  | `provider` | string | Provider ID for the recap model. |
144
158
  | `model` | string | Model ID for the recap model. |
145
- | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
159
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the recap model. |
160
+
161
+ #### `ModelThinkingLevel`
162
+
163
+ Valid values: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`.
146
164
 
147
165
  ### Turn off the features you don't like
148
166
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
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",
@@ -1,11 +1,8 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
 
3
- import { credentials, loadPackageDefinition, Metadata } from "@grpc/grpc-js";
4
- import { loadSync } from "@grpc/proto-loader";
5
-
6
3
  import { toNumber } from "../../../utils/format";
7
4
 
8
- import type { ClientUnaryCall, ServiceClientConstructor, ServiceError } from "@grpc/grpc-js";
5
+ import type { ClientUnaryCall, Metadata, ServiceClientConstructor, ServiceError } from "@grpc/grpc-js";
9
6
  import type { Credits, CreditsProvider } from "../types";
10
7
 
11
8
  type GatewayClient = InstanceType<ServiceClientConstructor>;
@@ -43,9 +40,16 @@ let client: GatewayClient | undefined;
43
40
  /** The API key maps to a fixed account, so cache the resolved resource name. */
44
41
  const accountByKey = new Map<string, string>();
45
42
 
46
- function getClient(): GatewayClient {
43
+ async function getClient(): Promise<GatewayClient> {
47
44
  if (client) return client;
48
45
 
46
+ // @grpc/* is heavy to import (~45 ms cold); load it lazily so startup never pays for it unless
47
+ // Fireworks credits are actually fetched.
48
+ const [{ credentials, loadPackageDefinition }, { loadSync }] = await Promise.all([
49
+ import("@grpc/grpc-js"),
50
+ import("@grpc/proto-loader"),
51
+ ]);
52
+
49
53
  const protoPath = fileURLToPath(new URL("./fireworks.proto", import.meta.url));
50
54
  const definition = loadSync(protoPath, { keepCase: true, longs: String, defaults: true });
51
55
  const proto = loadPackageDefinition(definition) as unknown as { gateway: { Gateway: ServiceClientConstructor } };
@@ -55,12 +59,14 @@ function getClient(): GatewayClient {
55
59
  }
56
60
 
57
61
  async function unary<T>(method: string, request: object, apiKey: string, signal: AbortSignal): Promise<T> {
62
+ const grpc = await import("@grpc/grpc-js");
63
+ const gateway = await getClient();
64
+
58
65
  return new Promise<T>((resolve, reject) => {
59
- const metadata = new Metadata();
66
+ const metadata = new grpc.Metadata();
60
67
  metadata.set("x-api-key", apiKey);
61
68
 
62
69
  const deadline = new Date(Date.now() + DEADLINE_MS);
63
- const gateway = getClient();
64
70
  const invoke = gateway[method] as (
65
71
  request: object,
66
72
  metadata: Metadata,
@@ -22,23 +22,13 @@ export const nameAction = defineAction({
22
22
  "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
23
23
  "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
24
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
25
  },
33
26
  required: ["name"],
34
27
  promptGuidelines: [
35
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.",
36
29
  ],
37
30
  renderParams(args, theme) {
38
- const name = theme.fg("muted", sanitizeText(args.name ?? ""));
39
- const reason = sanitizeText(args.reason ?? "");
40
-
41
- return [reason ? name + theme.fg("muted", "\n\n" + reason) : name];
31
+ return [theme.fg("muted", sanitizeText(args.name ?? ""))];
42
32
  },
43
33
  renderResult() {
44
34
  // "name" has no success UI; errors are rendered by the registry's fallback.
@@ -31,14 +31,16 @@ export const whoamiAction = defineAction({
31
31
  return container;
32
32
  }
33
33
 
34
+ const formatLine = (label: string, value: string) => `${label.padEnd(7)} ${value}`;
35
+
34
36
  if (details.sessionName) {
35
- container.addChild(new Text(theme.fg("muted", `session ${details.sessionName}`), 0, 0));
37
+ container.addChild(new Text(theme.fg("muted", formatLine("session", details.sessionName)), 0, 0));
36
38
  }
37
39
 
38
40
  if (details.model) {
39
41
  const row = toModelRow(details.model, details.thinkingLevel);
40
42
  const cells = [row.label, row.cost, row.context].join(" ");
41
- container.addChild(new Text(theme.fg("muted", `model ${cells}`), 0, 0));
43
+ container.addChild(new Text(theme.fg("muted", formatLine("model", cells)), 0, 0));
42
44
  }
43
45
 
44
46
  return container;
@@ -14,9 +14,9 @@ export function registerPresets(pi: ExtensionAPI): void {
14
14
  type: "string",
15
15
  });
16
16
 
17
- pi.on("session_start", async (_event, ctx) => {
17
+ pi.on("session_start", async (event, ctx) => {
18
18
  const config = loadConfig(ctx).presets;
19
- const presetFlag = pi.getFlag("preset");
19
+ const presetFlag = event.reason === "startup" ? pi.getFlag("preset") : undefined;
20
20
 
21
21
  if (!config || Object.keys(config).length === 0) {
22
22
  if (presetFlag) ctx.ui.notify("No presets defined in spark.json", "warning");
@@ -1,5 +1,5 @@
1
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
- import { Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
1
+ import { keyText, DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
3
 
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { PresetManager } from "./manager";
@@ -20,12 +20,11 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
20
20
  }));
21
21
 
22
22
  const container = new Container();
23
-
24
23
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
25
- container.addChild(new Spacer(1));
26
- container.addChild(new Text(theme.bold("Select preset"), 0, 0));
27
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 0, 0));
28
- container.addChild(new Spacer(1));
24
+
25
+ const box = new Box(1, 1);
26
+ box.addChild(new Text(theme.bold(theme.fg("accent", "Select preset")), 0, 0));
27
+ box.addChild(new Spacer(1));
29
28
 
30
29
  const selectList = new SelectList(items, 10, {
31
30
  selectedPrefix: (text) => theme.fg("accent", text),
@@ -36,9 +35,17 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
36
35
  });
37
36
  selectList.onSelect = (item) => done(item.value);
38
37
  selectList.onCancel = () => done(null);
39
- container.addChild(selectList);
38
+ box.addChild(selectList);
39
+ box.addChild(new Spacer(1));
40
+
41
+ const keyHints = [
42
+ ["↑↓", "navigate"],
43
+ [keyText("tui.select.confirm"), "select"],
44
+ [keyText("tui.select.cancel"), "cancel"],
45
+ ] as const;
46
+ box.addChild(new Text(keyHints.map((item) => `${theme.fg("dim", item[0])} ${theme.fg("muted", item[1])}`).join(" "), 0, 0));
40
47
 
41
- container.addChild(new Spacer(1));
48
+ container.addChild(box);
42
49
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
43
50
 
44
51
  return {
@@ -31,7 +31,7 @@ export const idleTimeoutSchema = z
31
31
  type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
32
32
  type IdleHash = string | number | boolean;
33
33
 
34
- const DEFAULT_IDLE_MS = 3 * 60 * 1000;
34
+ const DEFAULT_IDLE_MS = 5 * 60 * 1000;
35
35
  const POLL_MS = 1_000;
36
36
 
37
37
  export class IdleListener<T> {
@@ -1,6 +1,4 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
-
1
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
2
  import type { TextContent } from "@earendil-works/pi-ai";
5
3
 
6
4
  /** Exa's hosted MCP endpoint (Streamable HTTP). No API key required for the free tier. */
@@ -56,6 +54,13 @@ export class ExaClient {
56
54
 
57
55
  if (!this.connecting) {
58
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
+
59
64
  const next = new Client({ name: "pi-spark", version: "0" });
60
65
  const transport = new StreamableHTTPClientTransport(new URL(EXA_MCP_URL));
61
66
  await next.connect(transport as Parameters<Client["connect"]>[0]);
package/src/utils/tool.ts CHANGED
@@ -4,7 +4,7 @@ import { Type } from "typebox";
4
4
 
5
5
  import { formatDuration, joinTextContent } from "./format";
6
6
 
7
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
7
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
9
  import type { Static, TObject, TProperties, TSchema } from "typebox";
10
10
 
@@ -28,8 +28,9 @@ export interface Action<C, F extends TProperties = TProperties, D extends Action
28
28
  showTiming?: boolean;
29
29
  /** Styled segments shown after the `<tool> <action>` prefix. */
30
30
  renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
31
+ /** Only handles successful output; error output is handled centrally. */
31
32
  renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
32
- execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
33
+ execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<D> | undefined): Promise<AgentToolResult<D>>;
33
34
  }
34
35
 
35
36
  /** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
@@ -75,7 +76,6 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
75
76
 
76
77
  const activeTiming = new ActiveTiming();
77
78
  const noTiming = new NoTiming();
78
- // Resolve per-action timing from the stable call args, so error results (no details) still settle.
79
79
  const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
80
80
 
81
81
  const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
@@ -111,7 +111,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
111
111
 
112
112
  return timingFor(context.args.action).renderResult(inner, options, theme, context);
113
113
  },
114
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
114
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
115
115
  const action = byName.get(params.action);
116
116
  if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
117
117
 
@@ -121,7 +121,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
121
121
  }
122
122
  }
123
123
 
124
- return action.execute(params as never, config.createContext(ctx), signal);
124
+ return action.execute(params as any, config.createContext(ctx), signal, onUpdate);
125
125
  },
126
126
  });
127
127
  }
@@ -147,55 +147,67 @@ interface TimingState {
147
147
  startedAt?: number;
148
148
  endedAt?: number;
149
149
  interval?: ReturnType<typeof setInterval>;
150
+ hasResult?: boolean;
150
151
  }
151
152
 
152
153
  class ActiveTiming implements Timing {
154
+ // No result yet: `renderCall` owns the live "Elapsed" ticker.
153
155
  renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
154
156
  const state = context.state as TimingState;
157
+
155
158
  if (context.executionStarted && state.startedAt === undefined) {
156
159
  state.startedAt = Date.now();
157
160
  delete state.endedAt;
161
+ delete state.hasResult;
158
162
  }
159
163
 
160
- // Result is in: renderResult owns the timing line, so just settle here.
161
- if (!context.isPartial) {
162
- this.settle(state);
163
- return inner;
164
- }
165
-
166
- // Still running, no result yet: show a live "Elapsed" line, ticking once a second.
167
- if (state.startedAt === undefined) return inner;
164
+ // Not started, or final (`renderResult` owns the "Took" line): render nothing here.
165
+ if (state.startedAt === undefined || !context.isPartial) return inner;
168
166
 
169
167
  state.interval ??= setInterval(() => context.invalidate(), 1000);
170
- return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme);
168
+
169
+ // `renderResult` runs later in the same render pass, so decide visibility at render time:
170
+ // hide once any result has arrived.
171
+ return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme, () => !state.hasResult);
171
172
  }
172
173
 
174
+ // A result exists: `renderResult` owns the line — "Elapsed" while streaming, "Took" once final.
173
175
  renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
174
176
  const state = context.state as TimingState;
175
- if (!options.isPartial || context.isError) this.settle(state);
177
+ state.hasResult = true;
178
+
179
+ const isRunning = options.isPartial && !context.isError;
180
+ if (!isRunning) this.settle(state);
176
181
  if (state.startedAt === undefined) return inner;
177
182
 
178
- const label = options.isPartial ? "Elapsed" : "Took";
179
183
  const endTime = state.endedAt ?? Date.now();
180
- return this.withTimingLine(inner, label, endTime - state.startedAt, theme);
184
+ return this.withTimingLine(inner, isRunning ? "Elapsed" : "Took", endTime - state.startedAt, theme);
181
185
  }
182
186
 
183
187
  private settle(state: TimingState): void {
184
- if (state.startedAt !== undefined) state.endedAt ??= Date.now();
188
+ if (state.startedAt !== undefined) {
189
+ state.endedAt ??= Date.now();
190
+ }
191
+
185
192
  if (state.interval) {
186
193
  clearInterval(state.interval);
187
194
  delete state.interval;
188
195
  }
189
196
  }
190
197
 
191
- private withTimingLine(content: Component, label: string, ms: number, theme: Theme): Container {
198
+ private withTimingLine(content: Component, label: string, ms: number, theme: Theme, visible?: () => boolean): Component {
192
199
  const container = new Container();
193
200
  container.addChild(content);
194
201
 
195
202
  container.addChild(new Spacer(1));
196
- container.addChild(new Text(`${theme.fg("muted", `${label} ${formatDuration(ms)}`)}`, 0, 0));
203
+ container.addChild(new Text(theme.fg("muted", `${label} ${formatDuration(ms)}`), 0, 0));
204
+
205
+ if (!visible) return container;
197
206
 
198
- return container;
207
+ return {
208
+ invalidate: () => container.invalidate(),
209
+ render: (width) => (visible() ? container.render(width) : content.render(width)),
210
+ };
199
211
  }
200
212
  }
201
213