@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/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 { isGitUrl } from "../utils.ts";
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 defaultSource = options.path ?? resolve(process.cwd());
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
- "Required when no default index was configured at startup. " +
10
- "The index is cached after the first call, so repeat queries are fast.";
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
- "When working in a local project, pass the project root as `repo`. " +
16
- "For remote repos, pass an explicit https:// URL. Never guess or infer URLs. " +
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, defaultSource: string | null = null): McpServer {
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
- "Pass a git URL or local path as `repo` to index it on demand; indexes are cached for the session.",
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().nullable().optional().describe(REPO_DESCRIPTION),
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 ?? null, defaultSource, cache);
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().nullable().optional().describe(REPO_DESCRIPTION),
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 ?? null, defaultSource, cache);
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
- readonly chunks: Chunk[];
23
- readonly loadedFromDisk: boolean;
23
+ private chunksInternal: Chunk[];
24
+ private bm25Index: BM25Index;
25
+ private semanticIndex: SemanticIndex;
26
+ private loadedFromDiskFlag: boolean;
24
27
 
25
- private readonly bm25Index: BM25Index;
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 readonly fileMapping: Map<string, number[]>;
31
- private readonly languageMapping: Map<string, number[]>;
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.chunks = options.chunks;
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.loadedFromDisk = options.loadedFromDisk ?? false;
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.chunks.length; i++) {
55
- const chunk = this.chunks[i];
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.loadedFromDisk) {
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
+ }