pi-spark 0.11.2 → 0.13.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.
Files changed (52) hide show
  1. package/README.md +76 -57
  2. package/assets/cover.png +0 -0
  3. package/index.ts +31 -0
  4. package/package.json +24 -9
  5. package/{extensions/shared → src}/components/inline-text.ts +1 -1
  6. package/{extensions/shared → src}/config/index.ts +37 -12
  7. package/src/config/schema.ts +30 -0
  8. package/{extensions/shared → src}/events/auto-collect-events.ts +1 -1
  9. package/src/events/index.ts +3 -0
  10. package/src/features/credits/config.ts +3 -0
  11. package/src/features/credits/index.ts +49 -0
  12. package/src/features/credits/manager.ts +64 -0
  13. package/src/features/credits/providers/deepseek.ts +36 -0
  14. package/src/features/credits/providers/fireworks.proto +42 -0
  15. package/src/features/credits/providers/fireworks.ts +111 -0
  16. package/src/features/credits/providers/index.ts +22 -0
  17. package/src/features/credits/providers/moonshot.ts +39 -0
  18. package/src/features/credits/providers/openai-codex.ts +62 -0
  19. package/src/features/credits/providers/openrouter.ts +35 -0
  20. package/src/features/credits/providers/vercel-ai-gateway.ts +30 -0
  21. package/src/features/credits/status.ts +43 -0
  22. package/src/features/credits/types.ts +24 -0
  23. package/{extensions → src/features}/editor/index.ts +8 -11
  24. package/{extensions → src/features}/footer/index.ts +7 -9
  25. package/{extensions → src/features}/fullscreen/index.ts +6 -9
  26. package/src/features/model/config.ts +3 -0
  27. package/{extensions/models → src/features/model}/index.ts +5 -5
  28. package/{extensions → src/features}/name/index.ts +6 -8
  29. package/{extensions → src/features}/presets/config.ts +1 -1
  30. package/{extensions → src/features}/presets/index.ts +4 -4
  31. package/{extensions → src/features}/presets/manager.ts +2 -2
  32. package/{extensions → src/features}/recap/config.ts +1 -1
  33. package/{extensions → src/features}/recap/index.ts +4 -6
  34. package/{extensions → src/features}/recap/manager.ts +5 -5
  35. package/{extensions → src/features}/recap/model.ts +2 -2
  36. package/{extensions/shared/format/index.ts → src/utils/format.ts} +29 -0
  37. package/{extensions/shared/usage/index.ts → src/utils/usage.ts} +0 -18
  38. package/extensions/models/config.ts +0 -3
  39. package/extensions/shared/config/schema.ts +0 -22
  40. package/extensions/shared/events/index.ts +0 -3
  41. /package/{extensions/shared → src}/components/split-line.ts +0 -0
  42. /package/{extensions/shared → src}/config/model.ts +0 -0
  43. /package/{extensions/shared → src}/events/preset.ts +0 -0
  44. /package/{extensions → src/features}/editor/config.ts +0 -0
  45. /package/{extensions → src/features}/editor/spinner.ts +0 -0
  46. /package/{extensions → src/features}/footer/config.ts +0 -0
  47. /package/{extensions → src/features}/fullscreen/config.ts +0 -0
  48. /package/{extensions → src/features}/fullscreen/filler.ts +0 -0
  49. /package/{extensions → src/features}/name/config.ts +0 -0
  50. /package/{extensions → src/features}/presets/selector.ts +0 -0
  51. /package/{extensions → src/features}/recap/idle.ts +0 -0
  52. /package/{extensions → src/features}/recap/widget.ts +0 -0
package/README.md CHANGED
@@ -1,40 +1,54 @@
1
+ ![Cover](./assets/cover.png)
2
+
1
3
  # pi-spark
2
4
 
3
- [Pi](https://pi.dev/) extensions that polish the daily experience.
5
+ [Pi](https://pi.dev/) package that polishes your daily experience and keeps you at the frontier of agentic workflows.
4
6
 
5
- ## Extensions
7
+ ## Features
6
8
 
7
- - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
8
- - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
9
- - **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.
10
- - **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.
11
- - **Name:** exposes a `name` tool so the agent can give the current session a concise, recognizable name in the session selector.
12
- - **Presets:** switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
13
- - **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).
9
+ ### Compact TUI: editor, footer, and fullscreen
14
10
 
15
- ![Screenshot](./assets/screenshot.png)
11
+ pi-spark ships with custom editor, footer, and fullscreen rendering, replacing the default ones. The compact TUI gives you a calm, immersive experience without distraction.
16
12
 
17
- ## Install
13
+ - The editor shows a working indicator inspired by [Amp](https://ampcode.com/) and the current model on the top border. If you use [presets](#presets), the active preset appears there too.
14
+ - The footer shows session information, extension statuses, cost, and context usage on one line.
15
+ - The fullscreen rendering clears the terminal screen and scrollback on session start and exit, and pins the editor and footer to the bottom.
18
16
 
19
- Install from npm:
17
+ ### Credits
20
18
 
21
- ```bash
22
- pi install npm:pi-spark
23
- ```
19
+ 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.
24
20
 
25
- For local development from this monorepo:
21
+ - Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
22
+ - 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)).
26
23
 
27
- ```bash
28
- pi install /path/to/pi-packages/packages/pi-spark
29
- ```
24
+ ### Presets
25
+
26
+ 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.
27
+
28
+ - Switch interactively with `/preset`, or jump straight to one with `/preset <name>`.
29
+ - Start pi on a given preset with `pi --preset <name>`.
30
+ - 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
+
32
+ ### Recap
33
+
34
+ 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).
35
+
36
+ - A recap is generated automatically once the session stays idle past `recap.idle` in `spark.json`.
37
+ - Run `/recap` to generate one manually at any time.
38
+ - The recap can use its own model, configured separately from your working model.
30
39
 
31
- ## Configure
40
+ ### Agent tools: `name` and `model`
32
41
 
33
- Spark reads config from `~/.pi/agent/spark.json` and from the current project’s `.pi/spark.json`. Project config overrides matching global fields.
42
+ pi-spark provides the pi coding agent with tools to inspect and manipulate itself. Currently, `name` and `model` tools are included.
34
43
 
35
- All extensions are enabled by default. Set a specific extension to `false` to disable it, for example, `"footer": false` disables the footer extension.
44
+ - `name` sets or updates the current session's name.
45
+ - `model` shows the active model or lists pi models.
36
46
 
37
- Example:
47
+ ## Configuration
48
+
49
+ pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
50
+
51
+ For example:
38
52
 
39
53
  ```json
40
54
  {
@@ -63,56 +77,61 @@ Example:
63
77
  }
64
78
  ```
65
79
 
66
- ### Editor
67
-
68
- - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
69
-
70
- ### Footer
71
-
72
- - pi-spark replaces the footer with a compact one-line view of session metadata, extension statuses, cost, and context usage.
80
+ ### References
73
81
 
74
- ### Fullscreen
82
+ All fields are optional. Each top-level feature runs with the defaults below unless you [turn it off](#turn-off-the-features-you-dont-like).
75
83
 
76
- - 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.
84
+ | Field | Value (or `false`) | Description |
85
+ | --- | --- | --- |
86
+ | `credits` | `{}` | Shows the active provider's credit balance or rate-limit usage in the status line. |
87
+ | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
88
+ | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
89
+ | `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. |
92
+ | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
93
+ | `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
77
94
 
78
- ### Models
95
+ #### `EditorConfig`
79
96
 
80
- - The agent can call the `model` tool with two actions:
81
- - `active`: gets the active provider, model, and thinking level.
82
- - `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.
97
+ The `spinner` field is optional and defaults to `tildes`.
83
98
 
84
- ### Name
99
+ | Field | Value | Description |
100
+ | --- | --- | --- |
101
+ | `spinner` | `dots` | `⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠦, ⠧, ⠇, ⠏` |
102
+ | | `lights` | `○, ●` |
103
+ | | `tildes` (default) | `∼, ≈, ≋, ≈, ∼` |
104
+ | | `pulse` | `·, •, ●, •, ·` |
85
105
 
86
- - The agent can set or refresh the current session's name and optionally give a reason.
106
+ #### `Preset`
87
107
 
88
- ### Presets
108
+ Each preset must set all three fields.
89
109
 
90
- - Each key under `presets` defines a named model preset with `provider`, `model`, and optional `thinkingLevel` fields.
110
+ | Field | Value | Description |
111
+ | --- | --- | --- |
112
+ | `provider` | string | Provider id, e.g. `anthropic`. |
113
+ | `model` | string | Model id, e.g. `claude-opus-4-8`. |
114
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
91
115
 
92
- Use presets in these ways:
116
+ #### `RecapConfig`
93
117
 
94
- - Select interactively with `/preset` or `/preset <key>`
95
- - Start pi with a preset using `pi --preset <key>`
96
- - Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS)
118
+ All fields are optional, including `thinkingLevel`.
97
119
 
98
- ### Recap
120
+ | Field | Value | Description |
121
+ | --- | --- | --- |
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. |
125
+ | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
99
126
 
100
- - pi-spark can generate a short recap after the session has been idle or when you run `/recap` manually.
101
- - The `recap.idle` value sets how long the session must stay idle before a recap is generated. It accepts either a millisecond number or a human-readable duration string parsed by [vercel/ms](https://github.com/vercel/ms) (e.g., `"3m"`, `"30s"`, `"2 minutes"`), and must resolve to at least 5000ms.
102
- - The recap model can be customized with `provider`, `model`, and `thinkingLevel`.
127
+ ### Turn off the features you don't like
103
128
 
104
- ## Recommended pi settings
129
+ All features are enabled by default. Set a specific feature to `false` in `spark.json` to disable it.
105
130
 
106
- [Pi 0.79.0](https://pi.dev/news/releases/0.79.0) added a project trust dialog that asks before loading project-local resources ([earendil-works/pi#5514](https://github.com/earendil-works/pi/issues/5514)), and [pi 0.79.1](https://pi.dev/news/releases/0.79.1) made the default behavior configurable via `defaultProjectTrust`. To keep startup minimal, set it to `"always"` in `~/.pi/agent/settings.json` (or change it with `/settings`):
131
+ For example, to disable the customized footer:
107
132
 
108
133
  ```json
109
134
  {
110
- "defaultProjectTrust": "always"
135
+ "footer": false
111
136
  }
112
137
  ```
113
-
114
- Project trust is an [input-loading guard](https://pi.dev/docs/latest/security#project-trust), so use `"always"` only if you trust the projects you open.
115
-
116
- ## Other pi packages
117
-
118
- See my [pi-packages](https://github.com/zlliang/pi-packages) repo for the full list.
Binary file
package/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { autoCollectEvents } from "./src/events";
2
+ import { registerCredits } from "./src/features/credits";
3
+ import { registerEditor } from "./src/features/editor";
4
+ import { registerFooter } from "./src/features/footer";
5
+ import { registerFullscreen } from "./src/features/fullscreen";
6
+ import { registerModel } from "./src/features/model";
7
+ import { registerName } from "./src/features/name";
8
+ import { registerPresets } from "./src/features/presets";
9
+ import { registerRecap } from "./src/features/recap";
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+
13
+ /**
14
+ * pi-spark is a single extension that bundles several features. Each feature registers itself
15
+ * like a standalone extension and reads its config via the shared, cached `loadConfig`. This file
16
+ * is just the registry that wires them together.
17
+ */
18
+ export default function (pi: ExtensionAPI) {
19
+ // Own the event-bus subscription lifecycle here so features never manage cleanup themselves;
20
+ // the collector disposes every subscription on session_shutdown.
21
+ const events = autoCollectEvents(pi);
22
+
23
+ registerCredits(pi);
24
+ registerEditor(pi, events);
25
+ registerFooter(pi);
26
+ registerFullscreen(pi);
27
+ registerModel(pi);
28
+ registerName(pi);
29
+ registerPresets(pi);
30
+ registerRecap(pi);
31
+ }
package/package.json CHANGED
@@ -1,30 +1,36 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.11.2",
4
- "description": "Pi extensions that polish the daily experience.",
3
+ "version": "0.13.0",
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",
7
7
  "pi-package"
8
8
  ],
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/zlliang/pi-packages.git",
12
- "directory": "packages/pi-spark"
11
+ "url": "git+https://github.com/zlliang/pi-spark.git"
13
12
  },
14
13
  "license": "MIT",
15
14
  "type": "module",
16
15
  "files": [
17
- "extensions",
16
+ "index.ts",
17
+ "src",
18
18
  "assets",
19
- "README.md"
19
+ "README.md",
20
+ "LICENSE"
20
21
  ],
21
22
  "pi": {
22
23
  "extensions": [
23
- "./extensions"
24
+ "./index.ts"
24
25
  ],
25
- "image": "https://raw.githubusercontent.com/zlliang/pi-packages/main/assets/cover.png"
26
+ "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit"
26
30
  },
27
31
  "dependencies": {
32
+ "@grpc/grpc-js": "^1.14.4",
33
+ "@grpc/proto-loader": "^0.8.1",
28
34
  "liqe": "^3.8.7",
29
35
  "ms": "4.0.0-nightly.202508271359",
30
36
  "zod": "^4.4.3"
@@ -35,5 +41,14 @@
35
41
  "@earendil-works/pi-coding-agent": "*",
36
42
  "@earendil-works/pi-tui": "*",
37
43
  "typebox": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-agent-core": "*",
47
+ "@earendil-works/pi-ai": "*",
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "@earendil-works/pi-tui": "*",
50
+ "@types/node": "*",
51
+ "typebox": "*",
52
+ "typescript": "^6.0.3"
38
53
  }
39
- }
54
+ }
@@ -1,6 +1,6 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
 
3
- import { sanitizeText } from "../format";
3
+ import { sanitizeText } from "../utils/format";
4
4
 
5
5
  import type { Component } from "@earendil-works/pi-tui";
6
6
 
@@ -2,26 +2,51 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
- import { configSchemas } from "./schema";
5
+ import { sparkConfigSchema } from "./schema";
6
6
 
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import type { ConfigField, ConfigValue } from "./schema";
8
+ import type { SparkConfig } from "./schema";
9
9
 
10
10
  type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
11
11
  type JsonObject = { [key: string]: JsonValue };
12
12
 
13
- export function loadConfig<Field extends ConfigField>(ctx: ExtensionContext, field: Field, fileName: string = "spark.json"): ConfigValue<Field> | undefined {
14
- const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName));
15
- const rawValue = rawConfig?.[field];
16
- if (rawValue === false) return undefined;
17
-
18
- const result = configSchemas[field].safeParse(rawValue === true || rawValue === undefined ? {} : rawValue);
19
- if (result.success) return result.data as ConfigValue<Field>;
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
+ model: false,
20
+ name: false,
21
+ presets: false,
22
+ recap: false,
23
+ });
24
+
25
+ const cache = new Map<string, SparkConfig>();
26
+
27
+ /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
28
+ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json"): SparkConfig {
29
+ const key = `${ctx.cwd}\u0000${fileName}`;
30
+ const cached = cache.get(key);
31
+ if (cached) return cached;
32
+
33
+ const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
34
+ const result = sparkConfigSchema.safeParse(rawConfig);
35
+
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");
39
+
40
+ const config = DISABLED_CONFIG;
41
+ cache.set(key, config);
42
+
43
+ return config;
44
+ }
20
45
 
21
- const message = result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; ");
22
- ctx.ui.notify(`Invalid spark config: ${message}`, "error");
46
+ const config = result.data;
47
+ cache.set(key, config);
23
48
 
24
- return undefined;
49
+ return config;
25
50
  }
26
51
 
27
52
  function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
@@ -0,0 +1,30 @@
1
+ import * as z from "zod";
2
+
3
+ import { creditsConfigSchema } from "../features/credits/config";
4
+ import { editorConfigSchema } from "../features/editor/config";
5
+ import { footerConfigSchema } from "../features/footer/config";
6
+ import { fullscreenConfigSchema } from "../features/fullscreen/config";
7
+ import { modelConfigSchema } from "../features/model/config";
8
+ import { nameConfigSchema } from "../features/name/config";
9
+ import { presetsConfigSchema } from "../features/presets/config";
10
+ import { recapConfigSchema } from "../features/recap/config";
11
+
12
+ const disabled = z.literal(false);
13
+
14
+ /**
15
+ * Each feature field is `{ ... } | false`: `false` disables the feature, an object configures
16
+ * it, and an omitted field falls back to defaults (`{}`, enabled).
17
+ */
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
+ model: modelConfigSchema.or(disabled).default({}),
24
+ name: nameConfigSchema.or(disabled).default({}),
25
+ presets: presetsConfigSchema.or(disabled).default({}),
26
+ recap: recapConfigSchema.or(disabled).default({}),
27
+ });
28
+
29
+ /** Resolved config for every feature; `false` means the feature is disabled. */
30
+ export type SparkConfig = z.infer<typeof sparkConfigSchema>;
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, EventBus } from "@earendil-works/pi-coding-agent";
2
2
 
3
- class EventCollector {
3
+ export class EventCollector {
4
4
  private pi: ExtensionAPI;
5
5
  private collected: Set<() => void> = new Set();
6
6
 
@@ -0,0 +1,3 @@
1
+ export { autoCollectEvents, EventCollector } from "./auto-collect-events";
2
+
3
+ export * from "./preset";
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const creditsConfigSchema = z.object({});
@@ -0,0 +1,49 @@
1
+ import { CreditsManager } from "./manager";
2
+ import { loadConfig } from "../../config";
3
+ import { isUsage } from "../../utils/usage";
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
+
8
+ function hasCost(message: AgentMessage): boolean {
9
+ const usage = (message as { usage?: unknown }).usage;
10
+ if (!isUsage(usage)) return false;
11
+
12
+ return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
13
+ }
14
+
15
+ export function registerCredits(pi: ExtensionAPI): void {
16
+ let creditsManager: CreditsManager | undefined = undefined;
17
+
18
+ pi.on("session_start", (_event, ctx) => {
19
+ const config = loadConfig(ctx).credits;
20
+ if (!ctx.hasUI || !config) return;
21
+
22
+ creditsManager = new CreditsManager();
23
+ creditsManager.refresh(ctx);
24
+ });
25
+
26
+ pi.on("model_select", (_event, ctx) => {
27
+ creditsManager?.refresh(ctx);
28
+ });
29
+
30
+ pi.on("turn_end", (event, ctx) => {
31
+ if (!hasCost(event.message)) return;
32
+
33
+ creditsManager?.refresh(ctx);
34
+ });
35
+
36
+ pi.on("session_compact", (_event, ctx) => {
37
+ creditsManager?.refresh(ctx);
38
+ });
39
+
40
+ pi.on("session_tree", (event, ctx) => {
41
+ if (!event.summaryEntry) return;
42
+
43
+ creditsManager?.refresh(ctx);
44
+ });
45
+
46
+ pi.on("session_shutdown", () => {
47
+ creditsManager = undefined;
48
+ });
49
+ }
@@ -0,0 +1,64 @@
1
+ import { findProvider } from "./providers";
2
+ import { renderCredits, renderError } from "./status";
3
+
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { CreditsProvider } from "./types";
6
+
7
+ const STATUS_KEY = "credits";
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ export class CreditsManager {
11
+ private inflight: AbortController | undefined = undefined;
12
+ private currentProvider: string | undefined = undefined;
13
+
14
+ async refresh(ctx: ExtensionContext): Promise<void> {
15
+ this.inflight?.abort();
16
+
17
+ const provider = findProvider(ctx.model?.provider);
18
+ if (!provider) {
19
+ this.inflight = undefined;
20
+ this.currentProvider = undefined;
21
+ ctx.ui.setStatus(STATUS_KEY, undefined);
22
+ return;
23
+ }
24
+
25
+ // Clear stale credits from another provider while the new fetch is in flight.
26
+ if (this.currentProvider !== provider.id) {
27
+ this.currentProvider = provider.id;
28
+ ctx.ui.setStatus(STATUS_KEY, undefined);
29
+ }
30
+
31
+ const controller = new AbortController();
32
+ this.inflight = controller;
33
+
34
+ await this.fetch(ctx, provider, controller.signal).finally(() => {
35
+ if (this.inflight === controller) this.inflight = undefined;
36
+ });
37
+ }
38
+
39
+ private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
40
+ try {
41
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
42
+ if (!apiKey) {
43
+ ctx.ui.setStatus(STATUS_KEY, undefined);
44
+ return;
45
+ }
46
+
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));
51
+
52
+ // The active model may have changed while the request was in flight.
53
+ if (ctx.model?.provider !== provider.id) return;
54
+
55
+ ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
56
+ } catch (error) {
57
+ if (signal.aborted || ctx.signal?.aborted) return;
58
+ if (ctx.model?.provider !== provider.id) return;
59
+
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, provider.label, message));
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ import { convertToUSD, toNumber } from "../../../utils/format";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "deepseek";
6
+ const URL = "https://api.deepseek.com/user/balance";
7
+
8
+ interface DeepSeekBalanceResponse {
9
+ balance_infos?: DeepSeekBalanceInfo[] | null;
10
+ }
11
+
12
+ interface DeepSeekBalanceInfo {
13
+ currency?: string;
14
+ total_balance?: string | number;
15
+ }
16
+
17
+ export const deepseekProvider: CreditsProvider = {
18
+ id: PROVIDER,
19
+ label: "DeepSeek",
20
+
21
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
22
+ const headers: Record<string, string> = {
23
+ Accept: "application/json",
24
+ Authorization: `Bearer ${apiKey}`,
25
+ };
26
+
27
+ const response = await fetch(URL, { headers, signal });
28
+ if (!response.ok) throw new Error("request failed");
29
+
30
+ const payload = (await response.json()) as DeepSeekBalanceResponse;
31
+ const balance = payload.balance_infos?.find((entry) => entry.currency === "USD") ?? payload.balance_infos?.[0];
32
+ const remaining = await convertToUSD(toNumber(balance?.total_balance), balance?.currency, signal);
33
+
34
+ return { type: "balance", remaining };
35
+ },
36
+ };
@@ -0,0 +1,42 @@
1
+ syntax = "proto3";
2
+
3
+ // Minimal slice of the Fireworks control-plane gateway API (`gateway.Gateway`), reconstructed from
4
+ // the `firectl` binary. Only the messages and RPCs needed to read an account's credit balance are
5
+ // declared here.
6
+ package gateway;
7
+
8
+ // Mirrors google.type.Money.
9
+ message Money {
10
+ string currency_code = 1;
11
+ int64 units = 2;
12
+ int32 nanos = 3;
13
+ }
14
+
15
+ message Balance {
16
+ Money money = 1;
17
+ }
18
+
19
+ message GetBalanceRequest {
20
+ string name = 1; // resource name, e.g., "accounts/<account_id>"
21
+ }
22
+
23
+ message Account {
24
+ string name = 1; // resource name, e.g., "accounts/<account_id>"
25
+ string display_name = 2;
26
+ }
27
+
28
+ message ListAccountsRequest {
29
+ int32 page_size = 1;
30
+ string page_token = 2;
31
+ }
32
+
33
+ message ListAccountsResponse {
34
+ repeated Account accounts = 1;
35
+ string next_page_token = 2;
36
+ int32 total_size = 3;
37
+ }
38
+
39
+ service Gateway {
40
+ rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
41
+ rpc GetBalance(GetBalanceRequest) returns (Balance);
42
+ }