@takara-ai/miru-code 0.1.2 → 0.1.4
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 +230 -34
- package/package.json +1 -1
- package/src/cli.ts +39 -2
- package/src/credentials.ts +101 -0
- package/src/embeddings/openai.ts +12 -3
- package/src/embeddings/validate.ts +94 -0
- package/src/env.ts +4 -2
- package/src/index/dense.ts +8 -0
- package/src/index/incremental.ts +130 -0
- package/src/index/quantize.ts +24 -0
- package/src/index/vectors.ts +18 -0
- package/src/mcp/index-cache.ts +138 -85
- package/src/miru-index.ts +55 -12
- package/src/setup.ts +168 -0
- package/src/spinner.ts +57 -0
package/README.md
CHANGED
|
@@ -2,9 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Hybrid code search for AI agents — **TypeScript**, **Bun**, and **Takara embeddings**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## What is Miru?
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
*Miru* (見る) means “to see” or “to look” in Japanese. Miru helps coding agents **find code by meaning** instead of guessing file paths or running exhaustive greps.
|
|
8
|
+
|
|
9
|
+
When an agent asks “where is auth middleware configured?” or “how do we batch embeddings?”, Miru returns the most relevant **chunks** of your codebase — file path, line range, and snippet — so the agent can read only what matters.
|
|
10
|
+
|
|
11
|
+
### Why hybrid search?
|
|
12
|
+
|
|
13
|
+
Pure semantic search misses exact symbol matches. Pure keyword search misses paraphrased intent. Miru combines both:
|
|
14
|
+
|
|
15
|
+
1. **Dense (semantic)** — Takara code embeddings (`ds1-potion-code-16m`) over structurally chunked source files.
|
|
16
|
+
2. **Sparse (BM25)** — keyword scoring with an inverted index; parallelized in Bun workers on large repos.
|
|
17
|
+
3. **RRF fusion** — reciprocal rank fusion merges the two ranked lists.
|
|
18
|
+
4. **Reranking** — code-tuned penalties and boosts (e.g. multi-chunk files, query-term overlap).
|
|
19
|
+
|
|
20
|
+
Indexes are built on demand, cached on disk, and invalidated when files change. You can search **code**, **docs**, **config**, or **all** content types.
|
|
21
|
+
|
|
22
|
+
### How agents use Miru
|
|
23
|
+
|
|
24
|
+
| Mode | Best for | What the agent gets |
|
|
25
|
+
|------|----------|---------------------|
|
|
26
|
+
| **MCP server** | Cursor, Claude Code, VS Code Copilot, Gemini CLI, Kiro, OpenCode | Native `search` and `find_related` tools over the workspace |
|
|
27
|
+
| **CLI sub-agent** | Any agent with shell access | `miru init --agent <id>` writes a specialist agent that runs `miru search` / `miru find-related` via Bash |
|
|
28
|
+
|
|
29
|
+
Both modes share the same index and cache. Pick MCP when your IDE supports it; use the sub-agent when you want a dedicated exploration agent without MCP wiring.
|
|
8
30
|
|
|
9
31
|
## Install
|
|
10
32
|
|
|
@@ -12,7 +34,7 @@ Requires [Bun](https://bun.sh) 1.1+ on your PATH (`curl -fsSL https://bun.sh/ins
|
|
|
12
34
|
|
|
13
35
|
```bash
|
|
14
36
|
# Run once without installing
|
|
15
|
-
|
|
37
|
+
bunx @takara-ai/miru-code search "auth middleware" ./src
|
|
16
38
|
|
|
17
39
|
# Global CLI
|
|
18
40
|
bun add -g @takara-ai/miru-code
|
|
@@ -34,7 +56,7 @@ const index = await MiruIndex.fromPath("./src");
|
|
|
34
56
|
bun install
|
|
35
57
|
```
|
|
36
58
|
|
|
37
|
-
Add credentials to `.env.local` (loaded automatically when you run from this repo), or pass them in your MCP config `env` block (recommended
|
|
59
|
+
Add credentials to `.env.local` (loaded automatically when you run from this repo), or pass them in your MCP config `env` block (recommended — MCP does not inherit IDE env vars reliably).
|
|
38
60
|
|
|
39
61
|
| Variable | Default |
|
|
40
62
|
|----------|---------|
|
|
@@ -52,64 +74,153 @@ Parallelism: file reads/chunking, embedding batches (up to concurrency in flight
|
|
|
52
74
|
|
|
53
75
|
`OPENAI_*` env aliases are also accepted where noted in the CLI help.
|
|
54
76
|
|
|
55
|
-
##
|
|
77
|
+
## IDE integration
|
|
78
|
+
|
|
79
|
+
Miru supports six agent platforms. For each, you can wire the **MCP server** (recommended) and/or install a **CLI sub-agent** with `miru init`.
|
|
80
|
+
|
|
81
|
+
### Quick reference
|
|
82
|
+
|
|
83
|
+
| IDE | MCP config file | Sub-agent command | Sub-agent output |
|
|
84
|
+
|-----|-----------------|-------------------|------------------|
|
|
85
|
+
| [Cursor](#cursor) | `~/.cursor/mcp.json` or `.cursor/mcp.json` | `miru init --agent cursor` | `.cursor/agents/miru-code.md` |
|
|
86
|
+
| [Claude Code](#claude-code) | `.mcp.json` (project root) or `~/.claude.json` | `miru init --agent claude` | `.claude/agents/miru-code.md` |
|
|
87
|
+
| [VS Code / Copilot](#vs-code--github-copilot) | `.vscode/mcp.json` or user profile `mcp.json` | `miru init --agent copilot` | `.github/agents/miru-code.md` |
|
|
88
|
+
| [Gemini CLI](#gemini-cli) | `~/.gemini/settings.json` or `.gemini/settings.json` | `miru init --agent gemini` | `.gemini/agents/miru-code.md` |
|
|
89
|
+
| [Kiro](#kiro) | `~/.kiro/settings/mcp.json` or `.kiro/settings/mcp.json` | `miru init --agent kiro` | `.kiro/agents/miru-code.md` |
|
|
90
|
+
| [OpenCode](#opencode) | `~/.config/opencode/opencode.json` or `opencode.json` | `miru init --agent opencode` | `.opencode/agents/miru-code.md` |
|
|
91
|
+
|
|
92
|
+
Run `miru init --agent <id> --force` to overwrite an existing sub-agent file.
|
|
93
|
+
|
|
94
|
+
### MCP server
|
|
95
|
+
|
|
96
|
+
Run with **no arguments** to start the stdio MCP server. It indexes the **current working directory** (most IDEs set this to your workspace). Optional path/ref flags pre-index a different repo.
|
|
56
97
|
|
|
57
98
|
```bash
|
|
58
|
-
|
|
59
|
-
|
|
99
|
+
bunx @takara-ai/miru-code
|
|
100
|
+
# or after bun add -g @takara-ai/miru-code: miru
|
|
101
|
+
```
|
|
60
102
|
|
|
61
|
-
|
|
62
|
-
bun run miru find-related src/embeddings/openai.ts 120 ./src
|
|
103
|
+
**Tools:** `search`, `find_related` — pass `repo` only when querying a different path or git URL.
|
|
63
104
|
|
|
64
|
-
|
|
65
|
-
bun run miru init --agent cursor
|
|
105
|
+
For MCP configs, use `bunx` as the command (`bun` with `x` in `args` works too — they're equivalent).
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
107
|
+
Put the bearer token in `env`; MCP will not read `.env.local` unless those vars are unset.
|
|
108
|
+
|
|
109
|
+
#### Shared env block
|
|
110
|
+
|
|
111
|
+
Use this in every MCP config below (adjust the key name if your IDE uses `environment` instead of `env`):
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"TAKARA_API_KEY": "your-takara-bearer-token",
|
|
116
|
+
"MIRU_OPENAI_BASE_URL": "https://infer.dev.takara.ai/v1",
|
|
117
|
+
"MIRU_OPENAI_EMBEDDING_MODEL": "ds1-potion-code-16m",
|
|
118
|
+
"MIRU_EMBEDDING_DIMENSIONS": "256"
|
|
119
|
+
}
|
|
69
120
|
```
|
|
70
121
|
|
|
71
|
-
|
|
122
|
+
### Cursor
|
|
72
123
|
|
|
73
|
-
|
|
124
|
+
**MCP** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
|
|
74
125
|
|
|
75
|
-
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"mcpServers": {
|
|
129
|
+
"miru": {
|
|
130
|
+
"command": "bunx",
|
|
131
|
+
"args": ["@takara-ai/miru-code"],
|
|
132
|
+
"env": {
|
|
133
|
+
"TAKARA_API_KEY": "your-takara-bearer-token"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
After `bun add -g @takara-ai/miru-code`, you can use `"command": "miru"` instead.
|
|
141
|
+
|
|
142
|
+
**Sub-agent:**
|
|
76
143
|
|
|
77
144
|
```bash
|
|
78
|
-
|
|
79
|
-
# or after bun add -g @takara-ai/miru-code: miru
|
|
80
|
-
# or without installing: bun x @takara-ai/miru-code
|
|
145
|
+
miru init --agent cursor
|
|
81
146
|
```
|
|
82
147
|
|
|
83
|
-
|
|
148
|
+
Commit `.cursor/agents/miru-code.md` to share the exploration agent with your team.
|
|
84
149
|
|
|
85
|
-
|
|
150
|
+
### Claude Code
|
|
86
151
|
|
|
87
|
-
|
|
152
|
+
**MCP** — project `.mcp.json` (check into git) or user scope in `~/.claude.json`:
|
|
88
153
|
|
|
89
154
|
```json
|
|
90
155
|
{
|
|
91
156
|
"mcpServers": {
|
|
92
157
|
"miru": {
|
|
93
|
-
"
|
|
94
|
-
"
|
|
158
|
+
"type": "stdio",
|
|
159
|
+
"command": "bunx",
|
|
160
|
+
"args": ["@takara-ai/miru-code"],
|
|
95
161
|
"env": {
|
|
96
|
-
"TAKARA_API_KEY": "
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
162
|
+
"TAKARA_API_KEY": "${TAKARA_API_KEY}"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Claude Code supports `${VAR}` expansion in config files. Restart the session and run `/mcp` to verify the connection.
|
|
170
|
+
|
|
171
|
+
Or via CLI:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
claude mcp add miru -s project -- bunx @takara-ai/miru-code
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Sub-agent:**
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
miru init --agent claude
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Writes `.claude/agents/miru-code.md` — invoke the `miru-code` agent for semantic exploration tasks.
|
|
184
|
+
|
|
185
|
+
### VS Code / GitHub Copilot
|
|
186
|
+
|
|
187
|
+
**MCP** — workspace `.vscode/mcp.json` or user profile (Command Palette: **MCP: Open User Configuration**):
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"servers": {
|
|
192
|
+
"miru": {
|
|
193
|
+
"type": "stdio",
|
|
194
|
+
"command": "bunx",
|
|
195
|
+
"args": ["@takara-ai/miru-code"],
|
|
196
|
+
"env": {
|
|
197
|
+
"TAKARA_API_KEY": "your-takara-bearer-token"
|
|
100
198
|
}
|
|
101
199
|
}
|
|
102
200
|
}
|
|
103
201
|
}
|
|
104
202
|
```
|
|
105
203
|
|
|
106
|
-
|
|
204
|
+
VS Code uses `"servers"` (not `mcpServers`). Use **MCP: List Servers** to start or restart the server after editing the config.
|
|
205
|
+
|
|
206
|
+
**Sub-agent:**
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
miru init --agent copilot
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Writes `.github/agents/miru-code.md` for Copilot’s agent system.
|
|
213
|
+
|
|
214
|
+
### Gemini CLI
|
|
215
|
+
|
|
216
|
+
**MCP** — `~/.gemini/settings.json` or project `.gemini/settings.json`:
|
|
107
217
|
|
|
108
218
|
```json
|
|
109
219
|
{
|
|
110
220
|
"mcpServers": {
|
|
111
221
|
"miru": {
|
|
112
|
-
"command": "
|
|
222
|
+
"command": "bunx",
|
|
223
|
+
"args": ["@takara-ai/miru-code"],
|
|
113
224
|
"env": {
|
|
114
225
|
"TAKARA_API_KEY": "your-takara-bearer-token"
|
|
115
226
|
}
|
|
@@ -118,15 +229,58 @@ Alternatively, after `bun add -g @takara-ai/miru-code`:
|
|
|
118
229
|
}
|
|
119
230
|
```
|
|
120
231
|
|
|
121
|
-
|
|
232
|
+
Or add interactively: `gemini mcp add`. Run `/mcp` inside a session to confirm tools are available.
|
|
233
|
+
|
|
234
|
+
**Sub-agent:**
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
miru init --agent gemini
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Writes `.gemini/agents/miru-code.md`.
|
|
241
|
+
|
|
242
|
+
### Kiro
|
|
243
|
+
|
|
244
|
+
**MCP** — `~/.kiro/settings/mcp.json` or workspace `.kiro/settings/mcp.json`:
|
|
122
245
|
|
|
123
246
|
```json
|
|
124
247
|
{
|
|
125
248
|
"mcpServers": {
|
|
126
249
|
"miru": {
|
|
127
|
-
"command": "
|
|
128
|
-
"args": ["/
|
|
250
|
+
"command": "bunx",
|
|
251
|
+
"args": ["@takara-ai/miru-code"],
|
|
129
252
|
"env": {
|
|
253
|
+
"TAKARA_API_KEY": "your-takara-bearer-token",
|
|
254
|
+
"PATH": "/usr/local/bin:/usr/bin:/bin"
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Kiro runs with a minimal environment — include `PATH` if Bun is not on the default path. Approve env vars when prompted in the IDE.
|
|
262
|
+
|
|
263
|
+
**Sub-agent:**
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
miru init --agent kiro
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Writes `.kiro/agents/miru-code.md`.
|
|
270
|
+
|
|
271
|
+
### OpenCode
|
|
272
|
+
|
|
273
|
+
**MCP** — `~/.config/opencode/opencode.json` or project `opencode.json`:
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
{
|
|
277
|
+
"$schema": "https://opencode.ai/config.json",
|
|
278
|
+
"mcp": {
|
|
279
|
+
"miru": {
|
|
280
|
+
"type": "local",
|
|
281
|
+
"command": ["bunx", "@takara-ai/miru-code"],
|
|
282
|
+
"enabled": true,
|
|
283
|
+
"environment": {
|
|
130
284
|
"TAKARA_API_KEY": "your-takara-bearer-token"
|
|
131
285
|
}
|
|
132
286
|
}
|
|
@@ -134,7 +288,49 @@ Local development (path to this repo):
|
|
|
134
288
|
}
|
|
135
289
|
```
|
|
136
290
|
|
|
137
|
-
|
|
291
|
+
OpenCode uses the `mcp` key (not `mcpServers`), requires `"type": "local"`, and uses `environment` instead of `env`. Verify with `opencode mcp list`.
|
|
292
|
+
|
|
293
|
+
**Sub-agent:**
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
miru init --agent opencode
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Writes `.opencode/agents/miru-code.md` (subagent mode with bash/read permissions).
|
|
300
|
+
|
|
301
|
+
### Local development (any IDE)
|
|
302
|
+
|
|
303
|
+
Point MCP at this repo instead of the published package:
|
|
304
|
+
|
|
305
|
+
```json
|
|
306
|
+
{
|
|
307
|
+
"command": "bun",
|
|
308
|
+
"args": ["/absolute/path/to/miru-code/src/cli.ts"],
|
|
309
|
+
"env": {
|
|
310
|
+
"TAKARA_API_KEY": "your-takara-bearer-token"
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Merge into the appropriate wrapper key (`mcpServers`, `servers`, or `mcp`) for your IDE.
|
|
316
|
+
|
|
317
|
+
## CLI
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
# Hybrid search (positional, Python-compatible)
|
|
321
|
+
bun run miru search "where are embeddings created" ./src -k 5
|
|
322
|
+
|
|
323
|
+
# Find semantically related chunks
|
|
324
|
+
bun run miru find-related src/embeddings/openai.ts 120 ./src
|
|
325
|
+
|
|
326
|
+
# Write a miru-code sub-agent for your IDE
|
|
327
|
+
bun run miru init --agent cursor
|
|
328
|
+
|
|
329
|
+
# Clear disk cache
|
|
330
|
+
bun run miru clear ./src
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Indexes are cached under `~/Library/Caches/miru` (macOS) or `~/.cache/miru` (Linux).
|
|
138
334
|
|
|
139
335
|
## Library
|
|
140
336
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { type AgentId, writeAgentFile } from "./agents.ts";
|
|
4
4
|
import { clearCache } from "./cache.ts";
|
|
5
|
+
import { loadStoredCredentials } from "./credentials.ts";
|
|
5
6
|
import { loadEnvFiles } from "./env-files.ts";
|
|
6
7
|
import { serveMcp } from "./mcp/serve.ts";
|
|
7
8
|
import { MiruIndex } from "./miru-index.ts";
|
|
9
|
+
import { ensureCredentials, runClearCredentials, runSetup } from "./setup.ts";
|
|
8
10
|
import type { ContentType } from "./types.ts";
|
|
9
11
|
import { formatResults, isGitUrl, resolveChunk, resolveContent } from "./utils.ts";
|
|
10
12
|
|
|
11
13
|
loadEnvFiles();
|
|
14
|
+
await loadStoredCredentials();
|
|
12
15
|
|
|
13
|
-
const CLI_COMMANDS = new Set(["search", "find-related", "init", "clear", "-h", "--help"]);
|
|
16
|
+
const CLI_COMMANDS = new Set(["search", "find-related", "init", "setup", "clear", "-h", "--help"]);
|
|
14
17
|
|
|
15
18
|
const AGENTS = new Set<AgentId>(["claude", "copilot", "cursor", "gemini", "kiro", "opencode"]);
|
|
16
19
|
|
|
@@ -64,6 +67,7 @@ async function runSearch(
|
|
|
64
67
|
topK: number,
|
|
65
68
|
content: ContentType[],
|
|
66
69
|
): Promise<void> {
|
|
70
|
+
await ensureCredentials();
|
|
67
71
|
const index = await MiruIndex.fromSource(path, content);
|
|
68
72
|
await index.saveToDefaultCache(path);
|
|
69
73
|
const results = await index.search({ query, topK });
|
|
@@ -79,6 +83,7 @@ async function runFindRelated(
|
|
|
79
83
|
topK: number,
|
|
80
84
|
content: ContentType[],
|
|
81
85
|
): Promise<void> {
|
|
86
|
+
await ensureCredentials();
|
|
82
87
|
const index = await MiruIndex.fromSource(path, content);
|
|
83
88
|
const chunk = resolveChunk(index.chunks, filePath, line);
|
|
84
89
|
if (!chunk) {
|
|
@@ -111,6 +116,7 @@ function printHelp(): void {
|
|
|
111
116
|
CLI:
|
|
112
117
|
miru search <query> [path] [-k N] [--content code|docs|config|all]
|
|
113
118
|
miru find-related <file_path> <line> [path] [-k N] [--content ...]
|
|
119
|
+
miru setup [--key KEY] [--force] [--clear]
|
|
114
120
|
miru init [--agent cursor] [--force]
|
|
115
121
|
miru clear [path]
|
|
116
122
|
|
|
@@ -156,6 +162,32 @@ async function runCli(argv: string[]): Promise<void> {
|
|
|
156
162
|
return;
|
|
157
163
|
}
|
|
158
164
|
|
|
165
|
+
if (command === "setup") {
|
|
166
|
+
let apiKey: string | undefined;
|
|
167
|
+
let force = false;
|
|
168
|
+
let clear = false;
|
|
169
|
+
for (let i = 0; i < rest.length; i++) {
|
|
170
|
+
const arg = rest[i];
|
|
171
|
+
if (arg === "--force") {
|
|
172
|
+
force = true;
|
|
173
|
+
} else if (arg === "--clear") {
|
|
174
|
+
clear = true;
|
|
175
|
+
} else if ((arg === "--key" || arg === "-k") && rest[i + 1]) {
|
|
176
|
+
apiKey = rest[++i];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (clear) {
|
|
180
|
+
if (apiKey) {
|
|
181
|
+
console.error("Usage: miru setup --clear cannot be combined with --key.");
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
await runClearCredentials();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await runSetup({ apiKey, force });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
159
191
|
if (command === "clear") {
|
|
160
192
|
const path = resolve(rest[0] ?? process.cwd());
|
|
161
193
|
await runClear(path);
|
|
@@ -229,6 +261,11 @@ async function runMcp(argv: string[]): Promise<void> {
|
|
|
229
261
|
});
|
|
230
262
|
}
|
|
231
263
|
|
|
264
|
+
async function runMcpWithCredentials(argv: string[]): Promise<void> {
|
|
265
|
+
await ensureCredentials({ interactive: false });
|
|
266
|
+
await runMcp(argv);
|
|
267
|
+
}
|
|
268
|
+
|
|
232
269
|
async function main(): Promise<void> {
|
|
233
270
|
const argv = process.argv.slice(2);
|
|
234
271
|
const first = argv[0];
|
|
@@ -236,7 +273,7 @@ async function main(): Promise<void> {
|
|
|
236
273
|
await runCli(argv);
|
|
237
274
|
return;
|
|
238
275
|
}
|
|
239
|
-
await
|
|
276
|
+
await runMcpWithCredentials(argv);
|
|
240
277
|
}
|
|
241
278
|
|
|
242
279
|
main().catch((err) => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { EMBEDDING_API_KEY_NAMES, envFirstString } from "./env.ts";
|
|
4
|
+
|
|
5
|
+
const CREDENTIALS_FILENAME = "credentials.json";
|
|
6
|
+
const CREDENTIALS_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
interface StoredCredentials {
|
|
9
|
+
version: number;
|
|
10
|
+
takara_api_key: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveCredentialsDir(): string {
|
|
14
|
+
const override = process.env.MIRU_CREDENTIALS_DIR;
|
|
15
|
+
if (override) {
|
|
16
|
+
return override;
|
|
17
|
+
}
|
|
18
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
const base = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
21
|
+
return join(base, "miru");
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === "darwin") {
|
|
24
|
+
return join(home, "Library", "Application Support", "miru");
|
|
25
|
+
}
|
|
26
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
27
|
+
return join(xdg, "miru");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveCredentialsPath(): string {
|
|
31
|
+
return join(resolveCredentialsDir(), CREDENTIALS_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hasEmbeddingApiKeyInEnv(): boolean {
|
|
35
|
+
return Boolean(envFirstString([...EMBEDDING_API_KEY_NAMES], ""));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readStoredCredentials(): Promise<StoredCredentials | null> {
|
|
39
|
+
const path = resolveCredentialsPath();
|
|
40
|
+
if (!(await Bun.file(path).exists())) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(path, "utf-8")) as StoredCredentials;
|
|
45
|
+
if (parsed.version !== CREDENTIALS_VERSION || !parsed.takara_api_key) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Set TAKARA_API_KEY from the user credentials file when env is unset. */
|
|
55
|
+
export async function loadStoredCredentials(): Promise<boolean> {
|
|
56
|
+
if (hasEmbeddingApiKeyInEnv()) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const stored = await readStoredCredentials();
|
|
60
|
+
if (!stored) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
process.env.TAKARA_API_KEY = stored.takara_api_key;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveStoredCredentials(apiKey: string): Promise<string> {
|
|
68
|
+
const dir = resolveCredentialsDir();
|
|
69
|
+
const path = resolveCredentialsPath();
|
|
70
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
const payload: StoredCredentials = {
|
|
72
|
+
version: CREDENTIALS_VERSION,
|
|
73
|
+
takara_api_key: apiKey,
|
|
74
|
+
};
|
|
75
|
+
await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
mode: 0o600,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await chmod(path, 0o600);
|
|
81
|
+
} catch {
|
|
82
|
+
// Windows may not support Unix mode bits on all filesystems.
|
|
83
|
+
}
|
|
84
|
+
return path;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function clearStoredCredentials(): Promise<{ cleared: boolean; path: string }> {
|
|
88
|
+
const path = resolveCredentialsPath();
|
|
89
|
+
if (!(await Bun.file(path).exists())) {
|
|
90
|
+
return { cleared: false, path };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const stored = await readStoredCredentials();
|
|
94
|
+
await rm(path, { force: true });
|
|
95
|
+
|
|
96
|
+
if (stored && process.env.TAKARA_API_KEY === stored.takara_api_key) {
|
|
97
|
+
delete process.env.TAKARA_API_KEY;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { cleared: true, path };
|
|
101
|
+
}
|
package/src/embeddings/openai.ts
CHANGED
|
@@ -63,14 +63,23 @@ function splitIntoWindows(text: string, maxChars: number): string[] {
|
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
/** Unpaired UTF-16 surrogates are invalid in JSON and break strict API parsers. */
|
|
67
|
+
function stripLoneSurrogates(text: string): string {
|
|
68
|
+
return text.replace(
|
|
69
|
+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
|
70
|
+
"\uFFFD",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sanitizeEmbeddingInput(text: string): string {
|
|
75
|
+
const out = stripLoneSurrogates(text);
|
|
67
76
|
// Some OpenAI-compatible gateways mis-handle backslash escapes in JSON text
|
|
68
77
|
// payloads. Keep a benchmark fallback mode to forcefully neutralize them.
|
|
69
78
|
const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? process.env.SEMBLE_EMBED_ESCAPE_MODE ?? "quad";
|
|
70
79
|
if (mode === "strip") {
|
|
71
|
-
return
|
|
80
|
+
return out.replaceAll("\\", "/");
|
|
72
81
|
}
|
|
73
|
-
return
|
|
82
|
+
return out.replaceAll("\\", "\\\\\\\\");
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
function isPayloadTooLargeError(err: unknown): boolean {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveEmbeddingBaseUrl,
|
|
3
|
+
resolveEmbeddingDimensions,
|
|
4
|
+
resolveEmbeddingModel,
|
|
5
|
+
} from "./openai.ts";
|
|
6
|
+
|
|
7
|
+
export interface ValidateApiKeyResult {
|
|
8
|
+
valid: boolean;
|
|
9
|
+
status?: number;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function validateEmbeddingApiKey(options: {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
dimensions?: number;
|
|
18
|
+
}): Promise<ValidateApiKeyResult> {
|
|
19
|
+
const baseUrl = (options.baseUrl ?? resolveEmbeddingBaseUrl()).replace(/\/$/, "");
|
|
20
|
+
const model = options.model ?? resolveEmbeddingModel();
|
|
21
|
+
const dimensions = options.dimensions ?? resolveEmbeddingDimensions(model);
|
|
22
|
+
const endpoint = `${baseUrl}/embeddings`;
|
|
23
|
+
|
|
24
|
+
const body: Record<string, unknown> = {
|
|
25
|
+
model,
|
|
26
|
+
input: "miru setup validation",
|
|
27
|
+
};
|
|
28
|
+
if (dimensions != null) {
|
|
29
|
+
body.dimensions = dimensions;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let response: Response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(endpoint, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return {
|
|
45
|
+
valid: false,
|
|
46
|
+
message: `Could not reach embedding API at ${baseUrl}: ${message}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
if (response.status === 401 || response.status === 403) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
status: response.status,
|
|
56
|
+
message: "Invalid API key (authentication failed).",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
status: response.status,
|
|
62
|
+
message: `Embedding API returned ${response.status}: ${text.slice(0, 200)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const payload = (await response.json()) as {
|
|
68
|
+
data?: Array<{ embedding?: number[] }>;
|
|
69
|
+
};
|
|
70
|
+
const embedding = payload.data?.[0]?.embedding;
|
|
71
|
+
if (!embedding || embedding.length === 0) {
|
|
72
|
+
return {
|
|
73
|
+
valid: false,
|
|
74
|
+
status: response.status,
|
|
75
|
+
message: "Embedding API returned an empty response.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (dimensions != null && embedding.length !== dimensions) {
|
|
79
|
+
return {
|
|
80
|
+
valid: false,
|
|
81
|
+
status: response.status,
|
|
82
|
+
message: `Expected ${dimensions} embedding dimensions, got ${embedding.length}.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return {
|
|
87
|
+
valid: false,
|
|
88
|
+
status: response.status,
|
|
89
|
+
message: "Embedding API returned invalid JSON.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { valid: true, status: response.status, message: "API key is valid." };
|
|
94
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -38,12 +38,14 @@ const EMBEDDING_API_KEY_NAMES = [
|
|
|
38
38
|
"SEMBLE_OPENAI_API_KEY",
|
|
39
39
|
] as const;
|
|
40
40
|
|
|
41
|
+
export { EMBEDDING_API_KEY_NAMES };
|
|
42
|
+
|
|
41
43
|
export function resolveEmbeddingApiKey(): string {
|
|
42
44
|
const key = envFirstString([...EMBEDDING_API_KEY_NAMES], "");
|
|
43
45
|
if (!key) {
|
|
44
46
|
throw new Error(
|
|
45
|
-
"Embedding API key required.
|
|
46
|
-
"in your MCP server env
|
|
47
|
+
"Embedding API key required. Run `miru setup`, set TAKARA_API_KEY, OPENAI_API_KEY, " +
|
|
48
|
+
"MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY in your MCP server env or .env.local.",
|
|
47
49
|
);
|
|
48
50
|
}
|
|
49
51
|
return key;
|