@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
|
@@ -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;
|
package/src/index/dense.ts
CHANGED
|
@@ -22,6 +22,14 @@ export class VectorIndex implements SemanticIndex {
|
|
|
22
22
|
return this.vectors;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
vectorAt(docIndex: number): Float32Array {
|
|
26
|
+
const vec = this.vectors[docIndex];
|
|
27
|
+
if (!vec) {
|
|
28
|
+
throw new Error(`Missing vector at index ${docIndex}`);
|
|
29
|
+
}
|
|
30
|
+
return vec;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
memoryBytes(): number {
|
|
26
34
|
return this.vectors.length * this.dimensions * 4;
|
|
27
35
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { chunkSource } from "../chunking/chunking.ts";
|
|
3
|
+
import type { EmbeddingBackend } from "../embeddings/openai.ts";
|
|
4
|
+
import { tokenize } from "../tokens.ts";
|
|
5
|
+
import type { Chunk, ContentType } from "../types.ts";
|
|
6
|
+
import { BM25Index } from "./bm25.ts";
|
|
7
|
+
import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
|
|
8
|
+
import type { SemanticIndex } from "./semantic-index.ts";
|
|
9
|
+
import { enrichForBm25 } from "./sparse.ts";
|
|
10
|
+
import { buildSemanticIndex } from "./vector-storage.ts";
|
|
11
|
+
import { vectorAt } from "./vectors.ts";
|
|
12
|
+
|
|
13
|
+
export function normalizeRelativePath(path: string): string {
|
|
14
|
+
return path.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function chunksForFile(root: string, relativePath: string): Promise<Chunk[]> {
|
|
18
|
+
const rel = normalizeRelativePath(relativePath);
|
|
19
|
+
const absolute = join(root, rel);
|
|
20
|
+
try {
|
|
21
|
+
const status = await getFileStatus(absolute);
|
|
22
|
+
if (status !== "valid") {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const source = await readFileText(absolute);
|
|
26
|
+
const language = detectLanguage(absolute);
|
|
27
|
+
return chunkSource(source, rel, language);
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isIndexableRelativePath(relativePath: string, extensions: Set<string>): boolean {
|
|
34
|
+
const rel = normalizeRelativePath(relativePath);
|
|
35
|
+
const name = rel.split("/").pop() ?? rel;
|
|
36
|
+
if (name.toLowerCase() === "dockerfile") {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const dot = name.lastIndexOf(".");
|
|
40
|
+
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
|
|
41
|
+
return extensions.has(ext);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace chunks for the given repo-relative paths only: remove old chunks,
|
|
46
|
+
* embed new ones, rebuild BM25 + semantic indexes from the merged vector set.
|
|
47
|
+
*/
|
|
48
|
+
export async function applyIncrementalFileChanges(options: {
|
|
49
|
+
root: string;
|
|
50
|
+
content: ContentType[];
|
|
51
|
+
embeddings: EmbeddingBackend;
|
|
52
|
+
chunks: Chunk[];
|
|
53
|
+
semanticIndex: SemanticIndex;
|
|
54
|
+
relativePaths: readonly string[];
|
|
55
|
+
}): Promise<{ chunks: Chunk[]; bm25: BM25Index; semantic: SemanticIndex }> {
|
|
56
|
+
const root = resolve(options.root);
|
|
57
|
+
const extensions = new Set(getExtensions(options.content).map((e) => e.toLowerCase()));
|
|
58
|
+
const targets = new Set(
|
|
59
|
+
options.relativePaths
|
|
60
|
+
.map(normalizeRelativePath)
|
|
61
|
+
.filter((p) => p.length > 0 && isIndexableRelativePath(p, extensions)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (targets.size === 0) {
|
|
65
|
+
return {
|
|
66
|
+
chunks: options.chunks,
|
|
67
|
+
bm25: rebuildBm25(options.chunks),
|
|
68
|
+
semantic: options.semanticIndex,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keptChunks: Chunk[] = [];
|
|
73
|
+
const keptVectors: Float32Array[] = [];
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < options.chunks.length; i++) {
|
|
76
|
+
const chunk = options.chunks[i];
|
|
77
|
+
if (!chunk) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const rel = normalizeRelativePath(chunk.file_path);
|
|
81
|
+
if (targets.has(rel)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
keptChunks.push(chunk);
|
|
85
|
+
keptVectors.push(vectorAt(options.semanticIndex, i));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const addedChunks: Chunk[] = [];
|
|
89
|
+
for (const rel of targets) {
|
|
90
|
+
addedChunks.push(...(await chunksForFile(root, rel)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let addedVectors: Float32Array[] = [];
|
|
94
|
+
if (addedChunks.length > 0) {
|
|
95
|
+
addedVectors = await options.embeddings.embedDocuments(addedChunks.map((c) => c.content));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const chunks = [...keptChunks, ...addedChunks];
|
|
99
|
+
const vectors = [...keptVectors, ...addedVectors];
|
|
100
|
+
|
|
101
|
+
if (chunks.length === 0) {
|
|
102
|
+
throw new Error(`No indexed chunks remain under ${root}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (vectors.length !== chunks.length) {
|
|
106
|
+
throw new Error(`Vector count ${vectors.length} does not match chunk count ${chunks.length}.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
chunks,
|
|
111
|
+
bm25: rebuildBm25(chunks),
|
|
112
|
+
semantic: buildSemanticIndex(vectors),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rebuildBm25(chunks: Chunk[]): BM25Index {
|
|
117
|
+
const bm25 = new BM25Index();
|
|
118
|
+
bm25.index(chunks.map((c) => tokenize(enrichForBm25(c))));
|
|
119
|
+
return bm25;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Map an absolute changed path to a repo-relative path for chunk keys. */
|
|
123
|
+
export function relativePathFromRoot(root: string, absoluteOrRelative: string): string {
|
|
124
|
+
const resolvedRoot = resolve(root);
|
|
125
|
+
const candidate = resolve(resolvedRoot, absoluteOrRelative);
|
|
126
|
+
if (candidate.startsWith(`${resolvedRoot}/`) || candidate === resolvedRoot) {
|
|
127
|
+
return normalizeRelativePath(relative(resolvedRoot, candidate));
|
|
128
|
+
}
|
|
129
|
+
return normalizeRelativePath(absoluteOrRelative);
|
|
130
|
+
}
|
package/src/index/quantize.ts
CHANGED
|
@@ -86,6 +86,30 @@ export class QuantizedVectorIndex implements SemanticIndex {
|
|
|
86
86
|
return this.codes.length * this.dim + this.scales.byteLength;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
vectorAt(docIndex: number): Float32Array {
|
|
90
|
+
const codes = this.codes[docIndex];
|
|
91
|
+
const scale = this.scales[docIndex];
|
|
92
|
+
if (!codes || scale === undefined) {
|
|
93
|
+
throw new Error(`Missing quantized vector at index ${docIndex}`);
|
|
94
|
+
}
|
|
95
|
+
const out = new Float32Array(codes.length);
|
|
96
|
+
for (let i = 0; i < codes.length; i++) {
|
|
97
|
+
out[i] = (codes[i] ?? 0) * scale;
|
|
98
|
+
}
|
|
99
|
+
let norm = 0;
|
|
100
|
+
for (let i = 0; i < out.length; i++) {
|
|
101
|
+
const v = out[i] ?? 0;
|
|
102
|
+
norm += v * v;
|
|
103
|
+
}
|
|
104
|
+
norm = Math.sqrt(norm);
|
|
105
|
+
if (norm > 0) {
|
|
106
|
+
for (let i = 0; i < out.length; i++) {
|
|
107
|
+
out[i] = (out[i] ?? 0) / norm;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
query(
|
|
90
114
|
queryVector: Float32Array,
|
|
91
115
|
k: number,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { VectorIndex } from "./dense.ts";
|
|
2
|
+
import { QuantizedVectorIndex } from "./quantize.ts";
|
|
3
|
+
import type { SemanticIndex } from "./semantic-index.ts";
|
|
4
|
+
|
|
5
|
+
/** Recover a float embedding for incremental re-indexing (unchanged chunks). */
|
|
6
|
+
export function vectorAt(index: SemanticIndex, docIndex: number): Float32Array {
|
|
7
|
+
if (index instanceof VectorIndex) {
|
|
8
|
+
const vec = index.getVectors()[docIndex];
|
|
9
|
+
if (!vec) {
|
|
10
|
+
throw new Error(`Missing vector at index ${docIndex}`);
|
|
11
|
+
}
|
|
12
|
+
return vec;
|
|
13
|
+
}
|
|
14
|
+
if (index instanceof QuantizedVectorIndex) {
|
|
15
|
+
return index.vectorAt(docIndex);
|
|
16
|
+
}
|
|
17
|
+
throw new Error("Unsupported semantic index type");
|
|
18
|
+
}
|
package/src/mcp/index-cache.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
|
|
3
4
|
import { MiruIndex } from "../miru-index.ts";
|
|
4
|
-
import { envOptionalInt } from "../env.ts";
|
|
5
5
|
import type { ContentType } from "../types.ts";
|
|
6
6
|
import { computeSourceCacheKey, isGitUrl } from "../utils.ts";
|
|
7
7
|
|
|
8
8
|
const CACHE_MAX_SIZE = 10;
|
|
9
|
-
const DEFAULT_REINDEX_DEBOUNCE_MS = 3000;
|
|
10
9
|
|
|
11
|
-
/** Directory names we skip for MCP fs.watch
|
|
10
|
+
/** Directory names we skip for MCP fs.watch update triggers (aligned with file-walker). */
|
|
12
11
|
const WATCH_IGNORED_DIR_NAMES = new Set([
|
|
13
12
|
".git",
|
|
14
13
|
".hg",
|
|
@@ -29,28 +28,20 @@ const WATCH_IGNORED_DIR_NAMES = new Set([
|
|
|
29
28
|
".eggs",
|
|
30
29
|
]);
|
|
31
30
|
|
|
32
|
-
class StaleIndexError extends Error {
|
|
33
|
-
constructor() {
|
|
34
|
-
super("Index build superseded by a newer change");
|
|
35
|
-
this.name = "StaleIndexError";
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
31
|
type CacheEntry = {
|
|
40
|
-
|
|
32
|
+
index: MiruIndex | null;
|
|
41
33
|
task: Promise<MiruIndex> | null;
|
|
34
|
+
pendingPaths: Set<string>;
|
|
35
|
+
flushQueued: boolean;
|
|
36
|
+
updateChain: Promise<void>;
|
|
42
37
|
};
|
|
43
38
|
|
|
44
|
-
function resolveReindexDebounceMs(): number {
|
|
45
|
-
return envOptionalInt(["MIRU_MCP_REINDEX_DEBOUNCE_MS"], 0) ?? DEFAULT_REINDEX_DEBOUNCE_MS;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
39
|
export function mcpWatchEnabled(): boolean {
|
|
49
40
|
const raw = process.env.MIRU_MCP_WATCH ?? process.env.SEMBLE_MCP_WATCH;
|
|
50
41
|
return raw !== "0" && raw !== "false";
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
/** Returns true when a watch event path should not trigger
|
|
44
|
+
/** Returns true when a watch event path should not trigger an update. */
|
|
54
45
|
export function shouldIgnoreWatchPath(relativePath: string | null | undefined): boolean {
|
|
55
46
|
if (!relativePath) {
|
|
56
47
|
return false;
|
|
@@ -66,24 +57,14 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
|
|
|
66
57
|
|
|
67
58
|
export class IndexCache {
|
|
68
59
|
private readonly content: ContentType[];
|
|
60
|
+
private readonly defaultRef: string | null;
|
|
69
61
|
private readonly entries = new Map<string, CacheEntry>();
|
|
70
|
-
private readonly reindexTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
71
62
|
private watcher: ReturnType<typeof watch> | null = null;
|
|
63
|
+
private watchedSource: string | null = null;
|
|
72
64
|
|
|
73
|
-
constructor(content: ContentType[] = ["code"]) {
|
|
65
|
+
constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
|
|
74
66
|
this.content = content;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
evict(source: string, ref?: string | null): void {
|
|
78
|
-
this.invalidate(computeSourceCacheKey(source, ref));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
private invalidate(cacheKey: string): void {
|
|
82
|
-
const entry = this.entries.get(cacheKey);
|
|
83
|
-
if (entry) {
|
|
84
|
-
entry.generation++;
|
|
85
|
-
entry.task = null;
|
|
86
|
-
}
|
|
67
|
+
this.defaultRef = defaultRef;
|
|
87
68
|
}
|
|
88
69
|
|
|
89
70
|
private ensureEntry(cacheKey: string): CacheEntry {
|
|
@@ -95,87 +76,182 @@ export class IndexCache {
|
|
|
95
76
|
this.clearEntry(oldest);
|
|
96
77
|
}
|
|
97
78
|
}
|
|
98
|
-
entry = {
|
|
79
|
+
entry = {
|
|
80
|
+
index: null,
|
|
81
|
+
task: null,
|
|
82
|
+
pendingPaths: new Set(),
|
|
83
|
+
flushQueued: false,
|
|
84
|
+
updateChain: Promise.resolve(),
|
|
85
|
+
};
|
|
99
86
|
this.entries.set(cacheKey, entry);
|
|
100
87
|
}
|
|
101
88
|
return entry;
|
|
102
89
|
}
|
|
103
90
|
|
|
104
91
|
private clearEntry(cacheKey: string): void {
|
|
105
|
-
const timer = this.reindexTimers.get(cacheKey);
|
|
106
|
-
if (timer) {
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
this.reindexTimers.delete(cacheKey);
|
|
109
|
-
}
|
|
110
92
|
this.entries.delete(cacheKey);
|
|
111
93
|
}
|
|
112
94
|
|
|
113
95
|
private startBuild(
|
|
114
96
|
source: string,
|
|
115
97
|
ref: string | null | undefined,
|
|
116
|
-
|
|
117
|
-
generationAtStart: number,
|
|
98
|
+
cacheKey: string,
|
|
118
99
|
): Promise<MiruIndex> {
|
|
119
|
-
|
|
100
|
+
const task = (async () => {
|
|
120
101
|
const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
|
|
121
|
-
if (entry.generation !== generationAtStart) {
|
|
122
|
-
throw new StaleIndexError();
|
|
123
|
-
}
|
|
124
102
|
if (!isGitUrl(source)) {
|
|
125
103
|
await index.saveToDefaultCache(resolve(source));
|
|
126
104
|
}
|
|
127
105
|
return index;
|
|
128
106
|
})();
|
|
107
|
+
|
|
108
|
+
const entry = this.ensureEntry(cacheKey);
|
|
109
|
+
entry.task = task;
|
|
110
|
+
void task
|
|
111
|
+
.then((index) => {
|
|
112
|
+
entry.index = index;
|
|
113
|
+
this.maybeStartWatcher(source);
|
|
114
|
+
void this.flushFileUpdates(cacheKey, source);
|
|
115
|
+
return index;
|
|
116
|
+
})
|
|
117
|
+
.catch(() => {
|
|
118
|
+
if (entry.task === task) {
|
|
119
|
+
entry.task = null;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return task;
|
|
129
124
|
}
|
|
130
125
|
|
|
131
126
|
async get(source: string, ref?: string | null): Promise<MiruIndex> {
|
|
132
|
-
const
|
|
127
|
+
const resolvedRef = ref ?? this.defaultRef;
|
|
128
|
+
const cacheKey = computeSourceCacheKey(source, resolvedRef);
|
|
129
|
+
const entry = this.ensureEntry(cacheKey);
|
|
130
|
+
|
|
131
|
+
if (!entry.task) {
|
|
132
|
+
this.startBuild(source, resolvedRef, cacheKey);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const index = await entry.task;
|
|
136
|
+
if (!index) {
|
|
137
|
+
throw new Error(`Failed to load index for ${source}`);
|
|
138
|
+
}
|
|
139
|
+
await entry.updateChain;
|
|
140
|
+
return index;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private scheduleFlush(cacheKey: string, source: string, entry: CacheEntry): void {
|
|
144
|
+
if (!entry.flushQueued) {
|
|
145
|
+
entry.flushQueued = true;
|
|
146
|
+
queueMicrotask(() => {
|
|
147
|
+
this.flushFileUpdates(cacheKey, source);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private queueIndexedPaths(source: string, index: MiruIndex): void {
|
|
153
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
154
|
+
const entry = this.ensureEntry(cacheKey);
|
|
155
|
+
for (const chunk of index.chunks) {
|
|
156
|
+
entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
|
|
157
|
+
}
|
|
158
|
+
this.scheduleFlush(cacheKey, source, entry);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
|
|
162
|
+
private noteAmbiguousDirectoryChange(source: string): void {
|
|
163
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
164
|
+
const entry = this.ensureEntry(cacheKey);
|
|
133
165
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
if (entry.index) {
|
|
167
|
+
this.queueIndexedPaths(source, entry.index);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
137
170
|
|
|
138
|
-
|
|
139
|
-
|
|
171
|
+
if (entry.task) {
|
|
172
|
+
void entry.task.then((index) => {
|
|
173
|
+
entry.index = index;
|
|
174
|
+
this.queueIndexedPaths(source, index);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private noteFileChange(source: string, filename: string | null | undefined): void {
|
|
180
|
+
if (!filename) {
|
|
181
|
+
this.noteAmbiguousDirectoryChange(source);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (shouldIgnoreWatchPath(filename)) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
189
|
+
const entry = this.ensureEntry(cacheKey);
|
|
190
|
+
const rel = relativePathFromRoot(source, filename);
|
|
191
|
+
if (!rel) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
entry.pendingPaths.add(rel);
|
|
195
|
+
this.scheduleFlush(cacheKey, source, entry);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private flushFileUpdates(cacheKey: string, source: string): void {
|
|
199
|
+
const entry = this.entries.get(cacheKey);
|
|
200
|
+
if (!entry) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
entry.flushQueued = false;
|
|
204
|
+
|
|
205
|
+
const run = async (): Promise<void> => {
|
|
206
|
+
const paths = [...entry.pendingPaths];
|
|
207
|
+
entry.pendingPaths.clear();
|
|
208
|
+
if (paths.length === 0) {
|
|
209
|
+
return;
|
|
140
210
|
}
|
|
141
211
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
212
|
+
let index = entry.index;
|
|
213
|
+
if (!index && entry.task) {
|
|
214
|
+
try {
|
|
215
|
+
index = await entry.task;
|
|
216
|
+
} catch {
|
|
217
|
+
for (const p of paths) {
|
|
218
|
+
entry.pendingPaths.add(p);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
147
221
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
entry.
|
|
222
|
+
}
|
|
223
|
+
if (!index) {
|
|
224
|
+
for (const p of paths) {
|
|
225
|
+
entry.pendingPaths.add(p);
|
|
152
226
|
}
|
|
153
|
-
|
|
154
|
-
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await index.applyFileChanges(paths);
|
|
232
|
+
if (!isGitUrl(source)) {
|
|
233
|
+
await index.persistToCache(resolve(source));
|
|
155
234
|
}
|
|
156
|
-
|
|
157
|
-
|
|
235
|
+
} catch {
|
|
236
|
+
for (const p of paths) {
|
|
237
|
+
entry.pendingPaths.add(p);
|
|
158
238
|
}
|
|
159
|
-
throw err;
|
|
160
239
|
}
|
|
161
|
-
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
entry.updateChain = entry.updateChain.then(run, run);
|
|
162
243
|
}
|
|
163
244
|
|
|
164
|
-
private
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (existing) {
|
|
168
|
-
clearTimeout(existing);
|
|
245
|
+
private maybeStartWatcher(source: string): void {
|
|
246
|
+
if (!mcpWatchEnabled() || isGitUrl(source)) {
|
|
247
|
+
return;
|
|
169
248
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}, debounceMs);
|
|
177
|
-
|
|
178
|
-
this.reindexTimers.set(cacheKey, timer);
|
|
249
|
+
const resolved = resolve(source);
|
|
250
|
+
if (this.watchedSource === resolved) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.watchedSource = resolved;
|
|
254
|
+
this.startWatcher(resolved);
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
startWatcher(path: string): void {
|
|
@@ -185,47 +261,40 @@ export class IndexCache {
|
|
|
185
261
|
}
|
|
186
262
|
|
|
187
263
|
this.watcher = watch(resolved, { recursive: true }, (_event, filename) => {
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
this.scheduleReindex(resolved);
|
|
264
|
+
this.noteFileChange(resolved, filename);
|
|
192
265
|
});
|
|
193
266
|
}
|
|
194
267
|
|
|
195
268
|
close(): void {
|
|
196
|
-
for (const timer of this.reindexTimers.values()) {
|
|
197
|
-
clearTimeout(timer);
|
|
198
|
-
}
|
|
199
|
-
this.reindexTimers.clear();
|
|
200
269
|
this.watcher?.close();
|
|
201
270
|
this.watcher = null;
|
|
271
|
+
this.watchedSource = null;
|
|
272
|
+
this.entries.clear();
|
|
202
273
|
}
|
|
203
274
|
}
|
|
204
275
|
|
|
205
276
|
export async function getIndexForRepo(
|
|
206
277
|
repo: string | null | undefined,
|
|
207
|
-
defaultSource: string | null,
|
|
208
278
|
cache: IndexCache,
|
|
209
279
|
ref?: string | null,
|
|
210
280
|
): Promise<MiruIndex> {
|
|
211
|
-
if (
|
|
281
|
+
if (!repo) {
|
|
212
282
|
throw new Error(
|
|
213
|
-
|
|
283
|
+
"Pass an https:// or http:// git URL or local directory path as `repo` (project root for local workspaces).",
|
|
214
284
|
);
|
|
215
285
|
}
|
|
216
286
|
|
|
217
|
-
|
|
218
|
-
if (!source) {
|
|
287
|
+
if (isGitUrl(repo) && !repo.startsWith("https://") && !repo.startsWith("http://")) {
|
|
219
288
|
throw new Error(
|
|
220
|
-
|
|
289
|
+
`Only https://, http://, or local directory paths are accepted as \`repo\`. Got: ${repo}`,
|
|
221
290
|
);
|
|
222
291
|
}
|
|
223
292
|
|
|
224
293
|
try {
|
|
225
|
-
return await cache.get(
|
|
294
|
+
return await cache.get(repo, ref);
|
|
226
295
|
} catch (err) {
|
|
227
296
|
const message = err instanceof Error ? err.message : String(err);
|
|
228
|
-
throw new Error(`Failed to index ${
|
|
297
|
+
throw new Error(`Failed to index ${repo}: ${message}`);
|
|
229
298
|
}
|
|
230
299
|
}
|
|
231
300
|
|