@takara-ai/miru-code 1.1.3 → 1.2.2

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/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
+ }
@@ -0,0 +1,219 @@
1
+ export interface TokenizerJson {
2
+ normalizer?: {
3
+ type?: string;
4
+ clean_text?: boolean;
5
+ handle_chinese_chars?: boolean;
6
+ strip_accents?: boolean | null;
7
+ lowercase?: boolean;
8
+ };
9
+ pre_tokenizer?: { type?: string };
10
+ model: {
11
+ type?: string;
12
+ vocab: Record<string, number>;
13
+ unk_token?: string;
14
+ continuing_subword_prefix?: string;
15
+ max_input_chars_per_word?: number;
16
+ };
17
+ }
18
+
19
+ export interface BertWordPieceTokenizer {
20
+ encode(text: string): string[];
21
+ count(text: string): number;
22
+ }
23
+
24
+ function isControl(char: string): boolean {
25
+ const cp = char.codePointAt(0) ?? 0;
26
+ // TAB/LF/CR are whitespace in Bert clean_text, not stripped controls.
27
+ if (cp === 0x09 || cp === 0x0a || cp === 0x0d) {
28
+ return false;
29
+ }
30
+ return cp === 0 || cp === 0xfffd || (cp >= 0x00 && cp <= 0x1f) || (cp >= 0x7f && cp <= 0x9f);
31
+ }
32
+
33
+ function isWhitespace(char: string): boolean {
34
+ const cp = char.codePointAt(0) ?? 0;
35
+ return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d;
36
+ }
37
+
38
+ function isPunctuation(char: string): boolean {
39
+ const cp = char.codePointAt(0) ?? 0;
40
+ if (
41
+ (cp >= 33 && cp <= 47) ||
42
+ (cp >= 58 && cp <= 64) ||
43
+ (cp >= 91 && cp <= 96) ||
44
+ (cp >= 123 && cp <= 126)
45
+ ) {
46
+ return true;
47
+ }
48
+ return /\p{P}/u.test(char);
49
+ }
50
+
51
+ function isChineseChar(cp: number): boolean {
52
+ return (
53
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
54
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
55
+ (cp >= 0x20000 && cp <= 0x2a6df) ||
56
+ (cp >= 0x2a700 && cp <= 0x2b73f) ||
57
+ (cp >= 0x2b740 && cp <= 0x2b81f) ||
58
+ (cp >= 0x2b820 && cp <= 0x2ceaf) ||
59
+ (cp >= 0xf900 && cp <= 0xfaff) ||
60
+ (cp >= 0x2f800 && cp <= 0x2fa1f)
61
+ );
62
+ }
63
+
64
+ function cleanText(text: string): string {
65
+ const output: string[] = [];
66
+ for (const char of text) {
67
+ if (isControl(char)) {
68
+ continue;
69
+ }
70
+ output.push(isWhitespace(char) ? " " : char);
71
+ }
72
+ return output.join("");
73
+ }
74
+
75
+ function tokenizeChineseChars(text: string): string {
76
+ const output: string[] = [];
77
+ for (const char of text) {
78
+ const cp = char.codePointAt(0) ?? 0;
79
+ if (isChineseChar(cp)) {
80
+ output.push(" ", char, " ");
81
+ } else {
82
+ output.push(char);
83
+ }
84
+ }
85
+ return output.join("");
86
+ }
87
+
88
+ function removeAccents(text: string): string {
89
+ return text.normalize("NFD").replace(/\p{M}/gu, "");
90
+ }
91
+
92
+ function whitespaceTokenize(text: string): string[] {
93
+ const trimmed = text.trim();
94
+ if (!trimmed) {
95
+ return [];
96
+ }
97
+ return trimmed.split(/\s+/);
98
+ }
99
+
100
+ function splitOnPunctuation(text: string): string[] {
101
+ const chars = [...text];
102
+ if (chars.length === 0) {
103
+ return [];
104
+ }
105
+
106
+ const output: string[][] = [];
107
+ let startNewWord = true;
108
+
109
+ for (const char of chars) {
110
+ if (isPunctuation(char)) {
111
+ output.push([char]);
112
+ startNewWord = true;
113
+ continue;
114
+ }
115
+ if (startNewWord) {
116
+ output.push([]);
117
+ startNewWord = false;
118
+ }
119
+ output[output.length - 1]?.push(char);
120
+ }
121
+
122
+ return output.map((parts) => parts.join("")).filter(Boolean);
123
+ }
124
+
125
+ function bertPreTokenize(text: string): string[] {
126
+ const words = whitespaceTokenize(text);
127
+ const splitTokens: string[] = [];
128
+ for (const word of words) {
129
+ splitTokens.push(...splitOnPunctuation(word));
130
+ }
131
+ return whitespaceTokenize(splitTokens.join(" "));
132
+ }
133
+
134
+ function wordpieceTokenize(
135
+ token: string,
136
+ vocab: Set<string>,
137
+ unkToken: string,
138
+ prefix: string,
139
+ maxInputCharsPerWord: number,
140
+ ): string[] {
141
+ if (token.length === 0) {
142
+ return [];
143
+ }
144
+ if (token.length > maxInputCharsPerWord) {
145
+ return [unkToken];
146
+ }
147
+
148
+ const chars = [...token];
149
+ const subTokens: string[] = [];
150
+ let start = 0;
151
+
152
+ while (start < chars.length) {
153
+ let end = chars.length;
154
+ let curSubstr: string | null = null;
155
+
156
+ while (start < end) {
157
+ let substr = chars.slice(start, end).join("");
158
+ if (start > 0) {
159
+ substr = `${prefix}${substr}`;
160
+ }
161
+ if (vocab.has(substr)) {
162
+ curSubstr = substr;
163
+ break;
164
+ }
165
+ end -= 1;
166
+ }
167
+
168
+ if (!curSubstr) {
169
+ return [unkToken];
170
+ }
171
+
172
+ subTokens.push(curSubstr);
173
+ start = end;
174
+ }
175
+
176
+ return subTokens;
177
+ }
178
+
179
+ export function createBertWordPieceTokenizer(json: TokenizerJson): BertWordPieceTokenizer {
180
+ const normalizer = json.normalizer ?? {};
181
+ const model = json.model;
182
+ const lowercase = normalizer.lowercase ?? true;
183
+ const stripAccents = normalizer.strip_accents ?? lowercase;
184
+ const vocab = new Set(Object.keys(model.vocab));
185
+ const unkToken = model.unk_token ?? "[UNK]";
186
+ const prefix = model.continuing_subword_prefix ?? "##";
187
+ const maxInputCharsPerWord = model.max_input_chars_per_word ?? 100;
188
+ const clean = normalizer.clean_text ?? true;
189
+ const handleChineseChars = normalizer.handle_chinese_chars ?? true;
190
+
191
+ function encode(text: string): string[] {
192
+ let normalized = text;
193
+ if (clean) {
194
+ normalized = cleanText(normalized);
195
+ }
196
+ if (handleChineseChars) {
197
+ normalized = tokenizeChineseChars(normalized);
198
+ }
199
+ if (lowercase) {
200
+ normalized = normalized.toLowerCase();
201
+ if (stripAccents) {
202
+ normalized = removeAccents(normalized);
203
+ }
204
+ }
205
+
206
+ const tokens: string[] = [];
207
+ for (const basic of bertPreTokenize(normalized)) {
208
+ tokens.push(...wordpieceTokenize(basic, vocab, unkToken, prefix, maxInputCharsPerWord));
209
+ }
210
+ return tokens;
211
+ }
212
+
213
+ return {
214
+ encode,
215
+ count(text: string): number {
216
+ return encode(text).length;
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ type BertWordPieceTokenizer,
4
+ createBertWordPieceTokenizer,
5
+ type TokenizerJson,
6
+ } from "./bert-wordpiece.ts";
7
+
8
+ export function loadTokenizerFromFile(path: string): BertWordPieceTokenizer {
9
+ const json = JSON.parse(readFileSync(path, "utf-8")) as TokenizerJson;
10
+ if (json.model?.type !== "WordPiece") {
11
+ throw new Error(`Unsupported tokenizer model type: ${json.model?.type ?? "unknown"}`);
12
+ }
13
+ return createBertWordPieceTokenizer(json);
14
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  export type ContentType = "code" | "docs" | "config";
2
2
 
3
+ /** Default index corpus: source code plus config (json/yaml/toml/…). */
4
+ export const DEFAULT_CONTENT_TYPES: readonly ContentType[] = ["code", "config"];
5
+
6
+ /** Mutable copy of {@link DEFAULT_CONTENT_TYPES} for API defaults. */
7
+ export function defaultContentTypes(): ContentType[] {
8
+ return [...DEFAULT_CONTENT_TYPES];
9
+ }
10
+
3
11
  export interface Chunk {
4
12
  content: string;
5
13
  file_path: string;
package/src/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, relative, resolve, sep } from "node:path";
2
2
  import { applySnippetsToResults, searchSnippetsEnabled } from "./snippet.ts";
3
3
  import type { Chunk, ContentType, SearchResult } from "./types.ts";
4
- import { chunkToDict } from "./types.ts";
4
+ import { chunkToDict, defaultContentTypes } from "./types.ts";
5
5
 
6
6
  const GIT_URL_SCHEMES = [
7
7
  "https://",
@@ -103,6 +103,10 @@ export type ExpandResults = {
103
103
  export const DEFAULT_MCP_TOP_K = 3;
104
104
  export const MAX_MCP_TOP_K = 10;
105
105
 
106
+ /** Extra chunks before/after the expand anchor — MCP `expand` tool defaults. */
107
+ export const DEFAULT_EXPAND_BEFORE = 1;
108
+ export const DEFAULT_EXPAND_AFTER = 1;
109
+
106
110
  /** Clamp MCP top_k to a sane range; omit for the default. */
107
111
  export function clampMcpTopK(topK?: number): number {
108
112
  const value = topK ?? DEFAULT_MCP_TOP_K;
@@ -246,6 +250,9 @@ export function formatExpandResults(
246
250
  }
247
251
 
248
252
  export function resolveContent(raw: string[]): ContentType[] {
253
+ if (raw.length === 0) {
254
+ return defaultContentTypes();
255
+ }
249
256
  if (raw.includes("all")) {
250
257
  return ["code", "docs", "config"];
251
258
  }