@takara-ai/miru-code 1.3.1 → 1.5.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/src/help.ts CHANGED
@@ -67,6 +67,27 @@ export function printEnvHelp(): void {
67
67
  writeStdout(" MIRU_TOKENIZER_JSON");
68
68
  writeStdout(" Path to tokenizer.json (default: <package>/tokenizer/tokenizer.json)");
69
69
  writeStdout("");
70
+ section("Self-hosted (AWS SageMaker)");
71
+ writeStdout(" MIRU_SAGEMAKER_ENDPOINT_ARN");
72
+ writeStdout(" arn:aws:sagemaker:<region>:<account-id>:endpoint/<name> — set this to");
73
+ writeStdout(" bypass Takara entirely and embed via your own SageMaker endpoint.");
74
+ writeStdout(" MIRU_SAGEMAKER_ENDPOINT_NAME / MIRU_SAGEMAKER_REGION");
75
+ writeStdout(" Alternative to the ARN when you'd rather name the endpoint + region");
76
+ writeStdout(" directly (falls back to AWS_REGION / AWS_DEFAULT_REGION).");
77
+ writeStdout(" MIRU_SAGEMAKER_NORMALIZE / MIRU_SAGEMAKER_TRUNCATE");
78
+ writeStdout(" Default: true");
79
+ writeStdout(" MIRU_SAGEMAKER_TRUNCATION_DIRECTION");
80
+ writeStdout(' "Left" | "Right" (default: Right)');
81
+ writeStdout(" MIRU_SAGEMAKER_PROMPT_NAME");
82
+ writeStdout(" Optional prompt_name passed to the endpoint");
83
+ writeStdout(" AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_PROFILE");
84
+ writeStdout(" Standard AWS credential resolution — nothing Miru-specific to set");
85
+ writeStdout("");
86
+ hint("Enterprise self-hosted setup: docs/self-hosted-sagemaker.md");
87
+ hint("Admin runbook: bun run sagemaker:create-invoke-user -- --endpoint-arn <arn>");
88
+ hint("Then: miru setup --sagemaker --arn <arn> --profile <name>");
89
+ hint("Setup confirms auth, saves SageMaker config, and removes any stored Takara API key.");
90
+ writeStdout("");
70
91
  }
71
92
 
72
93
  export function printFullHelp(): void {
@@ -126,13 +147,25 @@ export function printCommandHelp(command: string): void {
126
147
  writeStdout("");
127
148
  return;
128
149
  case "setup":
129
- commandHeader("setup", "Store and validate your Takara API key.");
150
+ commandHeader("setup", "Store and validate Takara or self-hosted SageMaker credentials.");
130
151
  section("Usage");
131
152
  writeStdout(" miru setup [--key TOKEN] [--force] [--clear]");
132
- section("Options");
153
+ writeStdout(" miru setup --sagemaker --arn ENDPOINT_ARN --profile NAME");
154
+ section("Options (Takara)");
133
155
  writeStdout(" --key, -k TOKEN Non-interactive key entry");
134
156
  writeStdout(" --force Replace an existing stored key");
135
157
  writeStdout(" --clear Remove stored credentials");
158
+ section("Options (SageMaker)");
159
+ writeStdout(" --sagemaker Switch setup to self-hosted SageMaker mode");
160
+ writeStdout(" --arn ARN Endpoint ARN (implies --sagemaker)");
161
+ writeStdout(" --profile NAME AWS profile to inherit credentials from");
162
+ writeStdout("");
163
+ hint("Enterprise guide: docs/self-hosted-sagemaker.md (Marketplace + invoke-user runbook).");
164
+ hint("Takara and SageMaker are mutually exclusive — setup for one removes the other.");
165
+ hint("SageMaker setup invokes the endpoint once (auth + embedding check), then saves.");
166
+ hint("Miru only inherits AWS credentials — it never creates IAM users or writes ~/.aws.");
167
+ hint("Admin runbook (repo): bun run sagemaker:create-invoke-user -- --endpoint-arn <arn>");
168
+ hint("Then: miru setup --sagemaker --arn <arn> --profile miru");
136
169
  writeStdout("");
137
170
  return;
138
171
  case "install":
@@ -165,8 +198,9 @@ export function printCommandHelp(command: string): void {
165
198
  writeStdout("Override path with MIRU_BENCHMARK_HISTORY_PATH. Append-only JSONL of savings.");
166
199
  writeStdout("Repo paths in the report are plaintext — use `clear` on shared machines.");
167
200
  writeStdout("");
168
- writeStdout("Savings compare Miru workflow tokens to a Grep baseline");
169
- writeStdout("(rg search + read of the top matched file), not the agent's full tool chain.");
201
+ writeStdout("Savings compare Miru MCP response tokens (search text + expand text)");
202
+ writeStdout("to a Grep baseline (rg search + read of the top matched file),");
203
+ writeStdout("not bare chunk content and not the agent's full tool chain.");
170
204
  writeStdout("Local paths only — git URL repos skip comparison with benchmark_skipped.");
171
205
  writeStdout("");
172
206
  return;
package/src/index/bm25.ts CHANGED
@@ -47,37 +47,46 @@ export class BM25Index {
47
47
  private postings = new Map<string, PostingsList>();
48
48
  private avgDocLength = 0;
49
49
  private numDocs = 0;
50
+ private totalLen = 0;
51
+
52
+ /** Append one tokenized document. Safe to call incrementally while indexing. */
53
+ addDocument(doc: string[]): number {
54
+ const docIndex = this.numDocs;
55
+ this.numDocs += 1;
56
+ this.docLengths.push(doc.length);
57
+ this.totalLen += doc.length;
58
+ this.avgDocLength = this.totalLen / this.numDocs;
59
+
60
+ const tf = new Map<string, number>();
61
+ for (const term of doc) {
62
+ tf.set(term, (tf.get(term) ?? 0) + 1);
63
+ }
64
+ for (const [term, count] of tf) {
65
+ this.docFreq.set(term, (this.docFreq.get(term) ?? 0) + 1);
66
+ let list = this.postings.get(term);
67
+ if (!list) {
68
+ list = [];
69
+ this.postings.set(term, list);
70
+ }
71
+ list.push([docIndex, count]);
72
+ }
73
+ return docIndex;
74
+ }
50
75
 
51
76
  index(tokenizedDocs: string[][]): void {
52
- this.numDocs = tokenizedDocs.length;
77
+ this.numDocs = 0;
53
78
  this.docFreq.clear();
54
79
  this.docLengths = [];
55
80
  this.postings.clear();
81
+ this.totalLen = 0;
82
+ this.avgDocLength = 0;
56
83
 
57
- let totalLen = 0;
58
- for (let docIndex = 0; docIndex < tokenizedDocs.length; docIndex++) {
59
- const doc = tokenizedDocs[docIndex];
84
+ for (const doc of tokenizedDocs) {
60
85
  if (!doc) {
61
86
  continue;
62
87
  }
63
- this.docLengths.push(doc.length);
64
- totalLen += doc.length;
65
-
66
- const tf = new Map<string, number>();
67
- for (const term of doc) {
68
- tf.set(term, (tf.get(term) ?? 0) + 1);
69
- }
70
- for (const [term, count] of tf) {
71
- this.docFreq.set(term, (this.docFreq.get(term) ?? 0) + 1);
72
- let list = this.postings.get(term);
73
- if (!list) {
74
- list = [];
75
- this.postings.set(term, list);
76
- }
77
- list.push([docIndex, count]);
78
- }
88
+ this.addDocument(doc);
79
89
  }
80
- this.avgDocLength = this.numDocs > 0 ? totalLen / this.numDocs : 0;
81
90
  }
82
91
 
83
92
  private queryTerms(queryTokens: string[]): { term: string; idf: number }[] {
@@ -222,6 +231,7 @@ export class BM25Index {
222
231
  idx.docLengths = data.docLengths;
223
232
  idx.avgDocLength = data.avgDocLength;
224
233
  idx.numDocs = data.numDocs;
234
+ idx.totalLen = data.avgDocLength * data.numDocs;
225
235
 
226
236
  for (const [term, list] of Object.entries(data.postings)) {
227
237
  idx.postings.set(term, list);
@@ -2,24 +2,33 @@ import { relative, resolve } from "node:path";
2
2
  import { chunkSource } from "../chunking/chunking.ts";
3
3
  import { ensureParserInit } from "../chunking/grammars.ts";
4
4
  import { resolveWorkerConcurrency } from "../concurrency.ts";
5
- import type { EmbeddingBackend } from "../embeddings/openai.ts";
5
+ import {
6
+ appendEmbeddingWindowJobs,
7
+ assignEmbeddingWindowVectors,
8
+ type EmbeddingBackend,
9
+ type EmbeddingWindowJob,
10
+ embedTextsWithBackend,
11
+ poolEmbeddingWindowBuckets,
12
+ resolveEmbeddingBatchSize,
13
+ resolveMaxEmbedChars,
14
+ } from "../embeddings/openai.ts";
6
15
  import { envOptionalInt } from "../env.ts";
7
16
  import { searchImprovementsEnabled } from "../ranking/features.ts";
8
- import { tokenize } from "../tokens.ts";
9
17
  import { type Chunk, type ContentType, defaultContentTypes } from "../types.ts";
10
18
  import { BM25Index } from "./bm25.ts";
11
19
  import { loadRootEntryChunks } from "./entry-chunks.ts";
12
20
  import { walkFiles } from "./file-walker.ts";
13
21
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
14
22
  import type { SemanticIndex } from "./semantic-index.ts";
15
- import { enrichForBm25 } from "./sparse.ts";
23
+ import { addChunkToBm25 } from "./sparse.ts";
16
24
  import { buildSemanticIndex } from "./vector-storage.ts";
17
25
 
18
- const DEFAULT_PIPELINE_EMBED_BATCH = 64;
19
- const DEFAULT_PIPELINE_EMBED_INFLIGHT = 8;
26
+ /** Keep enough embed HTTP calls in flight to saturate the serverless endpoint. */
27
+ const DEFAULT_PIPELINE_EMBED_INFLIGHT = 16;
20
28
 
29
+ /** One knob: same as embedding API batch size unless explicitly overridden. */
21
30
  function resolvePipelineEmbedBatch(): number {
22
- return envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH"], 1) ?? DEFAULT_PIPELINE_EMBED_BATCH;
31
+ return envOptionalInt(["MIRU_PIPELINE_EMBED_BATCH"], 1) ?? resolveEmbeddingBatchSize();
23
32
  }
24
33
 
25
34
  function resolvePipelineEmbedInflight(): number {
@@ -40,6 +49,7 @@ export async function createIndexFromPath(
40
49
  const started = performance.now();
41
50
  let fileProcessMs = 0;
42
51
  let embedBackpressureWaitMs = 0;
52
+ let bm25BuildMs = 0;
43
53
  let fileEnumerated = 0;
44
54
  let fileTasks = 0;
45
55
  let emptyFileTasks = 0;
@@ -57,14 +67,16 @@ export async function createIndexFromPath(
57
67
  const fileConcurrency = resolveWorkerConcurrency();
58
68
  const embedBatchSize = resolvePipelineEmbedBatch();
59
69
  const maxEmbedInflight = resolvePipelineEmbedInflight();
70
+ const maxEmbedChars = resolveMaxEmbedChars();
60
71
  const maxIndexFiles = resolveMaxIndexFiles();
61
- const readyBySeq = new Map<number, Chunk[]>();
62
- const pendingEmbedChunks: Chunk[] = [];
63
- const emittedChunkBatches: Chunk[][] = [];
64
- const embedInFlight = new Set<Promise<Float32Array[]>>();
65
- const embedPromises: Promise<Float32Array[]>[] = [];
66
- let fileSeq = 0;
67
- let nextEmitSeq = 0;
72
+ const emittedChunks: Chunk[] = [];
73
+ const windowVectorsByChunk: Float32Array[][] = [];
74
+ const pendingWindows: EmbeddingWindowJob[] = [];
75
+ const embedInFlight = new Set<Promise<void>>();
76
+ const embedPromises: Promise<void>[] = [];
77
+ const bm25 = new BM25Index();
78
+ let emittedApiBatches = 0;
79
+ let bm25Cursor = 0;
68
80
 
69
81
  const processFile = async (filePath: string): Promise<Chunk[]> => {
70
82
  const t = performance.now();
@@ -86,35 +98,64 @@ export async function createIndexFromPath(
86
98
  }
87
99
  };
88
100
 
89
- const flushReadySequentialChunks = (): void => {
90
- while (readyBySeq.has(nextEmitSeq)) {
91
- const chunks = readyBySeq.get(nextEmitSeq);
92
- readyBySeq.delete(nextEmitSeq);
93
- if (chunks) {
94
- pendingEmbedChunks.push(...chunks);
101
+ /** Burn idle time tokenizing + building BM25 while embeds are in flight. */
102
+ const progressBm25 = (budgetMs = 8): void => {
103
+ const t0 = performance.now();
104
+ while (bm25Cursor < emittedChunks.length && performance.now() - t0 < budgetMs) {
105
+ const chunk = emittedChunks[bm25Cursor];
106
+ if (!chunk) {
107
+ break;
95
108
  }
96
- nextEmitSeq++;
109
+ const tDoc = performance.now();
110
+ addChunkToBm25(bm25, chunk);
111
+ bm25BuildMs += performance.now() - tDoc;
112
+ bm25Cursor++;
113
+ }
114
+ };
115
+
116
+ const enqueueChunks = (chunks: Chunk[]): void => {
117
+ for (const chunk of chunks) {
118
+ const chunkIndex = emittedChunks.length;
119
+ emittedChunks.push(chunk);
120
+ windowVectorsByChunk.push([]);
121
+ appendEmbeddingWindowJobs(pendingWindows, chunkIndex, chunk.content, maxEmbedChars);
97
122
  }
98
123
  };
99
124
 
100
125
  const maybeScheduleEmbed = async (force = false): Promise<void> => {
101
- while (
102
- pendingEmbedChunks.length > 0 &&
103
- (force || pendingEmbedChunks.length >= embedBatchSize)
104
- ) {
126
+ while (pendingWindows.length > 0 && (force || pendingWindows.length >= embedBatchSize)) {
105
127
  if (embedInFlight.size >= maxEmbedInflight) {
106
128
  const waitStart = performance.now();
107
- await Promise.race(embedInFlight);
129
+ // Use backpressure stalls to advance BM25 on the main thread.
130
+ while (embedInFlight.size >= maxEmbedInflight) {
131
+ progressBm25(10);
132
+ await Promise.race([Promise.race(embedInFlight), Bun.sleep(0)]);
133
+ }
108
134
  embedBackpressureWaitMs += performance.now() - waitStart;
109
135
  continue;
110
136
  }
111
137
 
112
- const take = force
113
- ? pendingEmbedChunks.length
114
- : Math.min(embedBatchSize, pendingEmbedChunks.length);
115
- const batch = pendingEmbedChunks.splice(0, take);
116
- emittedChunkBatches.push(batch);
117
- const promise = embeddings.embedDocuments(batch.map((chunk) => chunk.content));
138
+ const take = Math.min(embedBatchSize, pendingWindows.length);
139
+ if (!force && take < embedBatchSize) {
140
+ break;
141
+ }
142
+
143
+ const batch = pendingWindows.splice(0, take);
144
+ emittedApiBatches++;
145
+ const promise = (async () => {
146
+ const vectors = await embedTextsWithBackend(
147
+ embeddings,
148
+ batch.map((job) => job.text),
149
+ );
150
+ if (vectors.length !== batch.length) {
151
+ throw new Error(
152
+ `Embedding API returned ${vectors.length} vectors for ${batch.length} inputs`,
153
+ );
154
+ }
155
+ assignEmbeddingWindowVectors(batch, vectors, windowVectorsByChunk);
156
+ // After each batch returns, spend a little time on BM25.
157
+ progressBm25(5);
158
+ })();
118
159
  embedPromises.push(promise);
119
160
  embedInFlight.add(promise);
120
161
  promise.finally(() => {
@@ -125,11 +166,13 @@ export async function createIndexFromPath(
125
166
 
126
167
  const runningFiles = new Set<Promise<void>>();
127
168
 
128
- const startFileTask = (filePath: string, seq: number): void => {
169
+ const startFileTask = (filePath: string): void => {
129
170
  const task = (async () => {
130
171
  const chunks = await processFile(filePath);
131
- readyBySeq.set(seq, chunks);
132
- flushReadySequentialChunks();
172
+ // Enqueue as soon as the file is ready (no sequential barrier) so the
173
+ // embed pipeline fills immediately under concurrency.
174
+ enqueueChunks(chunks);
175
+ progressBm25(3);
133
176
  await maybeScheduleEmbed(false);
134
177
  })().finally(() => {
135
178
  runningFiles.delete(task);
@@ -143,50 +186,62 @@ export async function createIndexFromPath(
143
186
  throw new Error(`Index file budget exceeded: max ${maxIndexFiles} files per operation`);
144
187
  }
145
188
  while (runningFiles.size >= fileConcurrency) {
189
+ progressBm25(5);
146
190
  await Promise.race(runningFiles);
147
191
  }
148
192
  fileTasks++;
149
- startFileTask(filePath, fileSeq++);
193
+ startFileTask(filePath);
150
194
  }
151
195
 
152
196
  while (runningFiles.size > 0) {
197
+ progressBm25(5);
153
198
  await Promise.race(runningFiles);
154
199
  }
155
200
 
156
201
  if (searchImprovementsEnabled()) {
157
202
  const entryChunks = await loadRootEntryChunks(resolved, displayRoot ? root : undefined);
158
203
  if (entryChunks.length > 0) {
159
- readyBySeq.set(fileSeq++, entryChunks);
160
- flushReadySequentialChunks();
204
+ enqueueChunks(entryChunks);
161
205
  }
162
206
  }
163
207
 
164
- flushReadySequentialChunks();
208
+ const embedsDrainedAt = performance.now();
165
209
  await maybeScheduleEmbed(true);
166
210
  await Promise.all(embedPromises);
211
+ // Finish any BM25 docs not yet processed during idle gaps.
212
+ while (bm25Cursor < emittedChunks.length) {
213
+ progressBm25(50);
214
+ }
215
+ const embedsDoneAt = performance.now();
167
216
 
168
- const chunks = emittedChunkBatches.flat();
217
+ const chunks = emittedChunks;
169
218
 
170
219
  if (chunks.length === 0) {
171
220
  throw new Error(`No supported files found under ${path}.`);
172
221
  }
173
222
 
174
- const vectors = (await Promise.all(embedPromises)).flat();
175
- const bm25 = new BM25Index();
176
- bm25.index(chunks.map((c) => tokenize(enrichForBm25(c))));
223
+ const poolStarted = performance.now();
224
+ const vectors = poolEmbeddingWindowBuckets(windowVectorsByChunk);
225
+ const poolMs = performance.now() - poolStarted;
226
+
227
+ const semanticStarted = performance.now();
177
228
  const semantic = buildSemanticIndex(vectors);
229
+ const semanticMs = performance.now() - semanticStarted;
178
230
 
179
231
  if (profile) {
180
232
  const finished = performance.now();
233
+ const wall = finished - started;
181
234
  const embedStats =
182
235
  "getStats" in embeddings && typeof embeddings.getStats === "function"
183
236
  ? embeddings.getStats()
184
237
  : null;
238
+ const pipelineWallMs = embedsDrainedAt - started;
239
+ const embedDrainMs = embedsDoneAt - embedsDrainedAt;
185
240
  console.error(
186
241
  JSON.stringify({
187
242
  profile: "index_build",
188
243
  path: resolved,
189
- elapsed_ms: finished - started,
244
+ elapsed_ms: wall,
190
245
  file_enumerated: fileEnumerated,
191
246
  file_tasks: fileTasks,
192
247
  empty_or_skipped_files: emptyFileTasks,
@@ -197,11 +252,26 @@ export async function createIndexFromPath(
197
252
  file_concurrency: fileConcurrency,
198
253
  embed_batch_size: embedBatchSize,
199
254
  embed_inflight: maxEmbedInflight,
200
- emitted_batches: emittedChunkBatches.length,
255
+ emitted_batches: emittedApiBatches,
256
+ max_embed_chars: maxEmbedChars,
201
257
  },
202
258
  stage_ms: {
203
259
  file_process_total: fileProcessMs,
204
260
  embed_backpressure_wait: embedBackpressureWaitMs,
261
+ bm25_build_cpu: bm25BuildMs,
262
+ wall_pipeline: pipelineWallMs,
263
+ wall_embed_drain: embedDrainMs,
264
+ wall_pool_windows: poolMs,
265
+ wall_bm25: 0,
266
+ wall_semantic: semanticMs,
267
+ wall_post: poolMs + semanticMs,
268
+ },
269
+ wall_pct: {
270
+ pipeline_overlap: +((100 * pipelineWallMs) / wall).toFixed(1),
271
+ embed_drain: +((100 * embedDrainMs) / wall).toFixed(1),
272
+ pool_windows: +((100 * poolMs) / wall).toFixed(1),
273
+ bm25: 0,
274
+ semantic: +((100 * semanticMs) / wall).toFixed(1),
205
275
  },
206
276
  embedding_transport: embedStats,
207
277
  }),
@@ -1,12 +1,11 @@
1
1
  import { isAbsolute, join, relative, resolve } from "node:path";
2
2
  import { chunkSource } from "../chunking/chunking.ts";
3
3
  import type { EmbeddingBackend } from "../embeddings/openai.ts";
4
- import { tokenize } from "../tokens.ts";
5
4
  import type { Chunk, ContentType } from "../types.ts";
6
- import { BM25Index } from "./bm25.ts";
5
+ import type { BM25Index } from "./bm25.ts";
7
6
  import { detectLanguage, getExtensions, getFileStatus, readFileText } from "./files.ts";
8
7
  import type { SemanticIndex } from "./semantic-index.ts";
9
- import { enrichForBm25 } from "./sparse.ts";
8
+ import { buildBm25FromChunks } from "./sparse.ts";
10
9
  import { buildSemanticIndex } from "./vector-storage.ts";
11
10
  import { vectorAt } from "./vectors.ts";
12
11
 
@@ -64,7 +63,7 @@ export async function applyIncrementalFileChanges(options: {
64
63
  if (targets.size === 0) {
65
64
  return {
66
65
  chunks: options.chunks,
67
- bm25: rebuildBm25(options.chunks),
66
+ bm25: buildBm25FromChunks(options.chunks),
68
67
  semantic: options.semanticIndex,
69
68
  };
70
69
  }
@@ -108,17 +107,11 @@ export async function applyIncrementalFileChanges(options: {
108
107
 
109
108
  return {
110
109
  chunks,
111
- bm25: rebuildBm25(chunks),
110
+ bm25: buildBm25FromChunks(chunks),
112
111
  semantic: buildSemanticIndex(vectors),
113
112
  };
114
113
  }
115
114
 
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
115
  /** Map an absolute changed path to a repo-relative path for chunk keys. */
123
116
  export function relativePathFromRoot(root: string, absoluteOrRelative: string): string {
124
117
  const resolvedRoot = resolve(root);
@@ -1,4 +1,6 @@
1
+ import { tokenize } from "../tokens.ts";
1
2
  import type { Chunk } from "../types.ts";
3
+ import { BM25Index } from "./bm25.ts";
2
4
 
3
5
  export function selectorToMask(
4
6
  selector: readonly number[] | null | undefined,
@@ -23,3 +25,15 @@ export function enrichForBm25(chunk: Chunk): string {
23
25
  const dirText = dirParts.slice(-3).join(" ");
24
26
  return `${chunk.content} ${stem} ${stem} ${dirText}`;
25
27
  }
28
+
29
+ export function addChunkToBm25(bm25: BM25Index, chunk: Chunk): void {
30
+ bm25.addDocument(tokenize(enrichForBm25(chunk)));
31
+ }
32
+
33
+ export function buildBm25FromChunks(chunks: readonly Chunk[]): BM25Index {
34
+ const bm25 = new BM25Index();
35
+ for (const chunk of chunks) {
36
+ addChunkToBm25(bm25, chunk);
37
+ }
38
+ return bm25;
39
+ }
@@ -127,7 +127,10 @@ export const MCP_SERVER_INSTRUCTIONS =
127
127
 
128
128
  export const MCP_BENCHMARK_SERVER_INSTRUCTIONS =
129
129
  `${MCP_SERVER_INSTRUCTIONS} ` +
130
- "BENCHMARK MODE: each `search` and `locate` includes compact `benchmark` {save_pct,miru_tok,grep_tok,saved_tok,rank1}. " +
130
+ "BENCHMARK MODE: each `search` and `locate` ends with a compact JSON line " +
131
+ '`{"benchmark":{save_pct,miru_tok,grep_tok,saved_tok,rank1[,search_tok,miru_only]}}`. ' +
132
+ "Results stay plain text. For search, `miru_tok` is search MCP text + one expand (workflow vs grep+read); " +
133
+ "`search_tok` is this search body alone. " +
131
134
  "Call `read_benchmark` for cumulative totals across both. Do not narrate benchmark stats unless the user asks. " +
132
135
  "To leave benchmark mode, tell the user to run `miru benchmark off` and restart the agent.";
133
136
 
package/src/literal.ts CHANGED
@@ -140,7 +140,7 @@ function filterChunksByGlob(
140
140
  if (includeMatcher && !includeMatcher.ignores(path)) {
141
141
  return false;
142
142
  }
143
- if (excludeMatcher && excludeMatcher.ignores(path)) {
143
+ if (excludeMatcher?.ignores(path)) {
144
144
  return false;
145
145
  }
146
146
  return true;
@@ -0,0 +1,67 @@
1
+ import type { ExpandResults } from "../utils.ts";
2
+
3
+ /** Plain-text rendering of `formatResults()` output — same info as the JSON shape, no scaffolding. */
4
+ export function formatResultsText(payload: {
5
+ query: string;
6
+ results: Record<string, unknown>[];
7
+ }): string {
8
+ if (payload.results.length === 0) {
9
+ return "No results found.";
10
+ }
11
+
12
+ const blocks = payload.results.map((result) => {
13
+ const chunk = result.chunk as Record<string, unknown>;
14
+ const startLine = chunk.truncated ? chunk.full_start_line : chunk.start_line;
15
+ const endLine = chunk.truncated ? chunk.full_end_line : chunk.end_line;
16
+ const truncatedNote = chunk.truncated ? " [truncated: call expand for full chunk]" : "";
17
+ const header = `${chunk.file_path}:${startLine}-${endLine}${truncatedNote}`;
18
+ return `${header}\n${chunk.content}`;
19
+ });
20
+
21
+ return blocks.join("\n\n");
22
+ }
23
+
24
+ /** Plain-text rendering of `formatLiteralLocate()` output. */
25
+ export function formatLiteralLocateText(payload: Record<string, unknown>): string {
26
+ const label =
27
+ (payload.literals as string[] | undefined)?.join(" | ") ?? (payload.literal as string);
28
+ const n = payload.n as number;
29
+ const files = payload.files as number;
30
+ const truncatedNote = payload.truncated ? " (truncated — pass a narrower include/exclude)" : "";
31
+ const header = `"${label}": ${n} match${n === 1 ? "" : "es"} across ${files} file${files === 1 ? "" : "s"}${truncatedNote}`;
32
+
33
+ const hits = payload.hits as
34
+ | Array<{ f: string; l: number; t?: string; ctx?: string[]; ctx_l?: number }>
35
+ | undefined;
36
+ if (!hits) {
37
+ return header;
38
+ }
39
+
40
+ const lines = [header, ""];
41
+ for (const hit of hits) {
42
+ lines.push(hit.t !== undefined ? `${hit.f}:${hit.l}: ${hit.t}` : `${hit.f}:${hit.l}`);
43
+ if (hit.ctx != null && hit.ctx_l !== undefined) {
44
+ const ctxStart = hit.ctx_l;
45
+ for (let i = 0; i < hit.ctx.length; i++) {
46
+ lines.push(` ${ctxStart + i}: ${hit.ctx[i]}`);
47
+ }
48
+ }
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+
53
+ /** Plain-text rendering of `formatExpandResults()` output. */
54
+ export function formatExpandResultsText(payload: ExpandResults): string {
55
+ const anchorRange = payload.anchor
56
+ ? `${payload.anchor.start_line}-${payload.anchor.end_line}`
57
+ : null;
58
+ const header = `${payload.file_path} — ${payload.chunk_count} chunk${payload.chunk_count === 1 ? "" : "s"} around line ${payload.line}`;
59
+
60
+ const blocks = payload.chunks.map((chunk) => {
61
+ const range = `${chunk.start_line}-${chunk.end_line}`;
62
+ const isAnchor = anchorRange === range;
63
+ return `${range}${isAnchor ? " (anchor)" : ""}\n${chunk.content}`;
64
+ });
65
+
66
+ return `${header}\n\n${blocks.join("\n\n")}`;
67
+ }
@@ -65,6 +65,11 @@ export function shouldIgnoreWatchPath(relativePath: string | null | undefined):
65
65
  return false;
66
66
  }
67
67
 
68
+ /**
69
+ * Session cache of MiruIndex instances. Keeps them fresh by reconciling
70
+ * mtimes on load (`checkAndQueueStaleFiles`) and applying incremental
71
+ * updates from fs.watch (`flushFileUpdates` / `applyFileChanges`).
72
+ */
68
73
  export class IndexCache {
69
74
  private readonly content: ContentType[];
70
75
  private readonly defaultRef: string | null;