codesearch-mcp 0.0.17-alpha.1 → 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 +48 -0
- package/README.md +95 -6
- package/dist/app.js +70 -10
- package/dist/src/commands/ConfigCommand.d.ts +1 -0
- package/dist/src/commands/ConfigCommand.js +84 -4
- package/dist/src/commands/IndexCommand.js +35 -7
- package/dist/src/commands/McpCommand.js +9 -4
- package/dist/src/commands/{SetupCommand.d.ts → WebCommand.d.ts} +3 -2
- package/dist/src/commands/WebCommand.js +56 -0
- package/dist/src/config-store.d.ts +85 -4
- package/dist/src/config-store.js +157 -32
- package/dist/src/config.d.ts +32 -8
- package/dist/src/config.js +93 -30
- package/dist/src/constants.d.ts +35 -0
- package/dist/src/constants.js +57 -4
- package/dist/src/container.d.ts +10 -6
- package/dist/src/container.js +10 -5
- 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/graph.d.ts +60 -0
- package/dist/src/graph.js +239 -120
- package/dist/src/help.js +20 -5
- package/dist/src/interfaces/IAstParser.d.ts +20 -3
- package/dist/src/interfaces/IChunker.d.ts +24 -0
- package/dist/src/interfaces/IChunker.js +2 -0
- package/dist/src/interfaces/IDatabase.d.ts +27 -0
- package/dist/src/interfaces/IDatabase.js +2 -0
- package/dist/src/interfaces/IEmbedder.d.ts +15 -2
- package/dist/src/interfaces/ILogger.d.ts +10 -0
- package/dist/src/interfaces/ILogger.js +2 -0
- package/dist/src/interfaces/IMcpToolHandler.d.ts +22 -0
- package/dist/src/interfaces/IMcpToolHandler.js +2 -0
- package/dist/src/interfaces/IMonorepoResolver.d.ts +33 -0
- package/dist/src/interfaces/IMonorepoResolver.js +2 -0
- package/dist/src/interfaces/IProjectDiscovery.d.ts +28 -0
- package/dist/src/interfaces/IProjectDiscovery.js +2 -0
- package/dist/src/interfaces/ISearcher.d.ts +37 -0
- package/dist/src/interfaces/ISearcher.js +2 -0
- package/dist/src/interfaces/index.d.ts +8 -0
- package/dist/src/interfaces/index.js +2 -0
- package/dist/src/owner-path.d.ts +10 -0
- package/dist/src/owner-path.js +27 -0
- package/dist/src/project-selector.d.ts +1 -1
- package/dist/src/services/AstChunker.d.ts +31 -0
- package/dist/src/services/AstChunker.js +114 -0
- package/dist/src/services/AstParserService.d.ts +10 -6
- package/dist/src/services/AstParserService.js +60 -15
- package/dist/src/services/DatabaseService.d.ts +14 -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 +22 -12
- package/dist/src/services/IndexerService.js +106 -46
- package/dist/src/services/InfinityEmbedder.d.ts +79 -0
- package/dist/src/services/InfinityEmbedder.js +199 -0
- package/dist/src/services/LoggerService.d.ts +2 -1
- package/dist/src/services/McpServer.d.ts +16 -7
- package/dist/src/services/McpServer.js +54 -367
- package/dist/src/services/MonorepoAwareDiscoveryService.d.ts +62 -0
- package/dist/src/services/MonorepoAwareDiscoveryService.js +125 -0
- package/dist/src/services/NxMonorepoResolver.d.ts +45 -0
- package/dist/src/services/NxMonorepoResolver.js +308 -0
- package/dist/src/services/OllamaEmbedder.d.ts +19 -5
- package/dist/src/services/OllamaEmbedder.js +91 -36
- package/dist/src/services/OpenAICompatibleEmbedder.d.ts +25 -0
- package/dist/src/services/OpenAICompatibleEmbedder.js +75 -0
- package/dist/src/services/ProjectDiscoveryService.d.ts +8 -0
- package/dist/src/{env-loader.js → services/ProjectDiscoveryService.js} +16 -5
- package/dist/src/services/SearcherService.d.ts +46 -7
- package/dist/src/services/SearcherService.js +183 -53
- package/dist/src/services/VoyageEmbedder.d.ts +22 -0
- package/dist/src/services/VoyageEmbedder.js +72 -0
- package/dist/src/services/WebServerService.d.ts +77 -0
- package/dist/src/services/WebServerService.js +267 -0
- 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 +249 -31
- package/dist/src/tools/FindSymbolTool.d.ts +8 -0
- package/dist/src/tools/FindSymbolTool.js +45 -0
- package/dist/src/tools/GetCallersTool.d.ts +8 -0
- package/dist/src/tools/GetCallersTool.js +42 -0
- package/dist/src/tools/GetDependenciesTool.d.ts +8 -0
- package/dist/src/tools/GetDependenciesTool.js +38 -0
- package/dist/src/tools/GetDependentsTool.d.ts +8 -0
- package/dist/src/tools/GetDependentsTool.js +57 -0
- package/dist/src/tools/ListFilesTool.d.ts +8 -0
- package/dist/src/tools/ListFilesTool.js +39 -0
- package/dist/src/tools/ListProjectsTool.d.ts +8 -0
- package/dist/src/tools/ListProjectsTool.js +44 -0
- package/dist/src/tools/ReadFileRangeTool.d.ts +8 -0
- package/dist/src/tools/ReadFileRangeTool.js +44 -0
- package/dist/src/tools/ReadFileTool.d.ts +8 -0
- package/dist/src/tools/ReadFileTool.js +42 -0
- package/dist/src/tools/SearchCodebaseTool.d.ts +13 -0
- package/dist/src/tools/SearchCodebaseTool.js +81 -0
- package/dist/src/tools/format-helpers.d.ts +8 -0
- package/dist/src/tools/format-helpers.js +58 -0
- package/dist/src/tools/index.d.ts +9 -0
- package/dist/src/tools/index.js +21 -0
- package/dist/src/web/dashboard.d.ts +9 -0
- package/dist/src/web/dashboard.js +342 -0
- package/docker/docker-compose.infinity-gpu.yml +19 -0
- package/docker/docker-compose.yml +25 -0
- package/docs/web-dashboard.md +187 -0
- package/package.json +25 -3
- package/dist/src/ast.d.ts +0 -2
- package/dist/src/ast.js +0 -110
- 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/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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoyageEmbedder = void 0;
|
|
4
|
+
const LoggerService_1 = require("./LoggerService");
|
|
5
|
+
const config_1 = require("../config");
|
|
6
|
+
const reranker_1 = require("./reranker");
|
|
7
|
+
const http_retry_1 = require("./http-retry");
|
|
8
|
+
const constants_1 = require("../constants");
|
|
9
|
+
/**
|
|
10
|
+
* Voyage AI embedder. Voyage's schema differs from OpenAI's: `input` is an array
|
|
11
|
+
* of strings, `input_type` (`query` | `document`) is required for best quality,
|
|
12
|
+
* and the optional `output_dimension` requests Matryoshka truncation. Response
|
|
13
|
+
* is `{ data: [{ embedding, index }] }`.
|
|
14
|
+
*/
|
|
15
|
+
class VoyageEmbedder {
|
|
16
|
+
config;
|
|
17
|
+
logger;
|
|
18
|
+
dimensions;
|
|
19
|
+
rerank;
|
|
20
|
+
url;
|
|
21
|
+
doFetch;
|
|
22
|
+
baseDelayMs;
|
|
23
|
+
constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
const base = config.embedding.baseURL.replace(/\/+$/, '');
|
|
27
|
+
this.url = /\/embeddings$/.test(base) ? base : `${base}/embeddings`;
|
|
28
|
+
this.doFetch = deps.fetchImpl ?? fetch;
|
|
29
|
+
this.baseDelayMs = deps.baseDelayMs;
|
|
30
|
+
this.dimensions = config.embedding.dimensions ?? (0, constants_1.registryDimension)(config.embedding.model) ?? 0;
|
|
31
|
+
this.rerank = (0, reranker_1.makeReranker)(config, logger, deps);
|
|
32
|
+
}
|
|
33
|
+
async embed(text, inputType) {
|
|
34
|
+
const [v] = await this.embedBatch([text], inputType);
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
async embedBatch(texts, inputType = 'document') {
|
|
38
|
+
if (texts.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
return (0, http_retry_1.withRetry)(async () => {
|
|
41
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
42
|
+
const apiKey = (0, config_1.resolveEnvRef)(this.config.embedding.apiKey);
|
|
43
|
+
if (apiKey)
|
|
44
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
45
|
+
const body = {
|
|
46
|
+
model: this.config.embedding.model,
|
|
47
|
+
input: texts,
|
|
48
|
+
input_type: inputType,
|
|
49
|
+
};
|
|
50
|
+
if (this.config.embedding.dimensions != null)
|
|
51
|
+
body.output_dimension = this.config.embedding.dimensions;
|
|
52
|
+
const res = await this.doFetch(this.url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers,
|
|
55
|
+
signal: AbortSignal.timeout(120_000),
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const errBody = (await res.text().catch(() => res.statusText)).slice(0, 200);
|
|
60
|
+
throw new http_retry_1.HttpError(res.status, `Voyage embeddings request failed: ${res.status} ${errBody}`);
|
|
61
|
+
}
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (!Array.isArray(data.data) || data.data.length !== texts.length) {
|
|
64
|
+
throw new Error(`Voyage embeddings response returned ${data.data?.length ?? 0} vectors for ${texts.length} inputs`);
|
|
65
|
+
}
|
|
66
|
+
return [...data.data]
|
|
67
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
68
|
+
.map(d => d.embedding);
|
|
69
|
+
}, { logger: this.logger, baseDelayMs: this.baseDelayMs, label: 'voyage-embed' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.VoyageEmbedder = VoyageEmbedder;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Config } from '../config';
|
|
2
|
+
import type { ISearcher } from '../interfaces';
|
|
3
|
+
import type { IEmbedder } from '../interfaces';
|
|
4
|
+
import type { ILogger } from '../interfaces';
|
|
5
|
+
import { type JsonConfig } from '../config-store';
|
|
6
|
+
/**
|
|
7
|
+
* Per-field choice lists for the config form. `strict` → render a dropdown that
|
|
8
|
+
* only accepts a listed value (e.g. the embedding model, tied to the vector
|
|
9
|
+
* dimension). Non-strict → a combobox: the values are suggestions but free text
|
|
10
|
+
* is still allowed (e.g. any Ollama model the user has pulled).
|
|
11
|
+
*/
|
|
12
|
+
export interface FieldOption {
|
|
13
|
+
values: string[];
|
|
14
|
+
strict: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Placeholder shown to the browser instead of a real secret. */
|
|
17
|
+
export declare const PASSWORD_MASK = "********";
|
|
18
|
+
/**
|
|
19
|
+
* Merge a config submitted from the browser with the stored one. Any secret
|
|
20
|
+
* field (DB password, embedding/reranker API keys) still holding the mask means
|
|
21
|
+
* the user did not change it — restore the stored value rather than overwrite it
|
|
22
|
+
* with the placeholder. `env:` references are not masked and round-trip as-is.
|
|
23
|
+
* Pure — no I/O. Exported for unit testing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function mergeConfigForSave(incoming: JsonConfig, existing: JsonConfig | null): JsonConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Return a copy of the config with raw secrets (DB password, raw API keys)
|
|
28
|
+
* replaced by the mask, so real secrets never reach the browser. `env:`
|
|
29
|
+
* references are left verbatim — they are not secret and the dashboard shows
|
|
30
|
+
* them as "set via <VAR>". The masked value round-trips through
|
|
31
|
+
* `mergeConfigForSave`. Pure — no I/O. Exported for unit testing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function maskConfig(cfg: JsonConfig): JsonConfig;
|
|
34
|
+
/**
|
|
35
|
+
* Local HTTP dashboard. Serves a schema-driven config editor, a project
|
|
36
|
+
* overview, and the dependency graph. Bind to 127.0.0.1 only — no auth.
|
|
37
|
+
*/
|
|
38
|
+
export declare class WebServer {
|
|
39
|
+
private readonly searcher;
|
|
40
|
+
private readonly config;
|
|
41
|
+
private readonly logger;
|
|
42
|
+
private readonly embedder;
|
|
43
|
+
private server?;
|
|
44
|
+
constructor(searcher: ISearcher, config: Config, logger: ILogger, embedder: IEmbedder);
|
|
45
|
+
start(port: number, host: string): Promise<void>;
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
/** The bound address once started (e.g. `.port` after listen(0)); null otherwise. */
|
|
48
|
+
address(): {
|
|
49
|
+
port: number;
|
|
50
|
+
} | null;
|
|
51
|
+
private handle;
|
|
52
|
+
private maskedConfig;
|
|
53
|
+
private saveConfig;
|
|
54
|
+
/**
|
|
55
|
+
* Choice lists for model fields. Both are non-strict comboboxes (free text
|
|
56
|
+
* allowed): the embedding model suggests the known registry models plus any
|
|
57
|
+
* locally-installed models; the reranker model suggests installed models.
|
|
58
|
+
* Backends beyond the registry (e.g. arbitrary HuggingFace models on Infinity)
|
|
59
|
+
* still work — their dimension is probed at index time.
|
|
60
|
+
*/
|
|
61
|
+
private fieldOptions;
|
|
62
|
+
private installedModels;
|
|
63
|
+
/** Tabbed graph views (Workspace + one per nested Nx repo). */
|
|
64
|
+
private graphViews;
|
|
65
|
+
/** `/api/graph` — the Workspace view's raw `{ nodes, edges }` (back-compat). */
|
|
66
|
+
private graphData;
|
|
67
|
+
/**
|
|
68
|
+
* The full interactive graph page — the same HTML as the standalone
|
|
69
|
+
* `project-graph.html` (inner tabs, filters, legend, project toggles). The
|
|
70
|
+
* dashboard's Graph tab embeds this via an iframe rather than re-implementing
|
|
71
|
+
* vis-network.
|
|
72
|
+
*/
|
|
73
|
+
private graphPage;
|
|
74
|
+
private readBody;
|
|
75
|
+
private json;
|
|
76
|
+
private html;
|
|
77
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
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.WebServer = exports.PASSWORD_MASK = void 0;
|
|
37
|
+
exports.mergeConfigForSave = mergeConfigForSave;
|
|
38
|
+
exports.maskConfig = maskConfig;
|
|
39
|
+
const http = __importStar(require("http"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const config_store_1 = require("../config-store");
|
|
42
|
+
const constants_1 = require("../constants");
|
|
43
|
+
const graph_1 = require("../graph");
|
|
44
|
+
const dashboard_1 = require("../web/dashboard");
|
|
45
|
+
/** Placeholder shown to the browser instead of a real secret. */
|
|
46
|
+
exports.PASSWORD_MASK = '********';
|
|
47
|
+
const MAX_BODY = 1_000_000; // 1 MB — config payloads are tiny
|
|
48
|
+
/** A secret worth masking: a non-empty string that is not an `env:` reference. */
|
|
49
|
+
function isRawSecret(v) {
|
|
50
|
+
return typeof v === 'string' && v !== '' && !v.startsWith('env:');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Merge a config submitted from the browser with the stored one. Any secret
|
|
54
|
+
* field (DB password, embedding/reranker API keys) still holding the mask means
|
|
55
|
+
* the user did not change it — restore the stored value rather than overwrite it
|
|
56
|
+
* with the placeholder. `env:` references are not masked and round-trip as-is.
|
|
57
|
+
* Pure — no I/O. Exported for unit testing.
|
|
58
|
+
*/
|
|
59
|
+
function mergeConfigForSave(incoming, existing) {
|
|
60
|
+
const merged = JSON.parse(JSON.stringify(incoming ?? {}));
|
|
61
|
+
const restore = (grp, field) => {
|
|
62
|
+
const g = merged[grp];
|
|
63
|
+
if (g && g[field] === exports.PASSWORD_MASK) {
|
|
64
|
+
const prev = existing?.[grp]?.[field];
|
|
65
|
+
if (prev !== undefined)
|
|
66
|
+
g[field] = prev;
|
|
67
|
+
else
|
|
68
|
+
delete g[field];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
restore('db', 'password');
|
|
72
|
+
restore('embedding', 'apiKey');
|
|
73
|
+
restore('reranker', 'apiKey');
|
|
74
|
+
return merged;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Return a copy of the config with raw secrets (DB password, raw API keys)
|
|
78
|
+
* replaced by the mask, so real secrets never reach the browser. `env:`
|
|
79
|
+
* references are left verbatim — they are not secret and the dashboard shows
|
|
80
|
+
* them as "set via <VAR>". The masked value round-trips through
|
|
81
|
+
* `mergeConfigForSave`. Pure — no I/O. Exported for unit testing.
|
|
82
|
+
*/
|
|
83
|
+
function maskConfig(cfg) {
|
|
84
|
+
const clone = JSON.parse(JSON.stringify(cfg ?? {}));
|
|
85
|
+
if (clone.db?.password)
|
|
86
|
+
clone.db.password = exports.PASSWORD_MASK;
|
|
87
|
+
if (isRawSecret(clone.embedding?.apiKey))
|
|
88
|
+
clone.embedding.apiKey = exports.PASSWORD_MASK;
|
|
89
|
+
if (isRawSecret(clone.reranker?.apiKey))
|
|
90
|
+
clone.reranker.apiKey = exports.PASSWORD_MASK;
|
|
91
|
+
return clone;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Local HTTP dashboard. Serves a schema-driven config editor, a project
|
|
95
|
+
* overview, and the dependency graph. Bind to 127.0.0.1 only — no auth.
|
|
96
|
+
*/
|
|
97
|
+
class WebServer {
|
|
98
|
+
searcher;
|
|
99
|
+
config;
|
|
100
|
+
logger;
|
|
101
|
+
embedder;
|
|
102
|
+
server;
|
|
103
|
+
constructor(searcher, config, logger, embedder) {
|
|
104
|
+
this.searcher = searcher;
|
|
105
|
+
this.config = config;
|
|
106
|
+
this.logger = logger;
|
|
107
|
+
this.embedder = embedder;
|
|
108
|
+
}
|
|
109
|
+
async start(port, host) {
|
|
110
|
+
this.server = http.createServer((req, res) => {
|
|
111
|
+
this.handle(req, res).catch(err => {
|
|
112
|
+
this.logger.error('WebServer: request failed', { reason: err instanceof Error ? err.message : String(err) });
|
|
113
|
+
if (!res.headersSent) {
|
|
114
|
+
res.writeHead(500);
|
|
115
|
+
res.end('Internal error');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
this.server.once('error', reject);
|
|
121
|
+
this.server.listen(port, host, () => resolve());
|
|
122
|
+
});
|
|
123
|
+
this.logger.info('WebServer: listening', { host, port });
|
|
124
|
+
}
|
|
125
|
+
async stop() {
|
|
126
|
+
if (!this.server)
|
|
127
|
+
return;
|
|
128
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
129
|
+
this.server = undefined;
|
|
130
|
+
}
|
|
131
|
+
/** The bound address once started (e.g. `.port` after listen(0)); null otherwise. */
|
|
132
|
+
address() {
|
|
133
|
+
const a = this.server?.address();
|
|
134
|
+
return a && typeof a === 'object' ? { port: a.port } : null;
|
|
135
|
+
}
|
|
136
|
+
async handle(req, res) {
|
|
137
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
138
|
+
const p = url.pathname;
|
|
139
|
+
const m = req.method ?? 'GET';
|
|
140
|
+
if (m === 'GET' && p === '/')
|
|
141
|
+
return this.html(res, (0, dashboard_1.renderDashboardHtml)());
|
|
142
|
+
if (m === 'GET' && p === '/api/schema')
|
|
143
|
+
return this.json(res, 200, (0, config_store_1.buildJsonSchema)());
|
|
144
|
+
if (m === 'GET' && p === '/api/defaults')
|
|
145
|
+
return this.json(res, 200, config_store_1.CONFIG_DEFAULTS);
|
|
146
|
+
if (m === 'GET' && p === '/api/field-options')
|
|
147
|
+
return this.json(res, 200, await this.fieldOptions());
|
|
148
|
+
if (m === 'GET' && p === '/api/config')
|
|
149
|
+
return this.json(res, 200, this.maskedConfig());
|
|
150
|
+
if (m === 'PUT' && p === '/api/config')
|
|
151
|
+
return this.saveConfig(req, res);
|
|
152
|
+
if (m === 'GET' && p === '/api/projects')
|
|
153
|
+
return this.json(res, 200, await this.searcher.listProjects());
|
|
154
|
+
if (m === 'GET' && p === '/api/graph')
|
|
155
|
+
return this.json(res, 200, await this.graphData());
|
|
156
|
+
if (m === 'GET' && p === '/graph')
|
|
157
|
+
return this.html(res, await this.graphPage());
|
|
158
|
+
this.json(res, 404, { error: 'Not found' });
|
|
159
|
+
}
|
|
160
|
+
maskedConfig() {
|
|
161
|
+
return maskConfig((0, config_store_1.readConfigJson)() ?? {});
|
|
162
|
+
}
|
|
163
|
+
async saveConfig(req, res) {
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = await this.readBody(req);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
// Distinguish the size limit from other stream errors.
|
|
170
|
+
return this.json(res, 413, { error: e instanceof Error ? e.message : 'Request body too large' });
|
|
171
|
+
}
|
|
172
|
+
let incoming;
|
|
173
|
+
try {
|
|
174
|
+
incoming = JSON.parse(body);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return this.json(res, 400, { error: 'Invalid JSON body' });
|
|
178
|
+
}
|
|
179
|
+
const merged = mergeConfigForSave(incoming, (0, config_store_1.readConfigJson)());
|
|
180
|
+
let validated;
|
|
181
|
+
try {
|
|
182
|
+
validated = (0, config_store_1.parseConfig)(merged);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
return this.json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
186
|
+
}
|
|
187
|
+
(0, config_store_1.writeConfigJson)(validated);
|
|
188
|
+
this.logger.info('WebServer: config updated');
|
|
189
|
+
this.json(res, 200, { ok: true });
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Choice lists for model fields. Both are non-strict comboboxes (free text
|
|
193
|
+
* allowed): the embedding model suggests the known registry models plus any
|
|
194
|
+
* locally-installed models; the reranker model suggests installed models.
|
|
195
|
+
* Backends beyond the registry (e.g. arbitrary HuggingFace models on Infinity)
|
|
196
|
+
* still work — their dimension is probed at index time.
|
|
197
|
+
*/
|
|
198
|
+
async fieldOptions() {
|
|
199
|
+
const installed = await this.installedModels();
|
|
200
|
+
const registryModels = Object.keys(constants_1.MODEL_REGISTRY);
|
|
201
|
+
return {
|
|
202
|
+
'embedding.model': { values: [...new Set([...registryModels, ...installed])], strict: false },
|
|
203
|
+
'reranker.model': { values: installed, strict: false },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async installedModels() {
|
|
207
|
+
try {
|
|
208
|
+
return (await this.embedder.listModels?.()) ?? [];
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
this.logger.warn('WebServer: could not list models', { reason: err instanceof Error ? err.message : String(err) });
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Tabbed graph views (Workspace + one per nested Nx repo). */
|
|
216
|
+
async graphViews() {
|
|
217
|
+
const projects = (await this.searcher.listProjects()).map(p => ({
|
|
218
|
+
name: p.serviceName,
|
|
219
|
+
// Real project root (resolved via discovery — handles nested-monorepo
|
|
220
|
+
// names). Fall back to the flat assumption if listProjects omits it.
|
|
221
|
+
path: p.path ?? path.join(this.config.workspaceRoot, p.serviceName),
|
|
222
|
+
}));
|
|
223
|
+
const projectEdges = await this.searcher.getProjectEdges();
|
|
224
|
+
return (0, graph_1.buildGraphViews)(projects, projectEdges, this.config.workspaceRoot);
|
|
225
|
+
}
|
|
226
|
+
/** `/api/graph` — the Workspace view's raw `{ nodes, edges }` (back-compat). */
|
|
227
|
+
async graphData() {
|
|
228
|
+
const views = await this.graphViews();
|
|
229
|
+
return { nodes: views[0].nodes, edges: views[0].edges };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The full interactive graph page — the same HTML as the standalone
|
|
233
|
+
* `project-graph.html` (inner tabs, filters, legend, project toggles). The
|
|
234
|
+
* dashboard's Graph tab embeds this via an iframe rather than re-implementing
|
|
235
|
+
* vis-network.
|
|
236
|
+
*/
|
|
237
|
+
async graphPage() {
|
|
238
|
+
return (0, graph_1.buildHtml)(await this.graphViews());
|
|
239
|
+
}
|
|
240
|
+
readBody(req) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
let data = '';
|
|
243
|
+
let tooLarge = false;
|
|
244
|
+
req.on('data', chunk => {
|
|
245
|
+
if (tooLarge)
|
|
246
|
+
return; // stop growing `data`; keep the socket alive so the 413 flushes
|
|
247
|
+
data += chunk;
|
|
248
|
+
if (data.length > MAX_BODY) {
|
|
249
|
+
tooLarge = true;
|
|
250
|
+
reject(new Error('Request body too large (max 1 MB)'));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
req.on('end', () => resolve(data));
|
|
254
|
+
req.on('error', reject);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
json(res, status, body) {
|
|
258
|
+
const s = JSON.stringify(body);
|
|
259
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(s) });
|
|
260
|
+
res.end(s);
|
|
261
|
+
}
|
|
262
|
+
html(res, body) {
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
264
|
+
res.end(body);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
exports.WebServer = WebServer;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ILogger } from '../interfaces/ILogger';
|
|
2
|
+
export declare class HttpError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
constructor(status: number, message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface RetryOpts {
|
|
7
|
+
/** Total attempts including the first (default 4). */
|
|
8
|
+
attempts?: number;
|
|
9
|
+
/** Base backoff in ms; grows exponentially (default 500). Set 0 in tests. */
|
|
10
|
+
baseDelayMs?: number;
|
|
11
|
+
logger?: ILogger;
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` with bounded exponential backoff on HTTP 429/5xx and network errors.
|
|
16
|
+
* Re-throws the last error once attempts are exhausted or the error is not
|
|
17
|
+
* transient.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOpts): Promise<T>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HttpError = void 0;
|
|
4
|
+
exports.withRetry = withRetry;
|
|
5
|
+
class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'HttpError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.HttpError = HttpError;
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return ms > 0 ? new Promise(r => setTimeout(r, ms)) : Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
/** True for transient failures worth retrying (rate limit / server error / network / timeout). */
|
|
18
|
+
function isRetryable(err) {
|
|
19
|
+
if (err instanceof HttpError)
|
|
20
|
+
return err.status === 429 || (err.status >= 500 && err.status <= 599);
|
|
21
|
+
// A request timeout (AbortSignal.timeout) rejects with a DOMException named
|
|
22
|
+
// 'TimeoutError' — the most common transient cloud failure, so retry it.
|
|
23
|
+
if (err != null && typeof err === 'object' && err.name === 'TimeoutError')
|
|
24
|
+
return true;
|
|
25
|
+
// Network-level errors (fetch throws) are transient too.
|
|
26
|
+
return err instanceof TypeError;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` with bounded exponential backoff on HTTP 429/5xx and network errors.
|
|
30
|
+
* Re-throws the last error once attempts are exhausted or the error is not
|
|
31
|
+
* transient.
|
|
32
|
+
*/
|
|
33
|
+
async function withRetry(fn, opts = {}) {
|
|
34
|
+
const attempts = opts.attempts ?? 4;
|
|
35
|
+
const base = opts.baseDelayMs ?? 500;
|
|
36
|
+
let lastErr;
|
|
37
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
lastErr = err;
|
|
43
|
+
if (attempt >= attempts || !isRetryable(err))
|
|
44
|
+
break;
|
|
45
|
+
const delay = base * Math.pow(2, attempt - 1);
|
|
46
|
+
opts.logger?.warn('http-retry: transient failure, retrying', {
|
|
47
|
+
label: opts.label, attempt, attempts, delayMs: delay,
|
|
48
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
49
|
+
});
|
|
50
|
+
await sleep(delay);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw lastErr;
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config } from '../config';
|
|
2
|
+
import type { ILogger } from '../interfaces/ILogger';
|
|
3
|
+
/**
|
|
4
|
+
* Build a rerank function from `config.reranker`, or return `undefined` when
|
|
5
|
+
* reranking is disabled / unconfigured. Targets an OpenAI-compatible `/rerank`
|
|
6
|
+
* endpoint returning `{ results: [{ index }] }` (Infinity, TEI, vLLM,
|
|
7
|
+
* Jina/Cohere-shape). Ollama has NO rerank API, so it is not a valid backend
|
|
8
|
+
* here — the embedding provider is independent of the rerank server
|
|
9
|
+
* (`reranker.baseURL`), so Ollama embeddings + an Infinity reranker is fine.
|
|
10
|
+
* Returns original indices into `documents`, best-first.
|
|
11
|
+
*/
|
|
12
|
+
export declare function makeReranker(config: Config, logger: ILogger, deps?: {
|
|
13
|
+
fetchImpl?: typeof fetch;
|
|
14
|
+
baseDelayMs?: number;
|
|
15
|
+
}): ((query: string, documents: string[], topN: number) => Promise<number[]>) | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeReranker = makeReranker;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const http_retry_1 = require("./http-retry");
|
|
6
|
+
/**
|
|
7
|
+
* Build a rerank function from `config.reranker`, or return `undefined` when
|
|
8
|
+
* reranking is disabled / unconfigured. Targets an OpenAI-compatible `/rerank`
|
|
9
|
+
* endpoint returning `{ results: [{ index }] }` (Infinity, TEI, vLLM,
|
|
10
|
+
* Jina/Cohere-shape). Ollama has NO rerank API, so it is not a valid backend
|
|
11
|
+
* here — the embedding provider is independent of the rerank server
|
|
12
|
+
* (`reranker.baseURL`), so Ollama embeddings + an Infinity reranker is fine.
|
|
13
|
+
* Returns original indices into `documents`, best-first.
|
|
14
|
+
*/
|
|
15
|
+
function makeReranker(config, logger, deps = {}) {
|
|
16
|
+
const r = config.reranker;
|
|
17
|
+
if (!r.enabled)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!r.model) {
|
|
20
|
+
logger.warn('reranker: enabled=true but reranker.model is not set — reranking disabled');
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
24
|
+
return async (query, documents, topN) => {
|
|
25
|
+
return (0, http_retry_1.withRetry)(async () => {
|
|
26
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
27
|
+
const apiKey = (0, config_1.resolveEnvRef)(r.apiKey);
|
|
28
|
+
if (apiKey)
|
|
29
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
30
|
+
const res = await doFetch(r.url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers,
|
|
33
|
+
signal: AbortSignal.timeout(30_000),
|
|
34
|
+
body: JSON.stringify({ model: r.model, query, documents, top_n: topN, truncate: true }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = (await res.text().catch(() => res.statusText)).slice(0, 200);
|
|
38
|
+
throw new http_retry_1.HttpError(res.status, `rerank failed: ${res.status} ${body}`);
|
|
39
|
+
}
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
return data.results.map(x => x.index);
|
|
42
|
+
}, { logger, baseDelayMs: deps.baseDelayMs, label: 'rerank' });
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/src/setup.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { LoggerService } from './services/LoggerService';
|
|
2
|
+
/**
|
|
3
|
+
* Default answer for the "start Docker containers?" prompt: no only when both
|
|
4
|
+
* the database and the embedding backend are already reachable. Pure — exported
|
|
5
|
+
* for unit testing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function dockerPromptDefault(dbReachable: boolean, embReachable: boolean): 'y' | 'n';
|
|
2
8
|
export declare function runSetup(logger?: LoggerService): Promise<void>;
|