pi-spark 0.14.7 → 0.16.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 (36) hide show
  1. package/README.md +26 -20
  2. package/assets/screenshot-credits.png +0 -0
  3. package/index.ts +2 -4
  4. package/package.json +3 -4
  5. package/src/config/index.ts +11 -45
  6. package/src/config/schema.ts +2 -4
  7. package/src/features/credits/providers/moonshot.ts +1 -1
  8. package/src/features/credits/status.ts +1 -1
  9. package/src/features/credits/types.ts +3 -3
  10. package/src/features/footer/index.ts +9 -2
  11. package/src/features/fullscreen/filler.ts +1 -1
  12. package/src/features/presets/manager.ts +1 -1
  13. package/src/features/presets/selector.ts +2 -2
  14. package/src/features/recap/manager.ts +6 -6
  15. package/src/features/title/config.ts +7 -0
  16. package/src/features/title/index.ts +26 -0
  17. package/src/features/title/manager.ts +117 -0
  18. package/src/utils/format.ts +2 -20
  19. package/src/{features/recap → utils}/model.ts +25 -10
  20. package/src/utils/usage.ts +1 -1
  21. package/assets/screenshot-tools.png +0 -0
  22. package/src/features/pi/actions/models.ts +0 -165
  23. package/src/features/pi/actions/name.ts +0 -58
  24. package/src/features/pi/actions/whoami.ts +0 -61
  25. package/src/features/pi/config.ts +0 -3
  26. package/src/features/pi/index.ts +0 -23
  27. package/src/features/pi/model.ts +0 -56
  28. package/src/features/pi/registry.ts +0 -28
  29. package/src/features/web/actions/fetch.ts +0 -47
  30. package/src/features/web/actions/search.ts +0 -50
  31. package/src/features/web/client.ts +0 -77
  32. package/src/features/web/config.ts +0 -3
  33. package/src/features/web/index.ts +0 -28
  34. package/src/features/web/registry.ts +0 -22
  35. package/src/features/web/render.ts +0 -34
  36. package/src/utils/tool.ts +0 -221
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
 
@@ -57,26 +57,18 @@ pi-spark generates a short recap of the current session after it goes idle, or o
57
57
 
58
58
  - A recap is generated automatically once the session stays idle past `recap.idle` in `spark.json`.
59
59
  - Run `/recap` to generate one manually at any time.
60
- - The recap can use its own model, configured separately from your working model.
60
+ - The recap generation can use its own model, configured separately from your working model.
61
61
 
62
62
  ![Recap](./assets/screenshot-recap.png)
63
63
 
64
- ### Agent tools: `pi` and `web`
64
+ ### Title
65
65
 
66
- pi-spark adds two agent tools:
66
+ pi-spark names the session automatically after the first exchange, so it's easy to find later in the session selector.
67
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)
68
+ - After the first message is sent and the agent replies, pi-spark generates a short title from the conversation and sets it as the session name.
69
+ - Generation is silent: nothing is shown in the UI, and the usage is recorded in a session entry like the recap.
70
+ - Sessions that already have a name are left untouched.
71
+ - The title generation can use its own model, configured separately from your working model.
80
72
 
81
73
  ## Configuration
82
74
 
@@ -107,6 +99,11 @@ For example:
107
99
  "provider": "openai-codex",
108
100
  "model": "gpt-5.4-mini",
109
101
  "thinkingLevel": "off"
102
+ },
103
+ "title": {
104
+ "provider": "openai-codex",
105
+ "model": "gpt-5.4-mini",
106
+ "thinkingLevel": "off"
110
107
  }
111
108
  }
112
109
  ```
@@ -121,10 +118,9 @@ All fields are optional. Each top-level feature runs with the defaults below unl
121
118
  | `editor` | `EditorConfig` | Shows a working indicator and the current model on the editor's top border. |
122
119
  | `footer` | `{}` | Shows session info, extension statuses, cost, and context usage on one line. |
123
120
  | `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
121
  | `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
126
122
  | `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. |
123
+ | `title` | `TitleConfig` | Names the session automatically after the first exchange. |
128
124
 
129
125
  #### `EditorConfig`
130
126
 
@@ -149,14 +145,24 @@ Each preset must set all three fields.
149
145
 
150
146
  #### `RecapConfig`
151
147
 
152
- All fields are optional, including `thinkingLevel`. If the recap model configuration is incomplete, pi-spark falls back to the session's main model.
148
+ All fields are optional. If the recap model configuration is incomplete, pi-spark falls back to the session's main model. `thinkingLevel` defaults to `off` (clamped to the model), so recap stays cheap regardless of your working thinking level.
153
149
 
154
150
  | Field | Value | Description |
155
151
  | --- | --- | --- |
156
152
  | `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. |
157
153
  | `provider` | string | Provider ID for the recap model. |
158
154
  | `model` | string | Model ID for the recap model. |
159
- | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the recap model. |
155
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the recap model. Defaults to `off`. |
156
+
157
+ #### `TitleConfig`
158
+
159
+ All fields are optional. If the title model configuration is incomplete, pi-spark falls back to the session's main model. `thinkingLevel` defaults to `off` (clamped to the model), so title generation stays cheap regardless of your working thinking level.
160
+
161
+ | Field | Value | Description |
162
+ | --- | --- | --- |
163
+ | `provider` | string | Provider ID for the title model. |
164
+ | `model` | string | Model ID for the title model. |
165
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the title model. Defaults to `off`. |
160
166
 
161
167
  #### `ModelThinkingLevel`
162
168
 
Binary file
package/index.ts CHANGED
@@ -3,10 +3,9 @@ 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";
8
+ import { registerTitle } from "./src/features/title";
10
9
 
11
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
11
 
@@ -24,8 +23,7 @@ export default function (pi: ExtensionAPI) {
24
23
  registerEditor(pi, events);
25
24
  registerFooter(pi);
26
25
  registerFullscreen(pi);
27
- registerPi(pi);
28
26
  registerPresets(pi);
29
27
  registerRecap(pi);
30
- registerWeb(pi);
28
+ registerTitle(pi);
31
29
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.14.7",
4
- "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
3
+ "version": "0.16.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,15 +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
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
11
  const cache = new Map<string, SparkConfig>();
14
12
 
15
13
  /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
@@ -18,8 +16,10 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
18
16
  const cached = cache.get(key);
19
17
  if (cached) return cached;
20
18
 
21
- const rawValue = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
22
- const raw = isPlainObject(rawValue) ? rawValue : {};
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) ?? {});
23
23
 
24
24
  // Validate each feature independently so a single invalid field disables only that feature
25
25
  // (falling back to its enabled defaults) instead of taking down the whole config.
@@ -46,12 +46,11 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
46
46
  }
47
47
 
48
48
  config[field] = {};
49
- const detail = result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; ");
50
- errors.push(detail);
49
+ errors.push(result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; "));
51
50
  }
52
51
 
53
52
  if (errors.length > 0) {
54
- ctx.ui.notify(`Invalid spark config, using defaults for: ${errors.join("; ")}`, "error");
53
+ ctx.ui.notify(`Invalid pi-spark config: ${errors.join("; ")}`, "error");
55
54
  }
56
55
 
57
56
  cache.set(key, config as SparkConfig);
@@ -59,46 +58,13 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
59
58
  }
60
59
 
61
60
  function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
62
- return [join(getAgentDir(), fileName), join(cwd, ".pi", fileName)];
63
- }
64
-
65
- function loadMergedJson(paths: string[]): JsonObject | undefined {
66
- let merged: JsonObject | undefined;
67
- paths.forEach((path) => {
68
- const value = readJsonFile(path);
69
- if (value === undefined) return;
70
-
71
- merged = mergeConfig(merged, value);
72
- });
73
-
74
- return merged;
61
+ return [join(getAgentDir(), fileName), join(cwd, CONFIG_DIR_NAME, fileName)];
75
62
  }
76
63
 
77
- function readJsonFile(path: string): JsonObject | undefined {
64
+ function readRawConfig(path: string): Record<string, unknown> | undefined {
78
65
  try {
79
- return JSON.parse(readFileSync(path, "utf8")) as JsonObject;
66
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
80
67
  } catch {
81
68
  return undefined;
82
69
  }
83
70
  }
84
-
85
- function mergeConfig(base: JsonObject | undefined, override: JsonObject): JsonObject {
86
- if (base === undefined) return override;
87
- if (!isPlainObject(base) || !isPlainObject(override)) return override;
88
-
89
- const result: Record<string, JsonValue> = { ...base };
90
- Object.entries(override).forEach(([key, overrideValue]) => {
91
- const baseValue = base[key];
92
- if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
93
- result[key] = { ...baseValue, ...overrideValue };
94
- } else {
95
- result[key] = overrideValue;
96
- }
97
- });
98
-
99
- return result;
100
- }
101
-
102
- function isPlainObject(value: unknown): value is Record<string, JsonValue> {
103
- return !!value && typeof value === "object" && !Array.isArray(value);
104
- }
@@ -4,10 +4,9 @@ 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";
9
+ import { titleConfigSchema } from "../features/title/config";
11
10
 
12
11
  /**
13
12
  * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
@@ -19,10 +18,9 @@ export const featureSchemas = {
19
18
  editor: editorConfigSchema,
20
19
  footer: footerConfigSchema,
21
20
  fullscreen: fullscreenConfigSchema,
22
- pi: piConfigSchema,
23
21
  presets: presetsConfigSchema,
24
22
  recap: recapConfigSchema,
25
- web: webConfigSchema,
23
+ title: titleConfigSchema,
26
24
  } as const;
27
25
 
28
26
  /** Resolved config for every feature; `false` means the feature is disabled. */
@@ -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 {
@@ -1,4 +1,4 @@
1
- import type { Provider } from "@earendil-works/pi-ai";
1
+ import type { ProviderId } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  /**
@@ -16,9 +16,9 @@ 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
- readonly id: Provider;
21
+ readonly id: ProviderId;
22
22
  readonly label: string;
23
23
  fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
24
24
  }
@@ -4,9 +4,12 @@ import { pathToFileURL } from "node:url";
4
4
 
5
5
  import { SplitLine } from "../../components/split-line";
6
6
  import { loadConfig } from "../../config";
7
- import { formatContextUsage, formatCost, formatCwd, linkText, sanitizeText } from "../../utils/format";
7
+ import { formatContextUsage, formatCost, formatCwd, sanitizeText } from "../../utils/format";
8
+ import { isFreeModel } from "../../utils/model";
8
9
  import { getEntryUsage } from "../../utils/usage";
9
10
 
11
+ import { hyperlink } from "@earendil-works/pi-tui";
12
+
10
13
  import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
11
14
  import type { Component } from "@earendil-works/pi-tui";
12
15
 
@@ -35,7 +38,7 @@ class FooterComponent implements Component {
35
38
  private getLeft(): string {
36
39
  const cwd = this.ctx.sessionManager.getCwd();
37
40
  const url = pathToFileURL(resolve(cwd));
38
- const cwdText = linkText(formatCwd(cwd, homedir()), url.href);
41
+ const cwdText = hyperlink(formatCwd(cwd, homedir()), url.href);
39
42
  const branch = this.footerData.getGitBranch();
40
43
  const sessionName = this.ctx.sessionManager.getSessionName();
41
44
 
@@ -63,6 +66,10 @@ class FooterComponent implements Component {
63
66
 
64
67
  private getStyledCostText(): string {
65
68
  const totalCost = this.ctx.sessionManager.getBranch().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
69
+
70
+ // Hide cost when it's zero and the current model is free.
71
+ if (totalCost === 0 && this.ctx.model && isFreeModel(this.ctx.model)) return "";
72
+
66
73
  const costText = formatCost(totalCost);
67
74
 
68
75
  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
  });
@@ -20,7 +20,7 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
20
20
  }));
21
21
 
22
22
  const container = new Container();
23
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
23
+ container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
24
24
 
25
25
  const box = new Box(1, 1);
26
26
  box.addChild(new Text(theme.bold(theme.fg("accent", "Select preset")), 0, 0));
@@ -46,7 +46,7 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
46
46
  box.addChild(new Text(keyHints.join(" "), 0, 0));
47
47
 
48
48
  container.addChild(box);
49
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
49
+ container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
50
50
 
51
51
  return {
52
52
  render: (width: number) => container.render(width),
@@ -1,9 +1,9 @@
1
- import { completeSimple } from "@earendil-works/pi-ai";
1
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
2
2
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
3
3
 
4
- import { resolveRecapModelSettings } from "./model";
5
4
  import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
6
5
  import { sanitizeText } from "../../utils/format";
6
+ import { resolveModelSettings } from "../../utils/model";
7
7
 
8
8
  import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -16,7 +16,7 @@ const SYSTEM_PROMPT = [
16
16
  "Summarize the user's goal, what was done, current state, and any clearly supported next step.",
17
17
  "Respond in the conversation's primary language. Address the user in the second person.",
18
18
  "Output 1-2 plain-text sentences under 40 words. No heading, markdown, bullets, or quotes.",
19
- ].join("\n");
19
+ ].join(" ");
20
20
 
21
21
  const MAX_TOKENS = 80;
22
22
  const MAX_CONVERSATION_CHARS = 8_000;
@@ -40,10 +40,10 @@ export class RecapManager {
40
40
  this.inflight = controller;
41
41
 
42
42
  try {
43
- const recapModelSettings = await resolveRecapModelSettings(this.pi, ctx, this.config);
44
- if (controller.signal.aborted || this.inflight !== controller || !recapModelSettings) return;
43
+ const modelSettings = await resolveModelSettings(ctx, this.config, "recap");
44
+ if (controller.signal.aborted || this.inflight !== controller || !modelSettings) return;
45
45
 
46
- const { model, thinkingLevel, warning } = recapModelSettings;
46
+ const { model, thinkingLevel, warning } = modelSettings;
47
47
 
48
48
  setRecapLoadingWidget(ctx, warning);
49
49
  this.active = false;
@@ -0,0 +1,7 @@
1
+ import * as z from "zod";
2
+
3
+ import { optionalModelSchema } from "../../config/model";
4
+
5
+ export const titleConfigSchema = optionalModelSchema;
6
+
7
+ export type TitleConfig = z.infer<typeof titleConfigSchema>;
@@ -0,0 +1,26 @@
1
+ import { TitleManager } from "./manager";
2
+ import { loadConfig } from "../../config";
3
+
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ export function registerTitle(pi: ExtensionAPI): void {
7
+ let titleManager: TitleManager | undefined = undefined;
8
+
9
+ pi.on("session_start", (_event, ctx) => {
10
+ const config = loadConfig(ctx).title;
11
+ if (!ctx.hasUI || !config) return;
12
+
13
+ titleManager = new TitleManager(pi, config);
14
+ });
15
+
16
+ // Generate the title after the first exchange, once the session has context to summarize.
17
+ // The manager runs at most once and skips sessions that already have a name.
18
+ pi.on("agent_end", (_event, ctx) => {
19
+ titleManager?.run(ctx);
20
+ });
21
+
22
+ pi.on("session_shutdown", () => {
23
+ titleManager?.dispose();
24
+ titleManager = undefined;
25
+ });
26
+ }
@@ -0,0 +1,117 @@
1
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
2
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { sanitizeText } from "../../utils/format";
5
+ import { resolveModelSettings } from "../../utils/model";
6
+
7
+ import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type { TitleConfig } from "./config";
10
+
11
+ const SYSTEM_PROMPT = [
12
+ "You write a concise title for a terminal coding agent session.",
13
+ "Base the title only on the transcript; do not invent topics, files, or intent.",
14
+ "Capture the user's main goal or the session's central task.",
15
+ "Respond in the conversation's primary language.",
16
+ "Output a single title of 3-8 words. No trailing punctuation, quotes, markdown, or prefix like 'Title:'.",
17
+ ].join(" ");
18
+
19
+ const MAX_TOKENS = 32;
20
+ const MAX_TITLE_CHARS = 80;
21
+ const MAX_CONVERSATION_CHARS = 8_000;
22
+
23
+ export class TitleManager {
24
+ private pi: ExtensionAPI;
25
+ private config: TitleConfig;
26
+ private inflight: AbortController | undefined;
27
+
28
+ constructor(pi: ExtensionAPI, config: TitleConfig) {
29
+ this.pi = pi;
30
+ this.config = config;
31
+ }
32
+
33
+ /** Generate and set the session title once, silently, from the current context. */
34
+ async run(ctx: ExtensionContext): Promise<void> {
35
+ if (this.pi.getSessionName()) return;
36
+
37
+ this.cancelInflight();
38
+ const controller = new AbortController();
39
+ this.inflight = controller;
40
+
41
+ try {
42
+ const modelSettings = await resolveModelSettings(ctx, this.config, "title", { notifyOnMissingModel: false });
43
+ if (controller.signal.aborted || this.inflight !== controller || !modelSettings) return;
44
+
45
+ const { model, thinkingLevel } = modelSettings;
46
+ const result = await this.generate(ctx, model, thinkingLevel, controller.signal);
47
+ if (controller.signal.aborted || this.inflight !== controller || !result.content) return;
48
+
49
+ this.pi.setSessionName(result.content);
50
+ this.pi.appendEntry("title", {
51
+ provider: model.provider,
52
+ model: model.id,
53
+ usage: result.usage,
54
+ content: result.content,
55
+ });
56
+ } catch {
57
+ // Title generation is best-effort and silent; ignore failures.
58
+ } finally {
59
+ if (this.inflight === controller) this.inflight = undefined;
60
+ }
61
+ }
62
+
63
+ dispose(): void {
64
+ this.cancelInflight();
65
+ }
66
+
67
+ private async generate(ctx: ExtensionContext, model: Model<Api>, thinkingLevel: ModelThinkingLevel, signal: AbortSignal): Promise<{ content: string; usage: Usage }> {
68
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
69
+ if (!auth.ok) throw new Error(auth.error);
70
+
71
+ const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
72
+ if (auth.apiKey) options.apiKey = auth.apiKey;
73
+ if (auth.headers) options.headers = auth.headers;
74
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
75
+
76
+ const response = await completeSimple(model, {
77
+ systemPrompt: SYSTEM_PROMPT,
78
+ messages: [{
79
+ role: "user",
80
+ content: [{ type: "text", text: this.buildPrompt(ctx) }],
81
+ timestamp: Date.now(),
82
+ }],
83
+ }, options);
84
+
85
+ const content = response.content
86
+ .filter((block) => block.type === "text")
87
+ .map((block) => block.text)
88
+ .join("\n")
89
+ .trim();
90
+
91
+ return { content: this.normalizeTitle(content), usage: response.usage };
92
+ }
93
+
94
+ private buildPrompt(ctx: ExtensionContext): string {
95
+ const messages = ctx.sessionManager.getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message);
96
+ const text = serializeConversation(convertToLlm(messages));
97
+ const conversation = text.length > MAX_CONVERSATION_CHARS ? text.slice(0, MAX_CONVERSATION_CHARS) : text;
98
+
99
+ return [
100
+ "Write a short title for this session, based on the transcript below.",
101
+ "",
102
+ "<conversation>",
103
+ conversation,
104
+ "</conversation>",
105
+ ].join("\n");
106
+ }
107
+
108
+ private normalizeTitle(content: string): string {
109
+ const title = sanitizeText(content).replace(/^["'`]+|["'`]+$/g, "").replace(/[.。!!??]+$/g, "").trim();
110
+ return title.length > MAX_TITLE_CHARS ? title.slice(0, MAX_TITLE_CHARS).trim() : title;
111
+ }
112
+
113
+ private cancelInflight(): void {
114
+ this.inflight?.abort();
115
+ this.inflight = undefined;
116
+ }
117
+ }
@@ -1,9 +1,9 @@
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 { ProviderId } from "@earendil-works/pi-ai";
4
4
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
5
5
 
6
- export function formatModel(provider?: Provider, model?: string, thinkingLevel?: string): string {
6
+ export function formatModel(provider?: ProviderId, model?: string, thinkingLevel?: string): string {
7
7
  return provider && model ? `${provider}/${model}${thinkingLevel ? `:${thinkingLevel}` : ""}` : "no-model";
8
8
  }
9
9
 
@@ -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);
@@ -54,11 +49,6 @@ function shortenPath(path: string): string {
54
49
  }).join(sep);
55
50
  }
56
51
 
57
- /** Wrap text in an OSC 8 hyperlink while preserving the visible text. */
58
- export function linkText(text: string, url: string): string {
59
- return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
60
- }
61
-
62
52
  /** Replace newlines, tabs, carriage returns with space, then collapse multiple spaces */
63
53
  export function sanitizeText(text: string): string {
64
54
  return text
@@ -67,14 +57,6 @@ export function sanitizeText(text: string): string {
67
57
  .trim();
68
58
  }
69
59
 
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
60
  /** Coerce a possibly-stringified numeric value to a finite number, or `undefined`. */
79
61
  export function toNumber(value?: string | number | null): number | undefined {
80
62
  if (value === undefined || value === null) return undefined;