@takara-ai/miru-code 1.1.2 → 1.2.1

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/literal.ts ADDED
@@ -0,0 +1,139 @@
1
+ /** Exact literal location over already-indexed Miru chunks (no disk re-walk). */
2
+
3
+ import type { Chunk } from "./types.ts";
4
+
5
+ export type LiteralMode = "count" | "locations" | "lines";
6
+
7
+ export interface LiteralHit {
8
+ file_path: string;
9
+ line: number;
10
+ /** Present only in `lines` mode. */
11
+ text?: string;
12
+ }
13
+
14
+ export interface LiteralLocateResult {
15
+ literal: string;
16
+ mode: LiteralMode;
17
+ /** Total unique file:line matches. */
18
+ n: number;
19
+ /** Unique files with at least one match. */
20
+ files: number;
21
+ /** True only when an explicit `limit` cut the hit list short. */
22
+ truncated: boolean;
23
+ hits: LiteralHit[];
24
+ }
25
+
26
+ export interface LiteralLocateOptions {
27
+ mode?: LiteralMode;
28
+ /**
29
+ * Optional cap on returned hits (ignored for `count`).
30
+ * Omit for all matches — agents must not fall back to Grep when truncated.
31
+ */
32
+ limit?: number;
33
+ ignore_case?: boolean;
34
+ }
35
+
36
+ function resolveLimit(limit: number | undefined): number | null {
37
+ if (limit == null || !Number.isFinite(limit)) {
38
+ return null;
39
+ }
40
+ return Math.max(1, Math.floor(limit));
41
+ }
42
+
43
+ function includesLiteral(haystack: string, needle: string, ignoreCase: boolean): boolean {
44
+ if (!ignoreCase) {
45
+ return haystack.includes(needle);
46
+ }
47
+ return haystack.toLowerCase().includes(needle.toLowerCase());
48
+ }
49
+
50
+ /**
51
+ * Scan indexed chunks for an exact substring. Dedupes by file:line when chunks overlap.
52
+ * Only covers indexed content (same corpus as `search` / `expand`).
53
+ * Returns every match unless `limit` is set.
54
+ */
55
+ export function locateLiteral(
56
+ chunks: readonly Chunk[],
57
+ literal: string,
58
+ options: LiteralLocateOptions = {},
59
+ ): LiteralLocateResult {
60
+ const needle = literal;
61
+ const mode = options.mode ?? "lines";
62
+ const ignoreCase = options.ignore_case === true;
63
+ const limit = resolveLimit(options.limit);
64
+
65
+ if (!needle) {
66
+ return { literal: needle, mode, n: 0, files: 0, truncated: false, hits: [] };
67
+ }
68
+
69
+ const seen = new Set<string>();
70
+ const fileSet = new Set<string>();
71
+ const hits: LiteralHit[] = [];
72
+ let n = 0;
73
+
74
+ for (const chunk of chunks) {
75
+ if (!includesLiteral(chunk.content, needle, ignoreCase)) {
76
+ continue;
77
+ }
78
+ const lines = chunk.content.split("\n");
79
+ for (let i = 0; i < lines.length; i++) {
80
+ const lineText = lines[i] ?? "";
81
+ if (!includesLiteral(lineText, needle, ignoreCase)) {
82
+ continue;
83
+ }
84
+ const line = chunk.start_line + i;
85
+ const key = `${chunk.file_path}:${line}`;
86
+ if (seen.has(key)) {
87
+ continue;
88
+ }
89
+ seen.add(key);
90
+ fileSet.add(chunk.file_path);
91
+ n += 1;
92
+ if (mode === "count") {
93
+ continue;
94
+ }
95
+ if (limit != null && hits.length >= limit) {
96
+ continue;
97
+ }
98
+ const hit: LiteralHit = { file_path: chunk.file_path, line };
99
+ if (mode === "lines") {
100
+ hit.text = lineText;
101
+ }
102
+ hits.push(hit);
103
+ }
104
+ }
105
+
106
+ return {
107
+ literal: needle,
108
+ mode,
109
+ n,
110
+ files: fileSet.size,
111
+ truncated: mode !== "count" && limit != null && n > hits.length,
112
+ hits: mode === "count" ? [] : hits,
113
+ };
114
+ }
115
+
116
+ /** Compact agent-facing JSON (minimal keys). */
117
+ export function formatLiteralLocate(result: LiteralLocateResult): Record<string, unknown> {
118
+ const base: Record<string, unknown> = {
119
+ literal: result.literal,
120
+ mode: result.mode,
121
+ n: result.n,
122
+ files: result.files,
123
+ };
124
+
125
+ if (result.mode === "count") {
126
+ return base;
127
+ }
128
+
129
+ if (result.truncated) {
130
+ base.truncated = true;
131
+ }
132
+ base.hits = result.hits.map((hit) => {
133
+ if (hit.text !== undefined) {
134
+ return { f: hit.file_path, l: hit.line, t: hit.text };
135
+ }
136
+ return { f: hit.file_path, l: hit.line };
137
+ });
138
+ return base;
139
+ }
@@ -4,7 +4,7 @@ import { walkFiles } from "../index/file-walker.ts";
4
4
  import { getExtensions } from "../index/files.ts";
5
5
  import { normalizeRelativePath, relativePathFromRoot } from "../index/incremental.ts";
6
6
  import { MiruIndex } from "../miru-index.ts";
7
- import type { ContentType } from "../types.ts";
7
+ import { type ContentType, defaultContentTypes } from "../types.ts";
8
8
  import {
9
9
  computeSourceCacheKey,
10
10
  isAllowedRepoSource,
@@ -71,7 +71,7 @@ export class IndexCache {
71
71
  private readonly entries = new Map<string, CacheEntry>();
72
72
  readonly watchers = new Map<string, WatcherHandle>();
73
73
 
74
- constructor(content: ContentType[] = ["code"], defaultRef: string | null = null) {
74
+ constructor(content: ContentType[] = defaultContentTypes(), defaultRef: string | null = null) {
75
75
  this.content = content;
76
76
  this.defaultRef = defaultRef;
77
77
  }
package/src/mcp/serve.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContentType } from "../types.ts";
1
+ import { type ContentType, defaultContentTypes } from "../types.ts";
2
2
  import { IndexCache } from "./index-cache.ts";
3
3
  import { createMcpServer } from "./server.ts";
4
4
  import { StdioTransport } from "./stdio.ts";
@@ -6,9 +6,10 @@ import { StdioTransport } from "./stdio.ts";
6
6
  export async function serveMcp(options: {
7
7
  ref?: string | null;
8
8
  content?: ContentType[];
9
+ benchmark?: boolean;
9
10
  }): Promise<void> {
10
- const cache = new IndexCache(options.content ?? ["code"], options.ref ?? null);
11
- const server = createMcpServer(cache);
11
+ const cache = new IndexCache(options.content ?? defaultContentTypes(), options.ref ?? null);
12
+ const server = createMcpServer(cache, { benchmark: options.benchmark ?? false });
12
13
  const transport = new StdioTransport();
13
14
  await server.connect(transport);
14
15
  }
package/src/mcp/server.ts CHANGED
@@ -1,14 +1,28 @@
1
1
  import * as z from "zod";
2
2
  import packageJson from "../../package.json";
3
+ import { attachSearchBenchmark, benchmarkSearchComparison } from "../benchmark/compare.ts";
3
4
  import {
5
+ appendBenchmarkQuery,
6
+ readBenchmarkRollup,
7
+ recordFromAgentSummary,
8
+ recordFromBenchmark,
9
+ } from "../benchmark/history.ts";
10
+ import { attachLocateBenchmark, benchmarkLocateComparison } from "../benchmark/locate-compare.ts";
11
+ import {
12
+ MCP_BENCHMARK_SERVER_INSTRUCTIONS,
4
13
  MCP_EXPAND_TOOL_DESCRIPTION,
5
14
  MCP_FIND_RELATED_TOOL_DESCRIPTION,
15
+ MCP_LOCATE_TOOL_DESCRIPTION,
16
+ MCP_READ_BENCHMARK_TOOL_DESCRIPTION,
6
17
  MCP_SEARCH_TOOL_DESCRIPTION,
7
18
  MCP_SERVER_INSTRUCTIONS,
8
19
  } from "../installer/search-policy.ts";
20
+ import { formatLiteralLocate, type LiteralMode } from "../literal.ts";
9
21
  import type { ContentType } from "../types.ts";
10
22
  import {
11
23
  clampMcpTopK,
24
+ DEFAULT_EXPAND_AFTER,
25
+ DEFAULT_EXPAND_BEFORE,
12
26
  DEFAULT_MCP_TOP_K,
13
27
  dedupeResultsByFile,
14
28
  expandChunksAtLine,
@@ -26,14 +40,31 @@ const REPO_DESCRIPTION =
26
40
  "Pass the project root for local workspaces. " +
27
41
  "The index is built on the first tool call and cached for the session.";
28
42
 
29
- export function createMcpServer(cache: IndexCache): MiruMcpServer {
43
+ const BENCHMARK_LOCAL_ONLY_NOTE =
44
+ "Benchmark comparisons require a local repo path; git URL repos are skipped.";
45
+
46
+ function withBenchmarkSkipped<T extends Record<string, unknown>>(
47
+ payload: T,
48
+ ): T & { benchmark_skipped: "local_repo_only"; note: string } {
49
+ return {
50
+ ...payload,
51
+ benchmark_skipped: "local_repo_only",
52
+ note: BENCHMARK_LOCAL_ONLY_NOTE,
53
+ };
54
+ }
55
+
56
+ export function createMcpServer(
57
+ cache: IndexCache,
58
+ options?: { benchmark?: boolean },
59
+ ): MiruMcpServer {
60
+ const benchmark = options?.benchmark ?? false;
30
61
  const server = new MiruMcpServer(
31
62
  {
32
63
  name: "miru",
33
64
  version: packageJson.version,
34
65
  },
35
66
  {
36
- instructions: MCP_SERVER_INSTRUCTIONS,
67
+ instructions: benchmark ? MCP_BENCHMARK_SERVER_INSTRUCTIONS : MCP_SERVER_INSTRUCTIONS,
37
68
  },
38
69
  );
39
70
 
@@ -64,15 +95,116 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
64
95
  async ({ query, repo, top_k: topK, dedupe_by_file: dedupeByFile }) => {
65
96
  try {
66
97
  const index = await getIndexForRepo(repo, cache);
67
- let results = await index.search({ query, topK: clampMcpTopK(topK) });
98
+ const repoRoot = localRepoRoot(repo);
99
+ const k = clampMcpTopK(topK);
100
+
101
+ if (benchmark && repoRoot) {
102
+ const comparison = await benchmarkSearchComparison({
103
+ query,
104
+ repoPath: repoRoot,
105
+ index,
106
+ topK: k,
107
+ dedupeByFile: dedupeByFile !== false,
108
+ });
109
+ const results = comparison.results;
110
+ if (results.length === 0) {
111
+ return toolText(JSON.stringify({ error: "No results found." }));
112
+ }
113
+ try {
114
+ await appendBenchmarkQuery(recordFromBenchmark(repoRoot, comparison.benchmark));
115
+ } catch {
116
+ // History is best-effort; never fail the search on persist errors.
117
+ }
118
+ return toolText(
119
+ JSON.stringify(
120
+ attachSearchBenchmark(
121
+ formatResults(query, results, { repoRoot, snippet: true }),
122
+ comparison.benchmark,
123
+ ),
124
+ ),
125
+ );
126
+ }
127
+
128
+ let results = await index.search({ query, topK: k });
68
129
  if (dedupeByFile !== false) {
69
130
  results = dedupeResultsByFile(results);
70
131
  }
71
132
  if (results.length === 0) {
72
133
  return toolText(JSON.stringify({ error: "No results found." }));
73
134
  }
74
- const repoRoot = localRepoRoot(repo);
75
- return toolText(JSON.stringify(formatResults(query, results, { repoRoot, snippet: true })));
135
+ const payload = formatResults(query, results, { repoRoot, snippet: true });
136
+ if (benchmark && !repoRoot) {
137
+ return toolText(JSON.stringify(withBenchmarkSkipped(payload)));
138
+ }
139
+ return toolText(JSON.stringify(payload));
140
+ } catch (err) {
141
+ return toolText(err instanceof Error ? err.message : String(err));
142
+ }
143
+ },
144
+ );
145
+
146
+ server.registerTool(
147
+ "locate",
148
+ {
149
+ description: MCP_LOCATE_TOOL_DESCRIPTION,
150
+ inputSchema: {
151
+ literal: z
152
+ .string()
153
+ .min(1)
154
+ .describe("Exact substring to find (env var, symbol, error code, quoted text)."),
155
+ repo: z.string().describe(REPO_DESCRIPTION),
156
+ mode: z
157
+ .enum(["count", "locations", "lines"])
158
+ .optional()
159
+ .describe(
160
+ "count=totals only; locations=path:line; lines=path:line+text (default). Prefer count/locations when possible.",
161
+ ),
162
+ limit: z
163
+ .number()
164
+ .int()
165
+ .min(1)
166
+ .optional()
167
+ .describe(
168
+ "Optional cap on returned hits. Omit to return ALL matches (recommended). Do not fall back to Grep if n is large — use mode=count or locations instead.",
169
+ ),
170
+ ignore_case: z.boolean().optional().describe("Case-insensitive match (default false)."),
171
+ },
172
+ },
173
+ async ({ literal, repo, mode, limit, ignore_case: ignoreCase }) => {
174
+ try {
175
+ const index = await getIndexForRepo(repo, cache);
176
+ const locateOpts = {
177
+ mode: (mode as LiteralMode | undefined) ?? "lines",
178
+ ...(limit != null ? { limit } : {}),
179
+ ignore_case: ignoreCase,
180
+ };
181
+
182
+ if (benchmark) {
183
+ const repoRoot = localRepoRoot(repo);
184
+ if (repoRoot) {
185
+ const comparison = await benchmarkLocateComparison({
186
+ literal,
187
+ repoPath: repoRoot,
188
+ index,
189
+ locate: locateOpts,
190
+ });
191
+ try {
192
+ await appendBenchmarkQuery(recordFromAgentSummary(repoRoot, comparison.benchmark));
193
+ } catch {
194
+ // History is best-effort; never fail locate on persist errors.
195
+ }
196
+ return toolText(
197
+ JSON.stringify(attachLocateBenchmark(comparison.payload, comparison.benchmark)),
198
+ );
199
+ }
200
+ }
201
+
202
+ const result = index.locateLiteral(literal, locateOpts);
203
+ const payload = formatLiteralLocate(result);
204
+ if (benchmark && !localRepoRoot(repo)) {
205
+ return toolText(JSON.stringify(withBenchmarkSkipped(payload)));
206
+ }
207
+ return toolText(JSON.stringify(payload));
76
208
  } catch (err) {
77
209
  return toolText(err instanceof Error ? err.message : String(err));
78
210
  }
@@ -97,21 +229,21 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
97
229
  .int()
98
230
  .min(0)
99
231
  .optional()
100
- .describe("Extra chunks before the anchor (default 1)."),
232
+ .describe(`Extra chunks before the anchor (default ${DEFAULT_EXPAND_BEFORE}).`),
101
233
  after: z
102
234
  .number()
103
235
  .int()
104
236
  .min(0)
105
237
  .optional()
106
- .describe("Extra chunks after the anchor (default 1)."),
238
+ .describe(`Extra chunks after the anchor (default ${DEFAULT_EXPAND_AFTER}).`),
107
239
  },
108
240
  },
109
241
  async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
110
242
  try {
111
243
  const index = await getIndexForRepo(repo, cache);
112
244
  const repoRoot = localRepoRoot(repo);
113
- const beforeCount = before ?? 1;
114
- const afterCount = after ?? 1;
245
+ const beforeCount = before ?? DEFAULT_EXPAND_BEFORE;
246
+ const afterCount = after ?? DEFAULT_EXPAND_AFTER;
115
247
  const { anchor, chunks: expanded } = expandChunksAtLine(
116
248
  index.chunks,
117
249
  filePath,
@@ -194,6 +326,31 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
194
326
  },
195
327
  );
196
328
 
329
+ if (benchmark) {
330
+ server.registerTool(
331
+ "read_benchmark",
332
+ {
333
+ description: MCP_READ_BENCHMARK_TOOL_DESCRIPTION,
334
+ inputSchema: {
335
+ repo: z
336
+ .string()
337
+ .optional()
338
+ .describe(
339
+ "Optional local repo path or git URL to filter the rollup. Omit for overall totals.",
340
+ ),
341
+ },
342
+ },
343
+ async ({ repo }) => {
344
+ try {
345
+ const rollup = await readBenchmarkRollup({ repo });
346
+ return toolText(JSON.stringify(rollup));
347
+ } catch (err) {
348
+ return toolText(err instanceof Error ? err.message : String(err));
349
+ }
350
+ },
351
+ );
352
+ }
353
+
197
354
  return server;
198
355
  }
199
356
 
package/src/miru-index.ts CHANGED
@@ -14,9 +14,10 @@ import { createIndexFromPath } from "./index/create.ts";
14
14
  import { applyIncrementalFileChanges } from "./index/incremental.ts";
15
15
  import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
16
16
  import type { SemanticIndex } from "./index/semantic-index.ts";
17
+ import { type LiteralLocateOptions, type LiteralLocateResult, locateLiteral } from "./literal.ts";
17
18
  import { hybridSearch, searchSemanticOnly } from "./search.ts";
18
19
  import type { Chunk, ContentType, SearchResult } from "./types.ts";
19
- import { chunkKey, chunkToDict } from "./types.ts";
20
+ import { chunkKey, chunkToDict, defaultContentTypes } from "./types.ts";
20
21
  import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
21
22
  import { indexCacheEpoch } from "./version.ts";
22
23
 
@@ -120,7 +121,7 @@ export class MiruIndex {
120
121
  this.chunksInternal = options.chunks;
121
122
  this.embeddingModel = options.embeddingModel;
122
123
  this._root = options.root ?? null;
123
- this.content = options.content ?? ["code"];
124
+ this.content = options.content ?? defaultContentTypes();
124
125
  this.loadedFromDiskFlag = options.loadedFromDisk ?? false;
125
126
  this.storedFileMtimes = new Map(Object.entries(options.storedFileMtimes ?? {}));
126
127
  this.fileMapping = new Map();
@@ -162,7 +163,7 @@ export class MiruIndex {
162
163
  */
163
164
  static async fromGit(
164
165
  url: string,
165
- content: ContentType[] = ["code"],
166
+ content: ContentType[] = defaultContentTypes(),
166
167
  embeddingModel?: string,
167
168
  ref?: string | null,
168
169
  ): Promise<MiruIndex> {
@@ -202,7 +203,7 @@ export class MiruIndex {
202
203
  /** Resolve a local path or git URL and delegate to `fromPath` or `fromGit`. */
203
204
  static async fromSource(
204
205
  source: string,
205
- content: ContentType[] = ["code"],
206
+ content: ContentType[] = defaultContentTypes(),
206
207
  embeddingModel?: string,
207
208
  ref?: string | null,
208
209
  ): Promise<MiruIndex> {
@@ -221,7 +222,7 @@ export class MiruIndex {
221
222
  */
222
223
  static async fromPath(
223
224
  path: string,
224
- content: ContentType[] = ["code"],
225
+ content: ContentType[] = defaultContentTypes(),
225
226
  embeddingModel?: string,
226
227
  ): Promise<MiruIndex> {
227
228
  const resolved = resolve(path);
@@ -271,7 +272,7 @@ export class MiruIndex {
271
272
  static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
272
273
  const model = embeddingModel ?? resolveEmbeddingModel();
273
274
  const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
274
- const content = (metadata.content_type as ContentType[]) ?? ["code"];
275
+ const content = (metadata.content_type as ContentType[]) ?? defaultContentTypes();
275
276
  const root = metadata.root_path ? String(metadata.root_path) : null;
276
277
  const storedFileMtimes = (metadata.file_mtimes as Record<string, number>) ?? {};
277
278
 
@@ -444,4 +445,12 @@ export class MiruIndex {
444
445
 
445
446
  return results.filter((r) => chunkKey(r.chunk) !== chunkKey(target)).slice(0, topK);
446
447
  }
448
+
449
+ /**
450
+ * Exact substring location over indexed chunks (no disk re-walk).
451
+ * Prefer for known literals; use `search` for meaning-based questions.
452
+ */
453
+ locateLiteral(literal: string, options?: LiteralLocateOptions): LiteralLocateResult {
454
+ return locateLiteral(this.chunksInternal, literal, options);
455
+ }
447
456
  }
package/src/snippet.ts CHANGED
@@ -149,6 +149,9 @@ export function applySnippetsToResults(
149
149
  });
150
150
  }
151
151
 
152
- export function estimateResultTokens(results: SearchResult[]): number {
153
- return results.reduce((sum, r) => sum + Math.floor(r.chunk.content.length / 4), 0);
154
- }
152
+ export {
153
+ countTokens,
154
+ estimateResultTokens,
155
+ tokenCountMethod,
156
+ tokenizerJsonPath,
157
+ } from "./token-count.ts";
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { BertWordPieceTokenizer } from "./tokenizer/bert-wordpiece.ts";
5
+ import { loadTokenizerFromFile } from "./tokenizer/load.ts";
6
+ import type { SearchResult } from "./types.ts";
7
+
8
+ const TOKENIZER_JSON_ENV = "MIRU_TOKENIZER_JSON";
9
+ const PACKAGE_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
10
+ const DEFAULT_TOKENIZER_JSON = join(PACKAGE_ROOT, "tokenizer", "tokenizer.json");
11
+
12
+ let cached: BertWordPieceTokenizer | undefined;
13
+ let cachedPath: string | null = null;
14
+
15
+ function resolveTokenizerJsonPath(): string {
16
+ const fromEnv = process.env[TOKENIZER_JSON_ENV]?.trim();
17
+ if (fromEnv) {
18
+ return resolve(fromEnv);
19
+ }
20
+ return DEFAULT_TOKENIZER_JSON;
21
+ }
22
+
23
+ function loadTokenizer(): BertWordPieceTokenizer {
24
+ if (cached) {
25
+ return cached;
26
+ }
27
+
28
+ const path = resolveTokenizerJsonPath();
29
+ if (!existsSync(path)) {
30
+ throw new Error(
31
+ `Tokenizer not found at ${path}. Set ${TOKENIZER_JSON_ENV} or add tokenizer/tokenizer.json to the package.`,
32
+ );
33
+ }
34
+
35
+ cached = loadTokenizerFromFile(path);
36
+ cachedPath = path;
37
+ return cached;
38
+ }
39
+
40
+ /** Reset cached tokenizer (for tests). */
41
+ export function resetTokenizerCache(): void {
42
+ cached = undefined;
43
+ cachedPath = null;
44
+ }
45
+
46
+ export function tokenizerJsonPath(): string {
47
+ loadTokenizer();
48
+ return cachedPath ?? resolveTokenizerJsonPath();
49
+ }
50
+
51
+ /** @deprecated Use tokenCountMethod() — kept for benchmark response compatibility. */
52
+ export function tokenCountMethod(): "wordpiece" {
53
+ return "wordpiece";
54
+ }
55
+
56
+ export function countTokens(text: string): number {
57
+ return loadTokenizer().count(text);
58
+ }
59
+
60
+ export function estimateResultTokens(results: SearchResult[]): number {
61
+ return results.reduce((sum, result) => sum + countTokens(result.chunk.content), 0);
62
+ }