@ninjaxtools/slopdex 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1691 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync, readFileSync, rmSync } from "node:fs";
5
+ import path7 from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ // src/code-index.ts
9
+ import { lstat, readFile } from "node:fs/promises";
10
+ import path6 from "node:path";
11
+
12
+ // src/errors.ts
13
+ var CodeIndexError = class extends Error {
14
+ constructor(message, options) {
15
+ super(message, options);
16
+ this.name = "CodeIndexError";
17
+ }
18
+ };
19
+ var GitDivergenceError = class extends CodeIndexError {
20
+ constructor(checkpoint, target) {
21
+ super(`Stored Git checkpoint ${checkpoint} is not an ancestor of ${target}; rebuild the index.`);
22
+ this.name = "GitDivergenceError";
23
+ }
24
+ };
25
+ var IncompatibleIndexError = class extends CodeIndexError {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "IncompatibleIndexError";
29
+ }
30
+ };
31
+
32
+ // src/git/repository.ts
33
+ import { spawn } from "node:child_process";
34
+ import path from "node:path";
35
+ var GitRepository = class {
36
+ constructor(rootDir) {
37
+ this.rootDir = rootDir;
38
+ }
39
+ rootDir;
40
+ async #run(args, allowExitOne = false) {
41
+ return await new Promise((resolve, reject) => {
42
+ const child = spawn("git", ["-C", this.rootDir, ...args], { stdio: ["ignore", "pipe", "pipe"] });
43
+ const stdout = [];
44
+ const stderr = [];
45
+ child.stdout.on("data", (value) => stdout.push(value));
46
+ child.stderr.on("data", (value) => stderr.push(value));
47
+ child.on("error", reject);
48
+ child.on("close", (code) => {
49
+ const exitCode = code ?? -1;
50
+ if (exitCode === 0 || allowExitOne && exitCode === 1) {
51
+ resolve({ stdout: Buffer.concat(stdout), code: exitCode });
52
+ return;
53
+ }
54
+ reject(new CodeIndexError(`Git command failed: ${Buffer.concat(stderr).toString("utf8").trim()}`));
55
+ });
56
+ });
57
+ }
58
+ async assertRepository() {
59
+ const result = await this.#run(["rev-parse", "--is-inside-work-tree"]);
60
+ if (result.stdout.toString("utf8").trim() !== "true") {
61
+ throw new CodeIndexError(`${this.rootDir} is not a Git worktree.`);
62
+ }
63
+ const topLevel = (await this.#run(["rev-parse", "--show-toplevel"])).stdout.toString("utf8").trim();
64
+ if (path.resolve(topLevel) !== path.resolve(this.rootDir)) {
65
+ throw new CodeIndexError(`rootDir must be the Git worktree root: ${topLevel}`);
66
+ }
67
+ }
68
+ async assertCleanWorkingTree(excludePaths = []) {
69
+ const pathspecs = [".", ...excludePaths.map((filePath) => `:(top,literal,exclude)${filePath}`)];
70
+ const result = await this.#run([
71
+ "status",
72
+ "--porcelain=v1",
73
+ "-z",
74
+ "--untracked-files=all",
75
+ "--ignore-submodules=none",
76
+ "--",
77
+ ...pathspecs
78
+ ]);
79
+ if (result.stdout.length > 0) {
80
+ throw new CodeIndexError("Cannot update from Git: the working tree has uncommitted changes. Commit or stash them first.");
81
+ }
82
+ }
83
+ async resolveCommit(ref) {
84
+ const result = await this.#run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
85
+ const oid = result.stdout.toString("ascii").trim();
86
+ if (!/^[0-9a-f]{40,64}$/.test(oid)) throw new CodeIndexError(`Could not resolve Git commit: ${ref}`);
87
+ return oid;
88
+ }
89
+ async isAncestor(ancestor, descendant) {
90
+ const result = await this.#run(["merge-base", "--is-ancestor", ancestor, descendant], true);
91
+ return result.code === 0;
92
+ }
93
+ async listTree(commit) {
94
+ const result = await this.#run(["ls-tree", "-r", "-z", "-l", "--full-tree", commit, "--"]);
95
+ const entries = /* @__PURE__ */ new Map();
96
+ for (const record of splitNull(result.stdout)) {
97
+ const tab = record.indexOf(" ");
98
+ if (tab < 0) continue;
99
+ const [mode, type, oid, sizeText] = record.slice(0, tab).trim().split(/\s+/);
100
+ const filePath = record.slice(tab + 1);
101
+ const size = Number(sizeText);
102
+ if ((mode === "100644" || mode === "100755") && type === "blob" && oid && Number.isSafeInteger(size)) {
103
+ entries.set(filePath, { path: filePath, oid, mode, size });
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+ async readBlob(oid) {
109
+ return (await this.#run(["cat-file", "blob", oid])).stdout;
110
+ }
111
+ async diff(base, target) {
112
+ const result = await this.#run(["diff", "--name-status", "-z", "--find-renames", `${base}..${target}`, "--"]);
113
+ const values = splitNull(result.stdout);
114
+ const changes = [];
115
+ for (let index = 0; index < values.length; ) {
116
+ const rawStatus = values[index++];
117
+ if (!rawStatus) continue;
118
+ const status = rawStatus[0];
119
+ if (status === "R" || status === "C") {
120
+ const oldPath = values[index++];
121
+ const filePath = values[index++];
122
+ if (oldPath && filePath) changes.push({ status, oldPath, path: filePath });
123
+ } else if (status === "A" || status === "M" || status === "D" || status === "T") {
124
+ const filePath = values[index++];
125
+ if (filePath) changes.push({ status, path: filePath });
126
+ } else {
127
+ throw new CodeIndexError(`Unsupported Git diff status: ${rawStatus}`);
128
+ }
129
+ }
130
+ return changes;
131
+ }
132
+ };
133
+ function splitNull(buffer) {
134
+ const value = buffer.toString("utf8");
135
+ const parts = value.split("\0");
136
+ if (parts.at(-1) === "") parts.pop();
137
+ return parts;
138
+ }
139
+
140
+ // src/parser/callable-parser.ts
141
+ import path3 from "node:path";
142
+ import Parser from "tree-sitter";
143
+ import JavaScript from "tree-sitter-javascript";
144
+ import TypeScript from "tree-sitter-typescript";
145
+
146
+ // src/utils.ts
147
+ import { createHash } from "node:crypto";
148
+ import path2 from "node:path";
149
+ function sha256(value) {
150
+ return createHash("sha256").update(value).digest("hex");
151
+ }
152
+ function normalizeRelativePath(rootDir, inputPath) {
153
+ const absolute = path2.resolve(rootDir, inputPath);
154
+ const relative = path2.relative(rootDir, absolute);
155
+ if (relative === "" || relative === ".") {
156
+ throw new CodeIndexError(`Expected a file path, received repository root: ${inputPath}`);
157
+ }
158
+ if (relative.startsWith(`..${path2.sep}`) || path2.isAbsolute(relative)) {
159
+ throw new CodeIndexError(`Path escapes repository root: ${inputPath}`);
160
+ }
161
+ return relative.split(path2.sep).join("/");
162
+ }
163
+ function throwIfAborted(signal) {
164
+ signal?.throwIfAborted();
165
+ }
166
+ function chunk(values, size) {
167
+ const batches = [];
168
+ for (let index = 0; index < values.length; index += size) {
169
+ batches.push(values.slice(index, index + size));
170
+ }
171
+ return batches;
172
+ }
173
+ function assertPositiveInteger(value, name) {
174
+ if (!Number.isInteger(value) || value < 1) {
175
+ throw new CodeIndexError(`${name} must be a positive integer.`);
176
+ }
177
+ }
178
+ function normalizeEmbeddingVector(value, dimensions) {
179
+ if (!Array.isArray(value) || value.length !== dimensions) {
180
+ throw new CodeIndexError(`Expected a finite ${dimensions}-dimensional embedding vector.`);
181
+ }
182
+ const converted = [];
183
+ for (const component of value) {
184
+ if (typeof component !== "number" || !Number.isFinite(component)) {
185
+ throw new CodeIndexError(`Expected a finite ${dimensions}-dimensional embedding vector.`);
186
+ }
187
+ const normalized = Math.fround(component);
188
+ if (!Number.isFinite(normalized)) {
189
+ throw new CodeIndexError(`Expected a finite ${dimensions}-dimensional embedding vector.`);
190
+ }
191
+ converted.push(normalized);
192
+ }
193
+ if (!converted.some((component) => component !== 0)) {
194
+ throw new CodeIndexError("Embedding vector must not be all zeroes.");
195
+ }
196
+ return converted;
197
+ }
198
+
199
+ // src/parser/callable-parser.ts
200
+ var parsers = /* @__PURE__ */ new Map();
201
+ function languageForPath(filePath) {
202
+ switch (path3.extname(filePath).toLowerCase()) {
203
+ case ".ts":
204
+ case ".mts":
205
+ case ".cts":
206
+ return "typescript";
207
+ case ".tsx":
208
+ return "tsx";
209
+ case ".js":
210
+ case ".mjs":
211
+ case ".cjs":
212
+ return "javascript";
213
+ case ".jsx":
214
+ return "jsx";
215
+ default:
216
+ return null;
217
+ }
218
+ }
219
+ function getParser(language) {
220
+ const existing = parsers.get(language);
221
+ if (existing) return existing;
222
+ const parser = new Parser();
223
+ if (language === "typescript") parser.setLanguage(TypeScript.typescript);
224
+ else if (language === "tsx") parser.setLanguage(TypeScript.tsx);
225
+ else parser.setLanguage(JavaScript);
226
+ parsers.set(language, parser);
227
+ return parser;
228
+ }
229
+ function parseTree(parser, content, relativePath) {
230
+ try {
231
+ return parser.parse(content, void 0, { bufferSize: Buffer.byteLength(content) + 1 });
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ throw new CodeIndexError(`Cannot index ${relativePath}: tree-sitter failed: ${message}`, { cause: error });
235
+ }
236
+ }
237
+ function maskSyntax(value, replacement) {
238
+ return replacement + value.slice(replacement.length).replace(/[^\r\n]/g, " ");
239
+ }
240
+ function recoverUnsupportedTypeScriptSyntax(parser, tree, content, relativePath) {
241
+ const errors = [];
242
+ const collectErrors = (node) => {
243
+ if (node.isError || node.isMissing) errors.push(node);
244
+ for (const child of node.children) collectErrors(child);
245
+ };
246
+ collectErrors(tree.rootNode);
247
+ if (errors.length === 0) return null;
248
+ let recoveredContent = content;
249
+ let changed = false;
250
+ const taggedTemplateTypeArguments = /* @__PURE__ */ new Map();
251
+ for (const error of errors) {
252
+ const parent = error.parent;
253
+ if (error.isMissing && error.type === "!" && parent?.type === "type_arguments" && /^\s*`/.test(content.slice(parent.endIndex))) {
254
+ taggedTemplateTypeArguments.set(parent.startIndex, parent);
255
+ }
256
+ }
257
+ for (const node of [...taggedTemplateTypeArguments.values()].toSorted((left, right) => right.startIndex - left.startIndex)) {
258
+ const value = recoveredContent.slice(node.startIndex, node.endIndex);
259
+ recoveredContent = `${recoveredContent.slice(0, node.startIndex)}${value.replace(/[^\r\n]/g, " ")}${recoveredContent.slice(node.endIndex)}`;
260
+ changed = true;
261
+ }
262
+ for (const node of errors.filter((candidate) => {
263
+ const parent = candidate.parent;
264
+ return !candidate.isMissing && candidate.type === "ERROR" && candidate.text === "type" && parent?.type === "export_statement" && content.slice(parent.startIndex, candidate.startIndex).trim() === "export" && /^\s*\*/.test(content.slice(candidate.endIndex, parent.endIndex));
265
+ }).toSorted((left, right) => right.startIndex - left.startIndex)) {
266
+ recoveredContent = `${recoveredContent.slice(0, node.startIndex)} ${recoveredContent.slice(node.endIndex)}`;
267
+ changed = true;
268
+ }
269
+ recoveredContent = recoveredContent.replace(
270
+ /\bimport\s*\(\s*(["'])(?:\\.|(?!\1)[^\\])*\1\s*\)/g,
271
+ (value) => {
272
+ changed = true;
273
+ return maskSyntax(value, "X");
274
+ }
275
+ );
276
+ if (!changed) return null;
277
+ const recoveredTree = parseTree(parser, recoveredContent, relativePath);
278
+ return recoveredTree.rootNode.hasError ? null : recoveredTree;
279
+ }
280
+ function textFor(content, node) {
281
+ return content.slice(node.startIndex, node.endIndex);
282
+ }
283
+ function fieldText(content, node, field) {
284
+ const child = node.childForFieldName(field);
285
+ return child ? textFor(content, child) : null;
286
+ }
287
+ function callableKind(node, name) {
288
+ if (name === "constructor") return "constructor";
289
+ if (node.type.includes("generator") || /^\s*(?:async\s+)?\*/.test(node.text)) return "generator";
290
+ return node.type === "method_definition" ? "method" : "function";
291
+ }
292
+ function bindingName(content, node) {
293
+ const nameNode = node.childForFieldName("name");
294
+ if (!nameNode || !["identifier", "property_identifier", "private_property_identifier"].includes(nameNode.type)) {
295
+ return null;
296
+ }
297
+ return textFor(content, nameNode).replace(/^#/, "");
298
+ }
299
+ function assignmentName(content, node) {
300
+ const left = node.childForFieldName("left");
301
+ if (!left) return null;
302
+ const value = textFor(content, left).trim();
303
+ const match = value.match(/(?:^|[.#])([A-Za-z_$][\w$]*)$/);
304
+ return match?.[1] ?? null;
305
+ }
306
+ function objectScopeName(content, node) {
307
+ const parent = node.parent;
308
+ if (!parent) return null;
309
+ if (parent.type === "variable_declarator" && parent.childForFieldName("value")?.id === node.id) {
310
+ return bindingName(content, parent);
311
+ }
312
+ if (parent.type === "pair" && parent.childForFieldName("value")?.id === node.id) {
313
+ return fieldText(content, parent, "key")?.replace(/["']/g, "") ?? null;
314
+ }
315
+ if (parent.type === "assignment_expression" && parent.childForFieldName("right")?.id === node.id) {
316
+ return assignmentName(content, parent);
317
+ }
318
+ return null;
319
+ }
320
+ function walk(node, content, scope, candidates) {
321
+ if (node.type === "class_declaration" || node.type === "class") {
322
+ const name = fieldText(content, node, "name") ?? objectScopeName(content, node);
323
+ const nextScope = name ? [...scope, name] : scope;
324
+ for (const child of node.namedChildren) walk(child, content, nextScope, candidates);
325
+ return;
326
+ }
327
+ if (node.type === "internal_module" || node.type === "module") {
328
+ const name = fieldText(content, node, "name")?.replace(/["']/g, "");
329
+ const nextScope = name ? [...scope, name] : scope;
330
+ for (const child of node.namedChildren) walk(child, content, nextScope, candidates);
331
+ return;
332
+ }
333
+ if (node.type === "object") {
334
+ const name = objectScopeName(content, node);
335
+ const nextScope = name ? [...scope, name] : scope;
336
+ for (const child of node.namedChildren) walk(child, content, nextScope, candidates);
337
+ return;
338
+ }
339
+ if (["function_declaration", "generator_function_declaration"].includes(node.type)) {
340
+ const name = fieldText(content, node, "name");
341
+ const body = node.childForFieldName("body");
342
+ if (name && body) {
343
+ candidates.push({ node, name, kind: callableKind(node, name), scope });
344
+ walk(body, content, [...scope, name], candidates);
345
+ }
346
+ return;
347
+ }
348
+ if (node.type === "method_definition") {
349
+ const name = fieldText(content, node, "name")?.replace(/^#/, "");
350
+ const body = node.childForFieldName("body");
351
+ if (name && body) {
352
+ candidates.push({ node, name, kind: callableKind(node, name), scope });
353
+ walk(body, content, [...scope, name], candidates);
354
+ }
355
+ return;
356
+ }
357
+ if (node.type === "public_field_definition" || node.type === "field_definition") {
358
+ const name = (fieldText(content, node, "name") ?? fieldText(content, node, "property"))?.replace(/^#/, "");
359
+ const value = node.childForFieldName("value");
360
+ if (name && value && ["arrow_function", "function_expression", "generator_function"].includes(value.type)) {
361
+ const kind = callableKind(value, name) === "generator" ? "generator" : "method";
362
+ candidates.push({ node: value, name, kind, scope });
363
+ const body = value.childForFieldName("body");
364
+ if (body) walk(body, content, [...scope, name], candidates);
365
+ return;
366
+ }
367
+ }
368
+ if (node.type === "variable_declarator") {
369
+ const name = bindingName(content, node);
370
+ const value = node.childForFieldName("value");
371
+ if (name && value && ["arrow_function", "function_expression", "generator_function"].includes(value.type)) {
372
+ candidates.push({ node: value, name, kind: callableKind(value, name), scope });
373
+ const body = value.childForFieldName("body");
374
+ if (body) walk(body, content, [...scope, name], candidates);
375
+ return;
376
+ }
377
+ }
378
+ if (node.type === "pair") {
379
+ const name = fieldText(content, node, "key")?.replace(/["']/g, "");
380
+ const value = node.childForFieldName("value");
381
+ if (name && value && ["arrow_function", "function_expression", "generator_function"].includes(value.type)) {
382
+ candidates.push({ node: value, name, kind: callableKind(value, name), scope });
383
+ const body = value.childForFieldName("body");
384
+ if (body) walk(body, content, [...scope, name], candidates);
385
+ return;
386
+ }
387
+ }
388
+ if (node.type === "assignment_expression") {
389
+ const name = assignmentName(content, node);
390
+ const value = node.childForFieldName("right");
391
+ if (name && value && ["arrow_function", "function_expression", "generator_function"].includes(value.type)) {
392
+ candidates.push({ node: value, name, kind: callableKind(value, name), scope });
393
+ const body = value.childForFieldName("body");
394
+ if (body) walk(body, content, [...scope, name], candidates);
395
+ return;
396
+ }
397
+ }
398
+ if (["function_expression", "generator_function"].includes(node.type)) {
399
+ const name = fieldText(content, node, "name");
400
+ const body = node.childForFieldName("body");
401
+ if (name && body) {
402
+ candidates.push({ node, name, kind: callableKind(node, name), scope });
403
+ walk(body, content, [...scope, name], candidates);
404
+ return;
405
+ }
406
+ }
407
+ for (const child of node.namedChildren) walk(child, content, scope, candidates);
408
+ }
409
+ function signatureFor(content, node, name) {
410
+ const parameters = node.childForFieldName("parameters");
411
+ if (!parameters) return name;
412
+ const returnType = node.childForFieldName("return_type");
413
+ return `${name}${textFor(content, parameters)}${returnType ? textFor(content, returnType) : ""}`;
414
+ }
415
+ function parseCallables(relativePath, content, onWarning = console.warn) {
416
+ const language = languageForPath(relativePath);
417
+ if (!language) return [];
418
+ const parser = getParser(language);
419
+ const parsedTree = parseTree(parser, content, relativePath);
420
+ const tree = parsedTree.rootNode.hasError ? recoverUnsupportedTypeScriptSyntax(parser, parsedTree, content, relativePath) ?? parsedTree : parsedTree;
421
+ if (tree.rootNode.hasError) {
422
+ onWarning(`Cannot fully parse ${relativePath}: tree-sitter reported syntax errors; indexing recoverable callables only.`);
423
+ }
424
+ const candidates = [];
425
+ walk(tree.rootNode, content, [], candidates);
426
+ candidates.sort((left, right) => left.node.startIndex - right.node.startIndex);
427
+ const occurrences = /* @__PURE__ */ new Map();
428
+ return candidates.map(({ node, name, kind, scope }) => {
429
+ const qualifiedName = [...scope, name].join(".");
430
+ const baseIdentity = `${relativePath}\0${qualifiedName}\0${kind}`;
431
+ const occurrence = occurrences.get(baseIdentity) ?? 0;
432
+ occurrences.set(baseIdentity, occurrence + 1);
433
+ const identityKey = sha256(`${baseIdentity}\0${occurrence}`);
434
+ const source = textFor(content, node);
435
+ const signature = signatureFor(content, node, name);
436
+ const embeddingInput = [
437
+ `language: ${language}`,
438
+ `kind: ${kind}`,
439
+ `symbol: ${qualifiedName}`,
440
+ signature ? `signature: ${signature}` : null,
441
+ "source:",
442
+ source
443
+ ].filter((value) => value !== null).join("\n");
444
+ return {
445
+ path: relativePath,
446
+ language,
447
+ kind,
448
+ name,
449
+ qualifiedName,
450
+ signature,
451
+ identityKey,
452
+ startLine: node.startPosition.row + 1,
453
+ startColumn: node.startPosition.column + 1,
454
+ endLine: node.endPosition.row + 1,
455
+ endColumn: node.endPosition.column + 1,
456
+ source,
457
+ sourceHash: sha256(source),
458
+ embeddingInput
459
+ };
460
+ });
461
+ }
462
+
463
+ // src/source-policy.ts
464
+ import path4 from "node:path";
465
+ var DEFAULT_EXCLUDED_SEGMENTS = /* @__PURE__ */ new Set([
466
+ ".git",
467
+ ".slopdex",
468
+ "node_modules",
469
+ "dist",
470
+ "build",
471
+ "coverage",
472
+ "vendor",
473
+ "generated"
474
+ ]);
475
+ var SourcePolicy = class {
476
+ #include;
477
+ #exclude;
478
+ constructor(include = [], exclude = []) {
479
+ this.#include = include;
480
+ this.#exclude = exclude;
481
+ }
482
+ includes(relativePath) {
483
+ const normalized = relativePath.replaceAll("\\", "/");
484
+ if (!languageForPath(normalized)) return false;
485
+ if (normalized.split("/").some((segment) => DEFAULT_EXCLUDED_SEGMENTS.has(segment))) return false;
486
+ if (this.#exclude.some((pattern) => path4.matchesGlob(normalized, pattern))) return false;
487
+ return this.#include.length === 0 || this.#include.some((pattern) => path4.matchesGlob(normalized, pattern));
488
+ }
489
+ };
490
+
491
+ // src/storage/database.ts
492
+ import { mkdirSync } from "node:fs";
493
+ import path5 from "node:path";
494
+ import { DatabaseSync } from "node:sqlite";
495
+ import * as sqliteVec from "sqlite-vec";
496
+ var SCHEMA_VERSION = "1";
497
+ var IndexDatabase = class {
498
+ #db;
499
+ #rootDir;
500
+ #indexPath;
501
+ #profile;
502
+ constructor(indexPath, rootDir, profile, readOnly = false) {
503
+ if (!readOnly) mkdirSync(path5.dirname(indexPath), { recursive: true });
504
+ this.#indexPath = indexPath;
505
+ this.#rootDir = rootDir;
506
+ this.#profile = profile;
507
+ this.#db = new DatabaseSync(indexPath, { allowExtension: true, readOnly });
508
+ sqliteVec.load(this.#db);
509
+ this.#db.enableLoadExtension(false);
510
+ if (readOnly) {
511
+ this.#db.exec("PRAGMA foreign_keys=ON");
512
+ } else {
513
+ this.#db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;");
514
+ this.#migrate();
515
+ }
516
+ this.#validateMetadata(readOnly);
517
+ }
518
+ close() {
519
+ this.#db.close();
520
+ }
521
+ #migrate() {
522
+ this.#db.exec(`
523
+ CREATE TABLE IF NOT EXISTS metadata (
524
+ key TEXT PRIMARY KEY,
525
+ value TEXT NOT NULL
526
+ );
527
+ CREATE TABLE IF NOT EXISTS files (
528
+ path TEXT PRIMARY KEY,
529
+ content_hash TEXT NOT NULL,
530
+ blob_oid TEXT,
531
+ source_mode TEXT NOT NULL CHECK(source_mode IN ('git', 'working-tree')),
532
+ indexed_commit TEXT,
533
+ previous_path TEXT,
534
+ language TEXT NOT NULL,
535
+ byte_size INTEGER NOT NULL
536
+ );
537
+ CREATE TABLE IF NOT EXISTS embeddings (
538
+ id INTEGER PRIMARY KEY,
539
+ embedding_key TEXT NOT NULL UNIQUE,
540
+ vector BLOB NOT NULL
541
+ );
542
+ CREATE TABLE IF NOT EXISTS functions (
543
+ id INTEGER PRIMARY KEY,
544
+ path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE,
545
+ language TEXT NOT NULL,
546
+ kind TEXT NOT NULL,
547
+ name TEXT NOT NULL,
548
+ qualified_name TEXT NOT NULL,
549
+ signature TEXT,
550
+ identity_key TEXT NOT NULL UNIQUE,
551
+ start_line INTEGER NOT NULL,
552
+ start_column INTEGER NOT NULL,
553
+ end_line INTEGER NOT NULL,
554
+ end_column INTEGER NOT NULL,
555
+ source TEXT NOT NULL,
556
+ source_hash TEXT NOT NULL,
557
+ embedding_input TEXT NOT NULL,
558
+ first_seen_commit TEXT,
559
+ last_seen_commit TEXT,
560
+ source_mode TEXT NOT NULL CHECK(source_mode IN ('git', 'working-tree')),
561
+ embedding_id INTEGER NOT NULL REFERENCES embeddings(id)
562
+ );
563
+ CREATE INDEX IF NOT EXISTS functions_path ON functions(path);
564
+ CREATE INDEX IF NOT EXISTS functions_embedding ON functions(embedding_id);
565
+ `);
566
+ const fileColumns = this.#db.prepare("PRAGMA table_info(files)").all();
567
+ if (!fileColumns.some((column) => column.name === "previous_path")) {
568
+ this.#db.exec("ALTER TABLE files ADD COLUMN previous_path TEXT");
569
+ }
570
+ }
571
+ #metadata(key) {
572
+ const row = this.#db.prepare("SELECT value FROM metadata WHERE key = ?").get(key);
573
+ return row?.value ?? null;
574
+ }
575
+ #setMetadata(key, value) {
576
+ this.#db.prepare(`
577
+ INSERT INTO metadata(key, value) VALUES (?, ?)
578
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
579
+ `).run(key, value);
580
+ }
581
+ #validateMetadata(readOnly) {
582
+ const schemaVersion = this.#metadata("schema_version");
583
+ if (readOnly && !schemaVersion) throw new IncompatibleIndexError("Target file is not a slopdex index.");
584
+ if (schemaVersion && schemaVersion !== SCHEMA_VERSION) {
585
+ throw new IncompatibleIndexError(`Unsupported index schema version ${schemaVersion}.`);
586
+ }
587
+ const storedRoot = this.#metadata("root_dir");
588
+ if (readOnly && !storedRoot) throw new IncompatibleIndexError("Target index has no repository identity.");
589
+ if (storedRoot && path5.resolve(storedRoot) !== this.#rootDir) {
590
+ throw new IncompatibleIndexError(`Index belongs to ${storedRoot}, not ${this.#rootDir}.`);
591
+ }
592
+ const profileJson = JSON.stringify(this.#profile);
593
+ const storedProfile = this.#metadata("embedding_profile");
594
+ if (readOnly && !storedProfile) throw new IncompatibleIndexError("Target index has no embedding profile.");
595
+ if (storedProfile && storedProfile !== profileJson) {
596
+ throw new IncompatibleIndexError("Embedding provider, model, dimensions, or strategy differs from this index.");
597
+ }
598
+ if (readOnly) return;
599
+ this.#transaction(() => {
600
+ this.#setMetadata("schema_version", SCHEMA_VERSION);
601
+ this.#setMetadata("root_dir", this.#rootDir);
602
+ this.#setMetadata("embedding_profile", profileJson);
603
+ if (!this.#metadata("generation")) this.#setMetadata("generation", "0");
604
+ });
605
+ }
606
+ #transaction(operation) {
607
+ this.#db.exec("BEGIN IMMEDIATE");
608
+ try {
609
+ const result = operation();
610
+ this.#db.exec("COMMIT");
611
+ return result;
612
+ } catch (error) {
613
+ this.#db.exec("ROLLBACK");
614
+ throw error;
615
+ }
616
+ }
617
+ getCheckpoint() {
618
+ return this.#metadata("git_checkpoint");
619
+ }
620
+ getGeneration() {
621
+ return Number(this.#metadata("generation") ?? 0);
622
+ }
623
+ getWorkingTreeFiles() {
624
+ return this.#db.prepare(`
625
+ SELECT path, previous_path AS previousPath
626
+ FROM files WHERE source_mode = 'working-tree' ORDER BY path
627
+ `).all();
628
+ }
629
+ getEmbeddingKeys(keys) {
630
+ const found = /* @__PURE__ */ new Set();
631
+ const statement = this.#db.prepare("SELECT 1 FROM embeddings WHERE embedding_key = ?");
632
+ for (const key of keys) {
633
+ if (statement.get(key)) found.add(key);
634
+ }
635
+ return found;
636
+ }
637
+ applyUpdate(options) {
638
+ return this.#transaction(() => {
639
+ if (options.expectedCheckpoint !== void 0 && this.getCheckpoint() !== options.expectedCheckpoint) {
640
+ throw new CodeIndexError("Git checkpoint changed while the update was being prepared.");
641
+ }
642
+ const stats = {
643
+ filesUpdated: options.files.length,
644
+ filesDeleted: 0,
645
+ functionsAdded: 0,
646
+ functionsUpdated: 0,
647
+ functionsDeleted: 0,
648
+ embeddingsCreated: 0,
649
+ checkpoint: options.checkpoint ?? this.getCheckpoint()
650
+ };
651
+ const deleteFile = this.#db.prepare("DELETE FROM files WHERE path = ?");
652
+ const countFunctions = this.#db.prepare("SELECT COUNT(*) AS count FROM functions WHERE path = ?");
653
+ const uniqueDeletes = new Set(options.deletePaths);
654
+ for (const file of options.files) {
655
+ uniqueDeletes.delete(file.path);
656
+ if (file.previousPath) uniqueDeletes.delete(file.previousPath);
657
+ if (file.replacePath) uniqueDeletes.delete(file.replacePath);
658
+ }
659
+ for (const filePath of uniqueDeletes) {
660
+ const count = Number(countFunctions.get(filePath).count);
661
+ const result = deleteFile.run(filePath);
662
+ if (result.changes > 0) {
663
+ stats.filesDeleted += 1;
664
+ stats.functionsDeleted += count;
665
+ }
666
+ }
667
+ for (const file of options.files) this.#replaceFile(file, stats);
668
+ this.#db.exec("DELETE FROM embeddings WHERE id NOT IN (SELECT DISTINCT embedding_id FROM functions)");
669
+ const generation = this.getGeneration() + 1;
670
+ this.#setMetadata("generation", String(generation));
671
+ if (options.checkpoint) this.#setMetadata("git_checkpoint", options.checkpoint);
672
+ return stats;
673
+ });
674
+ }
675
+ #replaceFile(file, stats) {
676
+ const sourcePath = file.replacePath ?? file.previousPath ?? file.path;
677
+ const oldRows = this.#rowsForPath(sourcePath);
678
+ const existingFile = this.#db.prepare("SELECT previous_path FROM files WHERE path = ?").get(sourcePath);
679
+ const oldMatches = reconcileFunctions(file.callables, oldRows);
680
+ if (sourcePath !== file.path) {
681
+ this.#db.prepare("DELETE FROM files WHERE path = ?").run(sourcePath);
682
+ } else {
683
+ this.#db.prepare("DELETE FROM files WHERE path = ?").run(file.path);
684
+ }
685
+ this.#db.prepare(`
686
+ INSERT INTO files(path, content_hash, blob_oid, source_mode, indexed_commit, previous_path, language, byte_size)
687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
688
+ `).run(
689
+ file.path,
690
+ file.contentHash,
691
+ file.blobOid,
692
+ file.sourceMode,
693
+ file.indexedCommit,
694
+ file.sourceMode === "working-tree" ? file.previousPath ?? existingFile?.previous_path ?? null : null,
695
+ file.language,
696
+ file.byteSize
697
+ );
698
+ const usedIds = /* @__PURE__ */ new Set();
699
+ const orderedCallables = [
700
+ ...file.callables.filter((callable) => oldMatches.has(callable)),
701
+ ...file.callables.filter((callable) => !oldMatches.has(callable))
702
+ ];
703
+ for (const callable of orderedCallables) {
704
+ if (callable.vector) {
705
+ const result = this.#db.prepare("INSERT OR IGNORE INTO embeddings(embedding_key, vector) VALUES (?, ?)").run(callable.embeddingKey, vectorBuffer(callable.vector));
706
+ stats.embeddingsCreated += Number(result.changes);
707
+ }
708
+ const embedding = this.#db.prepare("SELECT id FROM embeddings WHERE embedding_key = ?").get(callable.embeddingKey);
709
+ if (!embedding) throw new CodeIndexError(`Missing embedding for ${callable.qualifiedName}.`);
710
+ const old = oldMatches.get(callable);
711
+ if (old) usedIds.add(old.id);
712
+ const firstSeenCommit = old?.first_seen_commit ?? file.indexedCommit;
713
+ this.#db.prepare(`
714
+ INSERT INTO functions(
715
+ id, path, language, kind, name, qualified_name, signature, identity_key,
716
+ start_line, start_column, end_line, end_column, source, source_hash,
717
+ embedding_input, first_seen_commit, last_seen_commit, source_mode, embedding_id
718
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
719
+ `).run(
720
+ old?.id ?? null,
721
+ file.path,
722
+ callable.language,
723
+ callable.kind,
724
+ callable.name,
725
+ callable.qualifiedName,
726
+ callable.signature,
727
+ callable.identityKey,
728
+ callable.startLine,
729
+ callable.startColumn,
730
+ callable.endLine,
731
+ callable.endColumn,
732
+ callable.source,
733
+ callable.sourceHash,
734
+ callable.embeddingInput,
735
+ firstSeenCommit,
736
+ file.indexedCommit,
737
+ file.sourceMode,
738
+ embedding.id
739
+ );
740
+ if (old) stats.functionsUpdated += 1;
741
+ else stats.functionsAdded += 1;
742
+ }
743
+ stats.functionsDeleted += oldRows.length - usedIds.size;
744
+ }
745
+ #rowsForPath(filePath) {
746
+ return this.#db.prepare("SELECT * FROM functions WHERE path = ? ORDER BY start_line, start_column, id").all(filePath);
747
+ }
748
+ allFunctions() {
749
+ return this.#db.prepare("SELECT * FROM functions ORDER BY path, start_line, start_column, id").all().map(toIndexedFunction);
750
+ }
751
+ allFilePaths() {
752
+ return this.#db.prepare("SELECT path FROM files ORDER BY path").all().map((row) => row.path);
753
+ }
754
+ previousPath(filePath) {
755
+ const row = this.#db.prepare("SELECT previous_path FROM files WHERE path = ?").get(filePath);
756
+ return row?.previous_path ?? null;
757
+ }
758
+ functionsForPaths(paths) {
759
+ const results = [];
760
+ for (const filePath of paths) results.push(...this.#rowsForPath(filePath).map(toIndexedFunction));
761
+ return results;
762
+ }
763
+ searchVector(vector, options) {
764
+ const rows = this.#db.prepare(`
765
+ SELECT f.*, 1.0 - vec_distance_cosine(e.vector, ?) AS similarity
766
+ FROM functions f
767
+ JOIN embeddings e ON e.id = f.embedding_id
768
+ WHERE (? IS NULL OR f.id != ?)
769
+ AND (1.0 - vec_distance_cosine(e.vector, ?)) >= ?
770
+ AND (? IS NULL OR (1.0 - vec_distance_cosine(e.vector, ?)) <= ?)
771
+ ORDER BY similarity DESC, f.id ASC
772
+ LIMIT ?
773
+ `).all(
774
+ vectorBuffer(vector),
775
+ options.excludeId ?? null,
776
+ options.excludeId ?? null,
777
+ vectorBuffer(vector),
778
+ options.minSimilarity,
779
+ options.maxSimilarity ?? null,
780
+ vectorBuffer(vector),
781
+ options.maxSimilarity ?? null,
782
+ options.limit
783
+ );
784
+ return rows.map((row) => ({ function: toIndexedFunction(row), similarity: row.similarity }));
785
+ }
786
+ vectorForFunction(id) {
787
+ const row = this.#db.prepare(`
788
+ SELECT e.vector FROM functions f JOIN embeddings e ON e.id = f.embedding_id WHERE f.id = ?
789
+ `).get(id);
790
+ if (!row) throw new CodeIndexError(`Function ${id} does not exist.`);
791
+ return Array.from(new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4));
792
+ }
793
+ status() {
794
+ const functionCount = Number(this.#db.prepare("SELECT COUNT(*) AS count FROM functions").get().count);
795
+ const fileCount = Number(this.#db.prepare("SELECT COUNT(*) AS count FROM files").get().count);
796
+ return {
797
+ rootDir: this.#rootDir,
798
+ indexPath: this.#indexPath,
799
+ functionCount,
800
+ fileCount,
801
+ generation: this.getGeneration(),
802
+ gitCheckpoint: this.getCheckpoint(),
803
+ embeddingProfile: this.#profile
804
+ };
805
+ }
806
+ };
807
+ function vectorBuffer(vector) {
808
+ const values = Float32Array.from(vector);
809
+ return new Uint8Array(values.buffer);
810
+ }
811
+ function toIndexedFunction(row) {
812
+ return {
813
+ id: row.id,
814
+ path: row.path,
815
+ language: row.language,
816
+ kind: row.kind,
817
+ name: row.name,
818
+ qualifiedName: row.qualified_name,
819
+ signature: row.signature,
820
+ identityKey: row.identity_key,
821
+ startLine: row.start_line,
822
+ startColumn: row.start_column,
823
+ endLine: row.end_line,
824
+ endColumn: row.end_column,
825
+ source: row.source,
826
+ sourceHash: row.source_hash,
827
+ embeddingInput: row.embedding_input,
828
+ firstSeenCommit: row.first_seen_commit,
829
+ lastSeenCommit: row.last_seen_commit,
830
+ sourceMode: row.source_mode,
831
+ embeddingId: row.embedding_id
832
+ };
833
+ }
834
+ function reconcileFunctions(current, previous) {
835
+ const matches = /* @__PURE__ */ new Map();
836
+ const keys = /* @__PURE__ */ new Set([
837
+ ...current.map((callable) => `${callable.qualifiedName}\0${callable.kind}`),
838
+ ...previous.map((callable) => `${callable.qualified_name}\0${callable.kind}`)
839
+ ]);
840
+ for (const key of keys) {
841
+ const currentGroup = current.filter((callable) => `${callable.qualifiedName}\0${callable.kind}` === key);
842
+ const previousGroup = previous.filter((callable) => `${callable.qualified_name}\0${callable.kind}` === key);
843
+ const unmatchedPrevious = new Set(previousGroup);
844
+ const unmatchedCurrent = new Set(currentGroup);
845
+ for (const callable of currentGroup) {
846
+ const exact = [...unmatchedPrevious].find((old) => old.source_hash === callable.sourceHash);
847
+ if (exact) {
848
+ matches.set(callable, exact);
849
+ unmatchedPrevious.delete(exact);
850
+ unmatchedCurrent.delete(callable);
851
+ }
852
+ }
853
+ for (const callable of unmatchedCurrent) {
854
+ const old = unmatchedPrevious.values().next().value;
855
+ if (!old) break;
856
+ matches.set(callable, old);
857
+ unmatchedPrevious.delete(old);
858
+ }
859
+ }
860
+ return matches;
861
+ }
862
+
863
+ // src/code-index.ts
864
+ var DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
865
+ var DEFAULT_BATCH_SIZE = 32;
866
+ var CodeIndex = class {
867
+ rootDir;
868
+ indexPath;
869
+ provider;
870
+ #database;
871
+ #policy;
872
+ #maxFileSize;
873
+ #embeddingBatchSize;
874
+ #git;
875
+ #onWarning;
876
+ constructor(options) {
877
+ this.rootDir = path6.resolve(options.rootDir);
878
+ this.indexPath = path6.resolve(options.indexPath ?? path6.join(this.rootDir, ".slopdex", "index.sqlite"));
879
+ this.provider = options.provider;
880
+ const profile = normalizeProfile(options.provider.profile);
881
+ assertPositiveInteger(profile.dimensions, "embedding dimensions");
882
+ this.#database = new IndexDatabase(this.indexPath, this.rootDir, profile, options.readOnly ?? false);
883
+ this.#policy = new SourcePolicy(options.include, options.exclude);
884
+ this.#maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
885
+ this.#embeddingBatchSize = options.embeddingBatchSize ?? DEFAULT_BATCH_SIZE;
886
+ this.#onWarning = options.onWarning ?? console.warn;
887
+ assertPositiveInteger(this.#maxFileSize, "maxFileSize");
888
+ assertPositiveInteger(this.#embeddingBatchSize, "embeddingBatchSize");
889
+ this.#git = new GitRepository(this.rootDir);
890
+ }
891
+ close() {
892
+ this.#database.close();
893
+ }
894
+ status() {
895
+ return this.#database.status();
896
+ }
897
+ allFunctions() {
898
+ return this.#database.allFunctions();
899
+ }
900
+ async updateFiles(options) {
901
+ const normalizedRenames = (options.renames ?? []).map(({ from, to }) => ({
902
+ from: normalizeRelativePath(this.rootDir, from),
903
+ to: normalizeRelativePath(this.rootDir, to)
904
+ }));
905
+ validateRenames(normalizedRenames);
906
+ const upsertPaths = [...new Set((options.upsert ?? []).map((value) => normalizeRelativePath(this.rootDir, value)))];
907
+ const deletePaths = [...new Set((options.delete ?? []).map((value) => normalizeRelativePath(this.rootDir, value)))];
908
+ const renameMap = new Map(
909
+ normalizedRenames.map(({ from, to }) => [to, from])
910
+ );
911
+ for (const target of renameMap.keys()) {
912
+ if (!upsertPaths.includes(target)) upsertPaths.push(target);
913
+ }
914
+ const prepared = [];
915
+ for (const relativePath of upsertPaths) {
916
+ throwIfAborted(options.signal);
917
+ if (!this.#policy.includes(relativePath)) throw new CodeIndexError(`Unsupported or excluded source file: ${relativePath}`);
918
+ const absolutePath = path6.join(this.rootDir, relativePath);
919
+ const info = await lstat(absolutePath);
920
+ if (info.isSymbolicLink()) throw new CodeIndexError(`Symbolic links are not supported: ${relativePath}`);
921
+ if (!info.isFile()) throw new CodeIndexError(`Not a regular file: ${relativePath}`);
922
+ if (info.size > this.#maxFileSize) throw new CodeIndexError(`File exceeds maxFileSize: ${relativePath}`);
923
+ const content = await readFile(absolutePath);
924
+ const renameSource = renameMap.get(relativePath);
925
+ const originalPath = renameSource ? this.#database.previousPath(renameSource) ?? renameSource : void 0;
926
+ prepared.push(this.#prepareFile(relativePath, content, {
927
+ blobOid: null,
928
+ sourceMode: "working-tree",
929
+ indexedCommit: null,
930
+ ...originalPath ? { previousPath: originalPath } : {},
931
+ ...renameSource ? { replacePath: renameSource } : {}
932
+ }));
933
+ }
934
+ await this.#attachEmbeddings(prepared, options.signal);
935
+ return this.#database.applyUpdate({
936
+ files: prepared,
937
+ deletePaths: [...deletePaths, ...renameMap.values()]
938
+ });
939
+ }
940
+ async updateFromGit(options = {}) {
941
+ throwIfAborted(options.signal);
942
+ await this.#git.assertRepository();
943
+ const relativeIndexPath = path6.relative(this.rootDir, this.indexPath).replaceAll(path6.sep, "/");
944
+ const indexArtifacts = relativeIndexPath && !relativeIndexPath.startsWith("../") ? [relativeIndexPath, `${relativeIndexPath}-shm`, `${relativeIndexPath}-wal`, `${relativeIndexPath}-journal`] : [];
945
+ await this.#git.assertCleanWorkingTree(indexArtifacts);
946
+ const target = await this.#git.resolveCommit(options.target ?? "HEAD");
947
+ const checkpoint = this.#database.getCheckpoint();
948
+ let rebuild = checkpoint === null;
949
+ if (checkpoint && !await this.#git.isAncestor(checkpoint, target)) {
950
+ if (!options.rebuildOnDivergence) throw new GitDivergenceError(checkpoint, target);
951
+ rebuild = true;
952
+ }
953
+ const tree = await this.#git.listTree(target);
954
+ const workingFiles = this.#database.getWorkingTreeFiles();
955
+ const changes = rebuild || !checkpoint ? [] : await this.#git.diff(checkpoint, target);
956
+ const upserts = /* @__PURE__ */ new Map();
957
+ const deletes = /* @__PURE__ */ new Set();
958
+ if (rebuild) {
959
+ for (const existing of this.#database.allFilePaths()) deletes.add(existing);
960
+ for (const entry of tree.values()) {
961
+ if (this.#policy.includes(entry.path) && entry.size <= this.#maxFileSize) upserts.set(entry.path, { entry });
962
+ }
963
+ } else {
964
+ this.#classifyGitChanges(changes, tree, upserts, deletes);
965
+ for (const { path: dirtyPath } of workingFiles) {
966
+ const entry = tree.get(dirtyPath);
967
+ if (entry && this.#policy.includes(dirtyPath) && entry.size <= this.#maxFileSize) upserts.set(dirtyPath, { entry });
968
+ else deletes.add(dirtyPath);
969
+ }
970
+ const eligibleTargetPaths = new Set(
971
+ [...tree.values()].filter((entry) => this.#policy.includes(entry.path) && entry.size <= this.#maxFileSize).map((entry) => entry.path)
972
+ );
973
+ const indexedPaths = new Set(this.#database.allFilePaths());
974
+ for (const targetPath of eligibleTargetPaths) {
975
+ if (!indexedPaths.has(targetPath) && !upserts.has(targetPath)) {
976
+ upserts.set(targetPath, { entry: tree.get(targetPath) });
977
+ }
978
+ }
979
+ for (const indexedPath of indexedPaths) {
980
+ if (!eligibleTargetPaths.has(indexedPath)) deletes.add(indexedPath);
981
+ }
982
+ }
983
+ if (!rebuild && checkpoint === target && upserts.size === 0 && deletes.size === 0) return emptyStats(checkpoint);
984
+ const prepared = [];
985
+ for (const { entry, previousPath } of upserts.values()) {
986
+ throwIfAborted(options.signal);
987
+ const content = await this.#git.readBlob(entry.oid);
988
+ if (content.byteLength > this.#maxFileSize) {
989
+ deletes.add(entry.path);
990
+ continue;
991
+ }
992
+ prepared.push(this.#prepareFile(entry.path, content, {
993
+ blobOid: entry.oid,
994
+ sourceMode: "git",
995
+ indexedCommit: target,
996
+ ...previousPath ? { replacePath: previousPath } : {}
997
+ }));
998
+ }
999
+ await this.#attachEmbeddings(prepared, options.signal);
1000
+ const resolvedAgain = await this.#git.resolveCommit(options.target ?? "HEAD");
1001
+ if (resolvedAgain !== target) throw new CodeIndexError("Target Git ref changed while indexing; retry the update.");
1002
+ return this.#database.applyUpdate({
1003
+ files: prepared,
1004
+ deletePaths: [...deletes],
1005
+ checkpoint: target,
1006
+ expectedCheckpoint: checkpoint
1007
+ });
1008
+ }
1009
+ #classifyGitChanges(changes, tree, upserts, deletes) {
1010
+ for (const change of changes) {
1011
+ if (change.status === "D") {
1012
+ deletes.add(change.path);
1013
+ continue;
1014
+ }
1015
+ if (change.status === "R") {
1016
+ deletes.add(change.oldPath);
1017
+ const entry2 = tree.get(change.path);
1018
+ if (entry2 && this.#policy.includes(change.path) && entry2.size <= this.#maxFileSize) {
1019
+ upserts.set(change.path, { entry: entry2, previousPath: change.oldPath });
1020
+ }
1021
+ continue;
1022
+ }
1023
+ const entry = tree.get(change.path);
1024
+ if (entry && this.#policy.includes(change.path) && entry.size <= this.#maxFileSize) upserts.set(change.path, { entry });
1025
+ else deletes.add(change.path);
1026
+ }
1027
+ }
1028
+ #prepareFile(relativePath, buffer, provenance) {
1029
+ const content = buffer.toString("utf8");
1030
+ const callables = parseCallables(relativePath, content, this.#onWarning);
1031
+ const language = callables[0]?.language ?? path6.extname(relativePath).slice(1);
1032
+ return {
1033
+ path: relativePath,
1034
+ contentHash: sha256(content),
1035
+ blobOid: provenance.blobOid,
1036
+ sourceMode: provenance.sourceMode,
1037
+ indexedCommit: provenance.indexedCommit,
1038
+ language,
1039
+ byteSize: buffer.byteLength,
1040
+ ...provenance.previousPath ? { previousPath: provenance.previousPath } : {},
1041
+ ...provenance.replacePath ? { replacePath: provenance.replacePath } : {},
1042
+ callables
1043
+ };
1044
+ }
1045
+ async #attachEmbeddings(files, signal) {
1046
+ const profile = JSON.stringify(normalizeProfile(this.provider.profile));
1047
+ const unique = /* @__PURE__ */ new Map();
1048
+ for (const file of files) {
1049
+ for (const callable of file.callables) {
1050
+ callable.embeddingKey = sha256(`${profile}\0${callable.embeddingInput}`);
1051
+ const values = unique.get(callable.embeddingKey) ?? [];
1052
+ values.push(callable);
1053
+ unique.set(callable.embeddingKey, values);
1054
+ }
1055
+ }
1056
+ const existing = this.#database.getEmbeddingKeys([...unique.keys()]);
1057
+ const missing = [...unique.entries()].filter(([key]) => !existing.has(key));
1058
+ for (const batch of chunk(missing, this.#embeddingBatchSize)) {
1059
+ throwIfAborted(signal);
1060
+ const vectors = await this.provider.embedDocuments(batch.map(([, callables]) => callables[0].embeddingInput), signal ? { signal } : void 0);
1061
+ if (vectors.length !== batch.length) throw new CodeIndexError("Embedding provider returned an unexpected number of vectors.");
1062
+ vectors.forEach((vector, index) => {
1063
+ const converted = normalizeEmbeddingVector(vector, this.provider.profile.dimensions);
1064
+ for (const callable of batch[index][1]) callable.vector = converted;
1065
+ });
1066
+ }
1067
+ }
1068
+ async similaritySearch(options) {
1069
+ if (!options.query.trim()) throw new CodeIndexError("query must not be empty.");
1070
+ const limit = options.limit ?? 10;
1071
+ assertPositiveInteger(limit, "limit");
1072
+ throwIfAborted(options.signal);
1073
+ const vector = await this.provider.embedQuery(options.query, options.signal ? { signal: options.signal } : void 0);
1074
+ return this.searchByVector(vector, {
1075
+ limit,
1076
+ minSimilarity: options.minSimilarity ?? -1,
1077
+ ...options.maxSimilarity !== void 0 ? { maxSimilarity: options.maxSimilarity } : {}
1078
+ });
1079
+ }
1080
+ similarToFunction(functionId, options) {
1081
+ const vector = this.#database.vectorForFunction(functionId);
1082
+ return this.#database.searchVector(vector, {
1083
+ limit: options.limit,
1084
+ minSimilarity: options.minSimilarity,
1085
+ ...options.maxSimilarity !== void 0 ? { maxSimilarity: options.maxSimilarity } : {},
1086
+ excludeId: functionId
1087
+ });
1088
+ }
1089
+ vectorForFunction(functionId) {
1090
+ return this.#database.vectorForFunction(functionId);
1091
+ }
1092
+ searchByVector(vector, options) {
1093
+ return this.#database.searchVector(normalizeEmbeddingVector(vector, this.provider.profile.dimensions), options);
1094
+ }
1095
+ async sourceFunctions(filter) {
1096
+ const functions = filter.type === "all" ? this.allFunctions() : await this.#functionsAddedSince(filter.commit);
1097
+ if (!filter.path) return functions;
1098
+ const sourcePath = normalizeRelativePath(this.rootDir, filter.path);
1099
+ return functions.filter((callable) => callable.path === sourcePath || callable.path.startsWith(`${sourcePath}/`));
1100
+ }
1101
+ async #functionsAddedSince(baseRef) {
1102
+ await this.#git.assertRepository();
1103
+ const checkpoint = this.#database.getCheckpoint();
1104
+ if (!checkpoint) throw new CodeIndexError("added-since requires an index with a Git checkpoint.");
1105
+ const base = await this.#git.resolveCommit(baseRef);
1106
+ if (!await this.#git.isAncestor(base, checkpoint)) {
1107
+ throw new GitDivergenceError(base, checkpoint);
1108
+ }
1109
+ const changes = await this.#git.diff(base, checkpoint);
1110
+ const baseTree = await this.#git.listTree(base);
1111
+ const comparisonPaths = /* @__PURE__ */ new Map();
1112
+ for (const change of changes) {
1113
+ if (change.status === "D") continue;
1114
+ if (change.status === "R") comparisonPaths.set(change.path, change.oldPath);
1115
+ else if (change.status === "A" || change.status === "C") comparisonPaths.set(change.path, null);
1116
+ else comparisonPaths.set(change.path, change.path);
1117
+ }
1118
+ for (const dirtyFile of this.#database.getWorkingTreeFiles()) {
1119
+ if (!comparisonPaths.has(dirtyFile.path)) {
1120
+ comparisonPaths.set(dirtyFile.path, dirtyFile.previousPath ?? dirtyFile.path);
1121
+ }
1122
+ }
1123
+ const current = this.#database.functionsForPaths([...comparisonPaths.keys()]);
1124
+ const baseFunctionsByCurrentPath = /* @__PURE__ */ new Map();
1125
+ for (const [currentPath, basePath] of comparisonPaths) {
1126
+ if (!basePath) {
1127
+ baseFunctionsByCurrentPath.set(currentPath, []);
1128
+ continue;
1129
+ }
1130
+ const entry = baseTree.get(basePath);
1131
+ if (!entry) {
1132
+ baseFunctionsByCurrentPath.set(currentPath, []);
1133
+ continue;
1134
+ }
1135
+ const content = (await this.#git.readBlob(entry.oid)).toString("utf8");
1136
+ baseFunctionsByCurrentPath.set(currentPath, parseCallables(basePath, content, this.#onWarning));
1137
+ }
1138
+ const currentByPath = groupBy(current, (callable) => callable.path);
1139
+ const addedIds = /* @__PURE__ */ new Set();
1140
+ for (const [currentPath, currentFunctions] of currentByPath) {
1141
+ for (const id of addedFunctionIds(currentFunctions, baseFunctionsByCurrentPath.get(currentPath) ?? [])) {
1142
+ addedIds.add(id);
1143
+ }
1144
+ }
1145
+ return current.filter((callable) => addedIds.has(callable.id));
1146
+ }
1147
+ };
1148
+ function addedFunctionIds(current, base) {
1149
+ const currentGroups = groupBy(current, (callable) => `${callable.qualifiedName}\0${callable.kind}`);
1150
+ const baseGroups = groupBy(base, (callable) => `${callable.qualifiedName}\0${callable.kind}`);
1151
+ const added = [];
1152
+ for (const [key, currentGroup] of currentGroups) {
1153
+ const baseGroup = baseGroups.get(key) ?? [];
1154
+ const unmatchedBase = new Set(baseGroup.map((_, index) => index));
1155
+ const unmatchedCurrent = new Set(currentGroup.map((_, index) => index));
1156
+ for (let currentIndex = 0; currentIndex < currentGroup.length; currentIndex += 1) {
1157
+ const exactBaseIndex = [...unmatchedBase].find(
1158
+ (baseIndex) => baseGroup[baseIndex].sourceHash === currentGroup[currentIndex].sourceHash
1159
+ );
1160
+ if (exactBaseIndex !== void 0) {
1161
+ unmatchedBase.delete(exactBaseIndex);
1162
+ unmatchedCurrent.delete(currentIndex);
1163
+ }
1164
+ }
1165
+ for (const currentIndex of unmatchedCurrent) {
1166
+ const baseIndex = unmatchedBase.values().next().value;
1167
+ if (baseIndex === void 0) added.push(currentGroup[currentIndex].id);
1168
+ else unmatchedBase.delete(baseIndex);
1169
+ }
1170
+ }
1171
+ return added;
1172
+ }
1173
+ function normalizeProfile(profile) {
1174
+ return {
1175
+ provider: profile.provider,
1176
+ model: profile.model,
1177
+ dimensions: profile.dimensions,
1178
+ strategyVersion: profile.strategyVersion ?? "callable-v1"
1179
+ };
1180
+ }
1181
+ function emptyStats(checkpoint) {
1182
+ return {
1183
+ filesUpdated: 0,
1184
+ filesDeleted: 0,
1185
+ functionsAdded: 0,
1186
+ functionsUpdated: 0,
1187
+ functionsDeleted: 0,
1188
+ embeddingsCreated: 0,
1189
+ checkpoint
1190
+ };
1191
+ }
1192
+ function validateRenames(renames) {
1193
+ const sources = new Set(renames.map((rename) => rename.from));
1194
+ const targets = new Set(renames.map((rename) => rename.to));
1195
+ if (sources.size !== renames.length || targets.size !== renames.length) {
1196
+ throw new CodeIndexError("Rename sources and targets must be unique.");
1197
+ }
1198
+ if ([...sources].some((source) => targets.has(source))) {
1199
+ throw new CodeIndexError("Chained or cyclic renames must be applied in separate updates.");
1200
+ }
1201
+ }
1202
+ function groupBy(values, keyFor) {
1203
+ const groups = /* @__PURE__ */ new Map();
1204
+ for (const value of values) {
1205
+ const key = keyFor(value);
1206
+ const group = groups.get(key) ?? [];
1207
+ group.push(value);
1208
+ groups.set(key, group);
1209
+ }
1210
+ return groups;
1211
+ }
1212
+
1213
+ // src/embeddings/http.ts
1214
+ async function requestEmbeddings(options) {
1215
+ let lastError;
1216
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1217
+ try {
1218
+ const response = await fetch(options.url, {
1219
+ method: "POST",
1220
+ headers: {
1221
+ authorization: `Bearer ${options.apiKey}`,
1222
+ "content-type": "application/json"
1223
+ },
1224
+ body: JSON.stringify(options.body),
1225
+ ...options.signal ? { signal: options.signal } : {}
1226
+ });
1227
+ if (!response.ok) {
1228
+ const detail = (await response.text()).slice(0, 500);
1229
+ const error = new CodeIndexError(`Embedding request failed (${response.status}): ${detail}`);
1230
+ if (response.status !== 429 && response.status < 500) throw error;
1231
+ lastError = error;
1232
+ } else {
1233
+ const payload = await response.json();
1234
+ if (!Array.isArray(payload.data) || payload.data.length !== options.expectedCount) {
1235
+ throw new CodeIndexError("Embedding provider returned an unexpected number of vectors.");
1236
+ }
1237
+ const indexes = payload.data.map((item) => item.index);
1238
+ if (indexes.some((index) => !Number.isInteger(index)) || new Set(indexes).size !== options.expectedCount || indexes.some((index) => index < 0 || index >= options.expectedCount)) {
1239
+ throw new CodeIndexError("Embedding provider returned invalid vector indexes.");
1240
+ }
1241
+ const ordered = new Array(options.expectedCount);
1242
+ for (const item of payload.data) ordered[item.index] = item;
1243
+ try {
1244
+ return ordered.map((item) => normalizeEmbeddingVector(item.embedding, options.dimensions));
1245
+ } catch {
1246
+ throw new CodeIndexError("Embedding provider returned a malformed vector.");
1247
+ }
1248
+ }
1249
+ } catch (error) {
1250
+ if (options.signal?.aborted) throw error;
1251
+ lastError = error;
1252
+ if (error instanceof CodeIndexError && !/\((429|5\d\d)\)/.test(error.message)) throw error;
1253
+ }
1254
+ await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
1255
+ }
1256
+ throw new CodeIndexError("Embedding request failed after three attempts.", { cause: lastError });
1257
+ }
1258
+
1259
+ // src/embeddings/jina.ts
1260
+ var JinaEmbeddingProvider = class {
1261
+ profile;
1262
+ #apiKey;
1263
+ #url;
1264
+ constructor(options = {}) {
1265
+ this.#apiKey = options.apiKey ?? process.env.JINA_API_KEY ?? "";
1266
+ if (!this.#apiKey) throw new Error("JINA_API_KEY is required.");
1267
+ const model = options.model ?? "jina-embeddings-v4";
1268
+ const dimensions = options.dimensions ?? 1024;
1269
+ if (!Number.isInteger(dimensions) || dimensions < 1) throw new Error("dimensions must be a positive integer.");
1270
+ this.profile = {
1271
+ provider: "jina",
1272
+ model,
1273
+ dimensions,
1274
+ strategyVersion: "callable-v1:code-query-passage"
1275
+ };
1276
+ this.#url = options.baseUrl ?? "https://api.jina.ai/v1/embeddings";
1277
+ }
1278
+ embedDocuments(inputs, options) {
1279
+ if (inputs.length === 0) return Promise.resolve([]);
1280
+ return this.#embed(inputs, "code.passage", options?.signal);
1281
+ }
1282
+ async embedQuery(input, options) {
1283
+ return (await this.#embed([input], "code.query", options?.signal))[0];
1284
+ }
1285
+ #embed(inputs, task, signal) {
1286
+ return requestEmbeddings({
1287
+ url: this.#url,
1288
+ apiKey: this.#apiKey,
1289
+ body: {
1290
+ model: this.profile.model,
1291
+ dimensions: this.profile.dimensions,
1292
+ embedding_type: "float",
1293
+ truncate: true,
1294
+ task,
1295
+ input: inputs
1296
+ },
1297
+ expectedCount: inputs.length,
1298
+ dimensions: this.profile.dimensions,
1299
+ ...signal ? { signal } : {}
1300
+ });
1301
+ }
1302
+ };
1303
+
1304
+ // src/embeddings/openai.ts
1305
+ import { Tiktoken } from "js-tiktoken/lite";
1306
+ import cl100kBase from "js-tiktoken/ranks/cl100k_base";
1307
+ var MAX_INPUT_TOKENS = 8192;
1308
+ var tokenizer;
1309
+ function truncateInput(input) {
1310
+ tokenizer ??= new Tiktoken(cl100kBase);
1311
+ const tokens = tokenizer.encode(input);
1312
+ return tokens.length <= MAX_INPUT_TOKENS ? input : tokenizer.decode(tokens.slice(0, MAX_INPUT_TOKENS));
1313
+ }
1314
+ var OpenAIEmbeddingProvider = class {
1315
+ profile;
1316
+ #apiKey;
1317
+ #url;
1318
+ constructor(options = {}) {
1319
+ this.#apiKey = options.apiKey ?? process.env.OPENAI_API_KEY ?? "";
1320
+ if (!this.#apiKey) throw new Error("OPENAI_API_KEY is required.");
1321
+ const model = options.model ?? "text-embedding-3-small";
1322
+ const dimensions = options.dimensions ?? 1536;
1323
+ if (!Number.isInteger(dimensions) || dimensions < 1) throw new Error("dimensions must be a positive integer.");
1324
+ this.profile = { provider: "openai", model, dimensions, strategyVersion: "callable-v1" };
1325
+ this.#url = `${(options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "")}/embeddings`;
1326
+ }
1327
+ embedDocuments(inputs, options) {
1328
+ if (inputs.length === 0) return Promise.resolve([]);
1329
+ return this.#embed(inputs, options?.signal);
1330
+ }
1331
+ async embedQuery(input, options) {
1332
+ return (await this.#embed([input], options?.signal))[0];
1333
+ }
1334
+ #embed(inputs, signal) {
1335
+ return requestEmbeddings({
1336
+ url: this.#url,
1337
+ apiKey: this.#apiKey,
1338
+ body: {
1339
+ model: this.profile.model,
1340
+ dimensions: this.profile.dimensions,
1341
+ encoding_format: "float",
1342
+ input: inputs.map(truncateInput)
1343
+ },
1344
+ expectedCount: inputs.length,
1345
+ dimensions: this.profile.dimensions,
1346
+ ...signal ? { signal } : {}
1347
+ });
1348
+ }
1349
+ };
1350
+
1351
+ // src/format.ts
1352
+ function functionName(value) {
1353
+ return `${value.path} :: ${value.qualifiedName}`;
1354
+ }
1355
+ function formatSimilaritySummary(matches, source) {
1356
+ const lines = matches.map((match) => `${source ? " " : ""}${match.similarity.toFixed(4)} ${functionName(match.function)}`);
1357
+ if (!source) return lines.length > 0 ? lines.join("\n") : "No matches.";
1358
+ return [functionName(source), ...lines.length > 0 ? lines : [" No matches."]].join("\n");
1359
+ }
1360
+
1361
+ // src/search/cross-search.ts
1362
+ async function* crossSearch(options) {
1363
+ const target = options.target ?? options.source;
1364
+ const sourceProfile = JSON.stringify(options.source.status().embeddingProfile);
1365
+ const targetProfile = JSON.stringify(target.status().embeddingProfile);
1366
+ if (sourceProfile !== targetProfile) {
1367
+ throw new IncompatibleIndexError("Cross-search requires identical embedding profiles.");
1368
+ }
1369
+ const limit = options.limitPerFunction ?? 5;
1370
+ assertPositiveInteger(limit, "limitPerFunction");
1371
+ const sourceFunctions = await options.source.sourceFunctions(options.sourceFilter ?? { type: "all" });
1372
+ const sameIndex = target.indexPath === options.source.indexPath;
1373
+ const seenPairs = /* @__PURE__ */ new Set();
1374
+ for (let index = 0; index < sourceFunctions.length; index += 1) {
1375
+ throwIfAborted(options.signal);
1376
+ const source = sourceFunctions[index];
1377
+ const vector = options.source.vectorForFunction(source.id);
1378
+ const candidates = sameIndex ? options.source.similarToFunction(source.id, {
1379
+ limit,
1380
+ minSimilarity: options.minSimilarity ?? -1,
1381
+ ...options.maxSimilarity !== void 0 ? { maxSimilarity: options.maxSimilarity } : {}
1382
+ }) : target.searchByVector(vector, {
1383
+ limit,
1384
+ minSimilarity: options.minSimilarity ?? -1,
1385
+ ...options.maxSimilarity !== void 0 ? { maxSimilarity: options.maxSimilarity } : {}
1386
+ });
1387
+ const matches = sameIndex && !options.includeSymmetricDuplicates ? candidates.filter((match) => {
1388
+ const pair = source.id < match.function.id ? `${source.id}:${match.function.id}` : `${match.function.id}:${source.id}`;
1389
+ if (seenPairs.has(pair)) return false;
1390
+ seenPairs.add(pair);
1391
+ return true;
1392
+ }) : candidates;
1393
+ if (matches.length > 0) yield { source, matches };
1394
+ options.onProgress?.({ completed: index + 1, total: sourceFunctions.length });
1395
+ }
1396
+ }
1397
+
1398
+ // src/cli.ts
1399
+ var parsed = parseArgs({
1400
+ args: process.argv.slice(2),
1401
+ allowPositionals: true,
1402
+ strict: true,
1403
+ options: {
1404
+ root: { type: "string", default: process.cwd() },
1405
+ config: { type: "string" },
1406
+ index: { type: "string" },
1407
+ provider: { type: "string" },
1408
+ model: { type: "string" },
1409
+ dimensions: { type: "string" },
1410
+ target: { type: "string" },
1411
+ "target-root": { type: "string" },
1412
+ "target-index": { type: "string" },
1413
+ "added-since": { type: "string" },
1414
+ "source-path": { type: "string" },
1415
+ limit: { type: "string" },
1416
+ "min-similarity": { type: "string" },
1417
+ threshold: { type: "string" },
1418
+ format: { type: "string" },
1419
+ "include-symmetric-duplicates": { type: "boolean", default: false },
1420
+ "rebuild-on-divergence": { type: "boolean", default: false },
1421
+ help: { type: "boolean", short: "h", default: false }
1422
+ }
1423
+ });
1424
+ var [command, ...positionals] = parsed.positionals;
1425
+ if (parsed.values.help || !command) {
1426
+ printHelp();
1427
+ process.exit(parsed.values.help ? 0 : 1);
1428
+ }
1429
+ void main().catch((error) => {
1430
+ const message = error instanceof Error ? error.message : String(error);
1431
+ process.stderr.write(`slopdex: ${message}
1432
+ `);
1433
+ process.exitCode = error instanceof CodeIndexError ? 2 : 1;
1434
+ });
1435
+ async function main() {
1436
+ const rootDir = path7.resolve(parsed.values.root);
1437
+ const config = loadConfig(rootDir, parsed.values.config);
1438
+ const provider = createProvider(config);
1439
+ const indexOptions = {
1440
+ rootDir,
1441
+ provider,
1442
+ onWarning: (message) => process.stderr.write(`slopdex: warning: ${message}
1443
+ `),
1444
+ ...parsed.values.index || config.indexPath ? { indexPath: parsed.values.index ?? config.indexPath } : {},
1445
+ ...config.include ? { include: config.include } : {},
1446
+ ...config.exclude ? { exclude: config.exclude } : {},
1447
+ ...config.maxFileSize ? { maxFileSize: config.maxFileSize } : {},
1448
+ ...config.embeddingBatchSize ? { embeddingBatchSize: config.embeddingBatchSize } : {}
1449
+ };
1450
+ const initialized = await initializeMissingIndex(
1451
+ indexOptions,
1452
+ "index",
1453
+ command === "update-git" ? parsed.values.target ?? "HEAD" : "HEAD"
1454
+ );
1455
+ const index = new CodeIndex(indexOptions);
1456
+ try {
1457
+ switch (command) {
1458
+ case "status":
1459
+ printJson(index.status());
1460
+ break;
1461
+ case "update-files":
1462
+ if (positionals.length === 0) throw new CodeIndexError("update-files requires at least one path.");
1463
+ printJson(await index.updateFiles({ upsert: positionals }));
1464
+ break;
1465
+ case "delete-files":
1466
+ if (positionals.length === 0) throw new CodeIndexError("delete-files requires at least one path.");
1467
+ printJson(await index.updateFiles({ delete: positionals }));
1468
+ break;
1469
+ case "update-git": {
1470
+ const stats = initialized ?? await index.updateFromGit({
1471
+ ...parsed.values.target ? { target: parsed.values.target } : {},
1472
+ rebuildOnDivergence: parsed.values["rebuild-on-divergence"]
1473
+ });
1474
+ printJson(stats);
1475
+ break;
1476
+ }
1477
+ case "search": {
1478
+ const query = positionals.join(" ").trim();
1479
+ if (!query) throw new CodeIndexError("search requires a query.");
1480
+ const threshold = similarityThreshold();
1481
+ const results = await index.similaritySearch({
1482
+ query,
1483
+ limit: numberOption(parsed.values.limit, 10, "limit"),
1484
+ minSimilarity: threshold.min,
1485
+ ...threshold.max !== void 0 ? { maxSimilarity: threshold.max } : {}
1486
+ });
1487
+ if (outputFormat() === "summary") process.stdout.write(`${formatSimilaritySummary(results)}
1488
+ `);
1489
+ else printJson(results.map(presentMatch));
1490
+ break;
1491
+ }
1492
+ case "cross-search":
1493
+ await runCrossSearch(index, indexOptions, provider);
1494
+ break;
1495
+ default:
1496
+ throw new CodeIndexError(`Unknown command: ${command}`);
1497
+ }
1498
+ } finally {
1499
+ index.close();
1500
+ }
1501
+ }
1502
+ async function runCrossSearch(source, sourceOptions, provider) {
1503
+ const targetRoot = parsed.values["target-root"];
1504
+ const targetPath = parsed.values["target-index"];
1505
+ if (targetRoot && !targetPath || !targetRoot && targetPath) {
1506
+ throw new CodeIndexError("Cross-index search requires both --target-root and --target-index.");
1507
+ }
1508
+ const targetOptions = targetRoot && targetPath ? {
1509
+ ...sourceOptions,
1510
+ rootDir: path7.resolve(targetRoot),
1511
+ indexPath: path7.resolve(targetPath),
1512
+ provider
1513
+ } : void 0;
1514
+ if (targetOptions) await initializeMissingIndex(targetOptions, "target index", "HEAD");
1515
+ const target = targetOptions ? new CodeIndex({ ...targetOptions, readOnly: true }) : void 0;
1516
+ const format = outputFormat();
1517
+ const threshold = similarityThreshold();
1518
+ try {
1519
+ for await (const result of crossSearch({
1520
+ source,
1521
+ ...target ? { target } : {},
1522
+ sourceFilter: parsed.values["added-since"] ? {
1523
+ type: "added-since",
1524
+ commit: parsed.values["added-since"],
1525
+ ...parsed.values["source-path"] ? { path: parsed.values["source-path"] } : {}
1526
+ } : {
1527
+ type: "all",
1528
+ ...parsed.values["source-path"] ? { path: parsed.values["source-path"] } : {}
1529
+ },
1530
+ limitPerFunction: numberOption(parsed.values.limit, 5, "limit"),
1531
+ minSimilarity: threshold.min,
1532
+ ...threshold.max !== void 0 ? { maxSimilarity: threshold.max } : {},
1533
+ includeSymmetricDuplicates: parsed.values["include-symmetric-duplicates"]
1534
+ })) {
1535
+ if (format === "summary") {
1536
+ process.stdout.write(`${formatSimilaritySummary(result.matches, result.source)}
1537
+ `);
1538
+ } else {
1539
+ process.stdout.write(`${JSON.stringify({
1540
+ source: presentFunction(result.source),
1541
+ matches: result.matches.map(presentMatch)
1542
+ })}
1543
+ `);
1544
+ }
1545
+ }
1546
+ } finally {
1547
+ target?.close();
1548
+ }
1549
+ }
1550
+ async function initializeMissingIndex(options, label, target) {
1551
+ const indexPath = path7.resolve(options.indexPath ?? path7.join(path7.resolve(options.rootDir), ".slopdex", "index.sqlite"));
1552
+ if (existsSync(indexPath)) return null;
1553
+ process.stderr.write(`slopdex: ${label} not found at ${indexPath}; initializing automatically from committed ${target}.
1554
+ `);
1555
+ const index = new CodeIndex({ ...options, indexPath });
1556
+ let initialized = false;
1557
+ try {
1558
+ const stats = await index.updateFromGit({ target });
1559
+ initialized = true;
1560
+ return stats;
1561
+ } finally {
1562
+ index.close();
1563
+ if (!initialized) {
1564
+ for (const suffix of ["", "-shm", "-wal", "-journal"]) rmSync(`${indexPath}${suffix}`, { force: true });
1565
+ }
1566
+ }
1567
+ }
1568
+ function loadConfig(rootDir, configuredPath) {
1569
+ const configPath = path7.resolve(configuredPath ?? path7.join(rootDir, ".slopdex", "config.json"));
1570
+ if (!existsSync(configPath)) return commandLineConfig({});
1571
+ const value = JSON.parse(readFileSync(configPath, "utf8"));
1572
+ return commandLineConfig(value);
1573
+ }
1574
+ function commandLineConfig(config) {
1575
+ const dimensions = parsed.values.dimensions ? numberOption(parsed.values.dimensions, 0, "dimensions") : config.dimensions;
1576
+ const providerValue = parsed.values.provider ?? config.provider;
1577
+ if (providerValue !== void 0 && providerValue !== "openai" && providerValue !== "jina") {
1578
+ throw new CodeIndexError(`Unsupported provider: ${providerValue}`);
1579
+ }
1580
+ const provider = providerValue === "openai" || providerValue === "jina" ? providerValue : void 0;
1581
+ return {
1582
+ ...config,
1583
+ ...provider ? { provider } : {},
1584
+ ...parsed.values.model ? { model: parsed.values.model } : {},
1585
+ ...dimensions ? { dimensions } : {}
1586
+ };
1587
+ }
1588
+ function createProvider(config) {
1589
+ if ((config.provider ?? "openai") === "jina") {
1590
+ return new JinaEmbeddingProvider({
1591
+ ...config.model ? { model: config.model } : {},
1592
+ ...config.dimensions ? { dimensions: config.dimensions } : {}
1593
+ });
1594
+ }
1595
+ return new OpenAIEmbeddingProvider({
1596
+ ...config.model ? { model: config.model } : {},
1597
+ ...config.dimensions ? { dimensions: config.dimensions } : {}
1598
+ });
1599
+ }
1600
+ function numberOption(value, defaultValue, name) {
1601
+ if (value === void 0) return defaultValue;
1602
+ const parsedValue = Number(value);
1603
+ if (!Number.isFinite(parsedValue)) throw new CodeIndexError(`${name} must be a number.`);
1604
+ return parsedValue;
1605
+ }
1606
+ function similarityThreshold() {
1607
+ const threshold = parsed.values.threshold;
1608
+ const minimum = parsed.values["min-similarity"];
1609
+ if (threshold !== void 0 && minimum !== void 0) {
1610
+ throw new CodeIndexError("Use either --threshold or --min-similarity, not both.");
1611
+ }
1612
+ if (threshold === void 0) return { min: numberOption(minimum, -1, "min-similarity") };
1613
+ const number = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?";
1614
+ const range = new RegExp(`^\\s*(${number})\\s*-\\s*(${number})\\s*$`, "i").exec(threshold);
1615
+ if (!range) return { min: numberOption(threshold, -1, "threshold") };
1616
+ const min = Number(range[1]);
1617
+ const max = Number(range[2]);
1618
+ if (!Number.isFinite(min) || !Number.isFinite(max)) throw new CodeIndexError("threshold range bounds must be numbers.");
1619
+ if (min > max) throw new CodeIndexError("threshold range minimum must not exceed its maximum.");
1620
+ return { min, max };
1621
+ }
1622
+ function outputFormat() {
1623
+ const value = parsed.values.format ?? "json";
1624
+ if (value !== "json" && value !== "summary") throw new CodeIndexError("format must be json or summary.");
1625
+ return value;
1626
+ }
1627
+ function presentFunction(value) {
1628
+ const { embeddingInput: _embeddingInput, embeddingId: _embeddingId, ...result } = value;
1629
+ return result;
1630
+ }
1631
+ function presentMatch(value) {
1632
+ return { similarity: value.similarity, function: presentFunction(value.function) };
1633
+ }
1634
+ function printJson(value) {
1635
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
1636
+ `);
1637
+ }
1638
+ function printHelp() {
1639
+ process.stdout.write(`Usage: slopdex <command> [arguments] [options]
1640
+
1641
+ Commands:
1642
+ status Show index metadata
1643
+ update-files <path...> Index specific working-tree files
1644
+ delete-files <path...> Remove specific files from the index
1645
+ update-git Index committed changes since the checkpoint
1646
+ search <query> Search functions by semantic similarity
1647
+ cross-search Find nearest functions for each source function
1648
+
1649
+ Options:
1650
+ --root <path> Repository root (default: current directory)
1651
+ --config <path> Config file (default: .slopdex/config.json)
1652
+ --index <path> SQLite index path
1653
+ --provider <openai|jina> Embedding provider
1654
+ --model <name> Embedding model
1655
+ --dimensions <number> Embedding dimensions
1656
+ --target <ref> Target ref for update-git (default: HEAD)
1657
+ --rebuild-on-divergence Rebuild after a rebase or branch change
1658
+ --limit <number> Search result limit
1659
+ --threshold <number|range> Show similarities at/above a value or within a range
1660
+ --min-similarity <number> Minimum raw cosine similarity
1661
+ --format <json|summary> Similarity output format (default: json)
1662
+ --include-symmetric-duplicates Show both directions of same-index matches
1663
+ --added-since <commit> Restrict cross-search source functions
1664
+ --source-path <path> Restrict cross-search sources to a file or directory
1665
+ --target-root <path> Root of a second indexed codebase
1666
+ --target-index <path> SQLite path of a second index
1667
+
1668
+ Examples:
1669
+ Show metadata for the current index:
1670
+ slopdex status
1671
+
1672
+ Index specific working-tree files:
1673
+ slopdex update-files src/service.ts src/model.ts
1674
+
1675
+ Remove deleted files from the index:
1676
+ slopdex delete-files src/removed.ts
1677
+
1678
+ Index the latest committed Git snapshot:
1679
+ slopdex update-git --target HEAD
1680
+
1681
+ Find functions matching a semantic query:
1682
+ slopdex search "validate an authenticated session" --limit 10
1683
+
1684
+ Review similar functions as duplicate-code candidates:
1685
+ slopdex cross-search --format summary --threshold 0.8 --limit 5
1686
+
1687
+ Review functions under a path against the whole codebase:
1688
+ slopdex cross-search --source-path src/services --format summary
1689
+ `);
1690
+ }
1691
+ //# sourceMappingURL=cli.js.map