@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.
@@ -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
+ }
@@ -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
+ }
@@ -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 re-index triggers (aligned with file-walker). */
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
- generation: number;
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 a re-index. */
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 = { generation: 0, task: null };
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
- entry: CacheEntry,
117
- generationAtStart: number,
95
+ cacheKey: string,
118
96
  ): Promise<MiruIndex> {
119
- return (async () => {
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
- for (;;) {
135
- const entry = this.ensureEntry(cacheKey);
136
- const generationAtStart = entry.generation;
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
- if (!entry.task) {
139
- entry.task = this.startBuild(source, ref, entry, generationAtStart);
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
- const task = entry.task;
143
- try {
144
- const index = await task;
145
- if (entry.generation !== generationAtStart) {
146
- continue;
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
- return index;
149
- } catch (err) {
150
- if (entry.task === task) {
151
- entry.task = null;
217
+ }
218
+ if (!index) {
219
+ for (const p of paths) {
220
+ entry.pendingPaths.add(p);
152
221
  }
153
- if (err instanceof StaleIndexError) {
154
- continue;
222
+ return;
223
+ }
224
+
225
+ try {
226
+ await index.applyFileChanges(paths);
227
+ if (!isGitUrl(source)) {
228
+ await index.persistToCache(resolve(source));
155
229
  }
156
- if (this.entries.get(cacheKey) === entry) {
157
- this.entries.delete(cacheKey);
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
- const debounceMs = resolveReindexDebounceMs();
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
- if (shouldIgnoreWatchPath(filename)) {
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
- readonly chunks: Chunk[];
23
- readonly loadedFromDisk: boolean;
23
+ private chunksInternal: Chunk[];
24
+ private bm25Index: BM25Index;
25
+ private semanticIndex: SemanticIndex;
26
+ private loadedFromDiskFlag: boolean;
24
27
 
25
- private readonly bm25Index: BM25Index;
26
- private readonly semanticIndex: SemanticIndex;
27
- private readonly embeddingModel: string;
28
+ readonly embeddingModel: string;
28
29
  private readonly root: string | null;
29
30
  private readonly content: ContentType[];
30
- private readonly fileMapping: Map<string, number[]>;
31
- private readonly languageMapping: Map<string, number[]>;
31
+ private fileMapping: Map<string, number[]>;
32
+ private languageMapping: Map<string, number[]>;
33
+
34
+ get chunks(): Chunk[] {
35
+ return this.chunksInternal;
36
+ }
37
+
38
+ get loadedFromDisk(): boolean {
39
+ return this.loadedFromDiskFlag;
40
+ }
32
41
 
33
42
  constructor(options: {
34
43
  embeddings: EmbeddingBackend;
@@ -43,16 +52,21 @@ export class MiruIndex {
43
52
  this.embeddings = options.embeddings;
44
53
  this.bm25Index = options.bm25Index;
45
54
  this.semanticIndex = options.semanticIndex;
46
- this.chunks = options.chunks;
55
+ this.chunksInternal = options.chunks;
47
56
  this.embeddingModel = options.embeddingModel;
48
57
  this.root = options.root ?? null;
49
58
  this.content = options.content ?? ["code"];
50
- this.loadedFromDisk = options.loadedFromDisk ?? false;
59
+ this.loadedFromDiskFlag = options.loadedFromDisk ?? false;
60
+ this.fileMapping = new Map();
61
+ this.languageMapping = new Map();
62
+ this.rebuildMappings();
63
+ }
51
64
 
65
+ private rebuildMappings(): void {
52
66
  this.fileMapping = new Map();
53
67
  this.languageMapping = new Map();
54
- for (let i = 0; i < this.chunks.length; i++) {
55
- const chunk = this.chunks[i];
68
+ for (let i = 0; i < this.chunksInternal.length; i++) {
69
+ const chunk = this.chunksInternal[i];
56
70
  if (!chunk) {
57
71
  continue;
58
72
  }
@@ -203,11 +217,40 @@ export class MiruIndex {
203
217
  }
204
218
 
205
219
  async saveToDefaultCache(sourcePath: string): Promise<void> {
206
- if (!this.loadedFromDisk) {
220
+ if (!this.loadedFromDiskFlag) {
207
221
  await this.save(findIndexCachePath(sourcePath));
208
222
  }
209
223
  }
210
224
 
225
+ async persistToCache(sourcePath: string): Promise<void> {
226
+ await this.save(findIndexCachePath(sourcePath));
227
+ }
228
+
229
+ /** Re-chunk and re-embed only the given repo-relative paths; drop their old chunks. */
230
+ async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
231
+ if (!this.root) {
232
+ throw new Error("Incremental update requires a local index with root_path set.");
233
+ }
234
+ if (relativePaths.length === 0) {
235
+ return;
236
+ }
237
+
238
+ const updated = await applyIncrementalFileChanges({
239
+ root: this.root,
240
+ content: this.content,
241
+ embeddings: this.embeddings,
242
+ chunks: this.chunksInternal,
243
+ semanticIndex: this.semanticIndex,
244
+ relativePaths,
245
+ });
246
+
247
+ this.chunksInternal = updated.chunks;
248
+ this.bm25Index = updated.bm25;
249
+ this.semanticIndex = updated.semantic;
250
+ this.loadedFromDiskFlag = false;
251
+ this.rebuildMappings();
252
+ }
253
+
211
254
  private getSelector(filterLanguages?: string[], filterPaths?: string[]): number[] | undefined {
212
255
  const selector: number[] = [];
213
256
  for (const lang of filterLanguages ?? []) {