@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/README.md +178 -107
- package/package.json +9 -5
- package/src/benchmark/compare.ts +278 -0
- package/src/benchmark/grep.ts +352 -0
- package/src/benchmark/history.ts +206 -0
- package/src/benchmark/locate-compare.ts +65 -0
- package/src/benchmark/rg-literal.ts +143 -0
- package/src/benchmark/summary.ts +29 -0
- package/src/benchmark/types.ts +65 -0
- package/src/cli.ts +183 -4
- package/src/credentials.ts +8 -0
- package/src/help.ts +45 -2
- package/src/index/create.ts +2 -2
- package/src/index/incremental.ts +4 -3
- package/src/index.ts +3 -1
- package/src/installer/benchmark-mode.ts +248 -0
- package/src/installer/config.ts +114 -14
- package/src/installer/hooks/opencode-plugin.ts +6 -20
- package/src/installer/hooks/search-guard.ts +4 -5
- package/src/installer/installer.ts +58 -5
- package/src/installer/search-policy.ts +28 -9
- package/src/literal.ts +139 -0
- package/src/mcp/index-cache.ts +2 -2
- package/src/mcp/serve.ts +4 -3
- package/src/mcp/server.ts +166 -9
- package/src/miru-index.ts +15 -6
- package/src/snippet.ts +6 -3
- package/src/token-count.ts +62 -0
- package/src/tokenizer/bert-wordpiece.ts +219 -0
- package/src/tokenizer/load.ts +14 -0
- package/src/types.ts +8 -0
- package/src/utils.ts +8 -1
- package/tokenizer/tokenizer.json +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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(
|
|
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(
|
|
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 ??
|
|
114
|
-
const afterCount = after ??
|
|
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 ??
|
|
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[] =
|
|
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[] =
|
|
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[] =
|
|
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[]) ??
|
|
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
|
|
153
|
-
|
|
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
|
}
|