pi-spark 0.15.0 → 0.17.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 +29 -4
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/config/schema.ts +2 -0
- package/src/features/credits/providers/index.ts +2 -0
- package/src/features/credits/providers/kimi-code.ts +69 -0
- package/src/features/credits/providers/openai-codex.ts +2 -4
- package/src/features/credits/types.ts +2 -2
- package/src/features/footer/index.ts +4 -2
- package/src/features/presets/selector.ts +2 -2
- package/src/features/recap/manager.ts +6 -6
- package/src/features/title/config.ts +7 -0
- package/src/features/title/index.ts +26 -0
- package/src/features/title/manager.ts +117 -0
- package/src/utils/format.ts +2 -7
- package/src/utils/model.ts +105 -1
- package/src/features/recap/model.ts +0 -95
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ pi-spark ships with custom editor, footer, and fullscreen rendering, replacing t
|
|
|
36
36
|
|
|
37
37
|
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.
|
|
38
38
|
|
|
39
|
-
- Supported providers: DeepSeek, Fireworks, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
|
|
39
|
+
- Supported providers: DeepSeek, Fireworks, Kimi Code, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
|
|
40
40
|
- 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)).
|
|
41
41
|
|
|
42
42
|

|
|
@@ -57,10 +57,19 @@ 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
|

|
|
63
63
|
|
|
64
|
+
### Title
|
|
65
|
+
|
|
66
|
+
pi-spark names the session automatically after the first exchange, so it's easy to find later in the session selector.
|
|
67
|
+
|
|
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.
|
|
72
|
+
|
|
64
73
|
## Configuration
|
|
65
74
|
|
|
66
75
|
pi-spark reads config from `~/.pi/agent/spark.json` and from the current project's `.pi/spark.json`. Project config overrides matching global fields.
|
|
@@ -90,6 +99,11 @@ For example:
|
|
|
90
99
|
"provider": "openai-codex",
|
|
91
100
|
"model": "gpt-5.4-mini",
|
|
92
101
|
"thinkingLevel": "off"
|
|
102
|
+
},
|
|
103
|
+
"title": {
|
|
104
|
+
"provider": "openai-codex",
|
|
105
|
+
"model": "gpt-5.4-mini",
|
|
106
|
+
"thinkingLevel": "off"
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
```
|
|
@@ -106,6 +120,7 @@ All fields are optional. Each top-level feature runs with the defaults below unl
|
|
|
106
120
|
| `fullscreen` | `{}` | Clears the screen and scrollback on start and exit, and pins the editor and footer to the bottom. |
|
|
107
121
|
| `presets` | `{ [name]: Preset }` | Defines named model presets, keyed by name. |
|
|
108
122
|
| `recap` | `RecapConfig` | Generates a session recap when idle or on demand. |
|
|
123
|
+
| `title` | `TitleConfig` | Names the session automatically after the first exchange. |
|
|
109
124
|
|
|
110
125
|
#### `EditorConfig`
|
|
111
126
|
|
|
@@ -130,14 +145,24 @@ Each preset must set all three fields.
|
|
|
130
145
|
|
|
131
146
|
#### `RecapConfig`
|
|
132
147
|
|
|
133
|
-
All fields are optional
|
|
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.
|
|
134
149
|
|
|
135
150
|
| Field | Value | Description |
|
|
136
151
|
| --- | --- | --- |
|
|
137
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. |
|
|
138
153
|
| `provider` | string | Provider ID for the recap model. |
|
|
139
154
|
| `model` | string | Model ID for the recap model. |
|
|
140
|
-
| `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`. |
|
|
141
166
|
|
|
142
167
|
#### `ModelThinkingLevel`
|
|
143
168
|
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { registerFooter } from "./src/features/footer";
|
|
|
5
5
|
import { registerFullscreen } from "./src/features/fullscreen";
|
|
6
6
|
import { registerPresets } from "./src/features/presets";
|
|
7
7
|
import { registerRecap } from "./src/features/recap";
|
|
8
|
+
import { registerTitle } from "./src/features/title";
|
|
8
9
|
|
|
9
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
|
|
@@ -24,4 +25,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
24
25
|
registerFullscreen(pi);
|
|
25
26
|
registerPresets(pi);
|
|
26
27
|
registerRecap(pi);
|
|
28
|
+
registerTitle(pi);
|
|
27
29
|
}
|
package/package.json
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { footerConfigSchema } from "../features/footer/config";
|
|
|
6
6
|
import { fullscreenConfigSchema } from "../features/fullscreen/config";
|
|
7
7
|
import { presetsConfigSchema } from "../features/presets/config";
|
|
8
8
|
import { recapConfigSchema } from "../features/recap/config";
|
|
9
|
+
import { titleConfigSchema } from "../features/title/config";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
|
|
@@ -19,6 +20,7 @@ export const featureSchemas = {
|
|
|
19
20
|
fullscreen: fullscreenConfigSchema,
|
|
20
21
|
presets: presetsConfigSchema,
|
|
21
22
|
recap: recapConfigSchema,
|
|
23
|
+
title: titleConfigSchema,
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
/** Resolved config for every feature; `false` means the feature is disabled. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deepseekProvider } from "./deepseek";
|
|
2
2
|
import { fireworksProvider } from "./fireworks";
|
|
3
|
+
import { kimiCodeProvider } from "./kimi-code";
|
|
3
4
|
import { moonshotProvider, moonshotCnProvider } from "./moonshot";
|
|
4
5
|
import { openaiCodexProvider } from "./openai-codex";
|
|
5
6
|
import { openrouterProvider } from "./openrouter";
|
|
@@ -10,6 +11,7 @@ import type { CreditsProvider } from "../types";
|
|
|
10
11
|
const PROVIDERS: CreditsProvider[] = [
|
|
11
12
|
deepseekProvider,
|
|
12
13
|
fireworksProvider,
|
|
14
|
+
kimiCodeProvider,
|
|
13
15
|
moonshotProvider,
|
|
14
16
|
moonshotCnProvider,
|
|
15
17
|
openaiCodexProvider,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { toNumber } from "../../../utils/format";
|
|
2
|
+
|
|
3
|
+
import type { Credits, CreditsLane, CreditsProvider } from "../types";
|
|
4
|
+
|
|
5
|
+
const PROVIDER = "kimi-coding";
|
|
6
|
+
const URL = "https://api.kimi.com/coding/v1/usages";
|
|
7
|
+
|
|
8
|
+
interface KimiCodeUsageResponse {
|
|
9
|
+
usage?: { used?: string | number } | null;
|
|
10
|
+
limits?: {
|
|
11
|
+
window?: { duration?: number; timeUnit?: string } | null;
|
|
12
|
+
detail?: { used?: string | number } | null;
|
|
13
|
+
}[] | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toPercent(used: number | undefined): number | undefined {
|
|
17
|
+
if (used === undefined) return undefined;
|
|
18
|
+
return Math.min(100, Math.max(0, used));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatWindowLabel(window?: { duration?: number; timeUnit?: string } | null): string {
|
|
22
|
+
if (!window?.duration) return "Limit";
|
|
23
|
+
|
|
24
|
+
const duration = window.duration;
|
|
25
|
+
const unit = window.timeUnit?.toUpperCase() ?? "";
|
|
26
|
+
|
|
27
|
+
if (unit === "TIME_UNIT_MINUTE") {
|
|
28
|
+
if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h`;
|
|
29
|
+
return `${duration}m`;
|
|
30
|
+
}
|
|
31
|
+
if (unit === "TIME_UNIT_HOUR") return `${duration}h`;
|
|
32
|
+
if (unit === "TIME_UNIT_DAY") return `${duration}d`;
|
|
33
|
+
if (unit === "TIME_UNIT_MONTH") return `${duration}mo`;
|
|
34
|
+
return `${duration}${unit.replace("TIME_UNIT_", "").toLowerCase()}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildLane(label: string, detail?: KimiCodeUsageResponse["usage"]): CreditsLane {
|
|
38
|
+
return { label, percent: toPercent(toNumber(detail?.used)) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const kimiCodeProvider: CreditsProvider = {
|
|
42
|
+
id: PROVIDER,
|
|
43
|
+
label: "Kimi Code",
|
|
44
|
+
|
|
45
|
+
async fetch(_ctx, apiKey, signal): Promise<Credits> {
|
|
46
|
+
const headers: Record<string, string> = {
|
|
47
|
+
Accept: "application/json",
|
|
48
|
+
Authorization: `Bearer ${apiKey}`,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const response = await fetch(URL, { headers, signal });
|
|
52
|
+
if (!response.ok) throw new Error("request failed");
|
|
53
|
+
|
|
54
|
+
const payload = (await response.json()) as KimiCodeUsageResponse;
|
|
55
|
+
const lanes: CreditsLane[] = [];
|
|
56
|
+
|
|
57
|
+
payload.limits?.forEach((item) => {
|
|
58
|
+
if (!item) return;
|
|
59
|
+
|
|
60
|
+
const label = formatWindowLabel(item.window);
|
|
61
|
+
lanes.push(buildLane(label, item.detail));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const weeklyLane = buildLane("7d", payload.usage);
|
|
65
|
+
lanes.push(weeklyLane);
|
|
66
|
+
|
|
67
|
+
return { type: "windows", lanes };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -11,16 +11,14 @@ interface CodexUsageResponse {
|
|
|
11
11
|
primary_window?: CodexRateWindow | null;
|
|
12
12
|
secondary_window?: CodexRateWindow | null;
|
|
13
13
|
} | null;
|
|
14
|
-
credits?: {
|
|
15
|
-
unlimited?: boolean;
|
|
16
|
-
} | null;
|
|
14
|
+
credits?: { unlimited?: boolean } | null;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
interface CodexRateWindow {
|
|
20
18
|
used_percent?: number | string;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
function getAccountId(ctx: ExtensionContext): string | undefined {
|
|
24
22
|
const credential = ctx.modelRegistry.authStorage.get(PROVIDER) as { accountId?: string } | undefined;
|
|
25
23
|
const accountId = credential?.accountId;
|
|
26
24
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ProviderId } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -18,7 +18,7 @@ export interface CreditsLane {
|
|
|
18
18
|
|
|
19
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:
|
|
21
|
+
readonly id: ProviderId;
|
|
22
22
|
readonly label: string;
|
|
23
23
|
fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
|
|
24
24
|
}
|
|
@@ -4,10 +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,
|
|
7
|
+
import { formatContextUsage, formatCost, formatCwd, sanitizeText } from "../../utils/format";
|
|
8
8
|
import { isFreeModel } from "../../utils/model";
|
|
9
9
|
import { getEntryUsage } from "../../utils/usage";
|
|
10
10
|
|
|
11
|
+
import { hyperlink } from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
11
13
|
import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import type { Component } from "@earendil-works/pi-tui";
|
|
13
15
|
|
|
@@ -36,7 +38,7 @@ class FooterComponent implements Component {
|
|
|
36
38
|
private getLeft(): string {
|
|
37
39
|
const cwd = this.ctx.sessionManager.getCwd();
|
|
38
40
|
const url = pathToFileURL(resolve(cwd));
|
|
39
|
-
const cwdText =
|
|
41
|
+
const cwdText = hyperlink(formatCwd(cwd, homedir()), url.href);
|
|
40
42
|
const branch = this.footerData.getGitBranch();
|
|
41
43
|
const sessionName = this.ctx.sessionManager.getSessionName();
|
|
42
44
|
|
|
@@ -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("
|
|
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("
|
|
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("
|
|
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
|
|
44
|
-
if (controller.signal.aborted || this.inflight !== controller || !
|
|
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 } =
|
|
46
|
+
const { model, thinkingLevel, warning } = modelSettings;
|
|
47
47
|
|
|
48
48
|
setRecapLoadingWidget(ctx, warning);
|
|
49
49
|
this.active = false;
|
|
@@ -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
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
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?:
|
|
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
|
|
|
@@ -49,11 +49,6 @@ function shortenPath(path: string): string {
|
|
|
49
49
|
}).join(sep);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** Wrap text in an OSC 8 hyperlink while preserving the visible text. */
|
|
53
|
-
export function linkText(text: string, url: string): string {
|
|
54
|
-
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
52
|
/** Replace newlines, tabs, carriage returns with space, then collapse multiple spaces */
|
|
58
53
|
export function sanitizeText(text: string): string {
|
|
59
54
|
return text
|
package/src/utils/model.ts
CHANGED
|
@@ -1,6 +1,110 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
import { formatModel } from "./format";
|
|
4
|
+
|
|
5
|
+
import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { OptionalModelConfig } from "../config/model";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
|
|
10
|
+
|
|
11
|
+
export type ModelSettings = {
|
|
12
|
+
model: Model<Api>;
|
|
13
|
+
thinkingLevel: ModelThinkingLevel;
|
|
14
|
+
warning: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ModelSelection = {
|
|
18
|
+
model: Model<Api>;
|
|
19
|
+
warning: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ThinkingLevelSelection = {
|
|
23
|
+
thinkingLevel: ModelThinkingLevel;
|
|
24
|
+
warning: string | undefined;
|
|
25
|
+
};
|
|
2
26
|
|
|
3
27
|
/** Check if a model has zero costs for all categories. */
|
|
4
28
|
export function isFreeModel(model: Model<Api>): boolean {
|
|
5
29
|
return model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0 && model.cost.cacheWrite === 0;
|
|
6
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the model and thinking level for a background feature (recap, title, ...).
|
|
34
|
+
*
|
|
35
|
+
* `feature` names the config section so warnings can point at the offending fields. When the
|
|
36
|
+
* feature's model config is incomplete or unavailable, this falls back to the session's main
|
|
37
|
+
* model and reports why via `warning`. Pass `notifyOnMissingModel: false` for silent features.
|
|
38
|
+
*
|
|
39
|
+
* The thinking level defaults to "off" (clamped to the model) when not set in config, so these
|
|
40
|
+
* background features stay cheap regardless of the working model's thinking level.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveModelSettings(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, options: { notifyOnMissingModel?: boolean } = {}): Promise<ModelSettings | undefined> {
|
|
43
|
+
const fallbackModel = ctx.model;
|
|
44
|
+
if (!fallbackModel) {
|
|
45
|
+
if (options.notifyOnMissingModel ?? true) ctx.ui.notify(`No model selected for ${feature}`, "warning");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { model, warning: modelWarning } = await resolveModelSelection(ctx, config, feature, fallbackModel);
|
|
50
|
+
const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? DEFAULT_THINKING_LEVEL);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
model,
|
|
54
|
+
thinkingLevel,
|
|
55
|
+
warning: [modelWarning, thinkingLevelWarning].filter(Boolean).join(" ") || undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, feature: string, fallbackModel: Model<Api>): Promise<ModelSelection> {
|
|
60
|
+
if (!config.provider && !config.model) {
|
|
61
|
+
return {
|
|
62
|
+
model: fallbackModel,
|
|
63
|
+
warning: undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!config.provider || !config.model) {
|
|
68
|
+
return {
|
|
69
|
+
model: fallbackModel,
|
|
70
|
+
warning: `Both ${feature}.provider and ${feature}.model are required; using the current model.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const model = ctx.modelRegistry.find(config.provider, config.model);
|
|
75
|
+
if (!model) {
|
|
76
|
+
return {
|
|
77
|
+
model: fallbackModel,
|
|
78
|
+
warning: `Model ${formatModel(config.provider, config.model)} not found; using the current model.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
83
|
+
if (!auth.ok) {
|
|
84
|
+
return {
|
|
85
|
+
model: fallbackModel,
|
|
86
|
+
warning: `Model ${formatModel(config.provider, config.model)} unavailable: ${auth.error}; using the current model.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
model,
|
|
92
|
+
warning: undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): ThinkingLevelSelection {
|
|
97
|
+
const thinkingLevel = clampThinkingLevel(model, requested);
|
|
98
|
+
if (thinkingLevel === requested) {
|
|
99
|
+
return {
|
|
100
|
+
thinkingLevel,
|
|
101
|
+
warning: undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fallback = clampThinkingLevel(model, DEFAULT_THINKING_LEVEL);
|
|
106
|
+
return {
|
|
107
|
+
thinkingLevel: fallback,
|
|
108
|
+
warning: `Thinking level ${requested} is not supported by ${formatModel(model.provider, model.id)}; using ${fallback}.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { clampThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
-
|
|
3
|
-
import { formatModel } from "../../utils/format";
|
|
4
|
-
|
|
5
|
-
import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { OptionalModelConfig } from "../../config/model";
|
|
8
|
-
|
|
9
|
-
const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
|
|
10
|
-
|
|
11
|
-
type RecapModelSettings = {
|
|
12
|
-
model: Model<Api>;
|
|
13
|
-
thinkingLevel: ModelThinkingLevel;
|
|
14
|
-
warning: string | undefined;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type ModelSelection = {
|
|
18
|
-
model: Model<Api>;
|
|
19
|
-
warning: string | undefined;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
type ThinkingLevelSelection = {
|
|
23
|
-
thinkingLevel: ModelThinkingLevel;
|
|
24
|
-
warning: string | undefined;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export async function resolveRecapModelSettings(pi: ExtensionAPI, ctx: ExtensionContext, config: OptionalModelConfig): Promise<RecapModelSettings | undefined> {
|
|
28
|
-
const fallbackModel = ctx.model;
|
|
29
|
-
if (!fallbackModel) {
|
|
30
|
-
ctx.ui.notify("No model selected for recap", "warning");
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const { model, warning: modelWarning } = await resolveModelSelection(ctx, config, fallbackModel);
|
|
35
|
-
const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? pi.getThinkingLevel());
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
model,
|
|
39
|
-
thinkingLevel,
|
|
40
|
-
warning: [modelWarning, thinkingLevelWarning].filter(Boolean).join(" ") || undefined,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, fallbackModel: Model<Api>): Promise<ModelSelection> {
|
|
45
|
-
if (!config.provider && !config.model) {
|
|
46
|
-
return {
|
|
47
|
-
model: fallbackModel,
|
|
48
|
-
warning: undefined,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!config.provider || !config.model) {
|
|
53
|
-
return {
|
|
54
|
-
model: fallbackModel,
|
|
55
|
-
warning: "Both recap.provider and recap.model are required; using the current model.",
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const model = ctx.modelRegistry.find(config.provider, config.model);
|
|
60
|
-
if (!model) {
|
|
61
|
-
return {
|
|
62
|
-
model: fallbackModel,
|
|
63
|
-
warning: `Model ${formatModel(config.provider, config.model)} not found; using the current model.`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
68
|
-
if (!auth.ok) {
|
|
69
|
-
return {
|
|
70
|
-
model: fallbackModel,
|
|
71
|
-
warning: `Model ${formatModel(config.provider, config.model)} unavailable: ${auth.error}; using the current model.`,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
model,
|
|
77
|
-
warning: undefined,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): ThinkingLevelSelection {
|
|
82
|
-
const thinkingLevel = clampThinkingLevel(model, requested);
|
|
83
|
-
if (thinkingLevel === requested) {
|
|
84
|
-
return {
|
|
85
|
-
thinkingLevel,
|
|
86
|
-
warning: undefined,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const fallback = clampThinkingLevel(model, DEFAULT_THINKING_LEVEL);
|
|
91
|
-
return {
|
|
92
|
-
thinkingLevel: fallback,
|
|
93
|
-
warning: `Thinking level ${requested} is not supported by ${formatModel(model.provider, model.id)}; using ${fallback}.`,
|
|
94
|
-
};
|
|
95
|
-
}
|