@takara-ai/miru-code 0.5.0 → 0.6.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 +9 -0
- package/package.json +3 -1
- package/src/agents.ts +1 -4
- package/src/cli-ui.ts +4 -1
- package/src/cli.ts +1 -9
- package/src/concurrency.ts +1 -5
- package/src/embeddings/openai.ts +60 -30
- package/src/git.ts +1 -1
- package/src/index/create.ts +17 -10
- package/src/index/vector-storage.ts +1 -1
- package/src/installer/installer.ts +12 -13
- package/src/installer/prompt.ts +108 -32
- package/src/mcp/index-cache.ts +57 -24
- package/src/mcp/server.ts +19 -15
- package/src/ranking/location.ts +1 -5
- package/src/setup.ts +1 -1
- package/src/spinner.ts +1 -1
- package/src/utils.ts +39 -3
package/README.md
CHANGED
|
@@ -6,6 +6,12 @@ Find code by meaning, not grep. Miru returns the best **chunks** (path, lines, s
|
|
|
6
6
|
|
|
7
7
|
**Requires:** [Bun](https://bun.sh) 1.1+ · [Takara API key](https://takara.ai)
|
|
8
8
|
|
|
9
|
+
## Privacy and API usage
|
|
10
|
+
|
|
11
|
+
Miru sends **file contents** to the [Takara inference API](https://takara.ai) when building an index and when embedding search queries. Chunks from your repo are transmitted over HTTPS to generate embeddings. API usage may incur cost depending on your Takara plan.
|
|
12
|
+
|
|
13
|
+
If you index proprietary code, make sure that sending snippets to Takara's endpoint fits your security and compliance requirements. Use `MIRU_WORKSPACE_ROOT` to restrict MCP indexing to a single workspace directory.
|
|
14
|
+
|
|
9
15
|
## Install
|
|
10
16
|
|
|
11
17
|
```bash
|
|
@@ -143,6 +149,9 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
|
|
|
143
149
|
|----------|-------|
|
|
144
150
|
| `TAKARA_API_KEY` | Required |
|
|
145
151
|
| `MIRU_OPENAI_BASE_URL` | Default `https://infer.takara.ai/v1` |
|
|
152
|
+
| `MIRU_WORKSPACE_ROOT` | Restrict MCP local `repo` paths to this directory |
|
|
153
|
+
| `MIRU_MAX_INDEX_FILES` | Cap files indexed per operation |
|
|
154
|
+
| `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
|
|
146
155
|
| `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
|
|
147
156
|
| `NO_COLOR` | Disable CLI colors |
|
|
148
157
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takara-ai/miru-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"benchmark:ab": "bun run scripts/search-ab-local.ts",
|
|
54
54
|
"benchmark:ab-multi": "bun run scripts/search-ab-multi.ts",
|
|
55
55
|
"benchmark:recall": "bun run scripts/benchmark-recall-tokens.ts",
|
|
56
|
+
"benchmark:vs-grep": "bun run scripts/benchmark-miru-vs-grep.ts",
|
|
57
|
+
"benchmark:workflow": "bun run scripts/benchmark-workflow-tokens.ts",
|
|
56
58
|
"benchmark:quality": "bun run scripts/benchmark-quality.ts",
|
|
57
59
|
"benchmark:quantization": "bun run scripts/benchmark-quantization.ts"
|
|
58
60
|
},
|
package/src/agents.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
buildSubagentBody,
|
|
5
|
-
type NativeToolNames,
|
|
6
|
-
} from "./installer/search-policy.ts";
|
|
3
|
+
import { buildSubagentBody, type NativeToolNames } from "./installer/search-policy.ts";
|
|
7
4
|
|
|
8
5
|
export type AgentId = "claude" | "copilot" | "cursor" | "gemini" | "kiro" | "opencode";
|
|
9
6
|
|
package/src/cli-ui.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SearchResult } from "./types.ts";
|
|
2
|
+
import { formatRelevanceScore } from "./utils.ts";
|
|
2
3
|
|
|
3
4
|
const ANSI = {
|
|
4
5
|
reset: "\x1b[0m",
|
|
@@ -145,6 +146,8 @@ export function formatSearchResultsPretty(query: string, results: SearchResult[]
|
|
|
145
146
|
lines.push(bold(label) + dim(` for `) + cyan(`"${query}"`));
|
|
146
147
|
lines.push("");
|
|
147
148
|
|
|
149
|
+
const maxScore = results.reduce((max, result) => Math.max(max, result?.score ?? 0), 0);
|
|
150
|
+
|
|
148
151
|
for (let i = 0; i < results.length; i++) {
|
|
149
152
|
const result = results[i];
|
|
150
153
|
if (!result) {
|
|
@@ -154,7 +157,7 @@ export function formatSearchResultsPretty(query: string, results: SearchResult[]
|
|
|
154
157
|
const location = `${chunk.file_path}:${chunk.start_line}-${chunk.end_line}`;
|
|
155
158
|
const lang = chunk.language ? dim(` ${chunk.language}`) : "";
|
|
156
159
|
const rank = dim(`[${i + 1}]`);
|
|
157
|
-
const scoreText = magenta(score
|
|
160
|
+
const scoreText = magenta(formatRelevanceScore(score, maxScore));
|
|
158
161
|
|
|
159
162
|
lines.push(`${rank} ${scoreText} ${bold(location)}${lang}`);
|
|
160
163
|
lines.push(dim("─".repeat(Math.min(PREVIEW_WIDTH, 52))));
|
package/src/cli.ts
CHANGED
|
@@ -397,15 +397,7 @@ async function runCli(argv: string[]): Promise<void> {
|
|
|
397
397
|
}
|
|
398
398
|
const line = Number(lineRaw);
|
|
399
399
|
const path = resolveSearchPath(sizedRest[2] ?? process.cwd());
|
|
400
|
-
|
|
401
|
-
await runFindRelated(path, filePath, line, topK, content, jsonFlag);
|
|
402
|
-
} catch (err) {
|
|
403
|
-
if (err instanceof RelatedChunkNotFoundError) {
|
|
404
|
-
fail(err.message);
|
|
405
|
-
process.exit(1);
|
|
406
|
-
}
|
|
407
|
-
throw err;
|
|
408
|
-
}
|
|
400
|
+
await runFindRelated(path, filePath, line, topK, content, jsonFlag);
|
|
409
401
|
return;
|
|
410
402
|
}
|
|
411
403
|
|
package/src/concurrency.ts
CHANGED
|
@@ -11,11 +11,7 @@ export function resolveCpuCount(): number {
|
|
|
11
11
|
|
|
12
12
|
/** Worker pool size: logical CPUs minus 2, minimum 1. Override with MIRU_CONCURRENCY. */
|
|
13
13
|
export function resolveWorkerConcurrency(): number {
|
|
14
|
-
const raw =
|
|
15
|
-
process.env.MIRU_CONCURRENCY ??
|
|
16
|
-
process.env.SEMBLE_CONCURRENCY ??
|
|
17
|
-
process.env.MIRU_WORKERS ??
|
|
18
|
-
process.env.SEMBLE_WORKERS;
|
|
14
|
+
const raw = process.env.MIRU_CONCURRENCY ?? process.env.MIRU_WORKERS;
|
|
19
15
|
if (raw) {
|
|
20
16
|
const n = Number(raw);
|
|
21
17
|
if (Number.isFinite(n) && n >= 1) {
|
package/src/embeddings/openai.ts
CHANGED
|
@@ -32,14 +32,14 @@ export interface EmbeddingBackend {
|
|
|
32
32
|
|
|
33
33
|
export function resolveEmbeddingModel(): string {
|
|
34
34
|
return envFirstString(
|
|
35
|
-
["MIRU_OPENAI_EMBEDDING_MODEL", "
|
|
35
|
+
["MIRU_OPENAI_EMBEDDING_MODEL", "OPENAI_EMBEDDING_MODEL"],
|
|
36
36
|
DEFAULT_MODEL,
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function resolveMaxEmbedChars(): number {
|
|
41
41
|
return (
|
|
42
|
-
envOptionalInt(["MIRU_MAX_EMBED_CHARS"
|
|
42
|
+
envOptionalInt(["MIRU_MAX_EMBED_CHARS"], 256) ??
|
|
43
43
|
DEFAULT_MAX_EMBED_CHARS
|
|
44
44
|
);
|
|
45
45
|
}
|
|
@@ -73,13 +73,26 @@ function stripLoneSurrogates(text: string): string {
|
|
|
73
73
|
|
|
74
74
|
export function sanitizeEmbeddingInput(text: string): string {
|
|
75
75
|
const out = stripLoneSurrogates(text);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? "preserve";
|
|
77
|
+
if (mode === "quad") {
|
|
78
|
+
return out.replaceAll("\\", "\\\\\\\\");
|
|
79
|
+
}
|
|
79
80
|
if (mode === "strip") {
|
|
80
81
|
return out.replaceAll("\\", "/");
|
|
81
82
|
}
|
|
82
|
-
return out
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const TRANSIENT_EMBED_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
87
|
+
const MAX_TRANSIENT_EMBED_RETRIES = 4;
|
|
88
|
+
|
|
89
|
+
function isTransientEmbeddingError(err: unknown): boolean {
|
|
90
|
+
const status = (err as { status?: number }).status;
|
|
91
|
+
return status !== undefined && TRANSIENT_EMBED_STATUSES.has(status);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function transientEmbedBackoffMs(attempt: number): number {
|
|
95
|
+
return Math.min(250 * 2 ** attempt, 4000);
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
function isPayloadTooLargeError(err: unknown): boolean {
|
|
@@ -95,11 +108,7 @@ function isPayloadTooLargeError(err: unknown): boolean {
|
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
export function resolveEmbeddingDimensions(model?: string): number | undefined {
|
|
98
|
-
const fromEnv = envOptionalInt([
|
|
99
|
-
"MIRU_EMBEDDING_DIMENSIONS",
|
|
100
|
-
"SEMBLE_EMBEDDING_DIMENSIONS",
|
|
101
|
-
"OPENAI_EMBEDDING_DIMENSIONS",
|
|
102
|
-
]);
|
|
111
|
+
const fromEnv = envOptionalInt(["MIRU_EMBEDDING_DIMENSIONS", "OPENAI_EMBEDDING_DIMENSIONS"]);
|
|
103
112
|
if (fromEnv != null) {
|
|
104
113
|
return fromEnv;
|
|
105
114
|
}
|
|
@@ -108,17 +117,14 @@ export function resolveEmbeddingDimensions(model?: string): number | undefined {
|
|
|
108
117
|
|
|
109
118
|
export function resolveEmbeddingBatchSize(): number {
|
|
110
119
|
return (
|
|
111
|
-
envOptionalInt([
|
|
112
|
-
|
|
113
|
-
"SEMBLE_EMBEDDING_BATCH_SIZE",
|
|
114
|
-
"OPENAI_EMBEDDING_BATCH_SIZE",
|
|
115
|
-
]) ?? DEFAULT_BATCH_SIZE
|
|
120
|
+
envOptionalInt(["MIRU_EMBEDDING_BATCH_SIZE", "OPENAI_EMBEDDING_BATCH_SIZE"]) ??
|
|
121
|
+
DEFAULT_BATCH_SIZE
|
|
116
122
|
);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
export function resolveEmbeddingBaseUrl(): string {
|
|
120
126
|
return envFirstString(
|
|
121
|
-
["MIRU_OPENAI_BASE_URL", "
|
|
127
|
+
["MIRU_OPENAI_BASE_URL", "OPENAI_BASE_URL"],
|
|
122
128
|
DEFAULT_BASE_URL,
|
|
123
129
|
).replace(/\/$/, "");
|
|
124
130
|
}
|
|
@@ -282,23 +288,42 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
282
288
|
return out;
|
|
283
289
|
}
|
|
284
290
|
|
|
291
|
+
private async requestEmbeddings(texts: string[]): Promise<EmbeddingResponse> {
|
|
292
|
+
let lastErr: unknown;
|
|
293
|
+
for (let attempt = 0; attempt <= MAX_TRANSIENT_EMBED_RETRIES; attempt++) {
|
|
294
|
+
try {
|
|
295
|
+
const started = performance.now();
|
|
296
|
+
const response = await this.client.createEmbeddings(
|
|
297
|
+
texts,
|
|
298
|
+
this.model,
|
|
299
|
+
this.requestedDimensions,
|
|
300
|
+
);
|
|
301
|
+
const elapsed = performance.now() - started;
|
|
302
|
+
this.stats.requests += 1;
|
|
303
|
+
this.stats.inputItems += texts.length;
|
|
304
|
+
this.stats.inputChars += texts.reduce((acc, text) => acc + text.length, 0);
|
|
305
|
+
this.stats.totalRttMs += elapsed;
|
|
306
|
+
this.stats.maxRttMs = Math.max(this.stats.maxRttMs, elapsed);
|
|
307
|
+
return response;
|
|
308
|
+
} catch (err: unknown) {
|
|
309
|
+
lastErr = err;
|
|
310
|
+
if (isTransientEmbeddingError(err) && attempt < MAX_TRANSIENT_EMBED_RETRIES) {
|
|
311
|
+
this.stats.retries += 1;
|
|
312
|
+
await Bun.sleep(transientEmbedBackoffMs(attempt));
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
throw lastErr;
|
|
319
|
+
}
|
|
320
|
+
|
|
285
321
|
private async embedBatchRawWithRetry(texts: string[]): Promise<Float32Array[]> {
|
|
286
322
|
if (texts.length === 0) {
|
|
287
323
|
return [];
|
|
288
324
|
}
|
|
289
325
|
try {
|
|
290
|
-
const
|
|
291
|
-
const response = await this.client.createEmbeddings(
|
|
292
|
-
texts,
|
|
293
|
-
this.model,
|
|
294
|
-
this.requestedDimensions,
|
|
295
|
-
);
|
|
296
|
-
const elapsed = performance.now() - started;
|
|
297
|
-
this.stats.requests += 1;
|
|
298
|
-
this.stats.inputItems += texts.length;
|
|
299
|
-
this.stats.inputChars += texts.reduce((acc, text) => acc + text.length, 0);
|
|
300
|
-
this.stats.totalRttMs += elapsed;
|
|
301
|
-
this.stats.maxRttMs = Math.max(this.stats.maxRttMs, elapsed);
|
|
326
|
+
const response = await this.requestEmbeddings(texts);
|
|
302
327
|
const vectors = vectorsFromResponse(response.data, texts.length);
|
|
303
328
|
for (const vec of vectors) {
|
|
304
329
|
if (this.requestedDimensions != null && vec.length !== this.requestedDimensions) {
|
|
@@ -341,7 +366,12 @@ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
|
|
|
341
366
|
const rightText = text.slice(mid);
|
|
342
367
|
const left = await this.embedBatchRawWithRetry([leftText]);
|
|
343
368
|
const right = await this.embedBatchRawWithRetry([rightText]);
|
|
344
|
-
|
|
369
|
+
const parts = [left[0], right[0]].filter((vec): vec is Float32Array => vec !== undefined);
|
|
370
|
+
if (parts.length === 0) {
|
|
371
|
+
this.stats.errors += 1;
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
return [poolWindowVectors(parts)];
|
|
345
375
|
}
|
|
346
376
|
this.stats.errors += 1;
|
|
347
377
|
throw err;
|
package/src/git.ts
CHANGED
|
@@ -7,7 +7,7 @@ const DEFAULT_CLONE_TIMEOUT_SEC = 60;
|
|
|
7
7
|
|
|
8
8
|
export async function cloneGitRepository(url: string, ref?: string | null): Promise<string> {
|
|
9
9
|
const timeoutSec =
|
|
10
|
-
envOptionalInt(["MIRU_CLONE_TIMEOUT"
|
|
10
|
+
envOptionalInt(["MIRU_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
|
|
11
11
|
const dir = await mkdtemp(join(tmpdir(), "miru-git-"));
|
|
12
12
|
const args = ["clone", "--depth", "1"];
|
|
13
13
|
if (ref) {
|
package/src/index/create.ts
CHANGED
|
@@ -3,47 +3,52 @@ import { chunkSource } from "../chunking/chunking.ts";
|
|
|
3
3
|
import { resolveWorkerConcurrency } from "../concurrency.ts";
|
|
4
4
|
import type { EmbeddingBackend } from "../embeddings/openai.ts";
|
|
5
5
|
import { envOptionalInt } from "../env.ts";
|
|
6
|
+
import { searchImprovementsEnabled } from "../ranking/features.ts";
|
|
6
7
|
import { tokenize } from "../tokens.ts";
|
|
7
8
|
import type { Chunk, ContentType } from "../types.ts";
|
|
8
9
|
import { BM25Index } from "./bm25.ts";
|
|
10
|
+
import { loadRootEntryChunks } from "./entry-chunks.ts";
|
|
9
11
|
import { walkFiles } from "./file-walker.ts";
|
|
10
12
|
import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
|
|
11
13
|
import type { SemanticIndex } from "./semantic-index.ts";
|
|
12
|
-
import { loadRootEntryChunks } from "./entry-chunks.ts";
|
|
13
14
|
import { enrichForBm25 } from "./sparse.ts";
|
|
14
15
|
import { buildSemanticIndex } from "./vector-storage.ts";
|
|
15
|
-
import { searchImprovementsEnabled } from "../ranking/features.ts";
|
|
16
16
|
|
|
17
17
|
const DEFAULT_PIPELINE_EMBED_BATCH = 64;
|
|
18
18
|
const DEFAULT_PIPELINE_EMBED_INFLIGHT = 8;
|
|
19
19
|
|
|
20
20
|
function resolvePipelineEmbedBatch(): number {
|
|
21
21
|
return (
|
|
22
|
-
envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH"
|
|
22
|
+
envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH"], 1) ??
|
|
23
23
|
DEFAULT_PIPELINE_EMBED_BATCH
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function resolvePipelineEmbedInflight(): number {
|
|
28
28
|
return (
|
|
29
|
-
envOptionalInt(["MIRU_PIPELINE_EMBED_INFLIGHT"
|
|
29
|
+
envOptionalInt(["MIRU_PIPELINE_EMBED_INFLIGHT"], 1) ??
|
|
30
30
|
DEFAULT_PIPELINE_EMBED_INFLIGHT
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function resolveMaxIndexFiles(): number | undefined {
|
|
35
|
+
return envOptionalInt(["MIRU_MAX_INDEX_FILES"], 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export async function createIndexFromPath(
|
|
35
39
|
path: string,
|
|
36
40
|
embeddings: EmbeddingBackend,
|
|
37
41
|
content: ContentType[] = ["code"],
|
|
38
42
|
displayRoot?: string,
|
|
39
43
|
): Promise<{ bm25: BM25Index; semantic: SemanticIndex; chunks: Chunk[] }> {
|
|
40
|
-
const profile = process.env.MIRU_PROFILE === "1"
|
|
44
|
+
const profile = process.env.MIRU_PROFILE === "1";
|
|
41
45
|
const started = performance.now();
|
|
42
46
|
let fileProcessMs = 0;
|
|
43
47
|
let embedBackpressureWaitMs = 0;
|
|
44
48
|
let fileEnumerated = 0;
|
|
45
49
|
let fileTasks = 0;
|
|
46
50
|
let emptyFileTasks = 0;
|
|
51
|
+
let fileErrors = 0;
|
|
47
52
|
|
|
48
53
|
if (profile && "resetStats" in embeddings && typeof embeddings.resetStats === "function") {
|
|
49
54
|
embeddings.resetStats();
|
|
@@ -55,6 +60,7 @@ export async function createIndexFromPath(
|
|
|
55
60
|
const fileConcurrency = resolveWorkerConcurrency();
|
|
56
61
|
const embedBatchSize = resolvePipelineEmbedBatch();
|
|
57
62
|
const maxEmbedInflight = resolvePipelineEmbedInflight();
|
|
63
|
+
const maxIndexFiles = resolveMaxIndexFiles();
|
|
58
64
|
const readyBySeq = new Map<number, Chunk[]>();
|
|
59
65
|
const pendingEmbedChunks: Chunk[] = [];
|
|
60
66
|
const emittedChunkBatches: Chunk[][] = [];
|
|
@@ -76,7 +82,7 @@ export async function createIndexFromPath(
|
|
|
76
82
|
const chunkPath = displayRoot ? relative(root, filePath).replace(/\\/g, "/") : filePath;
|
|
77
83
|
return chunkSource(source, chunkPath, language);
|
|
78
84
|
} catch {
|
|
79
|
-
|
|
85
|
+
fileErrors++;
|
|
80
86
|
return [];
|
|
81
87
|
} finally {
|
|
82
88
|
fileProcessMs += performance.now() - t;
|
|
@@ -136,6 +142,9 @@ export async function createIndexFromPath(
|
|
|
136
142
|
|
|
137
143
|
for await (const filePath of walkFiles(resolved, extensions)) {
|
|
138
144
|
fileEnumerated++;
|
|
145
|
+
if (maxIndexFiles != null && fileEnumerated > maxIndexFiles) {
|
|
146
|
+
throw new Error(`Index file budget exceeded: max ${maxIndexFiles} files per operation`);
|
|
147
|
+
}
|
|
139
148
|
while (runningFiles.size >= fileConcurrency) {
|
|
140
149
|
await Promise.race(runningFiles);
|
|
141
150
|
}
|
|
@@ -148,10 +157,7 @@ export async function createIndexFromPath(
|
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
if (searchImprovementsEnabled()) {
|
|
151
|
-
const entryChunks = await loadRootEntryChunks(
|
|
152
|
-
resolved,
|
|
153
|
-
displayRoot ? root : undefined,
|
|
154
|
-
);
|
|
160
|
+
const entryChunks = await loadRootEntryChunks(resolved, displayRoot ? root : undefined);
|
|
155
161
|
if (entryChunks.length > 0) {
|
|
156
162
|
readyBySeq.set(fileSeq++, entryChunks);
|
|
157
163
|
flushReadySequentialChunks();
|
|
@@ -187,6 +193,7 @@ export async function createIndexFromPath(
|
|
|
187
193
|
file_enumerated: fileEnumerated,
|
|
188
194
|
file_tasks: fileTasks,
|
|
189
195
|
empty_or_skipped_files: emptyFileTasks,
|
|
196
|
+
file_errors: fileErrors,
|
|
190
197
|
chunks: chunks.length,
|
|
191
198
|
vectors: vectors.length,
|
|
192
199
|
pipeline: {
|
|
@@ -6,7 +6,7 @@ export type SemanticStorage = "int8" | "float32";
|
|
|
6
6
|
|
|
7
7
|
/** Default: int8 quantized vectors (~4x less RAM). Set MIRU_FLOAT_VECTORS=1 for float32. */
|
|
8
8
|
export function resolveSemanticStorage(): SemanticStorage {
|
|
9
|
-
return process.env.MIRU_FLOAT_VECTORS === "1"
|
|
9
|
+
return process.env.MIRU_FLOAT_VECTORS === "1"
|
|
10
10
|
? "float32"
|
|
11
11
|
: "int8";
|
|
12
12
|
}
|
|
@@ -34,6 +34,8 @@ interface Integration {
|
|
|
34
34
|
id: IntegrationId;
|
|
35
35
|
label: string;
|
|
36
36
|
description: string;
|
|
37
|
+
experimental?: boolean;
|
|
38
|
+
defaultChecked?: boolean;
|
|
37
39
|
planPath: (agent: AgentTarget) => string | null;
|
|
38
40
|
apply: (agent: AgentTarget, mode: InstallMode) => Promise<WriteResult | null>;
|
|
39
41
|
}
|
|
@@ -152,35 +154,37 @@ const INTEGRATIONS: Integration[] = [
|
|
|
152
154
|
{
|
|
153
155
|
id: "mcp",
|
|
154
156
|
label: "MCP server",
|
|
155
|
-
description: "
|
|
157
|
+
description: "Miru search tools via MCP",
|
|
156
158
|
planPath: (agent) => agent.mcp?.path ?? null,
|
|
157
159
|
apply: applyMcp,
|
|
158
160
|
},
|
|
159
161
|
{
|
|
160
162
|
id: "instructions",
|
|
161
163
|
label: "Instructions",
|
|
162
|
-
description: "
|
|
164
|
+
description: "search policy in agent docs",
|
|
163
165
|
planPath: (agent) => agent.instructionsPath,
|
|
164
166
|
apply: applyInstructions,
|
|
165
167
|
},
|
|
166
168
|
{
|
|
167
169
|
id: "subagent",
|
|
168
170
|
label: "Sub-agent",
|
|
169
|
-
description: "
|
|
171
|
+
description: "miru-code sub-agent file",
|
|
170
172
|
planPath: (agent) => agent.subagentPath,
|
|
171
173
|
apply: applySubagent,
|
|
172
174
|
},
|
|
173
175
|
{
|
|
174
176
|
id: "rules",
|
|
175
177
|
label: "Cursor rules",
|
|
176
|
-
description: "
|
|
178
|
+
description: "search policy in .cursor/rules",
|
|
177
179
|
planPath: (agent) => agent.cursorRulesPath,
|
|
178
180
|
apply: applyCursorRules,
|
|
179
181
|
},
|
|
180
182
|
{
|
|
181
183
|
id: "hooks",
|
|
182
184
|
label: "Search hooks",
|
|
183
|
-
description: "blocks
|
|
185
|
+
description: "blocks built-in search; routes to Miru MCP",
|
|
186
|
+
experimental: true,
|
|
187
|
+
defaultChecked: false,
|
|
184
188
|
planPath: (agent) => agent.hooksPath,
|
|
185
189
|
apply: applyHooks,
|
|
186
190
|
},
|
|
@@ -269,9 +273,9 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
const integrationItems = INTEGRATIONS.map((integration) => ({
|
|
272
|
-
label: `${integration.label} — ${integration.description}`,
|
|
276
|
+
label: `${integration.label}${integration.experimental ? dim(" (experimental)") : ""} — ${integration.description}`,
|
|
273
277
|
value: integration,
|
|
274
|
-
checked: true,
|
|
278
|
+
checked: install ? (integration.defaultChecked ?? true) : true,
|
|
275
279
|
}));
|
|
276
280
|
|
|
277
281
|
const chosenIntegrations = await promptMultiSelect(
|
|
@@ -294,12 +298,7 @@ export async function runInstaller(mode: InstallMode): Promise<void> {
|
|
|
294
298
|
|
|
295
299
|
await apply(mode, chosenAgents, chosenIntegrations);
|
|
296
300
|
|
|
297
|
-
success(
|
|
298
|
-
install ? "Done! Restart your agents to pick up changes." : "Done! Configuration removed.",
|
|
299
|
-
);
|
|
300
|
-
if (install) {
|
|
301
|
-
hint("Restart agents after install. Hooks block built-in search in favor of Miru MCP.");
|
|
302
|
-
}
|
|
301
|
+
success(install ? "Done. Restart agents to apply changes." : "Done. Configuration removed.");
|
|
303
302
|
writeStdout("");
|
|
304
303
|
}
|
|
305
304
|
|
package/src/installer/prompt.ts
CHANGED
|
@@ -21,23 +21,55 @@ function writeRaw(text: string): void {
|
|
|
21
21
|
output.write(text);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function
|
|
25
|
-
|
|
24
|
+
function moveCursorUp(lines: number): void {
|
|
25
|
+
if (lines > 0) {
|
|
26
|
+
writeRaw(`${ESC}[${lines}A`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
function moveCursorDown(lines: number): void {
|
|
31
|
+
if (lines > 0) {
|
|
32
|
+
writeRaw(`${ESC}[${lines}B`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rewriteLineAtOffsetFromBottom(offsetFromBottom: number, content: string): void {
|
|
37
|
+
moveCursorUp(offsetFromBottom);
|
|
38
|
+
writeRaw(`${CURSOR_START}${CLEAR_LINE}${content}`);
|
|
39
|
+
moveCursorDown(offsetFromBottom);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MULTI_SELECT_HEADER_LINES = 3;
|
|
43
|
+
const MULTI_SELECT_TRAILER_LINES = 2;
|
|
44
|
+
|
|
45
|
+
function multiSelectTotalLines(itemCount: number): number {
|
|
46
|
+
return MULTI_SELECT_HEADER_LINES + itemCount + MULTI_SELECT_TRAILER_LINES;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function multiSelectItemOffsetFromBottom(itemIndex: number, itemCount: number): number {
|
|
50
|
+
return multiSelectTotalLines(itemCount) - (MULTI_SELECT_HEADER_LINES + itemIndex);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatMultiSelectItemLine(
|
|
54
|
+
item: { label: string; checked: boolean },
|
|
55
|
+
selected: boolean,
|
|
56
|
+
): string {
|
|
57
|
+
const pointer = selected ? cyan("›") : " ";
|
|
58
|
+
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
59
|
+
const label = selected ? bold(item.label) : item.label;
|
|
60
|
+
return ` ${pointer} ${mark} ${label}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateMultiSelectItemRow(
|
|
64
|
+
item: { label: string; checked: boolean },
|
|
65
|
+
itemIndex: number,
|
|
66
|
+
itemCount: number,
|
|
67
|
+
selected: boolean,
|
|
68
|
+
): void {
|
|
69
|
+
rewriteLineAtOffsetFromBottom(
|
|
70
|
+
multiSelectItemOffsetFromBottom(itemIndex, itemCount),
|
|
71
|
+
formatMultiSelectItemLine(item, selected),
|
|
72
|
+
);
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
function readKey(): Promise<Buffer> {
|
|
@@ -125,11 +157,7 @@ function buildMultiSelectLines<T>(
|
|
|
125
157
|
if (!item) {
|
|
126
158
|
continue;
|
|
127
159
|
}
|
|
128
|
-
|
|
129
|
-
const pointer = selected ? cyan("›") : " ";
|
|
130
|
-
const mark = item.checked ? cyan("[x]") : dim("[ ]");
|
|
131
|
-
const label = selected ? bold(item.label) : item.label;
|
|
132
|
-
lines.push(` ${pointer} ${mark} ${label}`);
|
|
160
|
+
lines.push(formatMultiSelectItemLine(item, i === cursor));
|
|
133
161
|
}
|
|
134
162
|
lines.push("");
|
|
135
163
|
lines.push(dim(footer));
|
|
@@ -150,11 +178,12 @@ export async function promptMultiSelect<T>(
|
|
|
150
178
|
|
|
151
179
|
const footer = "↑↓ move space toggle a all enter confirm ctrl-c cancel";
|
|
152
180
|
let cursor = 0;
|
|
153
|
-
const
|
|
181
|
+
const itemCount = items.length;
|
|
154
182
|
|
|
155
183
|
return withRawMode(async () => {
|
|
184
|
+
writeRaw(`${buildMultiSelectLines(title, items, cursor, footer).join("\n")}\n`);
|
|
185
|
+
|
|
156
186
|
while (true) {
|
|
157
|
-
drawFrame(buildMultiSelectLines(title, items, cursor, footer));
|
|
158
187
|
const key = parseKey(await readKey());
|
|
159
188
|
|
|
160
189
|
if (key === "ctrl-c") {
|
|
@@ -166,33 +195,74 @@ export async function promptMultiSelect<T>(
|
|
|
166
195
|
return items.filter((item) => item.checked).map((item) => item.value);
|
|
167
196
|
}
|
|
168
197
|
if (key === "up") {
|
|
169
|
-
|
|
198
|
+
if (cursor > 0) {
|
|
199
|
+
const previous = cursor;
|
|
200
|
+
cursor--;
|
|
201
|
+
const previousItem = items[previous];
|
|
202
|
+
const currentItem = items[cursor];
|
|
203
|
+
if (previousItem) {
|
|
204
|
+
updateMultiSelectItemRow(previousItem, previous, itemCount, false);
|
|
205
|
+
}
|
|
206
|
+
if (currentItem) {
|
|
207
|
+
updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
170
210
|
} else if (key === "down") {
|
|
171
|
-
cursor
|
|
211
|
+
if (cursor < items.length - 1) {
|
|
212
|
+
const previous = cursor;
|
|
213
|
+
cursor++;
|
|
214
|
+
const previousItem = items[previous];
|
|
215
|
+
const currentItem = items[cursor];
|
|
216
|
+
if (previousItem) {
|
|
217
|
+
updateMultiSelectItemRow(previousItem, previous, itemCount, false);
|
|
218
|
+
}
|
|
219
|
+
if (currentItem) {
|
|
220
|
+
updateMultiSelectItemRow(currentItem, cursor, itemCount, true);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
172
223
|
} else if (key === "space") {
|
|
173
224
|
const item = items[cursor];
|
|
174
225
|
if (item) {
|
|
175
226
|
item.checked = !item.checked;
|
|
227
|
+
updateMultiSelectItemRow(item, cursor, itemCount, true);
|
|
176
228
|
}
|
|
177
229
|
} else if (key === "all") {
|
|
178
230
|
const allChecked = items.every((item) => item.checked);
|
|
179
|
-
for (
|
|
231
|
+
for (let i = 0; i < items.length; i++) {
|
|
232
|
+
const item = items[i];
|
|
233
|
+
if (!item) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
180
236
|
item.checked = !allChecked;
|
|
237
|
+
updateMultiSelectItemRow(item, i, itemCount, i === cursor);
|
|
181
238
|
}
|
|
182
239
|
}
|
|
183
240
|
}
|
|
184
241
|
});
|
|
185
242
|
}
|
|
186
243
|
|
|
187
|
-
function
|
|
244
|
+
function buildConfirmPromptLine(question: string, yesSelected: boolean): string {
|
|
188
245
|
const yes = yesSelected ? green(bold(" Yes ")) : dim(" Yes ");
|
|
189
246
|
const no = yesSelected ? dim(" No ") : green(bold(" No "));
|
|
247
|
+
return `${question} ${yes} / ${no}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildConfirmLines(question: string, yesSelected: boolean): string[] {
|
|
190
251
|
return [
|
|
191
|
-
|
|
252
|
+
buildConfirmPromptLine(question, yesSelected),
|
|
192
253
|
dim("←→ or y/n to choose enter to confirm ctrl-c to cancel"),
|
|
193
254
|
];
|
|
194
255
|
}
|
|
195
256
|
|
|
257
|
+
const CONFIRM_PROMPT_LINES = 2;
|
|
258
|
+
|
|
259
|
+
function updateConfirmPromptLine(question: string, yesSelected: boolean): void {
|
|
260
|
+
rewriteLineAtOffsetFromBottom(
|
|
261
|
+
CONFIRM_PROMPT_LINES,
|
|
262
|
+
buildConfirmPromptLine(question, yesSelected),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
196
266
|
export async function promptConfirm(question: string, defaultYes = true): Promise<boolean> {
|
|
197
267
|
if (!input.isTTY) {
|
|
198
268
|
const rl = readline.createInterface({ input, output });
|
|
@@ -209,11 +279,11 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
209
279
|
}
|
|
210
280
|
|
|
211
281
|
let yesSelected = defaultYes;
|
|
212
|
-
const drawFrame = createFrameWriter();
|
|
213
282
|
|
|
214
283
|
return withRawMode(async () => {
|
|
284
|
+
writeRaw(`${buildConfirmLines(question, yesSelected).join("\n")}\n`);
|
|
285
|
+
|
|
215
286
|
while (true) {
|
|
216
|
-
drawFrame(buildConfirmLines(question, yesSelected));
|
|
217
287
|
const key = parseKey(await readKey());
|
|
218
288
|
|
|
219
289
|
if (key === "ctrl-c") {
|
|
@@ -225,9 +295,15 @@ export async function promptConfirm(question: string, defaultYes = true): Promis
|
|
|
225
295
|
return yesSelected;
|
|
226
296
|
}
|
|
227
297
|
if (key === "left" || key === "no") {
|
|
228
|
-
yesSelected
|
|
298
|
+
if (yesSelected) {
|
|
299
|
+
yesSelected = false;
|
|
300
|
+
updateConfirmPromptLine(question, yesSelected);
|
|
301
|
+
}
|
|
229
302
|
} else if (key === "right" || key === "yes") {
|
|
230
|
-
yesSelected
|
|
303
|
+
if (!yesSelected) {
|
|
304
|
+
yesSelected = true;
|
|
305
|
+
updateConfirmPromptLine(question, yesSelected);
|
|
306
|
+
}
|
|
231
307
|
}
|
|
232
308
|
}
|
|
233
309
|
});
|
package/src/mcp/index-cache.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
|
|
4
4
|
import { MiruIndex } from "../miru-index.ts";
|
|
5
5
|
import type { ContentType } from "../types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
computeSourceCacheKey,
|
|
8
|
+
isAllowedRepoSource,
|
|
9
|
+
isGitUrl,
|
|
10
|
+
validateLocalRepoPath,
|
|
11
|
+
} from "../utils.ts";
|
|
7
12
|
|
|
8
13
|
const CACHE_MAX_SIZE = 10;
|
|
9
14
|
|
|
@@ -28,7 +33,10 @@ const WATCH_IGNORED_DIR_NAMES = new Set([
|
|
|
28
33
|
".eggs",
|
|
29
34
|
]);
|
|
30
35
|
|
|
36
|
+
type WatcherHandle = ReturnType<typeof watch>;
|
|
37
|
+
|
|
31
38
|
type CacheEntry = {
|
|
39
|
+
source: string;
|
|
32
40
|
index: MiruIndex | null;
|
|
33
41
|
task: Promise<MiruIndex> | null;
|
|
34
42
|
pendingPaths: Set<string>;
|
|
@@ -37,7 +45,7 @@ type CacheEntry = {
|
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export function mcpWatchEnabled(): boolean {
|
|
40
|
-
const raw = process.env.MIRU_MCP_WATCH
|
|
48
|
+
const raw = process.env.MIRU_MCP_WATCH;
|
|
41
49
|
return raw !== "0" && raw !== "false";
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -59,15 +67,14 @@ export class IndexCache {
|
|
|
59
67
|
private readonly content: ContentType[];
|
|
60
68
|
private readonly defaultRef: string | null;
|
|
61
69
|
private readonly entries = new Map<string, CacheEntry>();
|
|
62
|
-
|
|
63
|
-
private watchedSource: string | null = null;
|
|
70
|
+
readonly watchers = new Map<string, WatcherHandle>();
|
|
64
71
|
|
|
65
72
|
constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
|
|
66
73
|
this.content = content;
|
|
67
74
|
this.defaultRef = defaultRef;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
private ensureEntry(cacheKey: string): CacheEntry {
|
|
77
|
+
private ensureEntry(cacheKey: string, source: string): CacheEntry {
|
|
71
78
|
let entry = this.entries.get(cacheKey);
|
|
72
79
|
if (!entry) {
|
|
73
80
|
if (this.entries.size >= CACHE_MAX_SIZE) {
|
|
@@ -77,6 +84,7 @@ export class IndexCache {
|
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
entry = {
|
|
87
|
+
source,
|
|
80
88
|
index: null,
|
|
81
89
|
task: null,
|
|
82
90
|
pendingPaths: new Set(),
|
|
@@ -89,9 +97,19 @@ export class IndexCache {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
private clearEntry(cacheKey: string): void {
|
|
100
|
+
const entry = this.entries.get(cacheKey);
|
|
101
|
+
if (entry && !isGitUrl(entry.source)) {
|
|
102
|
+
this.stopWatcher(resolve(entry.source));
|
|
103
|
+
}
|
|
92
104
|
this.entries.delete(cacheKey);
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
private stopWatcher(resolvedPath: string): void {
|
|
108
|
+
const watcher = this.watchers.get(resolvedPath);
|
|
109
|
+
watcher?.close();
|
|
110
|
+
this.watchers.delete(resolvedPath);
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
private startBuild(
|
|
96
114
|
source: string,
|
|
97
115
|
ref: string | null | undefined,
|
|
@@ -105,7 +123,7 @@ export class IndexCache {
|
|
|
105
123
|
return index;
|
|
106
124
|
})();
|
|
107
125
|
|
|
108
|
-
const entry = this.ensureEntry(cacheKey);
|
|
126
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
109
127
|
entry.task = task;
|
|
110
128
|
void task
|
|
111
129
|
.then((index) => {
|
|
@@ -126,7 +144,7 @@ export class IndexCache {
|
|
|
126
144
|
async get(source: string, ref?: string | null): Promise<MiruIndex> {
|
|
127
145
|
const resolvedRef = ref ?? this.defaultRef;
|
|
128
146
|
const cacheKey = computeSourceCacheKey(source, resolvedRef);
|
|
129
|
-
const entry = this.ensureEntry(cacheKey);
|
|
147
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
130
148
|
|
|
131
149
|
if (!entry.task) {
|
|
132
150
|
this.startBuild(source, resolvedRef, cacheKey);
|
|
@@ -151,7 +169,7 @@ export class IndexCache {
|
|
|
151
169
|
|
|
152
170
|
private queueIndexedPaths(source: string, index: MiruIndex): void {
|
|
153
171
|
const cacheKey = computeSourceCacheKey(source);
|
|
154
|
-
const entry = this.ensureEntry(cacheKey);
|
|
172
|
+
const entry = this.ensureEntry(cacheKey, source);
|
|
155
173
|
for (const chunk of index.chunks) {
|
|
156
174
|
entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
|
|
157
175
|
}
|
|
@@ -161,7 +179,10 @@ export class IndexCache {
|
|
|
161
179
|
/** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
|
|
162
180
|
private noteAmbiguousDirectoryChange(source: string): void {
|
|
163
181
|
const cacheKey = computeSourceCacheKey(source);
|
|
164
|
-
const entry = this.
|
|
182
|
+
const entry = this.entries.get(cacheKey);
|
|
183
|
+
if (!entry) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
165
186
|
|
|
166
187
|
if (entry.index) {
|
|
167
188
|
this.queueIndexedPaths(source, entry.index);
|
|
@@ -186,7 +207,10 @@ export class IndexCache {
|
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
const cacheKey = computeSourceCacheKey(source);
|
|
189
|
-
const entry = this.
|
|
210
|
+
const entry = this.entries.get(cacheKey);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
190
214
|
const rel = relativePathFromRoot(source, filename);
|
|
191
215
|
if (!rel) {
|
|
192
216
|
return;
|
|
@@ -246,29 +270,31 @@ export class IndexCache {
|
|
|
246
270
|
if (!mcpWatchEnabled() || isGitUrl(source)) {
|
|
247
271
|
return;
|
|
248
272
|
}
|
|
249
|
-
|
|
250
|
-
if (this.watchedSource === resolved) {
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
this.watchedSource = resolved;
|
|
254
|
-
this.startWatcher(resolved);
|
|
273
|
+
this.startWatcher(source);
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
startWatcher(path: string): void {
|
|
258
277
|
const resolved = resolve(path);
|
|
259
|
-
if (this.
|
|
260
|
-
|
|
278
|
+
if (this.watchers.has(resolved)) {
|
|
279
|
+
return;
|
|
261
280
|
}
|
|
262
281
|
|
|
263
|
-
|
|
282
|
+
const watcher = watch(resolved, { recursive: true }, (_event, filename) => {
|
|
264
283
|
this.noteFileChange(resolved, filename);
|
|
265
284
|
});
|
|
285
|
+
this.watchers.set(resolved, watcher);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
get watcher(): WatcherHandle | null {
|
|
289
|
+
const handles = [...this.watchers.values()];
|
|
290
|
+
return handles[handles.length - 1] ?? null;
|
|
266
291
|
}
|
|
267
292
|
|
|
268
293
|
close(): void {
|
|
269
|
-
this.
|
|
270
|
-
|
|
271
|
-
|
|
294
|
+
for (const watcher of this.watchers.values()) {
|
|
295
|
+
watcher.close();
|
|
296
|
+
}
|
|
297
|
+
this.watchers.clear();
|
|
272
298
|
this.entries.clear();
|
|
273
299
|
}
|
|
274
300
|
}
|
|
@@ -284,12 +310,19 @@ export async function getIndexForRepo(
|
|
|
284
310
|
);
|
|
285
311
|
}
|
|
286
312
|
|
|
287
|
-
if (isGitUrl(repo) && !
|
|
313
|
+
if (isGitUrl(repo) && !isAllowedRepoSource(repo)) {
|
|
314
|
+
if (repo.startsWith("http://")) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"Plain http:// git URLs are disabled by default. Set MIRU_ALLOW_HTTP_GIT=1 to opt in.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
288
319
|
throw new Error(
|
|
289
|
-
`Only https
|
|
320
|
+
`Only https:// git URLs or local directory paths are accepted as \`repo\`. Got: ${repo}`,
|
|
290
321
|
);
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
validateLocalRepoPath(repo);
|
|
325
|
+
|
|
293
326
|
try {
|
|
294
327
|
return await cache.get(repo, ref);
|
|
295
328
|
} catch (err) {
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import packageJson from "../../package.json";
|
|
4
|
+
import {
|
|
5
|
+
MCP_EXPAND_TOOL_DESCRIPTION,
|
|
6
|
+
MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
7
|
+
MCP_SEARCH_TOOL_DESCRIPTION,
|
|
8
|
+
MCP_SERVER_INSTRUCTIONS,
|
|
9
|
+
} from "../installer/search-policy.ts";
|
|
3
10
|
import type { ContentType } from "../types.ts";
|
|
4
11
|
import {
|
|
5
12
|
clampMcpTopK,
|
|
@@ -12,12 +19,6 @@ import {
|
|
|
12
19
|
MAX_MCP_TOP_K,
|
|
13
20
|
resolveChunk,
|
|
14
21
|
} from "../utils.ts";
|
|
15
|
-
import {
|
|
16
|
-
MCP_EXPAND_TOOL_DESCRIPTION,
|
|
17
|
-
MCP_FIND_RELATED_TOOL_DESCRIPTION,
|
|
18
|
-
MCP_SEARCH_TOOL_DESCRIPTION,
|
|
19
|
-
MCP_SERVER_INSTRUCTIONS,
|
|
20
|
-
} from "../installer/search-policy.ts";
|
|
21
22
|
import { getIndexForRepo, type IndexCache, toolText } from "./index-cache.ts";
|
|
22
23
|
|
|
23
24
|
const REPO_DESCRIPTION =
|
|
@@ -29,7 +30,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
29
30
|
const server = new McpServer(
|
|
30
31
|
{
|
|
31
32
|
name: "miru",
|
|
32
|
-
version:
|
|
33
|
+
version: packageJson.version,
|
|
33
34
|
},
|
|
34
35
|
{
|
|
35
36
|
instructions: MCP_SERVER_INSTRUCTIONS,
|
|
@@ -39,8 +40,7 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
39
40
|
server.registerTool(
|
|
40
41
|
"search",
|
|
41
42
|
{
|
|
42
|
-
description:
|
|
43
|
-
`${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
|
|
43
|
+
description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
|
|
44
44
|
inputSchema: {
|
|
45
45
|
query: z.string().describe("Natural language or code query."),
|
|
46
46
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
@@ -86,7 +86,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
86
86
|
line: z
|
|
87
87
|
.number()
|
|
88
88
|
.int()
|
|
89
|
-
.describe(
|
|
89
|
+
.describe(
|
|
90
|
+
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
91
|
+
),
|
|
90
92
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
91
93
|
before: z
|
|
92
94
|
.number()
|
|
@@ -143,13 +145,13 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
143
145
|
inputSchema: {
|
|
144
146
|
file_path: z
|
|
145
147
|
.string()
|
|
146
|
-
.describe(
|
|
147
|
-
"Path from a search hit (`file_path` or `absolute_path` for local repos).",
|
|
148
|
-
),
|
|
148
|
+
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
149
149
|
line: z
|
|
150
150
|
.number()
|
|
151
151
|
.int()
|
|
152
|
-
.describe(
|
|
152
|
+
.describe(
|
|
153
|
+
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
154
|
+
),
|
|
153
155
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
154
156
|
top_k: z
|
|
155
157
|
.number()
|
|
@@ -157,7 +159,9 @@ export function createMcpServer(cache: IndexCache): McpServer {
|
|
|
157
159
|
.min(1)
|
|
158
160
|
.max(MAX_MCP_TOP_K)
|
|
159
161
|
.optional()
|
|
160
|
-
.describe(
|
|
162
|
+
.describe(
|
|
163
|
+
`Number of similar chunks to return (default ${DEFAULT_MCP_TOP_K}, max ${MAX_MCP_TOP_K}).`,
|
|
164
|
+
),
|
|
161
165
|
},
|
|
162
166
|
},
|
|
163
167
|
async ({ file_path: filePath, line, repo, top_k: topK }) => {
|
package/src/ranking/location.ts
CHANGED
|
@@ -172,10 +172,7 @@ export function penalizeInstallerForLocation(
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
function hasSpecificImplementationTarget(
|
|
176
|
-
query: string,
|
|
177
|
-
chunksByKey: Map<string, Chunk>,
|
|
178
|
-
): boolean {
|
|
175
|
+
function hasSpecificImplementationTarget(query: string, chunksByKey: Map<string, Chunk>): boolean {
|
|
179
176
|
const lowered = query.toLowerCase();
|
|
180
177
|
for (const hint of IMPLEMENTATION_HINTS) {
|
|
181
178
|
if (!lowered.includes(hint)) {
|
|
@@ -234,7 +231,6 @@ export function boostExactStemMatches(
|
|
|
234
231
|
}
|
|
235
232
|
const parts = keyword.split("-").filter((p) => p.length > 2);
|
|
236
233
|
if (parts.length > 1 && parts.some((p) => p === stem)) {
|
|
237
|
-
continue;
|
|
238
234
|
}
|
|
239
235
|
}
|
|
240
236
|
}
|
package/src/setup.ts
CHANGED
|
@@ -63,7 +63,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
|
63
63
|
spinner.start();
|
|
64
64
|
const result = await validateEmbeddingApiKey({ apiKey });
|
|
65
65
|
if (!result.valid) {
|
|
66
|
-
spinner.
|
|
66
|
+
spinner.stop();
|
|
67
67
|
throw new Error(result.message);
|
|
68
68
|
}
|
|
69
69
|
spinner.succeed("API key validated");
|
package/src/spinner.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -17,11 +17,22 @@ export function isGitUrl(path: string): boolean {
|
|
|
17
17
|
return GIT_URL_SCHEMES.some((scheme) => path.startsWith(scheme)) || SCP_GIT_URL_RE.test(path);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function httpGitCloneAllowed(): boolean {
|
|
21
|
+
const raw = process.env.MIRU_ALLOW_HTTP_GIT;
|
|
22
|
+
return raw === "1" || raw === "true";
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export function isAllowedRepoSource(repo: string): boolean {
|
|
21
26
|
if (!isGitUrl(repo)) {
|
|
22
27
|
return true;
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
if (repo.startsWith("https://")) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (repo.startsWith("http://")) {
|
|
33
|
+
return httpGitCloneAllowed();
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/** Resolved local repo root for MCP output; null for remote git URLs. */
|
|
@@ -29,6 +40,20 @@ export function localRepoRoot(repo: string): string | null {
|
|
|
29
40
|
return isGitUrl(repo) ? null : resolve(repo);
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
/** When MIRU_WORKSPACE_ROOT is set, local repo paths must stay inside it. */
|
|
44
|
+
export function validateLocalRepoPath(repo: string): void {
|
|
45
|
+
const workspaceRoot = process.env.MIRU_WORKSPACE_ROOT?.trim();
|
|
46
|
+
if (!workspaceRoot || isGitUrl(repo)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const resolvedRepo = resolve(repo);
|
|
50
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
51
|
+
const prefix = resolvedWorkspace.endsWith(sep) ? resolvedWorkspace : `${resolvedWorkspace}${sep}`;
|
|
52
|
+
if (resolvedRepo !== resolvedWorkspace && !resolvedRepo.startsWith(prefix)) {
|
|
53
|
+
throw new Error(`Local repo path is outside workspace: ${repo}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
/** Map an absolute or repo-relative path to the index-relative form. */
|
|
33
58
|
export function toIndexedFilePath(filePath: string, repoRoot?: string | null): string {
|
|
34
59
|
if (!repoRoot) {
|
|
@@ -155,6 +180,15 @@ export function expandChunksAtLine(
|
|
|
155
180
|
return { anchor, chunks: fileChunks.slice(start, end) };
|
|
156
181
|
}
|
|
157
182
|
|
|
183
|
+
/** Relative relevance for MCP/CLI output (top hit in a batch is always 100%). */
|
|
184
|
+
export function formatRelevanceScore(score: number, maxScore: number): string {
|
|
185
|
+
if (maxScore <= 0) {
|
|
186
|
+
return "0%";
|
|
187
|
+
}
|
|
188
|
+
const pct = Math.round((score / maxScore) * 100);
|
|
189
|
+
return `${pct}%`;
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
export function formatResults(
|
|
159
193
|
query: string,
|
|
160
194
|
results: SearchResult[],
|
|
@@ -163,6 +197,7 @@ export function formatResults(
|
|
|
163
197
|
const repoRoot = options?.repoRoot ?? null;
|
|
164
198
|
const useSnippet = options?.snippet ?? searchSnippetsEnabled();
|
|
165
199
|
const payload = useSnippet ? applySnippetsToResults(results, query) : null;
|
|
200
|
+
const maxScore = results.reduce((max, result) => Math.max(max, result.score), 0);
|
|
166
201
|
|
|
167
202
|
return {
|
|
168
203
|
query,
|
|
@@ -170,6 +205,7 @@ export function formatResults(
|
|
|
170
205
|
const entry = payload?.[index];
|
|
171
206
|
const chunk = entry?.result.chunk ?? result.chunk;
|
|
172
207
|
const dict = chunkToResponseDict(chunk, repoRoot);
|
|
208
|
+
const score = formatRelevanceScore(result.score, maxScore);
|
|
173
209
|
if (entry?.meta.truncated) {
|
|
174
210
|
return {
|
|
175
211
|
chunk: {
|
|
@@ -179,12 +215,12 @@ export function formatResults(
|
|
|
179
215
|
full_start_line: entry.meta.full_start_line,
|
|
180
216
|
full_end_line: entry.meta.full_end_line,
|
|
181
217
|
},
|
|
182
|
-
score
|
|
218
|
+
score,
|
|
183
219
|
};
|
|
184
220
|
}
|
|
185
221
|
return {
|
|
186
222
|
chunk: dict,
|
|
187
|
-
score
|
|
223
|
+
score,
|
|
188
224
|
};
|
|
189
225
|
}),
|
|
190
226
|
};
|