pi-provider-kimi-code 0.3.2 → 0.4.1
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 +99 -45
- package/index.ts +350 -1
- package/package.json +1 -1
- package/src/config.ts +151 -0
- package/src/tools/moonshot.ts +316 -0
package/README.md
CHANGED
|
@@ -1,42 +1,48 @@
|
|
|
1
1
|
# Pi extension for Kimi Code
|
|
2
|
+
|
|
2
3
|
[](https://www.npmjs.com/package/pi-provider-kimi-code)
|
|
3
4
|
[](./LICENSE)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
6
|
+
---
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
> [!CAUTION]
|
|
9
|
+
> ## THIS PLUGIN IS NO LONGER MAINTAINED — DO NOT USE
|
|
10
|
+
>
|
|
11
|
+
> **Why this project was abandoned:**
|
|
12
|
+
>
|
|
13
|
+
> - **Account suspended by mistake.** Moonshot suspended the maintainer's account citing a policy violation that never occurred.
|
|
14
|
+
> - **No explanation after reinstatement.** The account was eventually unblocked, but Moonshot provided no reason, no apology, and no clarification of what triggered the ban.
|
|
15
|
+
> - **Discriminatory treatment of Chinese users.** Moonshot's platform shows a consistent pattern of treating Chinese-language users differently — stricter enforcement, opaque policies, and no recourse.
|
|
16
|
+
>
|
|
17
|
+
> Building on top of a vendor that bans first and refuses to explain afterwards is not a foundation worth maintaining. **Use this code at your own risk. No issues will be triaged. No PRs will be merged. No releases will be cut.**
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
---
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
**Use Kimi Code in Pi, with the Kimi parts handled.**
|
|
14
22
|
|
|
15
|
-
-
|
|
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.
|
|
23
|
+
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.
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## Who is this for?
|
|
25
|
+
## Why this exists
|
|
23
26
|
|
|
24
|
-
|
|
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
|
+
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.
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
- **Use your Kimi account.** Log in with `/login kimi-coding`, or reuse an existing `kimi-cli` session.
|
|
30
|
+
- **Send files the Kimi way.** Large inline images go through Kimi's Files API and become `ms://` references instead of huge base64 payloads.
|
|
31
|
+
- **Know what the cache is doing.** Kimi caches by content prefix. This repo measures that behavior instead of pretending `prompt_cache_key` controls it.
|
|
32
|
+
- **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.
|
|
33
|
+
- **Turn on Kimi-native tools when you want them.** `moonshot_search` and `moonshot_fetch` are opt-in, configurable per user or per project.
|
|
29
34
|
|
|
30
35
|
## What this package adds
|
|
31
36
|
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
37
|
+
- Kimi account login in Pi, plus `KIMI_API_KEY` for CI or pay-per-token use.
|
|
38
|
+
- `kimi-cli` credential reuse from `~/.kimi/credentials/kimi-code.json`.
|
|
39
|
+
- Kimi Files API uploads for large inline images.
|
|
40
|
+
- Live model metadata from Kimi at login / refresh time.
|
|
41
|
+
- OpenAI-compatible mode by default, Anthropic-compatible mode on request.
|
|
42
|
+
- Kimi K2.6 reasoning level mapping for Pi's reasoning controls.
|
|
43
|
+
- Stream cleanup for Kimi's thinking-only placeholder text.
|
|
44
|
+
- Optional `moonshot_search` and `moonshot_fetch` tools via `/kimi-settings`.
|
|
45
|
+
- No build step; Pi loads the TypeScript extension directly.
|
|
40
46
|
|
|
41
47
|
## Install
|
|
42
48
|
|
|
@@ -57,32 +63,29 @@ pi -e /path/to/pi-provider-kimi-code
|
|
|
57
63
|
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
64
|
|
|
59
65
|
```bash
|
|
60
|
-
# Download and extract
|
|
61
66
|
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
67
|
pi install /tmp/pi-provider-kimi-code
|
|
65
68
|
```
|
|
66
69
|
|
|
67
70
|
## Sign in
|
|
68
71
|
|
|
69
|
-
### Browser login
|
|
70
|
-
|
|
71
72
|
Inside Pi, run:
|
|
72
73
|
|
|
73
|
-
```
|
|
74
|
+
```text
|
|
74
75
|
/login kimi-coding
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
A browser tab opens, you sign into your Kimi account, and
|
|
78
|
+
A browser tab opens, you sign into your Kimi account, and Pi stores the credential at `~/.pi/agent/auth.json`. Tokens refresh automatically.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
If you already use `kimi-cli`, this extension can reuse its credential file:
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
```text
|
|
83
|
+
~/.kimi/credentials/kimi-code.json
|
|
84
|
+
```
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
The file is read-only from this extension's side. Set `KIMI_SHARE_DIR` if your `kimi-cli` share directory lives somewhere else.
|
|
84
87
|
|
|
85
|
-
For CI or pay-per-token use, set `KIMI_API_KEY
|
|
88
|
+
For CI or pay-per-token use, set `KIMI_API_KEY` instead:
|
|
86
89
|
|
|
87
90
|
```bash
|
|
88
91
|
KIMI_API_KEY=sk-... pi
|
|
@@ -92,13 +95,19 @@ KIMI_API_KEY=sk-... pi
|
|
|
92
95
|
|
|
93
96
|
This provider publishes one Pi model ID:
|
|
94
97
|
|
|
95
|
-
```
|
|
98
|
+
```text
|
|
96
99
|
kimi-coding/kimi-for-coding
|
|
97
100
|
```
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
Select it inside Pi:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
/model kimi-coding/kimi-for-coding
|
|
106
|
+
```
|
|
100
107
|
|
|
101
|
-
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
Fallback values:
|
|
102
111
|
|
|
103
112
|
- Context window: `262144` tokens
|
|
104
113
|
- Max output: `32000` tokens
|
|
@@ -114,12 +123,45 @@ Kimi K2.6 supports Pi's reasoning levels. This provider maps them to Kimi's curr
|
|
|
114
123
|
|
|
115
124
|
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
125
|
|
|
117
|
-
|
|
126
|
+
## Optional Moonshot tools
|
|
127
|
+
|
|
128
|
+
Kimi Coding has server-side search and fetch tools. This extension can expose them to Pi as:
|
|
129
|
+
|
|
130
|
+
- `moonshot_search`
|
|
131
|
+
- `moonshot_fetch`
|
|
118
132
|
|
|
133
|
+
They are off by default. Turn them on when you want Kimi's own search/fetch path in the agent loop.
|
|
134
|
+
|
|
135
|
+
Inside Pi, run:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
/kimi-settings
|
|
119
139
|
```
|
|
120
|
-
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
Config files are JSON:
|
|
144
|
+
|
|
145
|
+
- Home: `~/.pi/pi-provider-kimi-code.json`
|
|
146
|
+
- Project override: `<cwd>/.pi/pi-provider-kimi-code.json`
|
|
147
|
+
|
|
148
|
+
Project config overrides home config with a deep merge. Missing files or missing keys mean both tools stay off.
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"tools": {
|
|
153
|
+
"moonshot_search": { "enabled": true, "default_collapsed": true },
|
|
154
|
+
"moonshot_fetch": { "enabled": true, "default_collapsed": true }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
121
157
|
```
|
|
122
158
|
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
`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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
123
165
|
## Common knobs
|
|
124
166
|
|
|
125
167
|
Most users do not need environment variables. Two are worth knowing:
|
|
@@ -127,7 +169,9 @@ Most users do not need environment variables. Two are worth knowing:
|
|
|
127
169
|
- `KIMI_API_KEY` — static API key for CI or pay-per-token use.
|
|
128
170
|
- `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` if your Pi setup needs Anthropic-compatible requests.
|
|
129
171
|
|
|
130
|
-
|
|
172
|
+
Moonshot search/fetch tools are configured through `/kimi-settings` or JSON config files, not env vars.
|
|
173
|
+
|
|
174
|
+
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
175
|
|
|
132
176
|
## Notes
|
|
133
177
|
|
|
@@ -137,9 +181,9 @@ The full list, including base URL overrides, `kimi-cli` path overrides, upload t
|
|
|
137
181
|
|
|
138
182
|
### Cache behavior
|
|
139
183
|
|
|
140
|
-
Kimi's cache is content-based. It fires automatically when your prompt prefix matches an earlier request. `prompt_cache_key
|
|
184
|
+
Kimi's cache is content-based. It fires automatically when your prompt prefix matches an earlier request. `prompt_cache_key`, Anthropic `cache_control` markers, and identifying headers like `X-Msh-Device-Id` are all ignored for cache decisions — cache is keyed purely by prompt content and is shared across machines, agent forks, and device-id rotations on the same Kimi account.
|
|
141
185
|
|
|
142
|
-
See [docs/caching.md](docs/caching.md) for the measured behavior: TTL, cross-protocol cache reuse, 256-token prefix alignment, and the cases that invalidate cache.
|
|
186
|
+
See [docs/caching.md](docs/caching.md) for the measured behavior: TTL, cross-protocol cache reuse, 256-token prefix alignment, device-id sharing, and the cases that invalidate cache.
|
|
143
187
|
|
|
144
188
|
### Protocol modes
|
|
145
189
|
|
|
@@ -170,12 +214,22 @@ Pi runs on Node's `fetch` / undici stack, which handles `http_proxy` / `https_pr
|
|
|
170
214
|
|
|
171
215
|
### `/login kimi-coding` prints a device code but the browser never opens
|
|
172
216
|
|
|
173
|
-
The login flow always prints a verification URL
|
|
217
|
+
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
218
|
|
|
175
219
|
### "Access denied" or subscription errors after a successful login
|
|
176
220
|
|
|
177
221
|
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
222
|
|
|
223
|
+
### Moonshot tools do not show up
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
If you changed the JSON config by hand, reload it through `/kimi-settings` or start a new Pi session.
|
|
228
|
+
|
|
229
|
+
### Moonshot tools return subscription or whitelist errors
|
|
230
|
+
|
|
231
|
+
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.
|
|
232
|
+
|
|
179
233
|
### Large images fail with a payload error
|
|
180
234
|
|
|
181
235
|
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
|
|
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
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
|
+
}
|