pi-provider-kimi-code 0.3.2 → 0.4.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
@@ -1,42 +1,33 @@
1
1
  # Pi extension for Kimi Code
2
+
2
3
  [![npm](https://img.shields.io/npm/v/pi-provider-kimi-code)](https://www.npmjs.com/package/pi-provider-kimi-code)
3
4
  [![license](https://img.shields.io/npm/l/pi-provider-kimi-code)](./LICENSE)
4
5
 
5
- **Use Kimi Code in Pi without losing the Kimi-specific bits.**
6
+ **Use Kimi Code in Pi, with the Kimi parts handled.**
6
7
 
7
- Pi gives you the harness and extension ecosystem. Kimi CLI gives you the official Kimi Code path. This package sits between them: Kimi Code as a Pi provider, plus the details the basic provider keeps simple — file uploads, cache behavior, session reuse, and a base for Kimi-native tools.
8
+ Pi already has a basic `kimi-coding` provider. This extension is for the parts that start to matter once Kimi Code becomes part of your real Pi workflow: account login reuse, file uploads, measured cache behavior, live model metadata, and optional Moonshot search/fetch tools.
8
9
 
9
10
  ## Why this exists
10
11
 
11
- Pi already has a built-in `kimi-coding` provider (see [`pi.dev/docs/.../providers`](https://pi.dev/docs/latest/providers)). It is good basic support: point Pi at Kimi Code and start coding.
12
-
13
- This extension is for the next layer. If you are building real Pi workflows, the Kimi-specific details start to matter:
14
-
15
- - **Files should go through Kimi's Files API.** Large images are uploaded to `/files` and referenced as `ms://` IDs instead of being sent as huge inline base64 payloads.
16
- - **Cache behavior should be visible.** Kimi caches by content prefix, not by `prompt_cache_key`. This repo measures that behavior and keeps the provider aligned with it.
17
- - **Existing Kimi sessions should carry over.** If you already use `kimi-cli`, this extension can reuse that login instead of making you manage another API key.
18
- - **Protocol choice should stay available.** The default is OpenAI-compatible mode, but Anthropic-compatible mode is still there when your Pi setup needs it.
19
-
20
- > On prompt caching: the built-in provider works fine. Kimi's Coding endpoint caches by content prefix hash automatically — neither `cache_control` markers nor `prompt_cache_key` actually drive cache hits. See [docs/caching.md](docs/caching.md) for measurements.
21
-
22
- ## Who is this for?
12
+ Pi is a small harness you adapt to your own workflow. Kimi CLI is Moonshot's official Kimi Code path. This package sits between them: Kimi Code as a Pi provider, plus the Kimi-specific details that the basic provider keeps simple.
23
13
 
24
- - You use Pi as your coding harness and want Kimi Code inside that workflow.
25
- - You already use `kimi-cli`, but want Pi's extensions, skills, prompts, themes, and custom UI pieces around the same Kimi Code account.
26
- - You are building on top of Pi and need the Kimi-specific parts to behave predictably: uploads, cache behavior, model metadata, and protocol choice.
27
-
28
- Pay-per-token via `KIMI_API_KEY` still works if you just want to try Kimi in CI or without a subscription.
14
+ - **Use your Kimi account.** Log in with `/login kimi-coding`, or reuse an existing `kimi-cli` session.
15
+ - **Send files the Kimi way.** Large inline images go through Kimi's Files API and become `ms://` references instead of huge base64 payloads.
16
+ - **Know what the cache is doing.** Kimi caches by content prefix. This repo measures that behavior instead of pretending `prompt_cache_key` controls it.
17
+ - **Use the safer protocol by default.** OpenAI-compatible mode is the default because tool results are less ambiguous there. Anthropic-compatible mode is still available.
18
+ - **Turn on Kimi-native tools when you want them.** `moonshot_search` and `moonshot_fetch` are opt-in, configurable per user or per project.
29
19
 
30
20
  ## What this package adds
31
21
 
32
- - **Kimi account login in Pi** — sign in with your Kimi Code account. `KIMI_API_KEY` still works for CI or pay-per-token use.
33
- - **`kimi-cli` session reuse** — if you already signed in with the official CLI, this extension can read `~/.kimi/credentials/kimi-code.json` and skip another browser login.
34
- - **Kimi Files API upload** — large images are uploaded to `/v1/files` and sent as `ms://` references instead of huge inline base64 payloads.
35
- - **Measured cache behavior** — Kimi's Coding endpoint caches by content prefix hash with TTL ≥ 5 minutes. This repo documents what changes the cache and what does not.
36
- - **Live model metadata** — name, context window, reasoning support, and image-input support refresh from Kimi's `/v1/models` endpoint at login / refresh time.
37
- - **Protocol choice** — OpenAI-compatible mode is the default. Anthropic-compatible mode is still available with `KIMI_CODE_PROTOCOL=anthropic`.
38
- - **Stream cleanup** — Kimi occasionally emits placeholder text during thinking-only responses; this extension hides those blocks before they reach the Pi UI.
39
- - **No build step** — Pi loads the TypeScript extension directly.
22
+ - Kimi account login in Pi, plus `KIMI_API_KEY` for CI or pay-per-token use.
23
+ - `kimi-cli` credential reuse from `~/.kimi/credentials/kimi-code.json`.
24
+ - Kimi Files API uploads for large inline images.
25
+ - Live model metadata from Kimi at login / refresh time.
26
+ - OpenAI-compatible mode by default, Anthropic-compatible mode on request.
27
+ - Kimi K2.6 reasoning level mapping for Pi's reasoning controls.
28
+ - Stream cleanup for Kimi's thinking-only placeholder text.
29
+ - Optional `moonshot_search` and `moonshot_fetch` tools via `/kimi-settings`.
30
+ - No build step; Pi loads the TypeScript extension directly.
40
31
 
41
32
  ## Install
42
33
 
@@ -57,32 +48,29 @@ pi -e /path/to/pi-provider-kimi-code
57
48
  If you prefer not to use npm, download the tarball from the [latest release](https://github.com/Leechael/pi-provider-kimi-code/releases/latest), extract it, and install from the local path:
58
49
 
59
50
  ```bash
60
- # Download and extract
61
51
  curl -L https://github.com/Leechael/pi-provider-kimi-code/releases/latest/download/pi-provider-kimi-code.tar.gz | tar -xz -C /tmp
62
-
63
- # Install from the extracted directory
64
52
  pi install /tmp/pi-provider-kimi-code
65
53
  ```
66
54
 
67
55
  ## Sign in
68
56
 
69
- ### Browser login
70
-
71
57
  Inside Pi, run:
72
58
 
73
- ```
59
+ ```text
74
60
  /login kimi-coding
75
61
  ```
76
62
 
77
- A browser tab opens, you sign into your Kimi account, and credentials are stored at `~/.pi/agent/auth.json`. Tokens refresh automatically.
63
+ A browser tab opens, you sign into your Kimi account, and Pi stores the credential at `~/.pi/agent/auth.json`. Tokens refresh automatically.
78
64
 
79
- ### Already logged in via `kimi-cli`?
65
+ If you already use `kimi-cli`, this extension can reuse its credential file:
80
66
 
81
- If `~/.kimi/credentials/kimi-code.json` exists, `/login kimi-coding` reads that file, refreshes the access token if needed, and finishes without opening a browser. The `kimi-cli` credential file is read-only from this extension's perspective — it is never overwritten. Set `KIMI_SHARE_DIR` to point at a non-default location.
67
+ ```text
68
+ ~/.kimi/credentials/kimi-code.json
69
+ ```
82
70
 
83
- ### API key
71
+ The file is read-only from this extension's side. Set `KIMI_SHARE_DIR` if your `kimi-cli` share directory lives somewhere else.
84
72
 
85
- For CI or pay-per-token use, set `KIMI_API_KEY`:
73
+ For CI or pay-per-token use, set `KIMI_API_KEY` instead:
86
74
 
87
75
  ```bash
88
76
  KIMI_API_KEY=sk-... pi
@@ -92,13 +80,19 @@ KIMI_API_KEY=sk-... pi
92
80
 
93
81
  This provider publishes one Pi model ID:
94
82
 
95
- ```
83
+ ```text
96
84
  kimi-coding/kimi-for-coding
97
85
  ```
98
86
 
99
- Kimi keeps the Coding model behind aliases, so this provider does not hardcode a long model list. When you log in or refresh your token, it asks Kimi for the current model info and updates what Pi sees. If your account is on a newer rollout or an internal test, Pi can pick up the latest model name and context size without waiting for this package to release.
87
+ Select it inside Pi:
88
+
89
+ ```text
90
+ /model kimi-coding/kimi-for-coding
91
+ ```
100
92
 
101
- The fallback values are:
93
+ Kimi keeps Coding models behind aliases. Rather than hardcoding a stale model list, this extension asks Kimi for the current model info when you log in or refresh. If your account is on a newer rollout or internal test, Pi can pick up the latest model name and context size without waiting for a package release.
94
+
95
+ Fallback values:
102
96
 
103
97
  - Context window: `262144` tokens
104
98
  - Max output: `32000` tokens
@@ -114,12 +108,45 @@ Kimi K2.6 supports Pi's reasoning levels. This provider maps them to Kimi's curr
114
108
 
115
109
  If Kimi reports a larger context window, the provider uses that value. You can also override the advertised context or model name with `KIMI_MODEL_MAX_CONTEXT_SIZE` / `KIMI_MODEL_NAME`; see [docs/ENV.md](docs/ENV.md).
116
110
 
117
- Select it inside Pi:
111
+ ## Optional Moonshot tools
112
+
113
+ Kimi Coding has server-side search and fetch tools. This extension can expose them to Pi as:
114
+
115
+ - `moonshot_search`
116
+ - `moonshot_fetch`
118
117
 
118
+ They are off by default. Turn them on when you want Kimi's own search/fetch path in the agent loop.
119
+
120
+ Inside Pi, run:
121
+
122
+ ```text
123
+ /kimi-settings
119
124
  ```
120
- /model kimi-coding/kimi-for-coding
125
+
126
+ That command shows your current Kimi usage summary and lets you edit the home or project config. Changes apply to the active session tool set.
127
+
128
+ Config files are JSON:
129
+
130
+ - Home: `~/.pi/pi-provider-kimi-code.json`
131
+ - Project override: `<cwd>/.pi/pi-provider-kimi-code.json`
132
+
133
+ Project config overrides home config with a deep merge. Missing files or missing keys mean both tools stay off.
134
+
135
+ ```json
136
+ {
137
+ "tools": {
138
+ "moonshot_search": { "enabled": true, "default_collapsed": true },
139
+ "moonshot_fetch": { "enabled": true, "default_collapsed": true }
140
+ }
141
+ }
121
142
  ```
122
143
 
144
+ Both tools require `/login kimi-coding` OAuth credentials. `KIMI_API_KEY` is not used for these tools. Your account also needs access to Moonshot's server-side search/fetch services; if not, the upstream service can return subscription or whitelist errors.
145
+
146
+ `default_collapsed` controls only the TUI preview. The full tool result still goes to the model; set it to `false` if you want previews expanded by default.
147
+
148
+ If you already use another search or fetch tool, pick one path for a session. Overlapping tools can make the model choose the wrong one.
149
+
123
150
  ## Common knobs
124
151
 
125
152
  Most users do not need environment variables. Two are worth knowing:
@@ -127,7 +154,9 @@ Most users do not need environment variables. Two are worth knowing:
127
154
  - `KIMI_API_KEY` — static API key for CI or pay-per-token use.
128
155
  - `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` if your Pi setup needs Anthropic-compatible requests.
129
156
 
130
- The full list, including base URL overrides, `kimi-cli` path overrides, upload tuning, debug logs, and model metadata overrides, lives in [docs/ENV.md](docs/ENV.md).
157
+ Moonshot search/fetch tools are configured through `/kimi-settings` or JSON config files, not env vars.
158
+
159
+ The full env list, including base URL overrides, `kimi-cli` path overrides, upload tuning, debug logs, and model metadata overrides, lives in [docs/ENV.md](docs/ENV.md).
131
160
 
132
161
  ## Notes
133
162
 
@@ -170,12 +199,22 @@ Pi runs on Node's `fetch` / undici stack, which handles `http_proxy` / `https_pr
170
199
 
171
200
  ### `/login kimi-coding` prints a device code but the browser never opens
172
201
 
173
- The login flow always prints a verification URL — opening it is the terminal's job. If your terminal or OS blocks auto-open, copy the URL and paste it into a browser manually.
202
+ The login flow always prints a verification URL. If your terminal or OS blocks auto-open, copy the URL and paste it into a browser manually.
174
203
 
175
204
  ### "Access denied" or subscription errors after a successful login
176
205
 
177
206
  Your Moonshot account needs an active Kimi Code subscription for the provider to do anything useful. If the same account works in `kimi-cli`, re-run `/login kimi-coding` to refresh credentials.
178
207
 
208
+ ### Moonshot tools do not show up
209
+
210
+ Run `/kimi-settings` and check whether `moonshot_search` / `moonshot_fetch` are enabled in the home or project config. These tools also require `/login kimi-coding`; `KIMI_API_KEY` is not enough.
211
+
212
+ If you changed the JSON config by hand, reload it through `/kimi-settings` or start a new Pi session.
213
+
214
+ ### Moonshot tools return subscription or whitelist errors
215
+
216
+ The tools call Moonshot's server-side search/fetch services. Some accounts may not have access even with an active Kimi Code Plan. The error comes from the upstream service.
217
+
179
218
  ### Large images fail with a payload error
180
219
 
181
220
  This extension uploads images over `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` (default 1 MB) to Kimi's Files API and references them as `ms://`. Set `KIMI_CODE_DEBUG=1` to see upload decisions in the provider logs.
package/index.ts CHANGED
@@ -19,8 +19,24 @@
19
19
  */
20
20
 
21
21
  import type { Api, Model } from "@earendil-works/pi-ai";
22
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ AuthStorage,
24
+ type ExtensionAPI,
25
+ type ExtensionCommandContext,
26
+ } from "@earendil-works/pi-coding-agent";
27
+ import os from "node:os";
28
+ import { relative } from "node:path";
23
29
 
30
+ import {
31
+ type KimiCodeConfig,
32
+ getProjectKimiCodeConfigPath,
33
+ getGlobalKimiCodeConfigPath,
34
+ loadHomeKimiCodeConfig,
35
+ loadKimiCodeConfig,
36
+ loadProjectKimiCodeConfig,
37
+ saveHomeKimiCodeConfig,
38
+ saveProjectKimiCodeConfig,
39
+ } from "./src/config.ts";
24
40
  import {
25
41
  DEFAULT_KIMI_MODEL_INPUT,
26
42
  KIMI_API_TYPE,
@@ -35,8 +51,332 @@ import {
35
51
  } from "./src/models.ts";
36
52
  import { loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
37
53
  import { streamSimpleKimi } from "./src/stream.ts";
54
+ import { buildMoonshotFetchTool, buildMoonshotSearchTool } from "./src/tools/moonshot.ts";
55
+
56
+ const MOONSHOT_TOOL_NAMES = ["moonshot_search", "moonshot_fetch"] as const;
57
+ type MoonshotToolName = (typeof MOONSHOT_TOOL_NAMES)[number];
58
+ type KimiConfigScope = "project" | "home";
59
+ const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
60
+ LEVEL_FREE: "Free",
61
+ LEVEL_BASIC: "Adagio",
62
+ LEVEL_STANDARD: "Moderato",
63
+ LEVEL_INTERMEDIATE: "Allegretto",
64
+ LEVEL_ADVANCED: "Allegro",
65
+ LEVEL_PREMIUM: "Vivace",
66
+ };
67
+
68
+ interface UsageRow {
69
+ label: string;
70
+ used: number;
71
+ limit: number;
72
+ }
73
+
74
+ function registerConfiguredMoonshotTools(
75
+ pi: ExtensionAPI,
76
+ config: KimiCodeConfig,
77
+ options: { updateActiveTools: boolean },
78
+ ): void {
79
+ if (config.tools.moonshot_search.enabled) {
80
+ pi.registerTool(
81
+ buildMoonshotSearchTool({
82
+ defaultCollapsed: config.tools.moonshot_search.default_collapsed,
83
+ }),
84
+ );
85
+ }
86
+ if (config.tools.moonshot_fetch.enabled) {
87
+ pi.registerTool(
88
+ buildMoonshotFetchTool({
89
+ defaultCollapsed: config.tools.moonshot_fetch.default_collapsed,
90
+ }),
91
+ );
92
+ }
93
+
94
+ if (!options.updateActiveTools) return;
95
+
96
+ const activeTools = new Set(pi.getActiveTools());
97
+ for (const toolName of MOONSHOT_TOOL_NAMES) {
98
+ if (config.tools[toolName].enabled) {
99
+ activeTools.add(toolName);
100
+ } else {
101
+ activeTools.delete(toolName);
102
+ }
103
+ }
104
+ pi.setActiveTools([...activeTools]);
105
+ }
106
+
107
+ function getKimiUsageToken(): string | null {
108
+ const credential = AuthStorage.create().get(PROVIDER_ID);
109
+ if (credential?.type === "oauth" && credential.access) return credential.access;
110
+ const apiKey = process.env.KIMI_API_KEY?.trim();
111
+ return apiKey || null;
112
+ }
113
+
114
+ function toNumber(value: unknown): number | null {
115
+ const number = Number(value);
116
+ return Number.isFinite(number) ? number : null;
117
+ }
118
+
119
+ function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
120
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
121
+ const record = value as Record<string, unknown>;
122
+ const limit = toNumber(record.limit);
123
+ const usedValue = toNumber(record.used);
124
+ const remaining = toNumber(record.remaining);
125
+ const used = usedValue ?? (limit !== null && remaining !== null ? limit - remaining : null);
126
+ if (limit === null && used === null) return null;
127
+ return {
128
+ label: String(record.name || record.title || fallbackLabel),
129
+ used: used ?? 0,
130
+ limit: limit ?? 0,
131
+ };
132
+ }
133
+
134
+ function parseUsageSummary(payload: unknown): string {
135
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
136
+ return "Usage: unavailable";
137
+ }
138
+
139
+ const record = payload as Record<string, unknown>;
140
+ const lines: string[] = [];
141
+ const membership = parseMembership(record);
142
+ if (membership) lines.push(membership);
143
+
144
+ const rows: UsageRow[] = [];
145
+ const summary = parseUsageRow(record.usage, "Weekly limit");
146
+ if (summary) rows.push(summary);
147
+
148
+ if (Array.isArray(record.limits)) {
149
+ for (const [index, item] of record.limits.entries()) {
150
+ const detail =
151
+ typeof item === "object" && item !== null && !Array.isArray(item)
152
+ ? ((item as Record<string, unknown>).detail ?? item)
153
+ : item;
154
+ const row = parseUsageRow(detail, index === 0 ? "5h rate limit" : `Limit #${index + 1}`);
155
+ if (row) rows.push(row);
156
+ }
157
+ }
158
+
159
+ lines.push(...rows.map(formatUsageRow));
160
+ return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
161
+ }
162
+
163
+ function parseMembership(record: Record<string, unknown>): string | null {
164
+ const user = record.user;
165
+ if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
166
+ const membership = (user as Record<string, unknown>).membership;
167
+ if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
168
+ return null;
169
+ }
170
+ const level = (membership as Record<string, unknown>).level;
171
+ if (typeof level !== "string" || !level) return null;
172
+ const name = MEMBERSHIP_LEVEL_NAMES[level];
173
+ return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
174
+ }
175
+
176
+ function formatUsageRow(row: UsageRow): string {
177
+ if (row.limit <= 0) return `${row.label}: ${row.used} used`;
178
+ const remaining = Math.max(0, Math.min(row.limit - row.used, row.limit));
179
+ const percent = Math.round((remaining / row.limit) * 100);
180
+ return `${row.label}: ${quotaBar(remaining, row.limit)} ${percent}% left (${remaining}/${row.limit})`;
181
+ }
182
+
183
+ function quotaBar(remaining: number, limit: number): string {
184
+ const width = 20;
185
+ const filled = Math.max(0, Math.min(width, Math.round((remaining / limit) * width)));
186
+ return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
187
+ }
188
+
189
+ async function fetchKimiUsageSummary(): Promise<string> {
190
+ const token = getKimiUsageToken();
191
+ if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
192
+
193
+ const controller = new AbortController();
194
+ const timeout = setTimeout(() => controller.abort(), 15_000);
195
+ try {
196
+ const response = await fetch("https://api.kimi.com/coding/v1/usages", {
197
+ method: "GET",
198
+ headers: {
199
+ ...getCommonHeaders(),
200
+ Authorization: `Bearer ${token}`,
201
+ },
202
+ signal: controller.signal,
203
+ });
204
+ if (!response.ok) return `Usage: fetch failed (${response.status})`;
205
+ return parseUsageSummary(await response.json());
206
+ } catch (error) {
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ return `Usage: fetch failed (${message})`;
209
+ } finally {
210
+ clearTimeout(timeout);
211
+ }
212
+ }
213
+
214
+ function moonshotStatus(config: KimiCodeConfig): string {
215
+ return MOONSHOT_TOOL_NAMES.map((toolName) => {
216
+ const tool = config.tools[toolName];
217
+ const enabled = tool.enabled ? "enabled" : "disabled";
218
+ const collapsed = tool.default_collapsed ? "collapsed" : "expanded";
219
+ return `${toolName}: ${enabled}, default ${collapsed}`;
220
+ }).join("\n");
221
+ }
222
+
223
+ function toggleEnabled(config: KimiCodeConfig, toolName: MoonshotToolName): KimiCodeConfig {
224
+ return {
225
+ tools: {
226
+ ...config.tools,
227
+ [toolName]: {
228
+ ...config.tools[toolName],
229
+ enabled: !config.tools[toolName].enabled,
230
+ },
231
+ },
232
+ };
233
+ }
234
+
235
+ function toggleCollapsed(config: KimiCodeConfig, toolName: MoonshotToolName): KimiCodeConfig {
236
+ return {
237
+ tools: {
238
+ ...config.tools,
239
+ [toolName]: {
240
+ ...config.tools[toolName],
241
+ default_collapsed: !config.tools[toolName].default_collapsed,
242
+ },
243
+ },
244
+ };
245
+ }
246
+
247
+ async function runKimiCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
248
+ let config = loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
249
+ let usage = await fetchKimiUsageSummary();
250
+ ctx.ui.notify(usage);
251
+
252
+ while (true) {
253
+ const choice = await ctx.ui.select(buildKimiMainTitle(config, ctx.cwd), [
254
+ `Edit project config (${relative(ctx.cwd, getProjectKimiCodeConfigPath(ctx.cwd))})`,
255
+ `Edit home config (${homeRelative(getGlobalKimiCodeConfigPath(os.homedir()))})`,
256
+ "Refresh usage",
257
+ "Done",
258
+ ]);
259
+
260
+ if (!choice || choice === "Done") return;
261
+ if (choice === "Refresh usage") {
262
+ usage = await fetchKimiUsageSummary();
263
+ ctx.ui.notify(usage);
264
+ continue;
265
+ }
266
+ if (choice.startsWith("Edit project config")) {
267
+ config = await editConfigScope(pi, ctx, "project");
268
+ } else if (choice.startsWith("Edit home config")) {
269
+ config = await editConfigScope(pi, ctx, "home");
270
+ }
271
+ }
272
+ }
273
+
274
+ async function editConfigScope(
275
+ pi: ExtensionAPI,
276
+ ctx: ExtensionCommandContext,
277
+ scope: KimiConfigScope,
278
+ ): Promise<KimiCodeConfig> {
279
+ let current = loadScopeKimiCodeConfig(scope, ctx.cwd);
280
+ while (true) {
281
+ const choice = await ctx.ui.select(buildConfigScopeTitle(scope, current, ctx.cwd), [
282
+ toolMenuItem(current, "moonshot_search"),
283
+ toolMenuItem(current, "moonshot_fetch"),
284
+ "Back",
285
+ ]);
286
+ if (!choice || choice === "Back") {
287
+ return loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
288
+ }
289
+ if (choice.startsWith("moonshot_search")) {
290
+ current = await editMoonshotTool(pi, ctx, scope, current, "moonshot_search");
291
+ } else if (choice.startsWith("moonshot_fetch")) {
292
+ current = await editMoonshotTool(pi, ctx, scope, current, "moonshot_fetch");
293
+ }
294
+ }
295
+ }
296
+
297
+ async function editMoonshotTool(
298
+ pi: ExtensionAPI,
299
+ ctx: ExtensionCommandContext,
300
+ scope: KimiConfigScope,
301
+ config: KimiCodeConfig,
302
+ toolName: MoonshotToolName,
303
+ ): Promise<KimiCodeConfig> {
304
+ let current = config;
305
+ while (true) {
306
+ const tool = current.tools[toolName];
307
+ const choice = await ctx.ui.select(
308
+ `Edit ${toolName}\n\n${formatToolStatus(current, toolName)}`,
309
+ [
310
+ tool.enabled ? `Disable ${toolName}` : `Enable ${toolName}`,
311
+ tool.default_collapsed ? "Expand previews by default" : "Collapse previews by default",
312
+ "Back",
313
+ ],
314
+ );
315
+ if (!choice || choice === "Back") return current;
316
+ if (choice.startsWith("Enable") || choice.startsWith("Disable")) {
317
+ current = toggleEnabled(current, toolName);
318
+ } else if (choice.endsWith("previews by default")) {
319
+ current = toggleCollapsed(current, toolName);
320
+ }
321
+ saveScopeKimiCodeConfig(scope, ctx.cwd, current);
322
+ const effective = loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
323
+ registerConfiguredMoonshotTools(pi, effective, { updateActiveTools: true });
324
+ ctx.ui.notify(`Saved ${toolName} config`, "info");
325
+ }
326
+ }
327
+
328
+ function loadScopeKimiCodeConfig(scope: KimiConfigScope, cwd: string): KimiCodeConfig {
329
+ if (scope === "project") return loadProjectKimiCodeConfig(cwd);
330
+ return loadHomeKimiCodeConfig(os.homedir());
331
+ }
332
+
333
+ function saveScopeKimiCodeConfig(
334
+ scope: KimiConfigScope,
335
+ cwd: string,
336
+ config: KimiCodeConfig,
337
+ ): void {
338
+ if (scope === "project") {
339
+ saveProjectKimiCodeConfig(cwd, config);
340
+ } else {
341
+ saveHomeKimiCodeConfig(os.homedir(), config);
342
+ }
343
+ }
344
+
345
+ function buildKimiMainTitle(_config: KimiCodeConfig, _cwd: string): string {
346
+ return "Kimi settings";
347
+ }
348
+
349
+ function buildConfigScopeTitle(
350
+ scope: KimiConfigScope,
351
+ config: KimiCodeConfig,
352
+ cwd: string,
353
+ ): string {
354
+ const path =
355
+ scope === "project"
356
+ ? relative(cwd, getProjectKimiCodeConfigPath(cwd))
357
+ : homeRelative(getGlobalKimiCodeConfigPath(os.homedir()));
358
+ return [`Edit ${scope} config`, `File: ${path}`, "", moonshotStatus(config)].join("\n");
359
+ }
360
+
361
+ function homeRelative(filePath: string): string {
362
+ const home = os.homedir();
363
+ return filePath.startsWith(`${home}/`) ? `~/${filePath.slice(home.length + 1)}` : filePath;
364
+ }
365
+
366
+ function toolMenuItem(config: KimiCodeConfig, toolName: MoonshotToolName): string {
367
+ return `${toolName} -> ${formatToolStatus(config, toolName)}`;
368
+ }
369
+
370
+ function formatToolStatus(config: KimiCodeConfig, toolName: MoonshotToolName): string {
371
+ const tool = config.tools[toolName];
372
+ const enabled = tool.enabled ? "enabled" : "disabled";
373
+ const collapsed = tool.default_collapsed ? "default collapsed" : "default expanded";
374
+ return `${enabled}, ${collapsed}`;
375
+ }
38
376
 
39
377
  export default function (pi: ExtensionAPI) {
378
+ const config = loadKimiCodeConfig({ cwd: process.cwd(), home: os.homedir() });
379
+
40
380
  pi.registerProvider(PROVIDER_ID, {
41
381
  baseUrl: getBaseUrl(),
42
382
  apiKey: "KIMI_API_KEY",
@@ -76,4 +416,13 @@ export default function (pi: ExtensionAPI) {
76
416
  },
77
417
  },
78
418
  });
419
+
420
+ registerConfiguredMoonshotTools(pi, config, { updateActiveTools: false });
421
+
422
+ pi.registerCommand("kimi-settings", {
423
+ description: "Show Kimi usage and configure optional Kimi tools",
424
+ handler: async (_args, ctx) => {
425
+ await runKimiCommand(pi, ctx);
426
+ },
427
+ });
79
428
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Use Kimi Code in Pi without losing the Kimi-specific bits",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export interface KimiCodeConfig {
5
+ tools: {
6
+ moonshot_search: { enabled: boolean; default_collapsed: boolean };
7
+ moonshot_fetch: { enabled: boolean; default_collapsed: boolean };
8
+ };
9
+ }
10
+
11
+ export interface LoadKimiCodeConfigOptions {
12
+ cwd: string;
13
+ home: string;
14
+ }
15
+
16
+ export const DEFAULT_KIMI_CODE_CONFIG: KimiCodeConfig = {
17
+ tools: {
18
+ moonshot_search: { enabled: false, default_collapsed: true },
19
+ moonshot_fetch: { enabled: false, default_collapsed: true },
20
+ },
21
+ };
22
+
23
+ export function getGlobalKimiCodeConfigPath(home: string): string {
24
+ return join(home, ".pi", "pi-provider-kimi-code.json");
25
+ }
26
+
27
+ export function getProjectKimiCodeConfigPath(cwd: string): string {
28
+ return join(cwd, ".pi", "pi-provider-kimi-code.json");
29
+ }
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function readConfigFile(path: string): Record<string, unknown> {
36
+ try {
37
+ if (!existsSync(path)) return {};
38
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
39
+ return isRecord(parsed) ? parsed : {};
40
+ } catch (error) {
41
+ console.error(`[kimi-coding] failed to read config file ${path}:`, error);
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function readConfigFileQuiet(path: string): Record<string, unknown> {
47
+ try {
48
+ if (!existsSync(path)) return {};
49
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
50
+ return isRecord(parsed) ? parsed : {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ function mergeConfig(
57
+ base: Record<string, unknown>,
58
+ override: Record<string, unknown>,
59
+ ): Record<string, unknown> {
60
+ const result: Record<string, unknown> = { ...base };
61
+ for (const [key, value] of Object.entries(override)) {
62
+ const current = result[key];
63
+ if (isRecord(current) && isRecord(value)) {
64
+ result[key] = mergeConfig(current, value);
65
+ } else {
66
+ result[key] = value;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+
72
+ function readEnabled(config: Record<string, unknown>, toolName: string): boolean {
73
+ const tools = config.tools;
74
+ if (!isRecord(tools)) return false;
75
+ const tool = tools[toolName];
76
+ if (!isRecord(tool)) return false;
77
+ return tool.enabled === true;
78
+ }
79
+
80
+ function readDefaultCollapsed(config: Record<string, unknown>, toolName: string): boolean {
81
+ const tools = config.tools;
82
+ if (!isRecord(tools)) return true;
83
+ const tool = tools[toolName];
84
+ if (!isRecord(tool)) return true;
85
+ return tool.default_collapsed !== false;
86
+ }
87
+
88
+ export function loadKimiCodeConfig(options: LoadKimiCodeConfigOptions): KimiCodeConfig {
89
+ const globalConfig = readConfigFile(getGlobalKimiCodeConfigPath(options.home));
90
+ const projectConfig = readConfigFile(getProjectKimiCodeConfigPath(options.cwd));
91
+ const merged = mergeConfig(globalConfig, projectConfig);
92
+
93
+ return {
94
+ tools: {
95
+ moonshot_search: {
96
+ enabled: readEnabled(merged, "moonshot_search"),
97
+ default_collapsed: readDefaultCollapsed(merged, "moonshot_search"),
98
+ },
99
+ moonshot_fetch: {
100
+ enabled: readEnabled(merged, "moonshot_fetch"),
101
+ default_collapsed: readDefaultCollapsed(merged, "moonshot_fetch"),
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ export function loadProjectKimiCodeConfig(cwd: string): KimiCodeConfig {
108
+ return loadKimiCodeConfigFile(getProjectKimiCodeConfigPath(cwd));
109
+ }
110
+
111
+ export function loadHomeKimiCodeConfig(home: string): KimiCodeConfig {
112
+ return loadKimiCodeConfigFile(getGlobalKimiCodeConfigPath(home));
113
+ }
114
+
115
+ function loadKimiCodeConfigFile(path: string): KimiCodeConfig {
116
+ const projectConfig = readConfigFileQuiet(path);
117
+ return {
118
+ tools: {
119
+ moonshot_search: {
120
+ enabled: readEnabled(projectConfig, "moonshot_search"),
121
+ default_collapsed: readDefaultCollapsed(projectConfig, "moonshot_search"),
122
+ },
123
+ moonshot_fetch: {
124
+ enabled: readEnabled(projectConfig, "moonshot_fetch"),
125
+ default_collapsed: readDefaultCollapsed(projectConfig, "moonshot_fetch"),
126
+ },
127
+ },
128
+ };
129
+ }
130
+
131
+ export function saveProjectKimiCodeConfig(cwd: string, config: KimiCodeConfig): void {
132
+ saveKimiCodeConfigFile(getProjectKimiCodeConfigPath(cwd), config);
133
+ }
134
+
135
+ export function saveHomeKimiCodeConfig(home: string, config: KimiCodeConfig): void {
136
+ saveKimiCodeConfigFile(getGlobalKimiCodeConfigPath(home), config);
137
+ }
138
+
139
+ function saveKimiCodeConfigFile(path: string, config: KimiCodeConfig): void {
140
+ const raw = readConfigFileQuiet(path);
141
+ const next = {
142
+ ...raw,
143
+ tools: {
144
+ ...(isRecord(raw.tools) ? raw.tools : {}),
145
+ moonshot_search: { ...config.tools.moonshot_search },
146
+ moonshot_fetch: { ...config.tools.moonshot_fetch },
147
+ },
148
+ };
149
+ mkdirSync(dirname(path), { recursive: true });
150
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
151
+ }
@@ -0,0 +1,316 @@
1
+ import { AuthStorage, defineTool, type AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+
4
+ import { PROVIDER_ID } from "../constants.ts";
5
+ import { getCommonHeaders } from "../device.ts";
6
+
7
+ const MOONSHOT_BASE_V1 = "https://api.kimi.com/coding/v1";
8
+ const MOONSHOT_TIMEOUT_MS = 180_000;
9
+
10
+ export const moonshotSearchSchema = Type.Object({
11
+ query: Type.String({ description: "The query text to search for." }),
12
+ limit: Type.Optional(
13
+ Type.Number({
14
+ description: "The number of results to return.",
15
+ default: 5,
16
+ minimum: 1,
17
+ maximum: 20,
18
+ }),
19
+ ),
20
+ include_content: Type.Optional(
21
+ Type.Boolean({
22
+ description: "Whether to include fetched page content in the search results.",
23
+ default: false,
24
+ }),
25
+ ),
26
+ });
27
+
28
+ export const moonshotFetchSchema = Type.Object({
29
+ url: Type.String({ description: "The URL to fetch content from." }),
30
+ });
31
+
32
+ export type MoonshotSearchParams = Static<typeof moonshotSearchSchema>;
33
+ export type MoonshotFetchParams = Static<typeof moonshotFetchSchema>;
34
+
35
+ export interface MoonshotSearchResult {
36
+ url: string;
37
+ title: string;
38
+ snippet: string;
39
+ content?: string;
40
+ }
41
+
42
+ export interface MoonshotFetchResult {
43
+ url: string;
44
+ content: string;
45
+ }
46
+
47
+ interface MoonshotToolDeps {
48
+ fetch: typeof fetch;
49
+ getAccessToken: () => string | null;
50
+ }
51
+
52
+ export interface BuildMoonshotToolOptions {
53
+ deps?: Partial<MoonshotToolDeps>;
54
+ defaultCollapsed?: boolean;
55
+ }
56
+
57
+ interface MoonshotSearchResponse {
58
+ search_results?: unknown;
59
+ }
60
+
61
+ function defaultGetAccessToken(): string | null {
62
+ const credential = AuthStorage.create().get(PROVIDER_ID);
63
+ if (!credential || credential.type !== "oauth") return null;
64
+ return credential.access;
65
+ }
66
+
67
+ function buildDeps(options: BuildMoonshotToolOptions = {}): MoonshotToolDeps {
68
+ return {
69
+ fetch: options.deps?.fetch ?? fetch,
70
+ getAccessToken: options.deps?.getAccessToken ?? defaultGetAccessToken,
71
+ };
72
+ }
73
+
74
+ function clampLimit(value: number | undefined): number {
75
+ if (typeof value !== "number" || !Number.isFinite(value)) return 5;
76
+ return Math.max(1, Math.min(20, Math.trunc(value)));
77
+ }
78
+
79
+ function errorResult<T>(message: string): AgentToolResult<T> {
80
+ return {
81
+ content: [{ type: "text", text: message }],
82
+ details: undefined as T,
83
+ };
84
+ }
85
+
86
+ function buildTimeoutSignal(signal: AbortSignal | undefined): {
87
+ signal: AbortSignal;
88
+ cleanup: () => void;
89
+ } {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), MOONSHOT_TIMEOUT_MS);
92
+ const abort = () => controller.abort();
93
+ if (signal?.aborted) {
94
+ controller.abort();
95
+ } else {
96
+ signal?.addEventListener("abort", abort, { once: true });
97
+ }
98
+
99
+ return {
100
+ signal: controller.signal,
101
+ cleanup: () => {
102
+ clearTimeout(timeout);
103
+ signal?.removeEventListener("abort", abort);
104
+ },
105
+ };
106
+ }
107
+
108
+ function buildHeaders(accessToken: string, toolCallId: string): Record<string, string> {
109
+ return {
110
+ ...getCommonHeaders(),
111
+ Authorization: `Bearer ${accessToken}`,
112
+ "Content-Type": "application/json",
113
+ "X-Msh-Tool-Call-Id": toolCallId,
114
+ };
115
+ }
116
+
117
+ function stringField(record: Record<string, unknown>, key: string): string {
118
+ const value = record[key];
119
+ return typeof value === "string" ? value : "";
120
+ }
121
+
122
+ function mapSearchResults(value: unknown, includeContent: boolean): MoonshotSearchResult[] {
123
+ if (!Array.isArray(value)) return [];
124
+ return value
125
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
126
+ .map((item) => {
127
+ const result: MoonshotSearchResult = {
128
+ url: stringField(item, "url"),
129
+ title: stringField(item, "title"),
130
+ snippet: stringField(item, "snippet"),
131
+ };
132
+ if (includeContent) {
133
+ result.content = stringField(item, "content");
134
+ }
135
+ return result;
136
+ });
137
+ }
138
+
139
+ function searchResultsText(results: MoonshotSearchResult[]): string {
140
+ if (results.length === 0) return "[]";
141
+ return JSON.stringify(results, null, 2);
142
+ }
143
+
144
+ function textComponent(text: string) {
145
+ return {
146
+ render: () => text.split("\n"),
147
+ invalidate: () => {},
148
+ };
149
+ }
150
+
151
+ function firstText(result: AgentToolResult<unknown>): string {
152
+ const first = result.content[0];
153
+ return first?.type === "text" ? first.text : "";
154
+ }
155
+
156
+ function shouldCollapse(defaultCollapsed: boolean, expanded: boolean | undefined): boolean {
157
+ return defaultCollapsed && expanded !== true;
158
+ }
159
+
160
+ function renderSearchResult(
161
+ result: AgentToolResult<MoonshotSearchResult[]>,
162
+ expanded: boolean | undefined,
163
+ defaultCollapsed: boolean,
164
+ ) {
165
+ if (!Array.isArray(result.details)) {
166
+ return textComponent(firstText(result));
167
+ }
168
+ if (!shouldCollapse(defaultCollapsed, expanded)) {
169
+ return textComponent(searchResultsText(result.details));
170
+ }
171
+
172
+ const count = result.details.length;
173
+ const first = result.details[0];
174
+ const suffix = first ? `; first: ${first.title || first.url}` : "";
175
+ return textComponent(`moonshot_search returned ${count} result(s)${suffix}`);
176
+ }
177
+
178
+ function renderFetchResult(
179
+ result: AgentToolResult<MoonshotFetchResult>,
180
+ expanded: boolean | undefined,
181
+ defaultCollapsed: boolean,
182
+ ) {
183
+ if (!result.details) {
184
+ return textComponent(firstText(result));
185
+ }
186
+ if (!shouldCollapse(defaultCollapsed, expanded)) {
187
+ return textComponent(result.details.content);
188
+ }
189
+
190
+ return textComponent(
191
+ `moonshot_fetch fetched ${result.details.url} (${result.details.content.length} chars)`,
192
+ );
193
+ }
194
+
195
+ async function readErrorBody(response: Response): Promise<string> {
196
+ return response.text().catch(() => "");
197
+ }
198
+
199
+ export function buildMoonshotSearchTool(options: BuildMoonshotToolOptions = {}) {
200
+ const deps = buildDeps(options);
201
+ const defaultCollapsed = options.defaultCollapsed ?? true;
202
+
203
+ return defineTool({
204
+ name: "moonshot_search",
205
+ label: "Moonshot Search",
206
+ description: "Search the web through Kimi Coding's server-side Moonshot search service.",
207
+ promptSnippet: "Search the web with Kimi Coding's Moonshot search service",
208
+ parameters: moonshotSearchSchema,
209
+
210
+ async execute(toolCallId, params, signal) {
211
+ const accessToken = deps.getAccessToken();
212
+ if (!accessToken) {
213
+ return errorResult<MoonshotSearchResult[]>(
214
+ "Missing Kimi Code OAuth credentials. Run /login kimi-coding first.",
215
+ );
216
+ }
217
+
218
+ const limit = clampLimit(params.limit);
219
+ const includeContent = params.include_content === true;
220
+ const timeout = buildTimeoutSignal(signal);
221
+ try {
222
+ const response = await deps.fetch(`${MOONSHOT_BASE_V1}/search`, {
223
+ method: "POST",
224
+ headers: buildHeaders(accessToken, toolCallId),
225
+ body: JSON.stringify({
226
+ text_query: params.query,
227
+ limit,
228
+ enable_page_crawling: includeContent,
229
+ timeout_seconds: 30,
230
+ }),
231
+ signal: timeout.signal,
232
+ });
233
+
234
+ if (!response.ok) {
235
+ const body = await readErrorBody(response);
236
+ return errorResult<MoonshotSearchResult[]>(
237
+ `Moonshot search failed: ${response.status}${body ? ` ${body}` : ""}`,
238
+ );
239
+ }
240
+
241
+ const data = (await response.json()) as MoonshotSearchResponse;
242
+ const results = mapSearchResults(data.search_results, includeContent);
243
+ return {
244
+ content: [{ type: "text", text: searchResultsText(results) }],
245
+ details: results,
246
+ };
247
+ } catch (error) {
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ return errorResult<MoonshotSearchResult[]>(`Moonshot search failed: ${message}`);
250
+ } finally {
251
+ timeout.cleanup();
252
+ }
253
+ },
254
+
255
+ renderResult(result, renderOptions) {
256
+ return renderSearchResult(result, renderOptions.expanded, defaultCollapsed);
257
+ },
258
+ });
259
+ }
260
+
261
+ export function buildMoonshotFetchTool(options: BuildMoonshotToolOptions = {}) {
262
+ const deps = buildDeps(options);
263
+ const defaultCollapsed = options.defaultCollapsed ?? true;
264
+
265
+ return defineTool({
266
+ name: "moonshot_fetch",
267
+ label: "Moonshot Fetch",
268
+ description: "Fetch a web page through Kimi Coding's server-side Moonshot fetch service.",
269
+ promptSnippet: "Fetch a web page with Kimi Coding's Moonshot fetch service",
270
+ parameters: moonshotFetchSchema,
271
+
272
+ async execute(toolCallId, params, signal) {
273
+ const accessToken = deps.getAccessToken();
274
+ if (!accessToken) {
275
+ return errorResult<MoonshotFetchResult>(
276
+ "Missing Kimi Code OAuth credentials. Run /login kimi-coding first.",
277
+ );
278
+ }
279
+
280
+ const timeout = buildTimeoutSignal(signal);
281
+ try {
282
+ const response = await deps.fetch(`${MOONSHOT_BASE_V1}/fetch`, {
283
+ method: "POST",
284
+ headers: {
285
+ ...buildHeaders(accessToken, toolCallId),
286
+ Accept: "text/markdown",
287
+ },
288
+ body: JSON.stringify({ url: params.url }),
289
+ signal: timeout.signal,
290
+ });
291
+
292
+ if (!response.ok) {
293
+ const body = await readErrorBody(response);
294
+ return errorResult<MoonshotFetchResult>(
295
+ `Moonshot fetch failed: ${response.status}${body ? ` ${body}` : ""}`,
296
+ );
297
+ }
298
+
299
+ const content = await response.text();
300
+ return {
301
+ content: [{ type: "text", text: content }],
302
+ details: { url: params.url, content },
303
+ };
304
+ } catch (error) {
305
+ const message = error instanceof Error ? error.message : String(error);
306
+ return errorResult<MoonshotFetchResult>(`Moonshot fetch failed: ${message}`);
307
+ } finally {
308
+ timeout.cleanup();
309
+ }
310
+ },
311
+
312
+ renderResult(result, renderOptions) {
313
+ return renderFetchResult(result, renderOptions.expanded, defaultCollapsed);
314
+ },
315
+ });
316
+ }