codesearch-mcp 0.0.18-alpha → 0.1.0-alpha
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/CHANGELOG.md +23 -0
- package/README.md +75 -6
- package/dist/app.js +36 -0
- package/dist/src/commands/IndexCommand.js +14 -1
- package/dist/src/commands/McpCommand.js +6 -2
- package/dist/src/config-store.d.ts +49 -11
- package/dist/src/config-store.js +79 -19
- package/dist/src/config.d.ts +32 -8
- package/dist/src/config.js +84 -20
- package/dist/src/constants.d.ts +36 -2
- package/dist/src/constants.js +58 -7
- package/dist/src/container.js +5 -3
- package/dist/src/db/index.d.ts +1 -1
- package/dist/src/db/index.js +2 -1
- package/dist/src/db/schema.d.ts +111 -0
- package/dist/src/db/schema.js +15 -1
- package/dist/src/help.js +5 -4
- package/dist/src/interfaces/IAstParser.d.ts +14 -0
- package/dist/src/interfaces/IChunker.d.ts +24 -0
- package/dist/src/interfaces/IChunker.js +2 -0
- package/dist/src/interfaces/IDatabase.d.ts +14 -0
- package/dist/src/interfaces/IEmbedder.d.ts +14 -2
- package/dist/src/interfaces/ISearcher.d.ts +1 -0
- package/dist/src/interfaces/index.d.ts +2 -1
- package/dist/src/owner-path.d.ts +10 -0
- package/dist/src/owner-path.js +27 -0
- package/dist/src/services/AstChunker.d.ts +31 -0
- package/dist/src/services/AstChunker.js +114 -0
- package/dist/src/services/AstParserService.d.ts +6 -0
- package/dist/src/services/AstParserService.js +31 -2
- package/dist/src/services/DatabaseService.d.ts +13 -1
- package/dist/src/services/DatabaseService.js +70 -25
- package/dist/src/services/EmbedderFactory.d.ts +9 -0
- package/dist/src/services/EmbedderFactory.js +29 -0
- package/dist/src/services/IndexerService.d.ts +17 -5
- package/dist/src/services/IndexerService.js +101 -38
- package/dist/src/services/InfinityEmbedder.d.ts +79 -0
- package/dist/src/services/InfinityEmbedder.js +199 -0
- package/dist/src/services/OllamaEmbedder.d.ts +16 -5
- package/dist/src/services/OllamaEmbedder.js +84 -36
- package/dist/src/services/OpenAICompatibleEmbedder.d.ts +25 -0
- package/dist/src/services/OpenAICompatibleEmbedder.js +75 -0
- package/dist/src/services/SearcherService.d.ts +7 -1
- package/dist/src/services/SearcherService.js +35 -16
- package/dist/src/services/VoyageEmbedder.d.ts +22 -0
- package/dist/src/services/VoyageEmbedder.js +72 -0
- package/dist/src/services/WebServerService.d.ts +16 -11
- package/dist/src/services/WebServerService.js +44 -24
- package/dist/src/services/http-retry.d.ts +19 -0
- package/dist/src/services/http-retry.js +54 -0
- package/dist/src/services/reranker.d.ts +15 -0
- package/dist/src/services/reranker.js +44 -0
- package/dist/src/setup.d.ts +6 -0
- package/dist/src/setup.js +243 -25
- package/dist/src/tools/SearchCodebaseTool.js +1 -1
- package/dist/src/tools/format-helpers.js +2 -1
- package/dist/src/web/dashboard.js +5 -0
- package/docker/docker-compose.infinity-gpu.yml +19 -0
- package/docker/docker-compose.yml +25 -0
- package/package.json +21 -2
- package/dist/src/ast.d.ts +0 -2
- package/dist/src/ast.js +0 -110
- package/dist/src/commands/SetupCommand.d.ts +0 -7
- package/dist/src/commands/SetupCommand.js +0 -14
- package/dist/src/db/data-source.d.ts +0 -2
- package/dist/src/db/data-source.js +0 -13
- package/dist/src/db/entities/code-chunk.entity.d.ts +0 -9
- package/dist/src/db/entities/code-chunk.entity.js +0 -54
- package/dist/src/db/entities/code-dependency.entity.d.ts +0 -9
- package/dist/src/db/entities/code-dependency.entity.js +0 -54
- package/dist/src/db/entities/code-symbol.entity.d.ts +0 -10
- package/dist/src/db/entities/code-symbol.entity.js +0 -59
- package/dist/src/db/init.d.ts +0 -2
- package/dist/src/db/init.js +0 -62
- package/dist/src/env-loader.d.ts +0 -1
- package/dist/src/env-loader.js +0 -41
- package/dist/src/indexer.d.ts +0 -17
- package/dist/src/indexer.js +0 -242
- package/dist/src/mcp.d.ts +0 -1
- package/dist/src/mcp.js +0 -403
- package/dist/src/ollama.d.ts +0 -2
- package/dist/src/ollama.js +0 -6
- package/dist/src/searcher.d.ts +0 -74
- package/dist/src/searcher.js +0 -446
- package/dist/vitest.config.d.ts +0 -2
- package/dist/vitest.config.js +0 -9
|
@@ -37,36 +37,92 @@ exports.IndexerService = void 0;
|
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const crypto_1 = require("crypto");
|
|
40
|
-
const textsplitters_1 = require("@langchain/textsplitters");
|
|
41
40
|
const cli_progress_1 = require("cli-progress");
|
|
42
41
|
const drizzle_orm_1 = require("drizzle-orm");
|
|
42
|
+
const owner_path_1 = require("../owner-path");
|
|
43
43
|
const db_1 = require("../db");
|
|
44
44
|
const file_utils_1 = require("../file-utils");
|
|
45
45
|
const LoggerService_1 = require("./LoggerService");
|
|
46
|
+
const constants_1 = require("../constants");
|
|
46
47
|
const SCHEMA_EXTENSIONS = /\.(prisma|graphql|gql)$/;
|
|
47
48
|
class IndexerService {
|
|
48
49
|
db;
|
|
49
50
|
embedder;
|
|
50
51
|
ast;
|
|
52
|
+
chunker;
|
|
51
53
|
config;
|
|
52
54
|
logger;
|
|
53
|
-
|
|
54
|
-
schemaSplitter;
|
|
55
|
-
constructor(db, embedder, ast, config, logger = LoggerService_1.LoggerService.silent()) {
|
|
55
|
+
constructor(db, embedder, ast, chunker, config, logger = LoggerService_1.LoggerService.silent()) {
|
|
56
56
|
this.db = db;
|
|
57
57
|
this.embedder = embedder;
|
|
58
58
|
this.ast = ast;
|
|
59
|
+
this.chunker = chunker;
|
|
59
60
|
this.config = config;
|
|
60
61
|
this.logger = logger;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the active embedding dimension (config override → registry → probe),
|
|
65
|
+
* reconcile it against `index_meta`, and prepare `code_chunk` accordingly:
|
|
66
|
+
* a dimension change drops+recreates the table; a same-dimension model/provider
|
|
67
|
+
* change clears its rows (vector spaces are not comparable across models); an
|
|
68
|
+
* unchanged model keeps the rows for incremental indexing. Back-compat: an
|
|
69
|
+
* existing populated table with no `index_meta` row is treated as the legacy
|
|
70
|
+
* `ollama`/`bge-m3`/1024 index. Writes `index_meta` to reflect the active model.
|
|
71
|
+
* Call once per run, before the scan loop.
|
|
72
|
+
*/
|
|
73
|
+
async prepareEmbeddingSchema() {
|
|
74
|
+
const { provider, model, dimensions: override } = this.config.embedding;
|
|
75
|
+
const next = {
|
|
76
|
+
provider,
|
|
77
|
+
model,
|
|
78
|
+
dimensions: await (0, constants_1.resolveDimension)(override, model, async () => {
|
|
79
|
+
const probe = await this.embedder.embed('probe', 'document');
|
|
80
|
+
return probe.length;
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
const meta = await this.db.readIndexMeta();
|
|
84
|
+
if (!meta) {
|
|
85
|
+
const chunkCount = await this.countChunks();
|
|
86
|
+
if (chunkCount === 0) {
|
|
87
|
+
// Fresh index — create the table at the resolved width and record it.
|
|
88
|
+
await this.db.recreateChunkTable(next.dimensions);
|
|
89
|
+
await this.db.writeIndexMeta(next);
|
|
90
|
+
this.logger.debug('IndexerService: prepared fresh index', { ...next });
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
// Legacy index (pre-index_meta): assume the historical default model.
|
|
94
|
+
await this.reconcileIndex({ provider: 'ollama', model: 'bge-m3', dimensions: 1024 }, next);
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
await this.reconcileIndex(meta, next);
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
async reconcileIndex(prev, next) {
|
|
101
|
+
if (prev.dimensions !== next.dimensions) {
|
|
102
|
+
console.log(`Embedding dimension changed (${prev.dimensions} → ${next.dimensions}) — recreating code_chunk.`);
|
|
103
|
+
this.logger.info('IndexerService: dimension change, recreating code_chunk', { prev, next });
|
|
104
|
+
await this.db.recreateChunkTable(next.dimensions);
|
|
105
|
+
}
|
|
106
|
+
else if (prev.provider !== next.provider || prev.model !== next.model) {
|
|
107
|
+
console.log(`Embedding model changed (${prev.provider}/${prev.model} → ${next.provider}/${next.model}) — clearing index.`);
|
|
108
|
+
this.logger.info('IndexerService: model change, clearing code_chunk', { prev, next });
|
|
109
|
+
await this.db.query(`DELETE FROM code_chunk`);
|
|
110
|
+
}
|
|
111
|
+
await this.db.writeIndexMeta(next);
|
|
112
|
+
}
|
|
113
|
+
async countChunks() {
|
|
114
|
+
try {
|
|
115
|
+
const rows = await this.db.query(`SELECT COUNT(*)::int AS n FROM code_chunk`);
|
|
116
|
+
return Number(rows[0]?.n ?? 0);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// A missing table means a fresh install → 0. Any other (transient) error
|
|
120
|
+
// must propagate — returning 0 here could wrongly recreate/drop a populated
|
|
121
|
+
// table on the `!meta` path.
|
|
122
|
+
if (/does not exist/i.test(err.message))
|
|
123
|
+
return 0;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
70
126
|
}
|
|
71
127
|
async backfillSymbols(signal) {
|
|
72
128
|
const t0 = Date.now();
|
|
@@ -290,9 +346,6 @@ class IndexerService {
|
|
|
290
346
|
});
|
|
291
347
|
return stats;
|
|
292
348
|
}
|
|
293
|
-
getSplitter(filePath) {
|
|
294
|
-
return SCHEMA_EXTENSIONS.test(filePath) ? this.schemaSplitter : this.jsSplitter;
|
|
295
|
-
}
|
|
296
349
|
static inferRole(relPath) {
|
|
297
350
|
const p = relPath.toLowerCase();
|
|
298
351
|
if (p.includes('/services/') || p.endsWith('.service.ts'))
|
|
@@ -317,9 +370,9 @@ class IndexerService {
|
|
|
317
370
|
return 'Utility';
|
|
318
371
|
return null;
|
|
319
372
|
}
|
|
320
|
-
getEnrichedPrompt(
|
|
321
|
-
const chunkStart =
|
|
322
|
-
const chunkEnd =
|
|
373
|
+
getEnrichedPrompt(piece, relPath, serviceName, _meta, edges, symbols) {
|
|
374
|
+
const chunkStart = piece.startLine;
|
|
375
|
+
const chunkEnd = piece.endLine;
|
|
323
376
|
const lines = [];
|
|
324
377
|
lines.push(`Project: ${serviceName} | File: ${relPath}`);
|
|
325
378
|
const role = IndexerService.inferRole(relPath);
|
|
@@ -335,12 +388,9 @@ class IndexerService {
|
|
|
335
388
|
const depNames = [...new Set(edges.map(e => path.basename(e.toFile, path.extname(e.toFile)).split('.')[0]))];
|
|
336
389
|
lines.push(`Dependencies: ${depNames.join(', ')}`);
|
|
337
390
|
}
|
|
338
|
-
lines.push(`Code: ${
|
|
391
|
+
lines.push(`Code: ${piece.content}`);
|
|
339
392
|
return lines.join('\n');
|
|
340
393
|
}
|
|
341
|
-
async embedText(prompt) {
|
|
342
|
-
return this.embedder.embed(prompt);
|
|
343
|
-
}
|
|
344
394
|
static hashContent(content) {
|
|
345
395
|
return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
|
|
346
396
|
}
|
|
@@ -364,7 +414,7 @@ class IndexerService {
|
|
|
364
414
|
return { status: 'skipped', chunks: 0, pendingMtimeRefresh: relFilePath };
|
|
365
415
|
}
|
|
366
416
|
const tAst = Date.now();
|
|
367
|
-
const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls } = analysis ?? { edges: [], symbols: [], calls: [] };
|
|
417
|
+
const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls, ownerRanges: rawOwnerRanges = [] } = analysis ?? { edges: [], symbols: [], calls: [] };
|
|
368
418
|
// astMs is near-zero — actual AST time is spent upfront in analyzeProject()
|
|
369
419
|
// Normalize edges to workspace-relative paths
|
|
370
420
|
const edges = rawEdges
|
|
@@ -390,25 +440,38 @@ class IndexerService {
|
|
|
390
440
|
// Normalize symbols: replace absolute filePath with workspace-relative, strip endLine (not stored)
|
|
391
441
|
const symbols = [...new Map(rawSymbols.map(s => ({ ...s, filePath: relFilePath })).map(s => [`${s.name}:${s.kind}`, s])).values()].map(({ project, filePath: fp, name, kind, signature, line, col }) => ({ project, filePath: fp, name, kind, signature, line, col }));
|
|
392
442
|
const astMs = Date.now() - tAst;
|
|
393
|
-
const chunks = await this.
|
|
443
|
+
const chunks = await this.chunker.chunk(code, {
|
|
444
|
+
strategy: this.config.indexer.chunkStrategy,
|
|
445
|
+
isSchemaFile: SCHEMA_EXTENSIONS.test(filePath),
|
|
446
|
+
symbols: rawSymbols,
|
|
447
|
+
});
|
|
394
448
|
const relPath = path.relative(projectRoot, filePath);
|
|
449
|
+
// Owner-path ranges: top-level symbols (container: null) + method ranges.
|
|
450
|
+
// Deepest range containing a chunk's start line wins → Class.method.
|
|
451
|
+
const ownerRanges = [
|
|
452
|
+
...rawSymbols
|
|
453
|
+
.filter(s => s.line != null)
|
|
454
|
+
.map(s => ({ name: s.name, container: null, line: s.line, endLine: s.endLine ?? s.line })),
|
|
455
|
+
...rawOwnerRanges,
|
|
456
|
+
];
|
|
395
457
|
const t2 = Date.now();
|
|
396
458
|
const records = [];
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
459
|
+
const embedBatchSize = this.config.embedding.batchSize;
|
|
460
|
+
for (let i = 0; i < chunks.length; i += embedBatchSize) {
|
|
461
|
+
const batch = chunks.slice(i, i + embedBatchSize);
|
|
462
|
+
const prompts = batch.map(piece => this.getEnrichedPrompt(piece, relPath, serviceName, meta ?? 'missing', edges, rawSymbols));
|
|
463
|
+
const vectors = await this.embedder.embedBatch(prompts, 'document');
|
|
464
|
+
for (let j = 0; j < batch.length; j++) {
|
|
465
|
+
const piece = batch[j];
|
|
466
|
+
records.push({
|
|
467
|
+
serviceName, filePath: relFilePath, startLine: piece.startLine,
|
|
468
|
+
content: piece.content,
|
|
469
|
+
embedding: `[${vectors[j]}]`,
|
|
407
470
|
lastModified: mtimeMs,
|
|
408
471
|
contentHash: hash,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
472
|
+
ownerPath: (0, owner_path_1.resolveOwnerPath)(piece.startLine, ownerRanges),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
412
475
|
}
|
|
413
476
|
const embedMs = Date.now() - t2;
|
|
414
477
|
const t3 = Date.now();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Config } from '../config';
|
|
2
|
+
import type { ILogger } from '../interfaces/ILogger';
|
|
3
|
+
import { OpenAICompatibleEmbedder, type EmbedderDeps } from './OpenAICompatibleEmbedder';
|
|
4
|
+
/** Result of a shell command run, mirroring the parts of `spawnSync` we use. */
|
|
5
|
+
export interface CommandResult {
|
|
6
|
+
status: number | null;
|
|
7
|
+
stdout?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Extra injection seams for `InfinityEmbedder` beyond the shared `EmbedderDeps`.
|
|
11
|
+
* All optional and defaulted to the real implementations; tests inject fakes so
|
|
12
|
+
* `ensureModelAvailable()` runs without a live Infinity server or Docker.
|
|
13
|
+
*/
|
|
14
|
+
export interface InfinityDeps extends EmbedderDeps {
|
|
15
|
+
/** Runs a shell command (docker), optionally with extra env. Defaults to `spawnSync`. */
|
|
16
|
+
runCommand?: (cmd: string, args: string[], env?: Record<string, string>) => CommandResult;
|
|
17
|
+
/**
|
|
18
|
+
* Path to the managed docker-compose.yml. Defaults to the file `rag-setup`
|
|
19
|
+
* writes into `~/.config/codesearch-mcp/`. A missing file (or one without an
|
|
20
|
+
* `infinity` service) means "external backend" — the Docker path is skipped.
|
|
21
|
+
*/
|
|
22
|
+
composePath?: string;
|
|
23
|
+
/** Sleep between readiness polls (ms). Defaults to 2000; tests pass 0. */
|
|
24
|
+
pollIntervalMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Strip trailing slashes and a trailing `/embeddings` or `/v1` to get the server
|
|
28
|
+
* root — Infinity serves `/health`, `/models`, `/rerank`, `/embeddings` from the
|
|
29
|
+
* root, so a baseURL written with an OpenAI-style suffix must be normalized here
|
|
30
|
+
* to keep the probe/models URLs aligned with the embeddings URL.
|
|
31
|
+
*/
|
|
32
|
+
export declare function serverRoot(baseURL: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Probe an Infinity-style `/health` endpoint. Resolves `true` when the server
|
|
35
|
+
* answers with a 2xx, `false` on any error or non-2xx. Reused by setup's
|
|
36
|
+
* `waitForInfinity`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function probeInfinityHealth(baseURL: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* List the model ids an Infinity server currently serves via its OpenAI-style
|
|
41
|
+
* `/models` listing (`{ data: [{ id }] }`). Returns `[]` on any error.
|
|
42
|
+
*/
|
|
43
|
+
export declare function listInfinityModels(baseURL: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
|
|
46
|
+
* HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
|
|
47
|
+
* unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
|
|
48
|
+
* because Infinity binds its model set at container startup and exposes no
|
|
49
|
+
* runtime model-pull API.
|
|
50
|
+
*/
|
|
51
|
+
export declare class InfinityEmbedder extends OpenAICompatibleEmbedder {
|
|
52
|
+
private readonly base;
|
|
53
|
+
private readonly doFetchImpl;
|
|
54
|
+
private readonly runCommand;
|
|
55
|
+
private readonly composePath;
|
|
56
|
+
private readonly pollIntervalMs;
|
|
57
|
+
private readonly log;
|
|
58
|
+
constructor(config: Config, logger?: ILogger, deps?: InfinityDeps);
|
|
59
|
+
private isHealthy;
|
|
60
|
+
private servedModels;
|
|
61
|
+
/** True when a managed compose file exists AND declares an `infinity` service. */
|
|
62
|
+
private hasManagedService;
|
|
63
|
+
/**
|
|
64
|
+
* Bring the Infinity backend to a serving state per the D4 decision tree:
|
|
65
|
+
* healthy + model served → OK
|
|
66
|
+
* healthy + model NOT served → throw (restart container with correct --model-id)
|
|
67
|
+
* unreachable + managed → `docker compose up -d infinity`, wait /health
|
|
68
|
+
* unreachable + external → print manual instructions (no Docker touched)
|
|
69
|
+
*
|
|
70
|
+
* Called by `IndexCommand` before `prepareEmbeddingSchema()`.
|
|
71
|
+
*/
|
|
72
|
+
ensureModelAvailable(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Poll `/health` until ready or the timeout elapses. Generous default (30 min)
|
|
75
|
+
* because a CPU first boot downloads the model set and runs a lengthy per-model
|
|
76
|
+
* warmup before `/health` flips to 200.
|
|
77
|
+
*/
|
|
78
|
+
private waitForHealthy;
|
|
79
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.InfinityEmbedder = void 0;
|
|
37
|
+
exports.serverRoot = serverRoot;
|
|
38
|
+
exports.probeInfinityHealth = probeInfinityHealth;
|
|
39
|
+
exports.listInfinityModels = listInfinityModels;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
43
|
+
const LoggerService_1 = require("./LoggerService");
|
|
44
|
+
const config_store_1 = require("../config-store");
|
|
45
|
+
const OpenAICompatibleEmbedder_1 = require("./OpenAICompatibleEmbedder");
|
|
46
|
+
/**
|
|
47
|
+
* Strip trailing slashes and a trailing `/embeddings` or `/v1` to get the server
|
|
48
|
+
* root — Infinity serves `/health`, `/models`, `/rerank`, `/embeddings` from the
|
|
49
|
+
* root, so a baseURL written with an OpenAI-style suffix must be normalized here
|
|
50
|
+
* to keep the probe/models URLs aligned with the embeddings URL.
|
|
51
|
+
*/
|
|
52
|
+
function serverRoot(baseURL) {
|
|
53
|
+
return baseURL.replace(/\/+$/, '').replace(/\/(embeddings|v1)$/, '');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Probe an Infinity-style `/health` endpoint. Resolves `true` when the server
|
|
57
|
+
* answers with a 2xx, `false` on any error or non-2xx. Reused by setup's
|
|
58
|
+
* `waitForInfinity`.
|
|
59
|
+
*/
|
|
60
|
+
async function probeInfinityHealth(baseURL, fetchImpl = fetch, timeoutMs = 2000) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchImpl(`${serverRoot(baseURL)}/health`, {
|
|
63
|
+
method: 'GET',
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
return res.ok;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* List the model ids an Infinity server currently serves via its OpenAI-style
|
|
74
|
+
* `/models` listing (`{ data: [{ id }] }`). Returns `[]` on any error.
|
|
75
|
+
*/
|
|
76
|
+
async function listInfinityModels(baseURL, fetchImpl = fetch, timeoutMs = 5000) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetchImpl(`${serverRoot(baseURL)}/models`, {
|
|
79
|
+
method: 'GET',
|
|
80
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
return [];
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
return (data.data ?? []).map(m => m.id).filter((id) => Boolean(id));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
|
|
93
|
+
* HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
|
|
94
|
+
* unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
|
|
95
|
+
* because Infinity binds its model set at container startup and exposes no
|
|
96
|
+
* runtime model-pull API.
|
|
97
|
+
*/
|
|
98
|
+
class InfinityEmbedder extends OpenAICompatibleEmbedder_1.OpenAICompatibleEmbedder {
|
|
99
|
+
base;
|
|
100
|
+
doFetchImpl;
|
|
101
|
+
runCommand;
|
|
102
|
+
composePath;
|
|
103
|
+
pollIntervalMs;
|
|
104
|
+
log;
|
|
105
|
+
constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
|
|
106
|
+
super(config, logger, deps);
|
|
107
|
+
this.base = serverRoot(config.embedding.baseURL);
|
|
108
|
+
this.doFetchImpl = deps.fetchImpl ?? fetch;
|
|
109
|
+
this.runCommand = deps.runCommand ?? ((cmd, args, env) => {
|
|
110
|
+
const r = (0, child_process_1.spawnSync)(cmd, args, { stdio: 'inherit', env: env ? { ...process.env, ...env } : process.env });
|
|
111
|
+
return { status: r.status, stdout: r.stdout?.toString() };
|
|
112
|
+
});
|
|
113
|
+
this.composePath = deps.composePath ?? path.join(config_store_1.CONFIG_DIR, 'docker-compose.yml');
|
|
114
|
+
this.pollIntervalMs = deps.pollIntervalMs ?? 2000;
|
|
115
|
+
this.log = logger;
|
|
116
|
+
}
|
|
117
|
+
isHealthy() {
|
|
118
|
+
return probeInfinityHealth(this.base, this.doFetchImpl);
|
|
119
|
+
}
|
|
120
|
+
servedModels() {
|
|
121
|
+
return listInfinityModels(this.base, this.doFetchImpl);
|
|
122
|
+
}
|
|
123
|
+
/** True when a managed compose file exists AND declares an `infinity` service. */
|
|
124
|
+
hasManagedService() {
|
|
125
|
+
try {
|
|
126
|
+
const content = fs.readFileSync(this.composePath, 'utf-8');
|
|
127
|
+
return /^\s*infinity:\s*$/m.test(content);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Bring the Infinity backend to a serving state per the D4 decision tree:
|
|
135
|
+
* healthy + model served → OK
|
|
136
|
+
* healthy + model NOT served → throw (restart container with correct --model-id)
|
|
137
|
+
* unreachable + managed → `docker compose up -d infinity`, wait /health
|
|
138
|
+
* unreachable + external → print manual instructions (no Docker touched)
|
|
139
|
+
*
|
|
140
|
+
* Called by `IndexCommand` before `prepareEmbeddingSchema()`.
|
|
141
|
+
*/
|
|
142
|
+
async ensureModelAvailable() {
|
|
143
|
+
const model = this.config.embedding.model;
|
|
144
|
+
if (await this.isHealthy()) {
|
|
145
|
+
const served = await this.servedModels();
|
|
146
|
+
// A server that lists no models (older Infinity, or listing disabled) is
|
|
147
|
+
// trusted — the embed request will surface a real error if the model is
|
|
148
|
+
// truly absent.
|
|
149
|
+
if (served.length === 0 || served.includes(model))
|
|
150
|
+
return;
|
|
151
|
+
throw new Error(`Infinity at ${this.base} serves [${served.join(', ')}] but embedding.model is "${model}". ` +
|
|
152
|
+
`Restart the container with the correct --model-id (e.g. recreate the managed container ` +
|
|
153
|
+
`via rag-setup, or fix embedding.model).`);
|
|
154
|
+
}
|
|
155
|
+
// Unreachable.
|
|
156
|
+
if (this.hasManagedService()) {
|
|
157
|
+
console.log(`Infinity not reachable at ${this.base} — starting the managed container...`);
|
|
158
|
+
this.log.info('InfinityEmbedder: starting managed container', { composePath: this.composePath });
|
|
159
|
+
// Bind the SAME model set the config expects (embed + reranker when enabled),
|
|
160
|
+
// so the auto-started container matches the config — otherwise compose would
|
|
161
|
+
// (re)create it with the compose default and silently drop the reranker /
|
|
162
|
+
// serve the wrong embed model. `--force-recreate` picks up a changed set.
|
|
163
|
+
const modelIds = [
|
|
164
|
+
this.config.embedding.model,
|
|
165
|
+
...(this.config.reranker.enabled && this.config.reranker.model ? [this.config.reranker.model] : []),
|
|
166
|
+
].join(';');
|
|
167
|
+
const r = this.runCommand('docker', ['compose', '-f', this.composePath, 'up', '-d', '--force-recreate', 'infinity'], { INFINITY_MODEL_ID: modelIds });
|
|
168
|
+
if (r.status !== 0) {
|
|
169
|
+
throw new Error(`Failed to start the managed Infinity container (docker compose exited ${r.status}). ` +
|
|
170
|
+
`Ensure Docker is running, or start Infinity manually.`);
|
|
171
|
+
}
|
|
172
|
+
await this.waitForHealthy();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Infinity is not reachable at ${this.base} and no managed container is configured. ` +
|
|
176
|
+
`Start it manually, e.g.:\n` +
|
|
177
|
+
` docker run -it --gpus all -p 7997:7997 michaelf34/infinity:latest \\\n` +
|
|
178
|
+
` v2 --model-id ${model} --port 7997\n` +
|
|
179
|
+
`(drop --gpus all to run on CPU), or re-run rag-setup to provision it.`);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Poll `/health` until ready or the timeout elapses. Generous default (30 min)
|
|
183
|
+
* because a CPU first boot downloads the model set and runs a lengthy per-model
|
|
184
|
+
* warmup before `/health` flips to 200.
|
|
185
|
+
*/
|
|
186
|
+
async waitForHealthy(maxWaitSec = 1800) {
|
|
187
|
+
console.log('Waiting for Infinity to be ready (first boot downloads + warms up the model set, this can take several minutes on CPU)...');
|
|
188
|
+
const deadline = Date.now() + maxWaitSec * 1000;
|
|
189
|
+
while (Date.now() < deadline) {
|
|
190
|
+
if (await this.isHealthy()) {
|
|
191
|
+
console.log(' ✓ Infinity ready.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await new Promise(r => setTimeout(r, this.pollIntervalMs));
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`Infinity did not become ready within ${maxWaitSec}s at ${this.base}.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
exports.InfinityEmbedder = InfinityEmbedder;
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { Config } from '../config';
|
|
2
|
-
import type { IEmbedder } from '../interfaces';
|
|
2
|
+
import type { IEmbedder, InputType } from '../interfaces';
|
|
3
3
|
import type { ILogger } from '../interfaces';
|
|
4
|
+
import type { EmbedderDeps } from './OpenAICompatibleEmbedder';
|
|
4
5
|
export declare class OllamaEmbedder implements IEmbedder {
|
|
5
6
|
private config;
|
|
6
7
|
private logger;
|
|
8
|
+
readonly dimensions: number;
|
|
7
9
|
private client;
|
|
8
10
|
rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
|
|
9
|
-
constructor(config: Config, logger?: ILogger);
|
|
10
|
-
embed(text: string): Promise<number[]>;
|
|
11
|
+
constructor(config: Config, logger?: ILogger, deps?: EmbedderDeps);
|
|
12
|
+
embed(text: string, _inputType?: InputType): Promise<number[]>;
|
|
13
|
+
embedBatch(texts: string[], _inputType?: InputType): Promise<number[][]>;
|
|
11
14
|
listModels(): Promise<string[]>;
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Pull the configured model if it is not already present locally, so a plain
|
|
17
|
+
* `rag-index` works after switching to (or back to) a local model that was
|
|
18
|
+
* never pulled — `rag-setup` is no longer the only place that fetches models.
|
|
19
|
+
* A tagless model name (`bge-m3`) matches its `:latest`/tagged install.
|
|
20
|
+
* Streams pull progress to the console: a live single-line bar on a TTY, or
|
|
21
|
+
* de-duplicated status lines otherwise.
|
|
22
|
+
*/
|
|
23
|
+
ensureModelAvailable(): Promise<void>;
|
|
24
|
+
private static formatPullProgress;
|
|
14
25
|
}
|
|
@@ -3,61 +3,109 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.OllamaEmbedder = void 0;
|
|
4
4
|
const ollama_1 = require("ollama");
|
|
5
5
|
const LoggerService_1 = require("./LoggerService");
|
|
6
|
+
const reranker_1 = require("./reranker");
|
|
7
|
+
const constants_1 = require("../constants");
|
|
6
8
|
class OllamaEmbedder {
|
|
7
9
|
config;
|
|
8
10
|
logger;
|
|
11
|
+
dimensions;
|
|
9
12
|
client;
|
|
10
13
|
rerank;
|
|
11
|
-
constructor(config, logger = LoggerService_1.LoggerService.silent()) {
|
|
14
|
+
constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
|
|
12
15
|
this.config = config;
|
|
13
16
|
this.logger = logger;
|
|
14
|
-
this.client = new ollama_1.Ollama({ host: config.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
this.rerank = this._rerank.bind(this);
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
this.logger.warn('OllamaEmbedder: rerankerEnabled=true but rerankerModel is not set — reranking disabled');
|
|
21
|
-
}
|
|
22
|
-
}
|
|
17
|
+
this.client = new ollama_1.Ollama({ host: config.embedding.baseURL });
|
|
18
|
+
this.dimensions = config.embedding.dimensions ?? (0, constants_1.registryDimension)(config.embedding.model) ?? 0;
|
|
19
|
+
this.rerank = (0, reranker_1.makeReranker)(config, logger, deps);
|
|
23
20
|
}
|
|
24
|
-
|
|
21
|
+
// Ollama has no query/document asymmetry — inputType is accepted and ignored.
|
|
22
|
+
async embed(text, _inputType) {
|
|
25
23
|
const { embedding } = await this.client.embeddings({
|
|
26
|
-
model: this.config.
|
|
24
|
+
model: this.config.embedding.model,
|
|
27
25
|
prompt: text,
|
|
28
26
|
});
|
|
29
27
|
return embedding;
|
|
30
28
|
}
|
|
29
|
+
async embedBatch(texts, _inputType) {
|
|
30
|
+
if (texts.length === 0)
|
|
31
|
+
return [];
|
|
32
|
+
try {
|
|
33
|
+
// /api/embed accepts an array input and returns one vector per input.
|
|
34
|
+
const res = await this.client.embed({ model: this.config.embedding.model, input: texts });
|
|
35
|
+
if (res.embeddings?.length === texts.length)
|
|
36
|
+
return res.embeddings;
|
|
37
|
+
this.logger.warn('OllamaEmbedder: batch embed count mismatch, falling back to sequential', {
|
|
38
|
+
expected: texts.length, got: res.embeddings?.length ?? 0,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
this.logger.warn('OllamaEmbedder: batch embed failed, falling back to sequential', {
|
|
43
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Sequential fallback — the contract (one vector per input, in order) holds.
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const t of texts)
|
|
49
|
+
out.push(await this.embed(t));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
31
52
|
async listModels() {
|
|
32
53
|
const res = await this.client.list();
|
|
33
54
|
return res.models.map(m => m.name).filter(Boolean).sort();
|
|
34
55
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Pull the configured model if it is not already present locally, so a plain
|
|
58
|
+
* `rag-index` works after switching to (or back to) a local model that was
|
|
59
|
+
* never pulled — `rag-setup` is no longer the only place that fetches models.
|
|
60
|
+
* A tagless model name (`bge-m3`) matches its `:latest`/tagged install.
|
|
61
|
+
* Streams pull progress to the console: a live single-line bar on a TTY, or
|
|
62
|
+
* de-duplicated status lines otherwise.
|
|
63
|
+
*/
|
|
64
|
+
async ensureModelAvailable() {
|
|
65
|
+
const model = this.config.embedding.model;
|
|
66
|
+
try {
|
|
67
|
+
const res = await this.client.list();
|
|
68
|
+
const present = res.models.some(m => m.name === model || m.name.startsWith(`${model}:`));
|
|
69
|
+
if (present)
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// Can't list (Ollama unreachable?) — try the pull; its error is clearer.
|
|
74
|
+
this.logger.warn('OllamaEmbedder: could not list models before pull', {
|
|
75
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
console.log(`Pulling Ollama model "${model}" (not found locally)...`);
|
|
79
|
+
this.logger.info('OllamaEmbedder: pulling model', { model });
|
|
80
|
+
const stream = await this.client.pull({ model, stream: true });
|
|
81
|
+
const isTty = Boolean(process.stdout.isTTY);
|
|
82
|
+
let lastLen = 0;
|
|
83
|
+
let lastLog = '';
|
|
84
|
+
for await (const part of stream) {
|
|
85
|
+
const out = ' ' + OllamaEmbedder.formatPullProgress(part);
|
|
86
|
+
if (isTty) {
|
|
87
|
+
process.stdout.write('\r' + out.padEnd(lastLen));
|
|
88
|
+
lastLen = out.length;
|
|
89
|
+
}
|
|
90
|
+
else if (out !== lastLog) {
|
|
91
|
+
console.log(out);
|
|
92
|
+
lastLog = out;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (isTty && lastLen > 0)
|
|
96
|
+
process.stdout.write('\r' + ' '.repeat(lastLen) + '\r');
|
|
97
|
+
console.log(` ✓ ${model} ready.`);
|
|
41
98
|
}
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
documents,
|
|
51
|
-
top_n: topN,
|
|
52
|
-
truncate: true,
|
|
53
|
-
}),
|
|
54
|
-
});
|
|
55
|
-
if (!res.ok) {
|
|
56
|
-
const body = (await res.text().catch(() => res.statusText)).slice(0, 200);
|
|
57
|
-
throw new Error(`Ollama rerank failed: ${res.status} ${body}`);
|
|
99
|
+
static formatPullProgress(p) {
|
|
100
|
+
const status = p.status ?? 'pulling';
|
|
101
|
+
if (p.total && p.total > 0) {
|
|
102
|
+
const pct = Math.min(100, Math.floor(((p.completed ?? 0) / p.total) * 100));
|
|
103
|
+
const width = 24;
|
|
104
|
+
const filled = Math.round((pct / 100) * width);
|
|
105
|
+
const bar = '[' + '█'.repeat(filled) + '░'.repeat(width - filled) + ']';
|
|
106
|
+
return `${status} ${bar} ${pct}% (${((p.completed ?? 0) / 1e6).toFixed(1)}/${(p.total / 1e6).toFixed(1)} MB)`;
|
|
58
107
|
}
|
|
59
|
-
|
|
60
|
-
return data.results.map(r => r.index);
|
|
108
|
+
return status;
|
|
61
109
|
}
|
|
62
110
|
}
|
|
63
111
|
exports.OllamaEmbedder = OllamaEmbedder;
|