@takara-ai/miru-code 1.9.0 → 1.10.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.
Files changed (59) hide show
  1. package/README.md +43 -48
  2. package/docs/coverage-badge.json +6 -0
  3. package/grammars/manifest.json +10 -2
  4. package/grammars/tree-sitter-astro.wasm +0 -0
  5. package/grammars/tree-sitter-php.wasm +0 -0
  6. package/grammars/tree-sitter-php_only.wasm +0 -0
  7. package/grammars/tree-sitter-sql.wasm +0 -0
  8. package/grammars/tree-sitter-svelte.wasm +0 -0
  9. package/grammars/tree-sitter-vue.wasm +0 -0
  10. package/package.json +10 -2
  11. package/src/agents.ts +4 -4
  12. package/src/auth/device.ts +26 -21
  13. package/src/auth/providers.ts +26 -11
  14. package/src/benchmark/compare.ts +3 -10
  15. package/src/benchmark/grep.ts +64 -37
  16. package/src/benchmark/locate-compare.ts +8 -1
  17. package/src/benchmark/rg-literal.ts +9 -4
  18. package/src/cache.ts +3 -3
  19. package/src/chunking/ast.ts +21 -3
  20. package/src/chunking/grammars.ts +8 -3
  21. package/src/chunking/structural.ts +13 -24
  22. package/src/cli-mode.ts +18 -0
  23. package/src/cli.ts +10 -45
  24. package/src/credentials.ts +8 -13
  25. package/src/embeddings/openai.ts +6 -3
  26. package/src/embeddings/sagemaker.ts +18 -3
  27. package/src/embeddings/validate.ts +10 -7
  28. package/src/git.ts +6 -2
  29. package/src/help.ts +5 -4
  30. package/src/index/bm25.ts +66 -6
  31. package/src/index/create.ts +5 -9
  32. package/src/index/files.ts +5 -1
  33. package/src/index/incremental.ts +48 -8
  34. package/src/index/sparse.ts +14 -2
  35. package/src/installer/agents.ts +85 -65
  36. package/src/installer/benchmark-mode.ts +40 -11
  37. package/src/installer/config.ts +105 -42
  38. package/src/installer/installer.ts +59 -34
  39. package/src/installer/legacy-hooks.ts +131 -0
  40. package/src/installer/prompt.ts +20 -22
  41. package/src/installer/search-policy.ts +8 -8
  42. package/src/mcp/index-cache.ts +16 -3
  43. package/src/mcp/server.ts +42 -22
  44. package/src/mcp/stdio.ts +17 -2
  45. package/src/miru-index.ts +54 -18
  46. package/src/prompt.ts +51 -14
  47. package/src/ranking/boosting.ts +5 -6
  48. package/src/ranking/location.ts +6 -10
  49. package/src/ranking/penalties.ts +14 -22
  50. package/src/search.ts +4 -12
  51. package/src/setup.ts +25 -13
  52. package/src/snippet.ts +8 -26
  53. package/src/spinner.ts +26 -16
  54. package/src/tokenizer/bert-wordpiece.ts +0 -6
  55. package/src/utils.ts +0 -4
  56. package/src/version.ts +4 -4
  57. package/src/installer/hooks/install.ts +0 -549
  58. package/src/installer/hooks/opencode-plugin.ts +0 -54
  59. package/src/installer/hooks/search-guard.ts +0 -272
@@ -26,6 +26,11 @@ function loadAwsCredentialProvider(): Promise<AwsCredentialProviderModule> {
26
26
  return awsCredentialProviderPromise;
27
27
  }
28
28
 
29
+ /** Warm the lazily loaded AWS SDK modules before the first SageMaker request. */
30
+ export async function preloadSageMakerSdk(): Promise<void> {
31
+ await Promise.all([loadSageMakerRuntime(), loadAwsCredentialProvider()]);
32
+ }
33
+
29
34
  /** arn:aws:sagemaker:<region>:<account-id>:endpoint/<endpoint-name> (also covers aws-cn/aws-us-gov). */
30
35
  const ENDPOINT_ARN_PATTERN = /^arn:aws[a-z0-9-]*:sagemaker:([a-z0-9-]+):(\d{12}):endpoint\/(.+)$/;
31
36
 
@@ -344,7 +349,13 @@ function buildRequestBody(
344
349
  return body;
345
350
  }
346
351
 
347
- export function createSageMakerClient(config: SageMakerEmbeddingConfig): EmbeddingClient {
352
+ export function createSageMakerClient(
353
+ config: SageMakerEmbeddingConfig,
354
+ loaders: {
355
+ loadRuntime?: () => Promise<SageMakerRuntimeModule>;
356
+ loadCredentials?: () => Promise<AwsCredentialProviderModule>;
357
+ } = {},
358
+ ): EmbeddingClient {
348
359
  let client: InstanceType<SageMakerRuntimeModule["SageMakerRuntimeClient"]> | null = null;
349
360
 
350
361
  return {
@@ -354,7 +365,10 @@ export function createSageMakerClient(config: SageMakerEmbeddingConfig): Embeddi
354
365
  dimensions?: number,
355
366
  ): Promise<EmbeddingResponse> {
356
367
  const [{ InvokeEndpointCommand, SageMakerRuntimeClient }, { defaultProvider }] =
357
- await Promise.all([loadSageMakerRuntime(), loadAwsCredentialProvider()]);
368
+ await Promise.all([
369
+ (loaders.loadRuntime ?? loadSageMakerRuntime)(),
370
+ (loaders.loadCredentials ?? loadAwsCredentialProvider)(),
371
+ ]);
358
372
  if (!client) {
359
373
  const profile = config.profile?.trim() || process.env.AWS_PROFILE?.trim();
360
374
  const sdkCredentials = defaultProvider({ profile });
@@ -414,9 +428,10 @@ export interface ValidateSageMakerResult {
414
428
  /** Real InvokeEndpoint round trip, used by `miru setup --sagemaker` to confirm auth + shape. */
415
429
  export async function validateSageMakerConnection(
416
430
  config: SageMakerEmbeddingConfig,
431
+ createClient: typeof createSageMakerClient = createSageMakerClient,
417
432
  ): Promise<ValidateSageMakerResult> {
418
433
  try {
419
- const client = createSageMakerClient(config);
434
+ const client = createClient(config);
420
435
  const response = await client.createEmbeddings("miru setup validation", "sagemaker-setup");
421
436
  const embedding = response.data[0]?.embedding;
422
437
  if (!embedding) {
@@ -12,12 +12,15 @@ export interface ValidateApiKeyResult {
12
12
  message: string;
13
13
  }
14
14
 
15
- export async function validateEmbeddingApiKey(options: {
16
- apiKey: string;
17
- baseUrl?: string;
18
- model?: string;
19
- dimensions?: number;
20
- }): Promise<ValidateApiKeyResult> {
15
+ export async function validateEmbeddingApiKey(
16
+ options: {
17
+ apiKey: string;
18
+ baseUrl?: string;
19
+ model?: string;
20
+ dimensions?: number;
21
+ },
22
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch,
23
+ ): Promise<ValidateApiKeyResult> {
21
24
  const baseUrl = (options.baseUrl ?? resolveEmbeddingBaseUrl()).replace(/\/$/, "");
22
25
  const model = options.model ?? resolveTakaraEmbeddingModel();
23
26
  const dimensions = options.dimensions ?? resolveEmbeddingDimensions(model);
@@ -33,7 +36,7 @@ export async function validateEmbeddingApiKey(options: {
33
36
 
34
37
  let response: Response;
35
38
  try {
36
- response = await fetch(endpoint, {
39
+ response = await fetchImpl(endpoint, {
37
40
  method: "POST",
38
41
  headers: {
39
42
  Authorization: `Bearer ${options.apiKey}`,
package/src/git.ts CHANGED
@@ -5,7 +5,11 @@ import { envOptionalInt } from "./env.ts";
5
5
 
6
6
  const DEFAULT_CLONE_TIMEOUT_SEC = 60;
7
7
 
8
- export async function cloneGitRepository(url: string, ref?: string | null): Promise<string> {
8
+ export async function cloneGitRepository(
9
+ url: string,
10
+ ref?: string | null,
11
+ spawn: typeof Bun.spawn = Bun.spawn,
12
+ ): Promise<string> {
9
13
  const timeoutSec = envOptionalInt(["MIRU_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
10
14
  const dir = await mkdtemp(join(tmpdir(), "miru-git-"));
11
15
  const args = ["clone", "--depth", "1"];
@@ -16,7 +20,7 @@ export async function cloneGitRepository(url: string, ref?: string | null): Prom
16
20
 
17
21
  let proc: ReturnType<typeof Bun.spawn>;
18
22
  try {
19
- proc = Bun.spawn(["git", ...args], {
23
+ proc = spawn(["git", ...args], {
20
24
  stdout: "pipe",
21
25
  stderr: "pipe",
22
26
  stdin: "ignore",
package/src/help.ts CHANGED
@@ -28,7 +28,7 @@ export function printMainHelp(): void {
28
28
  header();
29
29
 
30
30
  section("Usage");
31
- writeStdout(" miru Start MCP server (stdio)");
31
+ writeStdout(" miru Show help in a terminal");
32
32
  writeStdout(" miru <command> [options]");
33
33
  divider();
34
34
 
@@ -37,6 +37,7 @@ export function printMainHelp(): void {
37
37
  commandRow("locate", "Exact substring location in the index");
38
38
  commandRow("expand", "Adjacent chunks in the same file as a hit");
39
39
  commandRow("find-related", "Find chunks related to a file:line");
40
+ commandRow("mcp", "Start MCP server (stdio)");
40
41
  commandRow("setup", "Authenticate with Takara");
41
42
  commandRow("install", "Configure miru across coding agents");
42
43
  commandRow("uninstall", "Remove miru agent configuration");
@@ -177,7 +178,7 @@ export function printCommandHelp(command: string): void {
177
178
  case "install":
178
179
  commandHeader("install", "Interactive global agent setup.");
179
180
  writeStdout("Configures MCP server, instructions, sub-agent files, and optional");
180
- writeStdout("experimental integrations (search hooks, Caveman, STE writing) under");
181
+ writeStdout("experimental integrations (Caveman, STE writing) under");
181
182
  writeStdout("your user config (~/.claude, ~/.cursor, etc.).");
182
183
  writeStdout("");
183
184
  writeStdout(
@@ -243,9 +244,9 @@ export function printCommandHelp(command: string): void {
243
244
  writeStdout("");
244
245
  return;
245
246
  case "mcp":
246
- commandHeader("mcp", "Stdio MCP server (default with no subcommand).");
247
+ commandHeader("mcp", "Stdio MCP server.");
247
248
  section("Usage");
248
- writeStdout(" miru [--ref BRANCH] [--content TYPE ...] [--benchmark]");
249
+ writeStdout(" miru mcp [--ref BRANCH] [--content TYPE ...] [--benchmark]");
249
250
  writeStdout(" Default content: code config");
250
251
  writeStdout("");
251
252
  writeStdout("Indexes on the first search/expand/find_related tool call (repo argument).");
package/src/index/bm25.ts CHANGED
@@ -42,12 +42,21 @@ function runScoreWorker(job: Bm25ScoreJob): Promise<Bm25ScoreResult> {
42
42
  }
43
43
 
44
44
  export class BM25Index {
45
- private docFreq = new Map<string, number>();
46
- private docLengths: number[] = [];
47
- private postings = new Map<string, PostingsList>();
48
- private avgDocLength = 0;
49
- private numDocs = 0;
50
- private totalLen = 0;
45
+ private docFreq: Map<string, number>;
46
+ private docLengths: number[];
47
+ private postings: Map<string, PostingsList>;
48
+ private avgDocLength: number;
49
+ private numDocs: number;
50
+ private totalLen: number;
51
+
52
+ constructor() {
53
+ this.docFreq = new Map();
54
+ this.docLengths = [];
55
+ this.postings = new Map();
56
+ this.avgDocLength = 0;
57
+ this.numDocs = 0;
58
+ this.totalLen = 0;
59
+ }
51
60
 
52
61
  /** Append one tokenized document. Safe to call incrementally while indexing. */
53
62
  addDocument(doc: string[]): number {
@@ -73,6 +82,57 @@ export class BM25Index {
73
82
  return docIndex;
74
83
  }
75
84
 
85
+ get size(): number {
86
+ return this.numDocs;
87
+ }
88
+
89
+ /**
90
+ * A new index with the given documents removed and later doc indices shifted down,
91
+ * equal to re-indexing the surviving docs in order. `this` is left untouched, so
92
+ * searches already holding it never see a half-updated index.
93
+ */
94
+ withoutDocuments(removed: ReadonlySet<number>): BM25Index {
95
+ const next = new BM25Index();
96
+ const remap = new Int32Array(this.numDocs);
97
+ let kept = 0;
98
+ let firstRemoved = this.numDocs;
99
+ for (let doc = 0; doc < this.numDocs; doc++) {
100
+ if (removed.has(doc)) {
101
+ remap[doc] = -1;
102
+ if (firstRemoved === this.numDocs) {
103
+ firstRemoved = doc;
104
+ }
105
+ } else {
106
+ remap[doc] = kept++;
107
+ const length = this.docLengths[doc] ?? 0;
108
+ next.docLengths.push(length);
109
+ next.totalLen += length;
110
+ }
111
+ }
112
+ next.numDocs = kept;
113
+ next.avgDocLength = kept === 0 ? 0 : next.totalLen / kept;
114
+
115
+ for (const [term, list] of this.postings) {
116
+ // Lists ascend by doc index, so one ending before the first removal is untouched.
117
+ // Copy the array (addDocument appends to it) but share the immutable tuples.
118
+ const untouched = (list.at(-1)?.[0] ?? 0) < firstRemoved;
119
+ const nextList: PostingsList = untouched ? list.slice() : [];
120
+ if (!untouched) {
121
+ for (const entry of list) {
122
+ const mapped = remap[entry[0]] ?? -1;
123
+ if (mapped !== -1) {
124
+ nextList.push(mapped === entry[0] ? entry : [mapped, entry[1]]);
125
+ }
126
+ }
127
+ }
128
+ if (nextList.length > 0) {
129
+ next.postings.set(term, nextList);
130
+ next.docFreq.set(term, nextList.length);
131
+ }
132
+ }
133
+ return next;
134
+ }
135
+
76
136
  index(tokenizedDocs: string[][]): void {
77
137
  this.numDocs = 0;
78
138
  this.docFreq.clear();
@@ -136,9 +136,6 @@ export async function createIndexFromPath(
136
136
  }
137
137
 
138
138
  const take = Math.min(embedBatchSize, pendingWindows.length);
139
- if (!force && take < embedBatchSize) {
140
- break;
141
- }
142
139
 
143
140
  const batch = pendingWindows.splice(0, take);
144
141
  emittedApiBatches++;
@@ -158,9 +155,10 @@ export async function createIndexFromPath(
158
155
  })();
159
156
  embedPromises.push(promise);
160
157
  embedInFlight.add(promise);
161
- promise.finally(() => {
162
- embedInFlight.delete(promise);
163
- });
158
+ promise.then(
159
+ () => embedInFlight.delete(promise),
160
+ () => embedInFlight.delete(promise),
161
+ );
164
162
  }
165
163
  };
166
164
 
@@ -209,9 +207,7 @@ export async function createIndexFromPath(
209
207
  await maybeScheduleEmbed(true);
210
208
  await Promise.all(embedPromises);
211
209
  // Finish any BM25 docs not yet processed during idle gaps.
212
- while (bm25Cursor < emittedChunks.length) {
213
- progressBm25(50);
214
- }
210
+ progressBm25(Number.POSITIVE_INFINITY);
215
211
  const embedsDoneAt = performance.now();
216
212
 
217
213
  const chunks = emittedChunks;
@@ -52,6 +52,9 @@ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
52
52
  ".zig": "zig",
53
53
  ".vue": "vue",
54
54
  ".svelte": "svelte",
55
+ ".astro": "astro",
56
+ ".erb": "embeddedtemplate",
57
+ ".ejs": "embeddedtemplate",
55
58
  ".md": "markdown",
56
59
  ".mdx": "markdown",
57
60
  ".rst": "rst",
@@ -64,6 +67,7 @@ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
64
67
  ".cfg": "ini",
65
68
  ".xml": "xml",
66
69
  ".html": "html",
70
+ ".htm": "html",
67
71
  ".css": "css",
68
72
  ".scss": "scss",
69
73
  ".less": "less",
@@ -74,7 +78,7 @@ const CODE_EXTENSIONS = new Set(
74
78
  Object.entries(EXTENSION_TO_LANGUAGE)
75
79
  .filter(
76
80
  ([, lang]) =>
77
- !["markdown", "rst", "text", "json", "yaml", "toml", "ini", "xml", "html"].includes(lang),
81
+ !["markdown", "rst", "text", "json", "yaml", "toml", "ini", "xml"].includes(lang),
78
82
  )
79
83
  .map(([ext]) => ext),
80
84
  );
@@ -5,7 +5,7 @@ import type { Chunk, ContentType } from "../types.ts";
5
5
  import type { BM25Index } from "./bm25.ts";
6
6
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
7
7
  import type { SemanticIndex } from "./semantic-index.ts";
8
- import { buildBm25FromChunks } from "./sparse.ts";
8
+ import { buildBm25FromChunks, patchBm25 } from "./sparse.ts";
9
9
  import { buildSemanticIndex } from "./vector-storage.ts";
10
10
  import { vectorAt } from "./vectors.ts";
11
11
 
@@ -40,9 +40,34 @@ function isIndexableRelativePath(relativePath: string, extensions: Set<string>):
40
40
  return extensions.has(ext);
41
41
  }
42
42
 
43
+ /** Vector for every text: reused from `known`, otherwise embedded once (duplicates share a call). */
44
+ async function vectorsByText(
45
+ embeddings: EmbeddingBackend,
46
+ texts: readonly string[],
47
+ known: ReadonlyMap<string, Float32Array>,
48
+ ): Promise<Map<string, Float32Array>> {
49
+ const result = new Map(known);
50
+ const missing = [...new Set(texts)].filter((text) => !result.has(text));
51
+ if (missing.length === 0) {
52
+ return result;
53
+ }
54
+ const embedded = await embeddings.embedDocuments(missing);
55
+ if (embedded.length !== missing.length) {
56
+ throw new Error(
57
+ `Vector count ${embedded.length} does not match chunk count ${missing.length}.`,
58
+ );
59
+ }
60
+ for (const [i, text] of missing.entries()) {
61
+ result.set(text, embedded[i] as Float32Array);
62
+ }
63
+ return result;
64
+ }
65
+
43
66
  /**
44
67
  * Replace chunks for the given repo-relative paths only: remove old chunks,
45
- * embed new ones, rebuild BM25 + semantic indexes from the merged vector set.
68
+ * embed new ones, rebuild the semantic index from the merged vector set, and update
69
+ * BM25 by dropping the old docs and indexing only the new chunks. Pass `bm25` (the
70
+ * index matching `chunks`) to enable that; without it BM25 is rebuilt from scratch.
46
71
  */
47
72
  export async function applyIncrementalFileChanges(options: {
48
73
  root: string;
@@ -50,6 +75,7 @@ export async function applyIncrementalFileChanges(options: {
50
75
  embeddings: EmbeddingBackend;
51
76
  chunks: Chunk[];
52
77
  semanticIndex: SemanticIndex;
78
+ bm25?: BM25Index;
53
79
  relativePaths: readonly string[];
54
80
  }): Promise<{ chunks: Chunk[]; bm25: BM25Index; semantic: SemanticIndex }> {
55
81
  const root = resolve(options.root);
@@ -60,16 +86,24 @@ export async function applyIncrementalFileChanges(options: {
60
86
  .filter((p) => p.length > 0 && isIndexableRelativePath(p, extensions)),
61
87
  );
62
88
 
89
+ // BM25 doc i is chunk i; an index of another size can't be patched safely.
90
+ const baseBm25 = options.bm25?.size === options.chunks.length ? options.bm25 : undefined;
91
+
63
92
  if (targets.size === 0) {
64
93
  return {
65
94
  chunks: options.chunks,
66
- bm25: buildBm25FromChunks(options.chunks),
95
+ bm25: baseBm25 ?? buildBm25FromChunks(options.chunks),
67
96
  semantic: options.semanticIndex,
68
97
  };
69
98
  }
70
99
 
71
100
  const keptChunks: Chunk[] = [];
72
101
  const keptVectors: Float32Array[] = [];
102
+ const removedDocs = new Set<number>();
103
+ // An embedding is a pure function of chunk text, so a re-chunked file can reuse
104
+ // the vector of any old chunk with byte-identical content (keyed on the exact
105
+ // string, never a hash, so a collision can't leave a stale vector).
106
+ const reusableVectors = new Map<string, Float32Array>();
73
107
 
74
108
  for (let i = 0; i < options.chunks.length; i++) {
75
109
  const chunk = options.chunks[i];
@@ -78,6 +112,10 @@ export async function applyIncrementalFileChanges(options: {
78
112
  }
79
113
  const rel = normalizeRelativePath(chunk.file_path);
80
114
  if (targets.has(rel)) {
115
+ removedDocs.add(i);
116
+ if (!reusableVectors.has(chunk.content)) {
117
+ reusableVectors.set(chunk.content, vectorAt(options.semanticIndex, i));
118
+ }
81
119
  continue;
82
120
  }
83
121
  keptChunks.push(chunk);
@@ -89,10 +127,12 @@ export async function applyIncrementalFileChanges(options: {
89
127
  addedChunks.push(...(await chunksForFile(root, rel)));
90
128
  }
91
129
 
92
- let addedVectors: Float32Array[] = [];
93
- if (addedChunks.length > 0) {
94
- addedVectors = await options.embeddings.embedDocuments(addedChunks.map((c) => c.content));
95
- }
130
+ const vectorFor = await vectorsByText(
131
+ options.embeddings,
132
+ addedChunks.map((c) => c.content),
133
+ reusableVectors,
134
+ );
135
+ const addedVectors = addedChunks.map((c) => vectorFor.get(c.content) as Float32Array);
96
136
 
97
137
  const chunks = [...keptChunks, ...addedChunks];
98
138
  const vectors = [...keptVectors, ...addedVectors];
@@ -107,7 +147,7 @@ export async function applyIncrementalFileChanges(options: {
107
147
 
108
148
  return {
109
149
  chunks,
110
- bm25: buildBm25FromChunks(chunks),
150
+ bm25: baseBm25 ? patchBm25(baseBm25, removedDocs, addedChunks) : buildBm25FromChunks(chunks),
111
151
  semantic: buildSemanticIndex(vectors),
112
152
  };
113
153
  }
@@ -30,10 +30,22 @@ export function addChunkToBm25(bm25: BM25Index, chunk: Chunk): void {
30
30
  bm25.addDocument(tokenize(enrichForBm25(chunk)));
31
31
  }
32
32
 
33
- export function buildBm25FromChunks(chunks: readonly Chunk[]): BM25Index {
34
- const bm25 = new BM25Index();
33
+ function appendChunksToBm25(bm25: BM25Index, chunks: readonly Chunk[]): BM25Index {
35
34
  for (const chunk of chunks) {
36
35
  addChunkToBm25(bm25, chunk);
37
36
  }
38
37
  return bm25;
39
38
  }
39
+
40
+ export function buildBm25FromChunks(chunks: readonly Chunk[]): BM25Index {
41
+ return appendChunksToBm25(new BM25Index(), chunks);
42
+ }
43
+
44
+ /** Drop docs `removedDocs` from `base` and append `added`; `base` is left untouched. */
45
+ export function patchBm25(
46
+ base: BM25Index,
47
+ removedDocs: ReadonlySet<number>,
48
+ added: readonly Chunk[],
49
+ ): BM25Index {
50
+ return appendChunksToBm25(base.withoutDocuments(removedDocs), added);
51
+ }