@takara-ai/miru-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/miru.cjs +41 -0
  4. package/package.json +72 -0
  5. package/src/agents/claude.md +41 -0
  6. package/src/agents/copilot.md +41 -0
  7. package/src/agents/cursor.md +40 -0
  8. package/src/agents/gemini.md +43 -0
  9. package/src/agents/kiro.md +43 -0
  10. package/src/agents/opencode.md +44 -0
  11. package/src/agents.ts +30 -0
  12. package/src/cache.ts +141 -0
  13. package/src/chunking/chunking.ts +36 -0
  14. package/src/chunking/lines.ts +79 -0
  15. package/src/chunking/structural.ts +244 -0
  16. package/src/cli.ts +245 -0
  17. package/src/concurrency.ts +58 -0
  18. package/src/embeddings/openai.ts +442 -0
  19. package/src/env-files.ts +44 -0
  20. package/src/env.ts +50 -0
  21. package/src/git.ts +52 -0
  22. package/src/index/bm25-types.ts +18 -0
  23. package/src/index/bm25-worker.ts +48 -0
  24. package/src/index/bm25.ts +238 -0
  25. package/src/index/create.ts +195 -0
  26. package/src/index/dense.ts +105 -0
  27. package/src/index/file-walker.ts +113 -0
  28. package/src/index/files.ts +126 -0
  29. package/src/index/persistence.ts +100 -0
  30. package/src/index/quantize.ts +168 -0
  31. package/src/index/semantic-index.ts +11 -0
  32. package/src/index/sparse.ts +25 -0
  33. package/src/index/topk.ts +52 -0
  34. package/src/index/vector-storage.ts +27 -0
  35. package/src/index.ts +3 -0
  36. package/src/mcp/index-cache.ts +95 -0
  37. package/src/mcp/serve.ts +24 -0
  38. package/src/mcp/server.ts +100 -0
  39. package/src/miru-index.ts +269 -0
  40. package/src/ranking/boosting.ts +329 -0
  41. package/src/ranking/penalties.ts +110 -0
  42. package/src/ranking/weighting.ts +11 -0
  43. package/src/search.ts +180 -0
  44. package/src/tokens.ts +27 -0
  45. package/src/types.ts +47 -0
  46. package/src/utils.ts +64 -0
@@ -0,0 +1,442 @@
1
+ import { mapPool, resolveWorkerConcurrency } from "../concurrency.ts";
2
+ import { envFirstString, envOptionalInt, resolveEmbeddingApiKey } from "../env.ts";
3
+
4
+ const DEFAULT_MODEL = "ds1-potion-code-16m";
5
+ const DEFAULT_BASE_URL = "https://infer.dev.takara.ai/v1";
6
+ const DEFAULT_BATCH_SIZE = 32;
7
+ const DEFAULT_MAX_EMBED_CHARS = 1300;
8
+ const WINDOW_OVERLAP_CHARS = 120;
9
+
10
+ /** Native output size for Takara ds1-potion-code-16m (override with MIRU_EMBEDDING_DIMENSIONS). */
11
+ const MODEL_DEFAULT_DIMENSIONS: Record<string, number> = {
12
+ "ds1-potion-code-16m": 256,
13
+ };
14
+
15
+ export interface EmbeddingTransportStats {
16
+ requests: number;
17
+ retries: number;
18
+ payloadTooLarge: number;
19
+ errors: number;
20
+ inputItems: number;
21
+ inputChars: number;
22
+ totalRttMs: number;
23
+ maxRttMs: number;
24
+ }
25
+
26
+ export interface EmbeddingBackend {
27
+ readonly model: string;
28
+ readonly dimensions: number;
29
+ embedDocuments(texts: string[]): Promise<Float32Array[]>;
30
+ embedQuery(text: string): Promise<Float32Array>;
31
+ }
32
+
33
+ export function resolveEmbeddingModel(): string {
34
+ return envFirstString(
35
+ ["MIRU_OPENAI_EMBEDDING_MODEL", "SEMBLE_OPENAI_EMBEDDING_MODEL", "OPENAI_EMBEDDING_MODEL"],
36
+ DEFAULT_MODEL,
37
+ );
38
+ }
39
+
40
+ export function resolveMaxEmbedChars(): number {
41
+ return (
42
+ envOptionalInt(["MIRU_MAX_EMBED_CHARS", "SEMBLE_MAX_EMBED_CHARS"], 256) ??
43
+ DEFAULT_MAX_EMBED_CHARS
44
+ );
45
+ }
46
+
47
+ function splitIntoWindows(text: string, maxChars: number): string[] {
48
+ if (text.length <= maxChars) {
49
+ return [text];
50
+ }
51
+ const out: string[] = [];
52
+ const step = Math.max(64, maxChars - WINDOW_OVERLAP_CHARS);
53
+ for (let start = 0; start < text.length; start += step) {
54
+ const end = Math.min(text.length, start + maxChars);
55
+ const part = text.slice(start, end);
56
+ if (part.length > 0) {
57
+ out.push(part);
58
+ }
59
+ if (end >= text.length) {
60
+ break;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function sanitizeEmbeddingInput(text: string): string {
67
+ // Some OpenAI-compatible gateways mis-handle backslash escapes in JSON text
68
+ // payloads. Keep a benchmark fallback mode to forcefully neutralize them.
69
+ const mode = process.env.MIRU_EMBED_ESCAPE_MODE ?? process.env.SEMBLE_EMBED_ESCAPE_MODE ?? "quad";
70
+ if (mode === "strip") {
71
+ return text.replaceAll("\\", "/");
72
+ }
73
+ return text.replaceAll("\\", "\\\\\\\\");
74
+ }
75
+
76
+ function isPayloadTooLargeError(err: unknown): boolean {
77
+ const status = (err as { status?: number }).status;
78
+ if (status === 413) {
79
+ return true;
80
+ }
81
+ const message = (err as { message?: string }).message;
82
+ if (typeof message === "string" && message.includes("413")) {
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ export function resolveEmbeddingDimensions(model?: string): number | undefined {
89
+ const fromEnv = envOptionalInt([
90
+ "MIRU_EMBEDDING_DIMENSIONS",
91
+ "SEMBLE_EMBEDDING_DIMENSIONS",
92
+ "OPENAI_EMBEDDING_DIMENSIONS",
93
+ ]);
94
+ if (fromEnv != null) {
95
+ return fromEnv;
96
+ }
97
+ return MODEL_DEFAULT_DIMENSIONS[model ?? resolveEmbeddingModel()];
98
+ }
99
+
100
+ export function resolveEmbeddingBatchSize(): number {
101
+ return (
102
+ envOptionalInt([
103
+ "MIRU_EMBEDDING_BATCH_SIZE",
104
+ "SEMBLE_EMBEDDING_BATCH_SIZE",
105
+ "OPENAI_EMBEDDING_BATCH_SIZE",
106
+ ]) ?? DEFAULT_BATCH_SIZE
107
+ );
108
+ }
109
+
110
+ export function resolveEmbeddingBaseUrl(): string {
111
+ return envFirstString(
112
+ ["MIRU_OPENAI_BASE_URL", "SEMBLE_OPENAI_BASE_URL", "OPENAI_BASE_URL"],
113
+ DEFAULT_BASE_URL,
114
+ ).replace(/\/$/, "");
115
+ }
116
+
117
+ interface EmbeddingResponseItem {
118
+ index: number;
119
+ embedding: number[];
120
+ }
121
+
122
+ interface EmbeddingResponse {
123
+ data: EmbeddingResponseItem[];
124
+ }
125
+
126
+ interface EmbeddingClient {
127
+ createEmbeddings(
128
+ input: string[] | string,
129
+ model: string,
130
+ dimensions?: number,
131
+ ): Promise<EmbeddingResponse>;
132
+ }
133
+
134
+ class EmbeddingApiError extends Error {
135
+ readonly status: number;
136
+ readonly body: string;
137
+
138
+ constructor(status: number, body: string) {
139
+ super(`Embedding API error ${status}: ${body.slice(0, 500)}`);
140
+ this.name = "EmbeddingApiError";
141
+ this.status = status;
142
+ this.body = body;
143
+ }
144
+ }
145
+
146
+ function createClient(): EmbeddingClient {
147
+ const apiKey = resolveEmbeddingApiKey();
148
+
149
+ const baseUrl = resolveEmbeddingBaseUrl();
150
+ const endpoint = `${baseUrl}/embeddings`;
151
+
152
+ return {
153
+ async createEmbeddings(
154
+ input: string[] | string,
155
+ model: string,
156
+ dimensions?: number,
157
+ ): Promise<EmbeddingResponse> {
158
+ const body: Record<string, unknown> = { model, input };
159
+ if (dimensions != null) {
160
+ body.dimensions = dimensions;
161
+ }
162
+ const response = await fetch(endpoint, {
163
+ method: "POST",
164
+ headers: {
165
+ Authorization: `Bearer ${apiKey}`,
166
+ "Content-Type": "application/json",
167
+ },
168
+ body: JSON.stringify(body),
169
+ });
170
+
171
+ if (!response.ok) {
172
+ throw new EmbeddingApiError(response.status, await response.text());
173
+ }
174
+
175
+ const payload = (await response.json()) as EmbeddingResponse;
176
+ if (!payload || !Array.isArray(payload.data)) {
177
+ throw new Error("Embedding API returned invalid payload");
178
+ }
179
+ return payload;
180
+ },
181
+ };
182
+ }
183
+
184
+ export class OpenAIEmbeddingBackend implements EmbeddingBackend {
185
+ readonly model: string;
186
+ dimensions = 0;
187
+
188
+ private readonly client: EmbeddingClient;
189
+ private readonly batchSize: number;
190
+ private readonly maxEmbedChars: number;
191
+ private readonly requestedDimensions: number | undefined;
192
+ private stats: EmbeddingTransportStats;
193
+
194
+ constructor(options?: {
195
+ model?: string;
196
+ batchSize?: number;
197
+ maxEmbedChars?: number;
198
+ dimensions?: number;
199
+ client?: EmbeddingClient;
200
+ }) {
201
+ this.model = options?.model ?? resolveEmbeddingModel();
202
+ this.client = options?.client ?? createClient();
203
+ this.batchSize = options?.batchSize ?? resolveEmbeddingBatchSize();
204
+ this.maxEmbedChars = options?.maxEmbedChars ?? resolveMaxEmbedChars();
205
+ this.requestedDimensions = options?.dimensions ?? resolveEmbeddingDimensions(this.model);
206
+ this.stats = {
207
+ requests: 0,
208
+ retries: 0,
209
+ payloadTooLarge: 0,
210
+ errors: 0,
211
+ inputItems: 0,
212
+ inputChars: 0,
213
+ totalRttMs: 0,
214
+ maxRttMs: 0,
215
+ };
216
+ }
217
+
218
+ async embedDocuments(texts: string[]): Promise<Float32Array[]> {
219
+ if (texts.length === 0) {
220
+ return [];
221
+ }
222
+
223
+ interface WindowJob {
224
+ docIndex: number;
225
+ text: string;
226
+ }
227
+
228
+ const jobs: WindowJob[] = [];
229
+ const windowVectors: Float32Array[][] = texts.map(() => []);
230
+
231
+ for (let docIndex = 0; docIndex < texts.length; docIndex++) {
232
+ const text = texts[docIndex];
233
+ if (text === undefined) {
234
+ continue;
235
+ }
236
+ const windows = splitIntoWindows(text, this.maxEmbedChars).map(sanitizeEmbeddingInput);
237
+ for (const text of windows) {
238
+ jobs.push({ docIndex, text });
239
+ }
240
+ }
241
+
242
+ if (jobs.length === 0) {
243
+ return texts.map(() => new Float32Array(0));
244
+ }
245
+
246
+ const batches: WindowJob[][] = [];
247
+ for (let i = 0; i < jobs.length; i += this.batchSize) {
248
+ batches.push(jobs.slice(i, i + this.batchSize));
249
+ }
250
+
251
+ const concurrency = resolveWorkerConcurrency();
252
+ await mapPool(batches, concurrency, async (batch) => {
253
+ const vectors = await this.embedBatchRawWithRetry(batch.map((job) => job.text));
254
+ for (let i = 0; i < batch.length; i++) {
255
+ const job = batch[i];
256
+ const vector = vectors[i];
257
+ if (job === undefined || vector === undefined) {
258
+ continue;
259
+ }
260
+ const bucket = windowVectors[job.docIndex];
261
+ if (bucket) {
262
+ bucket.push(vector);
263
+ }
264
+ }
265
+ });
266
+
267
+ const out = windowVectors.map((vectors) => poolWindowVectors(vectors));
268
+ for (const vec of out) {
269
+ if (this.dimensions === 0 && vec.length > 0) {
270
+ this.dimensions = vec.length;
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+
276
+ private async embedBatchRawWithRetry(texts: string[]): Promise<Float32Array[]> {
277
+ if (texts.length === 0) {
278
+ return [];
279
+ }
280
+ try {
281
+ const started = performance.now();
282
+ const response = await this.client.createEmbeddings(
283
+ texts,
284
+ this.model,
285
+ this.requestedDimensions,
286
+ );
287
+ const elapsed = performance.now() - started;
288
+ this.stats.requests += 1;
289
+ this.stats.inputItems += texts.length;
290
+ this.stats.inputChars += texts.reduce((acc, text) => acc + text.length, 0);
291
+ this.stats.totalRttMs += elapsed;
292
+ this.stats.maxRttMs = Math.max(this.stats.maxRttMs, elapsed);
293
+ const vectors = vectorsFromResponse(response.data, texts.length);
294
+ for (const vec of vectors) {
295
+ if (this.requestedDimensions != null && vec.length !== this.requestedDimensions) {
296
+ throw new Error(
297
+ `Embedding API returned ${vec.length} dims for model ${this.model}, expected ${this.requestedDimensions}`,
298
+ );
299
+ }
300
+ if (this.dimensions === 0) {
301
+ this.dimensions = vec.length;
302
+ } else if (vec.length !== this.dimensions) {
303
+ throw new Error(
304
+ `Inconsistent embedding dimensions in batch: ${vec.length} vs ${this.dimensions}`,
305
+ );
306
+ }
307
+ }
308
+ return vectors;
309
+ } catch (err: unknown) {
310
+ if (isPayloadTooLargeError(err) && texts.length > 1) {
311
+ this.stats.payloadTooLarge += 1;
312
+ this.stats.retries += 1;
313
+ const mid = Math.ceil(texts.length / 2);
314
+ const left = await this.embedBatchRawWithRetry(texts.slice(0, mid));
315
+ const right = await this.embedBatchRawWithRetry(texts.slice(mid));
316
+ return [...left, ...right];
317
+ }
318
+ if (isPayloadTooLargeError(err) && texts.length === 1) {
319
+ this.stats.payloadTooLarge += 1;
320
+ this.stats.retries += 1;
321
+ const text = texts[0];
322
+ if (!text) {
323
+ this.stats.errors += 1;
324
+ throw err;
325
+ }
326
+ if (text.length <= 128) {
327
+ this.stats.errors += 1;
328
+ throw err;
329
+ }
330
+ const mid = Math.floor(text.length / 2);
331
+ const leftText = text.slice(0, mid);
332
+ const rightText = text.slice(mid);
333
+ const left = await this.embedBatchRawWithRetry([leftText]);
334
+ const right = await this.embedBatchRawWithRetry([rightText]);
335
+ return [...left, ...right];
336
+ }
337
+ this.stats.errors += 1;
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ async embedQuery(text: string): Promise<Float32Array> {
343
+ const [vec] = await this.embedDocuments([text]);
344
+ if (!vec) {
345
+ throw new Error("OpenAI returned no embedding for query");
346
+ }
347
+ return vec;
348
+ }
349
+
350
+ getStats(): EmbeddingTransportStats {
351
+ return { ...this.stats };
352
+ }
353
+
354
+ resetStats(): void {
355
+ this.stats = {
356
+ requests: 0,
357
+ retries: 0,
358
+ payloadTooLarge: 0,
359
+ errors: 0,
360
+ inputItems: 0,
361
+ inputChars: 0,
362
+ totalRttMs: 0,
363
+ maxRttMs: 0,
364
+ };
365
+ }
366
+ }
367
+
368
+ function vectorsFromResponse(data: EmbeddingResponseItem[], expected: number): Float32Array[] {
369
+ const byIndex = new Map<number, Float32Array>();
370
+ for (const item of data) {
371
+ if (item.index >= 0 && item.index < expected) {
372
+ byIndex.set(item.index, new Float32Array(item.embedding));
373
+ }
374
+ }
375
+ if (byIndex.size !== expected) {
376
+ throw new Error(
377
+ `Embedding API returned ${data.length} vectors for ${expected} inputs (${byIndex.size} unique indices)`,
378
+ );
379
+ }
380
+ return Array.from({ length: expected }, (_, i) => {
381
+ const vec = byIndex.get(i);
382
+ if (!vec) {
383
+ throw new Error(`Missing embedding vector at index ${i}`);
384
+ }
385
+ return vec;
386
+ });
387
+ }
388
+
389
+ function poolWindowVectors(vectors: Float32Array[]): Float32Array {
390
+ if (vectors.length === 0) {
391
+ throw new Error("Embedding API returned no vectors");
392
+ }
393
+ const first = vectors[0];
394
+ if (!first) {
395
+ throw new Error("Embedding API returned no vectors");
396
+ }
397
+ if (vectors.length === 1) {
398
+ return normalize(first);
399
+ }
400
+ const pooled = new Float32Array(first.length);
401
+ for (const vec of vectors) {
402
+ for (let i = 0; i < pooled.length; i++) {
403
+ pooled[i] += vec[i] ?? 0;
404
+ }
405
+ }
406
+ for (let i = 0; i < pooled.length; i++) {
407
+ pooled[i] /= vectors.length;
408
+ }
409
+ return normalize(pooled);
410
+ }
411
+
412
+ function normalize(vec: Float32Array): Float32Array {
413
+ let norm = 0;
414
+ for (let i = 0; i < vec.length; i++) {
415
+ const value = vec[i] ?? 0;
416
+ norm += value * value;
417
+ }
418
+ norm = Math.sqrt(norm);
419
+ if (norm === 0) {
420
+ return vec;
421
+ }
422
+ const out = new Float32Array(vec.length);
423
+ for (let i = 0; i < vec.length; i++) {
424
+ out[i] = (vec[i] ?? 0) / norm;
425
+ }
426
+ return out;
427
+ }
428
+
429
+ let defaultBackend: OpenAIEmbeddingBackend | null = null;
430
+
431
+ export function getEmbeddingBackend(model?: string): OpenAIEmbeddingBackend {
432
+ if (model) {
433
+ return new OpenAIEmbeddingBackend({
434
+ model,
435
+ dimensions: resolveEmbeddingDimensions(model),
436
+ });
437
+ }
438
+ if (!defaultBackend) {
439
+ defaultBackend = new OpenAIEmbeddingBackend();
440
+ }
441
+ return defaultBackend;
442
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function applyEnvLine(line: string): void {
5
+ const trimmed = line.trim();
6
+ if (!trimmed || trimmed.startsWith("#")) {
7
+ return;
8
+ }
9
+ const eq = trimmed.indexOf("=");
10
+ if (eq === -1) {
11
+ return;
12
+ }
13
+ const key = trimmed.slice(0, eq).trim();
14
+ if (!key || process.env[key] !== undefined) {
15
+ return;
16
+ }
17
+ let value = trimmed.slice(eq + 1).trim();
18
+ if (
19
+ (value.startsWith('"') && value.endsWith('"')) ||
20
+ (value.startsWith("'") && value.endsWith("'"))
21
+ ) {
22
+ value = value.slice(1, -1);
23
+ }
24
+ process.env[key] = value;
25
+ }
26
+
27
+ function loadEnvFile(path: string): void {
28
+ if (!existsSync(path)) {
29
+ return;
30
+ }
31
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
32
+ applyEnvLine(line);
33
+ }
34
+ }
35
+
36
+ /** Load .env.local / .env without overriding vars already set (e.g. MCP config env). */
37
+ export function loadEnvFiles(options?: { packageRoot?: string; cwd?: string }): void {
38
+ const cwd = options?.cwd ?? process.cwd();
39
+ const packageRoot = options?.packageRoot ?? join(import.meta.dir, "..");
40
+ for (const dir of [packageRoot, cwd]) {
41
+ loadEnvFile(join(dir, ".env.local"));
42
+ loadEnvFile(join(dir, ".env"));
43
+ }
44
+ }
package/src/env.ts ADDED
@@ -0,0 +1,50 @@
1
+ export function envInt(name: string, fallback: number, min = 1): number {
2
+ const raw = process.env[name];
3
+ if (!raw) {
4
+ return fallback;
5
+ }
6
+ const n = Number(raw);
7
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
8
+ }
9
+
10
+ export function envFirstString(names: string[], fallback: string): string {
11
+ for (const name of names) {
12
+ const value = process.env[name];
13
+ if (value) {
14
+ return value;
15
+ }
16
+ }
17
+ return fallback;
18
+ }
19
+
20
+ export function envOptionalInt(names: string[], min = 1): number | undefined {
21
+ for (const name of names) {
22
+ const raw = process.env[name];
23
+ if (!raw) {
24
+ continue;
25
+ }
26
+ const n = Number(raw);
27
+ if (Number.isFinite(n) && n >= min) {
28
+ return Math.floor(n);
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ const EMBEDDING_API_KEY_NAMES = [
35
+ "TAKARA_API_KEY",
36
+ "OPENAI_API_KEY",
37
+ "MIRU_OPENAI_API_KEY",
38
+ "SEMBLE_OPENAI_API_KEY",
39
+ ] as const;
40
+
41
+ export function resolveEmbeddingApiKey(): string {
42
+ const key = envFirstString([...EMBEDDING_API_KEY_NAMES], "");
43
+ if (!key) {
44
+ throw new Error(
45
+ "Embedding API key required. Set TAKARA_API_KEY, OPENAI_API_KEY, MIRU_OPENAI_API_KEY, or SEMBLE_OPENAI_API_KEY " +
46
+ "in your MCP server env (Cursor mcp.json) or .env.local.",
47
+ );
48
+ }
49
+ return key;
50
+ }
package/src/git.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { envOptionalInt } from "./env.ts";
5
+
6
+ const DEFAULT_CLONE_TIMEOUT_SEC = 60;
7
+
8
+ export async function cloneGitRepository(url: string, ref?: string | null): Promise<string> {
9
+ const timeoutSec =
10
+ envOptionalInt(["MIRU_CLONE_TIMEOUT", "SEMBLE_CLONE_TIMEOUT"], 1) ?? DEFAULT_CLONE_TIMEOUT_SEC;
11
+ const dir = await mkdtemp(join(tmpdir(), "miru-git-"));
12
+ const args = ["clone", "--depth", "1"];
13
+ if (ref) {
14
+ args.push("--branch", ref);
15
+ }
16
+ args.push("--", url, dir);
17
+
18
+ let proc: ReturnType<typeof Bun.spawn>;
19
+ try {
20
+ proc = Bun.spawn(["git", ...args], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ stdin: "ignore",
24
+ });
25
+ } catch {
26
+ await rm(dir, { recursive: true, force: true });
27
+ throw new Error("git is not installed or not on PATH");
28
+ }
29
+
30
+ let timedOut = false;
31
+ const timer = setTimeout(() => {
32
+ timedOut = true;
33
+ proc.kill();
34
+ }, timeoutSec * 1000);
35
+
36
+ const code = await proc.exited;
37
+ clearTimeout(timer);
38
+
39
+ if (timedOut) {
40
+ await rm(dir, { recursive: true, force: true });
41
+ throw new Error(`git clone timed out for ${url} (limit: ${timeoutSec}s)`);
42
+ }
43
+
44
+ if (code !== 0) {
45
+ const stderr =
46
+ proc.stderr && typeof proc.stderr !== "number" ? await new Response(proc.stderr).text() : "";
47
+ await rm(dir, { recursive: true, force: true });
48
+ throw new Error(`git clone failed for ${url}:\n${stderr.trim()}`);
49
+ }
50
+
51
+ return dir;
52
+ }
@@ -0,0 +1,18 @@
1
+ export type PostingsList = [docIndex: number, tf: number][];
2
+
3
+ export type SerializablePostings = Record<string, PostingsList>;
4
+
5
+ export interface Bm25ScoreJob {
6
+ startDoc: number;
7
+ endDoc: number;
8
+ docLengths: number[];
9
+ avgDocLength: number;
10
+ terms: { term: string; idf: number }[];
11
+ postings: SerializablePostings;
12
+ weightMask?: boolean[];
13
+ }
14
+
15
+ export interface Bm25ScoreResult {
16
+ startDoc: number;
17
+ scores: number[];
18
+ }
@@ -0,0 +1,48 @@
1
+ import type { Bm25ScoreJob, Bm25ScoreResult } from "./bm25-types.ts";
2
+
3
+ const K1 = 1.5;
4
+ const B = 0.75;
5
+
6
+ function scoreSlice(job: Bm25ScoreJob): number[] {
7
+ const { startDoc, endDoc, docLengths, avgDocLength, terms, postings, weightMask } = job;
8
+ const len = endDoc - startDoc;
9
+ const scores = new Array<number>(len).fill(0);
10
+ const avg = avgDocLength || 1;
11
+
12
+ for (const { term, idf } of terms) {
13
+ const list = postings[term];
14
+ if (!list) {
15
+ continue;
16
+ }
17
+ for (const [docIndex, tf] of list) {
18
+ if (docIndex < startDoc || docIndex >= endDoc) {
19
+ continue;
20
+ }
21
+ if (weightMask && !weightMask[docIndex]) {
22
+ continue;
23
+ }
24
+ const dl = docLengths[docIndex];
25
+ if (dl === undefined) {
26
+ continue;
27
+ }
28
+ const denom = tf + K1 * (1 - B + (B * dl) / avg);
29
+ const offset = docIndex - startDoc;
30
+ scores[offset] = (scores[offset] ?? 0) + idf * ((tf * (K1 + 1)) / denom);
31
+ }
32
+ }
33
+
34
+ return scores;
35
+ }
36
+
37
+ const workerScope = globalThis as unknown as {
38
+ onmessage: (event: MessageEvent<Bm25ScoreJob>) => void;
39
+ postMessage: (message: Bm25ScoreResult) => void;
40
+ };
41
+
42
+ workerScope.onmessage = (event: MessageEvent<Bm25ScoreJob>) => {
43
+ const job = event.data;
44
+ workerScope.postMessage({
45
+ startDoc: job.startDoc,
46
+ scores: scoreSlice(job),
47
+ });
48
+ };