pi-spark 0.14.6 → 0.15.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # pi-spark
4
4
 
5
- [Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
5
+ [Pi](https://pi.dev/) package that polishes your daily experience.
6
6
 
7
7
  ![Overview](./assets/screenshot-overview.png)
8
8
 
@@ -61,23 +61,6 @@ pi-spark generates a short recap of the current session after it goes idle, or o
61
61
 
62
62
  ![Recap](./assets/screenshot-recap.png)
63
63
 
64
- ### Agent tools: `pi` and `web`
65
-
66
- pi-spark adds two agent tools:
67
-
68
- The `pi` tool lets the pi coding agent inspect and manipulate itself (~740 tokens).
69
-
70
- - `models` lists and searches the model catalog.
71
- - `name` sets or updates the current session's name.
72
- - `whoami` shows the current pi state, including session name, active model, and thinking level.
73
-
74
- 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.
75
-
76
- - `search` finds current information across the web and returns ready-to-use content.
77
- - `fetch` reads the full content of known URLs as clean markdown, and can batch several URLs in one call.
78
-
79
- ![Agent tools](./assets/screenshot-tools.png)
80
-
81
64
  ## Configuration
82
65
 
83
66
  pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
@@ -121,10 +104,8 @@ All fields are optional. Each top-level feature runs with the defaults below unl
121
104
  | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
122
105
  | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
123
106
  | `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
124
- | `pi` | `{}` | Exposes the `pi` agent tool (`models`, `name`, `whoami` actions). |
125
107
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
126
108
  | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
127
- | `web` | `{}` | Exposes the `web` agent tool (`search`, `fetch` actions) via Exa's hosted MCP server. |
128
109
 
129
110
  #### `EditorConfig`
130
111
 
Binary file
package/index.ts CHANGED
@@ -3,10 +3,8 @@ 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 { registerPi } from "./src/features/pi";
7
6
  import { registerPresets } from "./src/features/presets";
8
7
  import { registerRecap } from "./src/features/recap";
9
- import { registerWeb } from "./src/features/web";
10
8
 
11
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
10
 
@@ -24,8 +22,6 @@ export default function (pi: ExtensionAPI) {
24
22
  registerEditor(pi, events);
25
23
  registerFooter(pi);
26
24
  registerFullscreen(pi);
27
- registerPi(pi);
28
25
  registerPresets(pi);
29
26
  registerRecap(pi);
30
- registerWeb(pi);
31
27
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.14.6",
4
- "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
3
+ "version": "0.15.0",
4
+ "description": "Pi package that polishes your daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
7
7
  "pi-package"
@@ -31,8 +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",
35
- "liqe": "^3.8.7",
34
+ "defu": "^6.1.7",
36
35
  "ms": "4.0.0-nightly.202508271359",
37
36
  "zod": "^4.4.3"
38
37
  },
@@ -1,27 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { defu } from "defu";
4
5
 
5
- import { sparkConfigSchema } from "./schema";
6
+ import { featureSchemas } from "./schema";
6
7
 
7
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { SparkConfig } from "./schema";
9
10
 
10
- type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
11
- type JsonObject = { [key: string]: JsonValue };
12
-
13
- /** All features disabled; used as the fallback when `spark.json` fails validation. */
14
- const DISABLED_CONFIG: SparkConfig = Object.freeze({
15
- credits: false,
16
- editor: false,
17
- footer: false,
18
- fullscreen: false,
19
- pi: false,
20
- presets: false,
21
- recap: false,
22
- web: false,
23
- });
24
-
25
11
  const cache = new Map<string, SparkConfig>();
26
12
 
27
13
  /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
@@ -30,66 +16,55 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
30
16
  const cached = cache.get(key);
31
17
  if (cached) return cached;
32
18
 
33
- const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
34
- const result = sparkConfigSchema.safeParse(rawConfig);
19
+ // Deep-merge the global file under the project file, so project settings win at scalar
20
+ // leaves while deep objects (e.g., `recap.model`) combine across both.
21
+ const [globalPath, projectPath] = getConfigPaths(ctx.cwd, fileName);
22
+ const raw = defu(readRawConfig(projectPath) ?? {}, readRawConfig(globalPath) ?? {});
35
23
 
36
- if (!result.success) {
37
- const message = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
38
- ctx.ui.notify(`Invalid spark config: ${message}`, "error");
24
+ // Validate each feature independently so a single invalid field disables only that feature
25
+ // (falling back to its enabled defaults) instead of taking down the whole config.
26
+ const config = {} as Record<keyof SparkConfig, unknown>;
27
+ const errors: string[] = [];
39
28
 
40
- const config = DISABLED_CONFIG;
41
- cache.set(key, config);
29
+ for (const field of Object.keys(featureSchemas) as (keyof SparkConfig)[]) {
30
+ const value = raw[field];
42
31
 
43
- return config;
44
- }
32
+ if (value === undefined) {
33
+ config[field] = {};
34
+ continue;
35
+ }
45
36
 
46
- const config = result.data;
47
- cache.set(key, config);
37
+ if (value === false) {
38
+ config[field] = false;
39
+ continue;
40
+ }
48
41
 
49
- return config;
50
- }
42
+ const result = featureSchemas[field].safeParse(value);
43
+ if (result.success) {
44
+ config[field] = result.data;
45
+ continue;
46
+ }
51
47
 
52
- function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
53
- return [join(getAgentDir(), fileName), join(cwd, ".pi", fileName)];
54
- }
48
+ config[field] = {};
49
+ errors.push(result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; "));
50
+ }
55
51
 
56
- function loadMergedJson(paths: string[]): JsonObject | undefined {
57
- let merged: JsonObject | undefined;
58
- paths.forEach((path) => {
59
- const value = readJsonFile(path);
60
- if (value === undefined) return;
52
+ if (errors.length > 0) {
53
+ ctx.ui.notify(`Invalid pi-spark config: ${errors.join("; ")}`, "error");
54
+ }
61
55
 
62
- merged = mergeConfig(merged, value);
63
- });
56
+ cache.set(key, config as SparkConfig);
57
+ return config as SparkConfig;
58
+ }
64
59
 
65
- return merged;
60
+ function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
61
+ return [join(getAgentDir(), fileName), join(cwd, CONFIG_DIR_NAME, fileName)];
66
62
  }
67
63
 
68
- function readJsonFile(path: string): JsonObject | undefined {
64
+ function readRawConfig(path: string): Record<string, unknown> | undefined {
69
65
  try {
70
- return JSON.parse(readFileSync(path, "utf8")) as JsonObject;
66
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
71
67
  } catch {
72
68
  return undefined;
73
69
  }
74
70
  }
75
-
76
- function mergeConfig(base: JsonObject | undefined, override: JsonObject): JsonObject {
77
- if (base === undefined) return override;
78
- if (!isPlainObject(base) || !isPlainObject(override)) return override;
79
-
80
- const result: Record<string, JsonValue> = { ...base };
81
- Object.entries(override).forEach(([key, overrideValue]) => {
82
- const baseValue = base[key];
83
- if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
84
- result[key] = { ...baseValue, ...overrideValue };
85
- } else {
86
- result[key] = overrideValue;
87
- }
88
- });
89
-
90
- return result;
91
- }
92
-
93
- function isPlainObject(value: unknown): value is Record<string, JsonValue> {
94
- return !!value && typeof value === "object" && !Array.isArray(value);
95
- }
@@ -4,27 +4,24 @@ 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 { piConfigSchema } from "../features/pi/config";
8
7
  import { presetsConfigSchema } from "../features/presets/config";
9
8
  import { recapConfigSchema } from "../features/recap/config";
10
- import { webConfigSchema } from "../features/web/config";
11
-
12
- const disabled = z.literal(false);
13
9
 
14
10
  /**
15
- * Each feature field is `{ ... } | false`: `false` disables the feature, an object configures
16
- * it, and an omitted field falls back to defaults (`{}`, enabled).
11
+ * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
12
+ * an omitted field falls back to `{}` (enabled with defaults), `false` disables the feature, and
13
+ * any other value is validated against the feature schema.
17
14
  */
18
- export const sparkConfigSchema = z.object({
19
- credits: creditsConfigSchema.or(disabled).default({}),
20
- editor: editorConfigSchema.or(disabled).default({}),
21
- footer: footerConfigSchema.or(disabled).default({}),
22
- fullscreen: fullscreenConfigSchema.or(disabled).default({}),
23
- pi: piConfigSchema.or(disabled).default({}),
24
- presets: presetsConfigSchema.or(disabled).default({}),
25
- recap: recapConfigSchema.or(disabled).default({}),
26
- web: webConfigSchema.or(disabled).default({}),
27
- });
15
+ export const featureSchemas = {
16
+ credits: creditsConfigSchema,
17
+ editor: editorConfigSchema,
18
+ footer: footerConfigSchema,
19
+ fullscreen: fullscreenConfigSchema,
20
+ presets: presetsConfigSchema,
21
+ recap: recapConfigSchema,
22
+ } as const;
28
23
 
29
24
  /** Resolved config for every feature; `false` means the feature is disabled. */
30
- export type SparkConfig = z.infer<typeof sparkConfigSchema>;
25
+ export type SparkConfig = {
26
+ [K in keyof typeof featureSchemas]: z.infer<(typeof featureSchemas)[K]> | false;
27
+ };
@@ -2,8 +2,8 @@ import { CreditsManager } from "./manager";
2
2
  import { loadConfig } from "../../config";
3
3
  import { isUsage } from "../../utils/usage";
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
5
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
 
8
8
  function hasCost(message: AgentMessage): boolean {
9
9
  const usage = (message as { usage?: unknown }).usage;
@@ -44,6 +44,7 @@ export function registerCredits(pi: ExtensionAPI): void {
44
44
  });
45
45
 
46
46
  pi.on("session_shutdown", () => {
47
+ creditsManager?.cancel();
47
48
  creditsManager = undefined;
48
49
  });
49
50
  }
@@ -36,25 +36,29 @@ export class CreditsManager {
36
36
  });
37
37
  }
38
38
 
39
+ cancel(): void {
40
+ this.inflight?.abort();
41
+ this.inflight = undefined;
42
+ }
43
+
39
44
  private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
40
45
  try {
41
46
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
47
+ if (signal.aborted) return;
42
48
  if (!apiKey) {
43
49
  ctx.ui.setStatus(STATUS_KEY, undefined);
44
50
  return;
45
51
  }
46
52
 
47
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal];
48
- if (ctx.signal) signals.push(ctx.signal);
49
-
50
- const credits = await provider.fetch(ctx, apiKey, AbortSignal.any(signals));
53
+ const signals = AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
54
+ const credits = await provider.fetch(ctx, apiKey, signals);
51
55
 
52
56
  // The active model may have changed while the request was in flight.
53
57
  if (ctx.model?.provider !== provider.id) return;
54
58
 
55
59
  ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
56
60
  } catch (error) {
57
- if (signal.aborted || ctx.signal?.aborted) return;
61
+ if (signal.aborted) return;
58
62
  if (ctx.model?.provider !== provider.id) return;
59
63
 
60
64
  const message = error instanceof Error ? error.message : String(error);
@@ -10,7 +10,7 @@ interface MoonshotBalanceResponse {
10
10
 
11
11
  /**
12
12
  * For Moonshot, the international and China-mainland accounts live on separate hosts and bill in
13
- * different currencies (USD vs CNY), which the endpoint does not report, so each pi provider ID
13
+ * different currencies (USD vs CNY), which the endpoint does not report, so each Pi provider ID
14
14
  * fixes both host and currency.
15
15
  */
16
16
  function createMoonshotProvider(id: string, host: string, currency: string): CreditsProvider {
@@ -14,7 +14,7 @@ export function renderCredits(theme: Theme, label: string, credits: Credits): st
14
14
  }
15
15
 
16
16
  export function renderError(theme: Theme, label: string, message: string): string {
17
- return theme.fg("error", `${label} credits unavailable: ${message}`);
17
+ return theme.fg("error", `${label} ${message}`);
18
18
  }
19
19
 
20
20
  function renderWindows(theme: Theme, credits: Extract<Credits, { type: "windows" }>): string {
@@ -16,7 +16,7 @@ export interface CreditsLane {
16
16
  percent: number | undefined;
17
17
  }
18
18
 
19
- /** A credits source for a pi provider, shown in the status line while that provider is active. */
19
+ /** A credits source for a Pi provider, shown in the status line while that provider is active. */
20
20
  export interface CreditsProvider {
21
21
  readonly id: Provider;
22
22
  readonly label: string;
@@ -78,7 +78,7 @@ class Editor extends CustomEditor {
78
78
  const modelBeforeText = this.slots.modelBefore;
79
79
  const modelText = formatModel(this.ctx.model?.provider, this.ctx.model?.id, this.pi.getThinkingLevel());
80
80
 
81
- return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" "));
81
+ return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" · "));
82
82
  }
83
83
  }
84
84
 
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
5
5
  import { SplitLine } from "../../components/split-line";
6
6
  import { loadConfig } from "../../config";
7
7
  import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../../utils/format";
8
+ import { isFreeModel } from "../../utils/model";
8
9
  import { getEntryUsage } from "../../utils/usage";
9
10
 
10
11
  import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
@@ -39,7 +40,7 @@ class FooterComponent implements Component {
39
40
  const branch = this.footerData.getGitBranch();
40
41
  const sessionName = this.ctx.sessionManager.getSessionName();
41
42
 
42
- return this.theme.fg("dim", [cwdText, branch, sessionName].filter(Boolean).join(" "));
43
+ return this.theme.fg("dim", [cwdText, branch, sessionName].filter(Boolean).join(" · "));
43
44
  }
44
45
 
45
46
  private getRight(): string {
@@ -47,7 +48,7 @@ class FooterComponent implements Component {
47
48
  const styledCostText = this.getStyledCostText();
48
49
  const styledContextUsageText = this.getStyledContextUsageText();
49
50
 
50
- return [statusesText, styledCostText, styledContextUsageText].filter(Boolean).join(this.theme.fg("dim", " "));
51
+ return [statusesText, styledCostText, styledContextUsageText].filter(Boolean).join(this.theme.fg("dim", " · "));
51
52
  }
52
53
 
53
54
  /** Get extension statuses, sorted by key alphabetically. */
@@ -58,11 +59,15 @@ class FooterComponent implements Component {
58
59
  return Array.from(extensionStatuses.entries())
59
60
  .sort(([a], [b]) => a.localeCompare(b))
60
61
  .map(([, text]) => sanitizeText(text))
61
- .join(this.theme.fg("dim", " "));
62
+ .join(this.theme.fg("dim", " · "));
62
63
  }
63
64
 
64
65
  private getStyledCostText(): string {
65
66
  const totalCost = this.ctx.sessionManager.getBranch().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
67
+
68
+ // Hide cost when it's zero and the current model is free.
69
+ if (totalCost === 0 && this.ctx.model && isFreeModel(this.ctx.model)) return "";
70
+
66
71
  const costText = formatCost(totalCost);
67
72
 
68
73
  if (totalCost > 20) return this.theme.fg("warning", costText);
@@ -22,7 +22,7 @@ export class BottomFiller implements Component {
22
22
  this.measuring = true;
23
23
 
24
24
  // Re-assert `clearOnShrink` every render pass; the TUI reads it after `render()` returns,
25
- // so this wins deterministically over pi's reset to the settings value on startup/reload.
25
+ // so this wins deterministically over Pi's reset to the settings value on startup/reload.
26
26
  this.tui.setClearOnShrink(true);
27
27
 
28
28
  const rows = this.tui.terminal.rows;
@@ -95,7 +95,7 @@ export class PresetManager {
95
95
  if (!preset) return;
96
96
 
97
97
  return this.keys.find((key) => {
98
- const p = this.presets[key]!;
98
+ const p = this.presets[key];
99
99
 
100
100
  return p.provider === preset.provider && p.model === preset.model && p.thinkingLevel === preset.thinkingLevel;
101
101
  });
@@ -1,4 +1,4 @@
1
- import { keyText, DynamicBorder } from "@earendil-works/pi-coding-agent";
1
+ import { keyHint, rawKeyHint, DynamicBorder } from "@earendil-works/pi-coding-agent";
2
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";
@@ -39,11 +39,11 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
39
39
  box.addChild(new Spacer(1));
40
40
 
41
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));
42
+ rawKeyHint("↑↓", "navigate"),
43
+ keyHint("tui.select.confirm", "select"),
44
+ keyHint("tui.select.cancel", "cancel"),
45
+ ];
46
+ box.addChild(new Text(keyHints.join(" "), 0, 0));
47
47
 
48
48
  container.addChild(box);
49
49
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
 
3
- import type { ImageContent, Provider, TextContent } from "@earendil-works/pi-ai";
3
+ import type { Provider } 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 {
@@ -28,11 +28,6 @@ export function formatCost(cost: number): string {
28
28
  return `$${cost.toFixed(2)}`;
29
29
  }
30
30
 
31
- /** Format a duration in ms as `1.2s`, like the built-in bash tool. */
32
- export function formatDuration(ms: number): string {
33
- return `${(ms / 1000).toFixed(1)}s`;
34
- }
35
-
36
31
  export function formatCwd(cwd: string, home: string): string {
37
32
  const resolvedCwd = resolve(cwd);
38
33
  const resolvedHome = resolve(home);
@@ -67,14 +62,6 @@ export function sanitizeText(text: string): string {
67
62
  .trim();
68
63
  }
69
64
 
70
- /** Join the text blocks of tool result content into a single newline-separated string. */
71
- export function joinTextContent(content: (TextContent | ImageContent)[]): string {
72
- return content
73
- .filter((block) => block.type === "text")
74
- .map((block) => block.text)
75
- .join("\n");
76
- }
77
-
78
65
  /** Coerce a possibly-stringified numeric value to a finite number, or `undefined`. */
79
66
  export function toNumber(value?: string | number | null): number | undefined {
80
67
  if (value === undefined || value === null) return undefined;
@@ -0,0 +1,6 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+
3
+ /** Check if a model has zero costs for all categories. */
4
+ export function isFreeModel(model: Model<Api>): boolean {
5
+ return model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0 && model.cost.cacheWrite === 0;
6
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
 
4
- /** Structural type guard for the pi `Usage` shape. */
4
+ /** Structural type guard for the Pi `Usage` shape. */
5
5
  export function isUsage(value: unknown): value is Usage {
6
6
  if (typeof value !== "object" || value === null) return false;
7
7
 
Binary file