@takara-ai/miru-code 0.1.3 → 0.2.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 +234 -36
- package/package.json +1 -2
- package/src/cli.ts +42 -12
- package/src/credentials.ts +101 -0
- package/src/embeddings/openai.ts +1 -1
- 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 +162 -93
- package/src/mcp/serve.ts +3 -13
- package/src/mcp/server.ts +10 -10
- package/src/miru-index.ts +55 -12
- package/src/setup.ts +168 -0
- package/src/spinner.ts +57 -0
package/src/mcp/serve.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
1
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
2
|
import type { ContentType } from "../types.ts";
|
|
4
|
-
import {
|
|
5
|
-
import { IndexCache, mcpWatchEnabled } from "./index-cache.ts";
|
|
3
|
+
import { IndexCache } from "./index-cache.ts";
|
|
6
4
|
import { createMcpServer } from "./server.ts";
|
|
7
5
|
|
|
8
6
|
export async function serveMcp(options: {
|
|
9
|
-
path?: string | null;
|
|
10
7
|
ref?: string | null;
|
|
11
8
|
content?: ContentType[];
|
|
12
9
|
}): Promise<void> {
|
|
13
|
-
const cache = new IndexCache(options.content ?? ["code"]);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
void cache.get(defaultSource, options.ref).catch(() => undefined);
|
|
17
|
-
if (!isGitUrl(defaultSource) && mcpWatchEnabled()) {
|
|
18
|
-
cache.startWatcher(defaultSource);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const server = createMcpServer(cache, defaultSource);
|
|
10
|
+
const cache = new IndexCache(options.content ?? ["code"], options.ref ?? null);
|
|
11
|
+
const server = createMcpServer(cache);
|
|
22
12
|
const transport = new StdioServerTransport();
|
|
23
13
|
await server.connect(transport);
|
|
24
14
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -6,17 +6,17 @@ import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
|
|
|
6
6
|
|
|
7
7
|
const REPO_DESCRIPTION =
|
|
8
8
|
"https:// or http:// git URL (e.g. https://github.com/org/repo) or local directory path to index and search. " +
|
|
9
|
-
"
|
|
10
|
-
"The index is
|
|
9
|
+
"Pass the project root for local workspaces. " +
|
|
10
|
+
"The index is built on the first tool call and cached for the session.";
|
|
11
11
|
|
|
12
12
|
const SERVER_INSTRUCTIONS =
|
|
13
13
|
"Instant code search for any local or remote git repository. " +
|
|
14
14
|
"Call `search` to find relevant code; call `find_related` on a result to discover similar code elsewhere. " +
|
|
15
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"Always pass the project root as `repo` on the first call (local path or https:// git URL). " +
|
|
16
|
+
"Never guess or infer URLs. " +
|
|
17
17
|
"Prefer these tools over Grep, Glob, or Read for any question about how code works.";
|
|
18
18
|
|
|
19
|
-
export function createMcpServer(cache: IndexCache
|
|
19
|
+
export function createMcpServer(cache: IndexCache): McpServer {
|
|
20
20
|
const server = new McpServer(
|
|
21
21
|
{
|
|
22
22
|
name: "miru",
|
|
@@ -32,16 +32,16 @@ export function createMcpServer(cache: IndexCache, defaultSource: string | null
|
|
|
32
32
|
{
|
|
33
33
|
description:
|
|
34
34
|
"Search a codebase with a natural-language or code query. " +
|
|
35
|
-
"
|
|
35
|
+
"Indexes `repo` on the first call; later calls reuse the session cache.",
|
|
36
36
|
inputSchema: {
|
|
37
37
|
query: z.string().describe("Natural language or code query."),
|
|
38
|
-
repo: z.string().
|
|
38
|
+
repo: z.string().describe(REPO_DESCRIPTION),
|
|
39
39
|
top_k: z.number().int().min(1).optional().describe("Number of results to return."),
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
async ({ query, repo, top_k: topK }) => {
|
|
43
43
|
try {
|
|
44
|
-
const index = await getIndexForRepo(repo
|
|
44
|
+
const index = await getIndexForRepo(repo, cache);
|
|
45
45
|
const results = await index.search({ query, topK: topK ?? 5 });
|
|
46
46
|
if (results.length === 0) {
|
|
47
47
|
return toolText(JSON.stringify({ error: "No results found." }));
|
|
@@ -66,13 +66,13 @@ export function createMcpServer(cache: IndexCache, defaultSource: string | null
|
|
|
66
66
|
"Path to the file as stored in the index (use file_path from a search result).",
|
|
67
67
|
),
|
|
68
68
|
line: z.number().int().describe("Line number (1-indexed)."),
|
|
69
|
-
repo: z.string().
|
|
69
|
+
repo: z.string().describe(REPO_DESCRIPTION),
|
|
70
70
|
top_k: z.number().int().min(1).optional().describe("Number of similar chunks to return."),
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
async ({ file_path: filePath, line, repo, top_k: topK }) => {
|
|
74
74
|
try {
|
|
75
|
-
const index = await getIndexForRepo(repo
|
|
75
|
+
const index = await getIndexForRepo(repo, cache);
|
|
76
76
|
const chunk = resolveChunk(index.chunks, filePath, line);
|
|
77
77
|
if (!chunk) {
|
|
78
78
|
return toolText(
|
package/src/miru-index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { cloneGitRepository } from "./git.ts";
|
|
11
11
|
import type { BM25Index } from "./index/bm25.ts";
|
|
12
12
|
import { createIndexFromPath } from "./index/create.ts";
|
|
13
|
+
import { applyIncrementalFileChanges } from "./index/incremental.ts";
|
|
13
14
|
import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
|
|
14
15
|
import type { SemanticIndex } from "./index/semantic-index.ts";
|
|
15
16
|
import { hybridSearch, searchSemanticOnly } from "./search.ts";
|
|
@@ -19,16 +20,24 @@ import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
|
|
|
19
20
|
|
|
20
21
|
export class MiruIndex {
|
|
21
22
|
readonly embeddings: EmbeddingBackend;
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
private chunksInternal: Chunk[];
|
|
24
|
+
private bm25Index: BM25Index;
|
|
25
|
+
private semanticIndex: SemanticIndex;
|
|
26
|
+
private loadedFromDiskFlag: boolean;
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
private readonly semanticIndex: SemanticIndex;
|
|
27
|
-
private readonly embeddingModel: string;
|
|
28
|
+
readonly embeddingModel: string;
|
|
28
29
|
private readonly root: string | null;
|
|
29
30
|
private readonly content: ContentType[];
|
|
30
|
-
private
|
|
31
|
-
private
|
|
31
|
+
private fileMapping: Map<string, number[]>;
|
|
32
|
+
private languageMapping: Map<string, number[]>;
|
|
33
|
+
|
|
34
|
+
get chunks(): Chunk[] {
|
|
35
|
+
return this.chunksInternal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get loadedFromDisk(): boolean {
|
|
39
|
+
return this.loadedFromDiskFlag;
|
|
40
|
+
}
|
|
32
41
|
|
|
33
42
|
constructor(options: {
|
|
34
43
|
embeddings: EmbeddingBackend;
|
|
@@ -43,16 +52,21 @@ export class MiruIndex {
|
|
|
43
52
|
this.embeddings = options.embeddings;
|
|
44
53
|
this.bm25Index = options.bm25Index;
|
|
45
54
|
this.semanticIndex = options.semanticIndex;
|
|
46
|
-
this.
|
|
55
|
+
this.chunksInternal = options.chunks;
|
|
47
56
|
this.embeddingModel = options.embeddingModel;
|
|
48
57
|
this.root = options.root ?? null;
|
|
49
58
|
this.content = options.content ?? ["code"];
|
|
50
|
-
this.
|
|
59
|
+
this.loadedFromDiskFlag = options.loadedFromDisk ?? false;
|
|
60
|
+
this.fileMapping = new Map();
|
|
61
|
+
this.languageMapping = new Map();
|
|
62
|
+
this.rebuildMappings();
|
|
63
|
+
}
|
|
51
64
|
|
|
65
|
+
private rebuildMappings(): void {
|
|
52
66
|
this.fileMapping = new Map();
|
|
53
67
|
this.languageMapping = new Map();
|
|
54
|
-
for (let i = 0; i < this.
|
|
55
|
-
const chunk = this.
|
|
68
|
+
for (let i = 0; i < this.chunksInternal.length; i++) {
|
|
69
|
+
const chunk = this.chunksInternal[i];
|
|
56
70
|
if (!chunk) {
|
|
57
71
|
continue;
|
|
58
72
|
}
|
|
@@ -203,11 +217,40 @@ export class MiruIndex {
|
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
async saveToDefaultCache(sourcePath: string): Promise<void> {
|
|
206
|
-
if (!this.
|
|
220
|
+
if (!this.loadedFromDiskFlag) {
|
|
207
221
|
await this.save(findIndexCachePath(sourcePath));
|
|
208
222
|
}
|
|
209
223
|
}
|
|
210
224
|
|
|
225
|
+
async persistToCache(sourcePath: string): Promise<void> {
|
|
226
|
+
await this.save(findIndexCachePath(sourcePath));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Re-chunk and re-embed only the given repo-relative paths; drop their old chunks. */
|
|
230
|
+
async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
|
|
231
|
+
if (!this.root) {
|
|
232
|
+
throw new Error("Incremental update requires a local index with root_path set.");
|
|
233
|
+
}
|
|
234
|
+
if (relativePaths.length === 0) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const updated = await applyIncrementalFileChanges({
|
|
239
|
+
root: this.root,
|
|
240
|
+
content: this.content,
|
|
241
|
+
embeddings: this.embeddings,
|
|
242
|
+
chunks: this.chunksInternal,
|
|
243
|
+
semanticIndex: this.semanticIndex,
|
|
244
|
+
relativePaths,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
this.chunksInternal = updated.chunks;
|
|
248
|
+
this.bm25Index = updated.bm25;
|
|
249
|
+
this.semanticIndex = updated.semantic;
|
|
250
|
+
this.loadedFromDiskFlag = false;
|
|
251
|
+
this.rebuildMappings();
|
|
252
|
+
}
|
|
253
|
+
|
|
211
254
|
private getSelector(filterLanguages?: string[], filterPaths?: string[]): number[] | undefined {
|
|
212
255
|
const selector: number[] = [];
|
|
213
256
|
for (const lang of filterLanguages ?? []) {
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
2
|
+
import {
|
|
3
|
+
clearStoredCredentials,
|
|
4
|
+
hasEmbeddingApiKeyInEnv,
|
|
5
|
+
loadStoredCredentials,
|
|
6
|
+
readStoredCredentials,
|
|
7
|
+
resolveCredentialsPath,
|
|
8
|
+
saveStoredCredentials,
|
|
9
|
+
} from "./credentials.ts";
|
|
10
|
+
import { validateEmbeddingApiKey } from "./embeddings/validate.ts";
|
|
11
|
+
import { resolveEmbeddingApiKey } from "./env.ts";
|
|
12
|
+
import { Spinner } from "./spinner.ts";
|
|
13
|
+
|
|
14
|
+
async function promptHidden(message: string): Promise<string> {
|
|
15
|
+
if (!input.isTTY) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"Cannot prompt for API key: stdin is not a TTY. Run `miru setup --key YOUR_KEY` or set TAKARA_API_KEY.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
output.write(message);
|
|
23
|
+
input.setRawMode?.(true);
|
|
24
|
+
input.resume();
|
|
25
|
+
input.setEncoding("utf8");
|
|
26
|
+
|
|
27
|
+
let value = "";
|
|
28
|
+
const onData = (chunk: string) => {
|
|
29
|
+
const char = chunk[0];
|
|
30
|
+
if (char === "\n" || char === "\r" || char === "\u0004") {
|
|
31
|
+
cleanup();
|
|
32
|
+
output.write("\n");
|
|
33
|
+
resolve(value.trim());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (char === "\u0003") {
|
|
37
|
+
cleanup();
|
|
38
|
+
output.write("\n");
|
|
39
|
+
reject(new Error("Setup cancelled."));
|
|
40
|
+
process.exit(130);
|
|
41
|
+
}
|
|
42
|
+
if (char === "\u007f" || char === "\b") {
|
|
43
|
+
if (value.length > 0) {
|
|
44
|
+
value = value.slice(0, -1);
|
|
45
|
+
output.write("\b \b");
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
value += chunk;
|
|
50
|
+
output.write("*");
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
input.setRawMode?.(false);
|
|
55
|
+
input.pause();
|
|
56
|
+
input.removeListener("data", onData);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
input.on("data", onData);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function promptApiKey(): Promise<string> {
|
|
64
|
+
let key = "";
|
|
65
|
+
while (!key) {
|
|
66
|
+
key = await promptHidden("Takara API key (input hidden): ");
|
|
67
|
+
if (!key) {
|
|
68
|
+
output.write("API key cannot be empty.\n");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RunSetupOptions {
|
|
75
|
+
apiKey?: string;
|
|
76
|
+
force?: boolean;
|
|
77
|
+
skipValidation?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
81
|
+
if (!options.force && hasEmbeddingApiKeyInEnv()) {
|
|
82
|
+
const path = resolveCredentialsPath();
|
|
83
|
+
const stored = await readStoredCredentials();
|
|
84
|
+
if (stored) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`API key already configured (env var and ${path}). Use --force to replace the stored key.\n`,
|
|
87
|
+
);
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
process.stderr.write(
|
|
91
|
+
"API key already set via environment variable. Stored credentials were not changed.\n",
|
|
92
|
+
);
|
|
93
|
+
return resolveCredentialsPath();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!options.force) {
|
|
97
|
+
const stored = await readStoredCredentials();
|
|
98
|
+
if (stored && !options.apiKey) {
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
`API key already stored at ${resolveCredentialsPath()}. Use --force to replace it.\n`,
|
|
101
|
+
);
|
|
102
|
+
process.env.TAKARA_API_KEY = stored.takara_api_key;
|
|
103
|
+
return resolveCredentialsPath();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stderr.write("\nMiru (見る) — first-time setup\n\n");
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
"Miru needs a Takara API key for code embeddings.\n" +
|
|
110
|
+
"Get a bearer token from Takara, then enter it below.\n\n",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const apiKey = options.apiKey ?? (await promptApiKey());
|
|
114
|
+
|
|
115
|
+
if (!options.skipValidation) {
|
|
116
|
+
const spinner = new Spinner("Validating API key");
|
|
117
|
+
spinner.start();
|
|
118
|
+
const result = await validateEmbeddingApiKey({ apiKey });
|
|
119
|
+
if (!result.valid) {
|
|
120
|
+
spinner.fail(result.message);
|
|
121
|
+
throw new Error(result.message);
|
|
122
|
+
}
|
|
123
|
+
spinner.succeed("API key validated");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const path = await saveStoredCredentials(apiKey);
|
|
127
|
+
process.env.TAKARA_API_KEY = apiKey;
|
|
128
|
+
process.stderr.write(`\nSaved credentials to ${path} (readable only by your user account).\n`);
|
|
129
|
+
process.stderr.write("You can also set TAKARA_API_KEY in MCP config for IDE integrations.\n\n");
|
|
130
|
+
return path;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function runClearCredentials(): Promise<void> {
|
|
134
|
+
const { cleared, path } = await clearStoredCredentials();
|
|
135
|
+
if (cleared) {
|
|
136
|
+
process.stderr.write(`Removed stored API key from ${path}.\n`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
process.stderr.write(`No stored API key at ${path}.\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function ensureCredentials(options?: { interactive?: boolean }): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
resolveEmbeddingApiKey();
|
|
145
|
+
return;
|
|
146
|
+
} catch {
|
|
147
|
+
await loadStoredCredentials();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
resolveEmbeddingApiKey();
|
|
152
|
+
return;
|
|
153
|
+
} catch {
|
|
154
|
+
// still missing
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const interactive = options?.interactive ?? process.stdin.isTTY;
|
|
158
|
+
if (interactive) {
|
|
159
|
+
await runSetup();
|
|
160
|
+
resolveEmbeddingApiKey();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(
|
|
165
|
+
"Embedding API key required. Run `miru setup` in a terminal, or set TAKARA_API_KEY " +
|
|
166
|
+
"in your MCP server env (Cursor mcp.json) or .env.local.",
|
|
167
|
+
);
|
|
168
|
+
}
|
package/src/spinner.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
|
|
3
|
+
export class Spinner {
|
|
4
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
5
|
+
private frame = 0;
|
|
6
|
+
|
|
7
|
+
constructor(private readonly message: string) {}
|
|
8
|
+
|
|
9
|
+
start(): void {
|
|
10
|
+
if (!process.stderr.isTTY) {
|
|
11
|
+
process.stderr.write(`${this.message}...\n`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
this.timer = setInterval(() => {
|
|
15
|
+
const glyph = FRAMES[this.frame++ % FRAMES.length];
|
|
16
|
+
process.stderr.write(`\r${glyph} ${this.message}`);
|
|
17
|
+
}, 80);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
stop(finalMessage?: string): void {
|
|
21
|
+
if (this.timer) {
|
|
22
|
+
clearInterval(this.timer);
|
|
23
|
+
this.timer = null;
|
|
24
|
+
}
|
|
25
|
+
if (process.stderr.isTTY) {
|
|
26
|
+
process.stderr.write("\r\x1b[K");
|
|
27
|
+
}
|
|
28
|
+
if (finalMessage) {
|
|
29
|
+
process.stderr.write(`${finalMessage}\n`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
succeed(message?: string): void {
|
|
34
|
+
this.stop(message ?? `${this.message} — ok`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fail(message?: string): void {
|
|
38
|
+
this.stop(message ?? `${this.message} — failed`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function withSpinner<T>(
|
|
43
|
+
message: string,
|
|
44
|
+
fn: () => Promise<T>,
|
|
45
|
+
options?: { successMessage?: string; failMessage?: string },
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
const spinner = new Spinner(message);
|
|
48
|
+
spinner.start();
|
|
49
|
+
try {
|
|
50
|
+
const result = await fn();
|
|
51
|
+
spinner.succeed(options?.successMessage);
|
|
52
|
+
return result;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
spinner.fail(options?.failMessage);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|