@takara-ai/miru-code 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -34
- package/package.json +1 -1
- package/src/cli.ts +39 -2
- package/src/credentials.ts +101 -0
- package/src/embeddings/openai.ts +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 +138 -85
- package/src/miru-index.ts +55 -12
- package/src/setup.ts +168 -0
- package/src/spinner.ts +57 -0
|
@@ -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;
|
|
@@ -67,25 +58,12 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
|
|
|
67
58
|
export class IndexCache {
|
|
68
59
|
private readonly content: ContentType[];
|
|
69
60
|
private readonly entries = new Map<string, CacheEntry>();
|
|
70
|
-
private readonly reindexTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
71
61
|
private watcher: ReturnType<typeof watch> | null = null;
|
|
72
62
|
|
|
73
63
|
constructor(content: ContentType[] = ["code"]) {
|
|
74
64
|
this.content = content;
|
|
75
65
|
}
|
|
76
66
|
|
|
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
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
67
|
private ensureEntry(cacheKey: string): CacheEntry {
|
|
90
68
|
let entry = this.entries.get(cacheKey);
|
|
91
69
|
if (!entry) {
|
|
@@ -95,87 +73,168 @@ export class IndexCache {
|
|
|
95
73
|
this.clearEntry(oldest);
|
|
96
74
|
}
|
|
97
75
|
}
|
|
98
|
-
entry = {
|
|
76
|
+
entry = {
|
|
77
|
+
index: null,
|
|
78
|
+
task: null,
|
|
79
|
+
pendingPaths: new Set(),
|
|
80
|
+
flushQueued: false,
|
|
81
|
+
updateChain: Promise.resolve(),
|
|
82
|
+
};
|
|
99
83
|
this.entries.set(cacheKey, entry);
|
|
100
84
|
}
|
|
101
85
|
return entry;
|
|
102
86
|
}
|
|
103
87
|
|
|
104
88
|
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
89
|
this.entries.delete(cacheKey);
|
|
111
90
|
}
|
|
112
91
|
|
|
113
92
|
private startBuild(
|
|
114
93
|
source: string,
|
|
115
94
|
ref: string | null | undefined,
|
|
116
|
-
|
|
117
|
-
generationAtStart: number,
|
|
95
|
+
cacheKey: string,
|
|
118
96
|
): Promise<MiruIndex> {
|
|
119
|
-
|
|
97
|
+
const task = (async () => {
|
|
120
98
|
const index = await MiruIndex.fromSource(source, this.content, undefined, ref);
|
|
121
|
-
if (entry.generation !== generationAtStart) {
|
|
122
|
-
throw new StaleIndexError();
|
|
123
|
-
}
|
|
124
99
|
if (!isGitUrl(source)) {
|
|
125
100
|
await index.saveToDefaultCache(resolve(source));
|
|
126
101
|
}
|
|
127
102
|
return index;
|
|
128
103
|
})();
|
|
104
|
+
|
|
105
|
+
const entry = this.ensureEntry(cacheKey);
|
|
106
|
+
entry.task = task;
|
|
107
|
+
void task
|
|
108
|
+
.then((index) => {
|
|
109
|
+
entry.index = index;
|
|
110
|
+
void this.flushFileUpdates(cacheKey, source);
|
|
111
|
+
return index;
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
if (entry.task === task) {
|
|
115
|
+
entry.task = null;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return task;
|
|
129
120
|
}
|
|
130
121
|
|
|
131
122
|
async get(source: string, ref?: string | null): Promise<MiruIndex> {
|
|
132
123
|
const cacheKey = computeSourceCacheKey(source, ref);
|
|
124
|
+
const entry = this.ensureEntry(cacheKey);
|
|
125
|
+
|
|
126
|
+
if (!entry.task) {
|
|
127
|
+
this.startBuild(source, ref, cacheKey);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const index = await entry.task;
|
|
131
|
+
if (!index) {
|
|
132
|
+
throw new Error(`Failed to load index for ${source}`);
|
|
133
|
+
}
|
|
134
|
+
await entry.updateChain;
|
|
135
|
+
return index;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private scheduleFlush(cacheKey: string, source: string, entry: CacheEntry): void {
|
|
139
|
+
if (!entry.flushQueued) {
|
|
140
|
+
entry.flushQueued = true;
|
|
141
|
+
queueMicrotask(() => {
|
|
142
|
+
this.flushFileUpdates(cacheKey, source);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private queueIndexedPaths(source: string, index: MiruIndex): void {
|
|
148
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
149
|
+
const entry = this.ensureEntry(cacheKey);
|
|
150
|
+
for (const chunk of index.chunks) {
|
|
151
|
+
entry.pendingPaths.add(normalizeRelativePath(chunk.file_path));
|
|
152
|
+
}
|
|
153
|
+
this.scheduleFlush(cacheKey, source, entry);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** macOS recursive fs.watch often omits filename; refresh all indexed paths incrementally. */
|
|
157
|
+
private noteAmbiguousDirectoryChange(source: string): void {
|
|
158
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
159
|
+
const entry = this.ensureEntry(cacheKey);
|
|
160
|
+
|
|
161
|
+
if (entry.index) {
|
|
162
|
+
this.queueIndexedPaths(source, entry.index);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (entry.task) {
|
|
167
|
+
void entry.task.then((index) => {
|
|
168
|
+
entry.index = index;
|
|
169
|
+
this.queueIndexedPaths(source, index);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private noteFileChange(source: string, filename: string | null | undefined): void {
|
|
175
|
+
if (!filename) {
|
|
176
|
+
this.noteAmbiguousDirectoryChange(source);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (shouldIgnoreWatchPath(filename)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const cacheKey = computeSourceCacheKey(source);
|
|
184
|
+
const entry = this.ensureEntry(cacheKey);
|
|
185
|
+
const rel = relativePathFromRoot(source, filename);
|
|
186
|
+
if (!rel) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
entry.pendingPaths.add(rel);
|
|
190
|
+
this.scheduleFlush(cacheKey, source, entry);
|
|
191
|
+
}
|
|
133
192
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
193
|
+
private flushFileUpdates(cacheKey: string, source: string): void {
|
|
194
|
+
const entry = this.entries.get(cacheKey);
|
|
195
|
+
if (!entry) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
entry.flushQueued = false;
|
|
137
199
|
|
|
138
|
-
|
|
139
|
-
|
|
200
|
+
const run = async (): Promise<void> => {
|
|
201
|
+
const paths = [...entry.pendingPaths];
|
|
202
|
+
entry.pendingPaths.clear();
|
|
203
|
+
if (paths.length === 0) {
|
|
204
|
+
return;
|
|
140
205
|
}
|
|
141
206
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
207
|
+
let index = entry.index;
|
|
208
|
+
if (!index && entry.task) {
|
|
209
|
+
try {
|
|
210
|
+
index = await entry.task;
|
|
211
|
+
} catch {
|
|
212
|
+
for (const p of paths) {
|
|
213
|
+
entry.pendingPaths.add(p);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
147
216
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
entry.
|
|
217
|
+
}
|
|
218
|
+
if (!index) {
|
|
219
|
+
for (const p of paths) {
|
|
220
|
+
entry.pendingPaths.add(p);
|
|
152
221
|
}
|
|
153
|
-
|
|
154
|
-
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await index.applyFileChanges(paths);
|
|
227
|
+
if (!isGitUrl(source)) {
|
|
228
|
+
await index.persistToCache(resolve(source));
|
|
155
229
|
}
|
|
156
|
-
|
|
157
|
-
|
|
230
|
+
} catch {
|
|
231
|
+
for (const p of paths) {
|
|
232
|
+
entry.pendingPaths.add(p);
|
|
158
233
|
}
|
|
159
|
-
throw err;
|
|
160
234
|
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private scheduleReindex(source: string, ref?: string | null): void {
|
|
165
|
-
const cacheKey = computeSourceCacheKey(source, ref);
|
|
166
|
-
const existing = this.reindexTimers.get(cacheKey);
|
|
167
|
-
if (existing) {
|
|
168
|
-
clearTimeout(existing);
|
|
169
|
-
}
|
|
235
|
+
};
|
|
170
236
|
|
|
171
|
-
|
|
172
|
-
const timer = setTimeout(() => {
|
|
173
|
-
this.reindexTimers.delete(cacheKey);
|
|
174
|
-
this.invalidate(cacheKey);
|
|
175
|
-
void this.get(source, ref).catch(() => undefined);
|
|
176
|
-
}, debounceMs);
|
|
177
|
-
|
|
178
|
-
this.reindexTimers.set(cacheKey, timer);
|
|
237
|
+
entry.updateChain = entry.updateChain.then(run, run);
|
|
179
238
|
}
|
|
180
239
|
|
|
181
240
|
startWatcher(path: string): void {
|
|
@@ -185,20 +244,14 @@ export class IndexCache {
|
|
|
185
244
|
}
|
|
186
245
|
|
|
187
246
|
this.watcher = watch(resolved, { recursive: true }, (_event, filename) => {
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
this.scheduleReindex(resolved);
|
|
247
|
+
this.noteFileChange(resolved, filename);
|
|
192
248
|
});
|
|
193
249
|
}
|
|
194
250
|
|
|
195
251
|
close(): void {
|
|
196
|
-
for (const timer of this.reindexTimers.values()) {
|
|
197
|
-
clearTimeout(timer);
|
|
198
|
-
}
|
|
199
|
-
this.reindexTimers.clear();
|
|
200
252
|
this.watcher?.close();
|
|
201
253
|
this.watcher = null;
|
|
254
|
+
this.entries.clear();
|
|
202
255
|
}
|
|
203
256
|
}
|
|
204
257
|
|
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 ?? []) {
|