n2-qln 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js ADDED
@@ -0,0 +1,65 @@
1
+ // QLN — Config loader (default + local deep merge)
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ /** Default configuration */
6
+ const defaults = {
7
+ /** Data directory (SQLite, indices, etc.) */
8
+ dataDir: path.join(__dirname, '..', 'data'),
9
+
10
+ /** Embedding configuration */
11
+ embedding: {
12
+ enabled: true,
13
+ model: 'nomic-embed-text',
14
+ endpoint: 'http://127.0.0.1:11434',
15
+ },
16
+
17
+ /** Tool execution configuration */
18
+ executor: {
19
+ httpEndpoint: null,
20
+ timeout: 20000,
21
+ },
22
+
23
+ /** Search configuration */
24
+ search: {
25
+ defaultTopK: 5,
26
+ threshold: 0.1,
27
+ },
28
+ };
29
+
30
+ /**
31
+ * Load config — apply config.local.js overrides on top of defaults.
32
+ * @returns {object} Merged config
33
+ */
34
+ function loadConfig() {
35
+ const config = JSON.parse(JSON.stringify(defaults));
36
+ const localPath = path.join(__dirname, '..', 'config.local.js');
37
+
38
+ if (fs.existsSync(localPath)) {
39
+ try {
40
+ const local = require(localPath);
41
+ deepMerge(config, local);
42
+ } catch (e) {
43
+ console.warn(`[QLN] config.local.js load failed: ${e.message}`);
44
+ }
45
+ }
46
+ return config;
47
+ }
48
+
49
+ /**
50
+ * Deep merge (merge source into target).
51
+ * @param {object} target
52
+ * @param {object} source
53
+ */
54
+ function deepMerge(target, source) {
55
+ for (const key of Object.keys(source)) {
56
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])
57
+ && target[key] && typeof target[key] === 'object') {
58
+ deepMerge(target[key], source[key]);
59
+ } else {
60
+ target[key] = source[key];
61
+ }
62
+ }
63
+ }
64
+
65
+ module.exports = { loadConfig, defaults };
@@ -0,0 +1,131 @@
1
+ // QLN — Embedding engine (Ollama nomic-embed-text)
2
+ // Vector embedding generation for semantic search (Stage 3)
3
+ const http = require('http');
4
+
5
+ /**
6
+ * Ollama-based local embedding engine.
7
+ * Graceful degradation when unavailable — Stage 1+2 still work.
8
+ */
9
+ class Embedding {
10
+ /**
11
+ * @param {object} config
12
+ * @param {string} [config.model='nomic-embed-text'] - Ollama model
13
+ * @param {string} [config.endpoint='http://127.0.0.1:11434'] - Ollama endpoint
14
+ */
15
+ constructor(config = {}) {
16
+ this.model = config.model || 'nomic-embed-text';
17
+ this.endpoint = config.endpoint || 'http://127.0.0.1:11434';
18
+ this.dimensions = null;
19
+ this._available = null;
20
+ }
21
+
22
+ /**
23
+ * Check Ollama availability (cached).
24
+ * @returns {Promise<boolean>}
25
+ */
26
+ async isAvailable() {
27
+ if (this._available !== null) return this._available;
28
+ try {
29
+ const vec = await this.embed('test');
30
+ this._available = vec.length > 0;
31
+ this.dimensions = vec.length;
32
+ return this._available;
33
+ } catch {
34
+ this._available = false;
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Generate vector embedding from text.
41
+ * @param {string} text
42
+ * @returns {Promise<number[]>}
43
+ */
44
+ async embed(text) {
45
+ if (!text || text.trim().length === 0) return [];
46
+ const input = text.length > 2000 ? text.slice(0, 2000) : text;
47
+
48
+ for (const apiPath of ['/api/embeddings', '/api/embed']) {
49
+ try {
50
+ const body = apiPath === '/api/embeddings'
51
+ ? { model: this.model, prompt: input }
52
+ : { model: this.model, input: input };
53
+ const result = await this._post(apiPath, body);
54
+
55
+ if (result.embedding && Array.isArray(result.embedding)) {
56
+ this.dimensions = result.embedding.length;
57
+ return result.embedding;
58
+ }
59
+ if (result.embeddings && Array.isArray(result.embeddings) && result.embeddings[0]) {
60
+ this.dimensions = result.embeddings[0].length;
61
+ return result.embeddings[0];
62
+ }
63
+ } catch { continue; }
64
+ }
65
+ return [];
66
+ }
67
+
68
+ /**
69
+ * Batch embedding generation.
70
+ * @param {string[]} texts
71
+ * @returns {Promise<number[][]>}
72
+ */
73
+ async embedBatch(texts) {
74
+ const results = [];
75
+ for (const text of texts) {
76
+ results.push(await this.embed(text));
77
+ }
78
+ return results;
79
+ }
80
+
81
+ /**
82
+ * Cosine similarity between two vectors.
83
+ * @param {number[]} a
84
+ * @param {number[]} b
85
+ * @returns {number} 0~1
86
+ */
87
+ cosineSimilarity(a, b) {
88
+ if (!a || !b || a.length === 0 || a.length !== b.length) return 0;
89
+ let dot = 0, normA = 0, normB = 0;
90
+ for (let i = 0; i < a.length; i++) {
91
+ dot += a[i] * b[i];
92
+ normA += a[i] * a[i];
93
+ normB += b[i] * b[i];
94
+ }
95
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
96
+ return denom === 0 ? 0 : dot / denom;
97
+ }
98
+
99
+ /** @private HTTP POST to Ollama API */
100
+ _post(apiPath, body) {
101
+ return new Promise((resolve, reject) => {
102
+ const url = new URL(this.endpoint);
103
+ const options = {
104
+ hostname: url.hostname,
105
+ port: url.port || 11434,
106
+ path: apiPath,
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ timeout: 30000,
110
+ };
111
+ const req = http.request(options, res => {
112
+ let data = '';
113
+ res.on('data', chunk => { data += chunk; });
114
+ res.on('end', () => {
115
+ if (res.statusCode >= 400) {
116
+ reject(new Error(`Ollama ${res.statusCode}: ${data.slice(0, 200)}`));
117
+ return;
118
+ }
119
+ try { resolve(JSON.parse(data)); }
120
+ catch { reject(new Error(`Invalid JSON: ${data.slice(0, 100)}`)); }
121
+ });
122
+ });
123
+ req.on('error', reject);
124
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
125
+ req.write(JSON.stringify(body));
126
+ req.end();
127
+ });
128
+ }
129
+ }
130
+
131
+ module.exports = { Embedding };
@@ -0,0 +1,104 @@
1
+ // QLN — L3 tool executor
2
+ // Execute tools via HTTP (localhost) or registered local handlers
3
+ const http = require('http');
4
+
5
+ /**
6
+ * Tool executor — HTTP proxy + local function calls.
7
+ *
8
+ * Execution priority:
9
+ * 1. Local registered handler (via addHandler)
10
+ * 2. HTTP proxy (when endpoint configured)
11
+ */
12
+ class Executor {
13
+ /**
14
+ * @param {object} config
15
+ * @param {string} [config.httpEndpoint] - Tool execution HTTP endpoint (e.g. "http://127.0.0.1:PORT")
16
+ * @param {number} [config.timeout=20000] - HTTP timeout (ms)
17
+ */
18
+ constructor(config = {}) {
19
+ this._httpEndpoint = config.httpEndpoint || null;
20
+ this._timeout = config.timeout || 20000;
21
+ /** @type {Map<string, Function>} Local handlers */
22
+ this._handlers = new Map();
23
+ }
24
+
25
+ /**
26
+ * Register a local tool handler.
27
+ * @param {string} name - Tool name
28
+ * @param {Function} handler - (args) => Promise<unknown>
29
+ */
30
+ addHandler(name, handler) {
31
+ this._handlers.set(name, handler);
32
+ }
33
+
34
+ /**
35
+ * Execute a tool.
36
+ * @param {string} name - Tool name
37
+ * @param {object} args - Tool arguments
38
+ * @returns {Promise<{result: unknown, source: string, elapsed: number}>}
39
+ */
40
+ async exec(name, args = {}) {
41
+ const t0 = Date.now();
42
+
43
+ // 1. Local handler first
44
+ if (this._handlers.has(name)) {
45
+ const handler = this._handlers.get(name);
46
+ const result = await handler(args);
47
+ return { result, source: 'local', elapsed: Date.now() - t0 };
48
+ }
49
+
50
+ // 2. HTTP proxy
51
+ if (this._httpEndpoint) {
52
+ const result = await this._execHttp(name, args);
53
+ return { result, source: 'http', elapsed: Date.now() - t0 };
54
+ }
55
+
56
+ throw new Error(`No handler found for tool: ${name}. Register with addHandler() or set httpEndpoint.`);
57
+ }
58
+
59
+ /**
60
+ * Dynamically set HTTP endpoint.
61
+ * @param {string} endpoint - "http://127.0.0.1:PORT" format
62
+ */
63
+ setHttpEndpoint(endpoint) {
64
+ this._httpEndpoint = endpoint;
65
+ }
66
+
67
+ /** @private HTTP POST /call → tool execution */
68
+ _execHttp(name, args) {
69
+ return new Promise((resolve, reject) => {
70
+ const url = new URL(this._httpEndpoint);
71
+ const bodyStr = JSON.stringify({ tool: name, args });
72
+ const timer = setTimeout(() => reject(new Error(`timeout (${this._timeout}ms)`)), this._timeout);
73
+
74
+ const req = http.request({
75
+ hostname: url.hostname,
76
+ port: url.port,
77
+ path: '/call',
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ 'Content-Length': Buffer.byteLength(bodyStr),
82
+ },
83
+ }, (res) => {
84
+ let body = '';
85
+ res.on('data', c => body += c);
86
+ res.on('end', () => {
87
+ clearTimeout(timer);
88
+ try {
89
+ const parsed = JSON.parse(body);
90
+ if (parsed.error) reject(new Error(parsed.error));
91
+ else resolve(parsed.result);
92
+ } catch {
93
+ reject(new Error(`Invalid response: ${body.slice(0, 200)}`));
94
+ }
95
+ });
96
+ });
97
+ req.on('error', e => { clearTimeout(timer); reject(e); });
98
+ req.write(bodyStr);
99
+ req.end();
100
+ });
101
+ }
102
+ }
103
+
104
+ module.exports = { Executor };
@@ -0,0 +1,217 @@
1
+ // QLN — L2 tool index (memory cache + SQLite persistence)
2
+ // Tool CRUD, batch registration, embedding precomputation, usage tracking
3
+ const { createToolEntry, buildSearchText } = require('./schema');
4
+
5
+ /**
6
+ * Tool registry — in-memory cache (Map) + SQLite persistence.
7
+ * Designed for up to 1000 tools.
8
+ */
9
+ class Registry {
10
+ /**
11
+ * @param {import('./store').Store} store - SQLite store
12
+ * @param {import('./embedding').Embedding} [embedding] - Embedding engine (optional)
13
+ */
14
+ constructor(store, embedding = null) {
15
+ this._store = store;
16
+ this._embedding = embedding;
17
+ /** @type {Map<string, object>} name → tool entry */
18
+ this._cache = new Map();
19
+ }
20
+
21
+ /** Load all entries from SQLite into memory cache */
22
+ load() {
23
+ this._cache.clear();
24
+ const rows = this._store.loadAll();
25
+ for (const row of rows) {
26
+ this._cache.set(row.name, this._rowToEntry(row));
27
+ }
28
+ }
29
+
30
+ // ── CRUD ──
31
+
32
+ /**
33
+ * Register a tool (update if exists, preserve existing stats).
34
+ * @param {object} raw - Raw tool data
35
+ * @returns {object} Normalized tool entry
36
+ */
37
+ register(raw) {
38
+ const entry = createToolEntry(raw);
39
+ const existing = this._cache.get(entry.name);
40
+ if (existing) {
41
+ entry.usageCount = existing.usageCount || entry.usageCount;
42
+ entry.successRate = existing.successRate ?? entry.successRate;
43
+ if (existing.embedding && !entry.embedding) {
44
+ entry.embedding = existing.embedding;
45
+ }
46
+ }
47
+ entry.searchText = buildSearchText(entry);
48
+ this._cache.set(entry.name, entry);
49
+ this._store.upsert(entry);
50
+ return entry;
51
+ }
52
+
53
+ /**
54
+ * Batch register tools.
55
+ * @param {object[]} tools
56
+ * @returns {number} Number of registered tools
57
+ */
58
+ registerBatch(tools) {
59
+ let count = 0;
60
+ for (const raw of tools) {
61
+ try { this.register(raw); count++; }
62
+ catch { /* skip invalid */ }
63
+ }
64
+ return count;
65
+ }
66
+
67
+ /**
68
+ * Remove a tool.
69
+ * @param {string} name
70
+ * @returns {boolean}
71
+ */
72
+ remove(name) {
73
+ const had = this._cache.has(name);
74
+ this._cache.delete(name);
75
+ if (had) this._store.remove(name);
76
+ return had;
77
+ }
78
+
79
+ /**
80
+ * Purge all tools by source (for re-sync).
81
+ * @param {string} source
82
+ * @returns {number} Number deleted
83
+ */
84
+ purgeBySource(source) {
85
+ let deleted = 0;
86
+ for (const [name, entry] of this._cache) {
87
+ if (entry.source === source) {
88
+ this._cache.delete(name);
89
+ deleted++;
90
+ }
91
+ }
92
+ this._store.purgeBySource(source);
93
+ return deleted;
94
+ }
95
+
96
+ /** @param {string} name @returns {object|null} */
97
+ get(name) { return this._cache.get(name) || null; }
98
+
99
+ /** @returns {object[]} */
100
+ getAll() { return Array.from(this._cache.values()); }
101
+
102
+ /** @returns {number} */
103
+ get size() { return this._cache.size; }
104
+
105
+ /**
106
+ * Remove all tools by provider name.
107
+ * @param {string} providerName
108
+ * @returns {number} Number of tools removed
109
+ */
110
+ removeByProvider(providerName) {
111
+ const toRemove = [];
112
+ for (const [name, entry] of this._cache) {
113
+ if (entry.provider === providerName) toRemove.push(name);
114
+ }
115
+ for (const name of toRemove) {
116
+ this.remove(name);
117
+ }
118
+ return toRemove.length;
119
+ }
120
+
121
+ // ── Embeddings ──
122
+
123
+ /**
124
+ * Precompute embeddings for tools without one.
125
+ * @returns {Promise<{embedded: number, skipped: number, failed: number}>}
126
+ */
127
+ async precomputeEmbeddings() {
128
+ if (!this._embedding) return { embedded: 0, skipped: 0, failed: 0 };
129
+ const available = await this._embedding.isAvailable();
130
+ if (!available) return { embedded: 0, skipped: 0, failed: 0 };
131
+
132
+ let embedded = 0, skipped = 0, failed = 0;
133
+ for (const [, entry] of this._cache) {
134
+ if (entry.embedding) { skipped++; continue; }
135
+ try {
136
+ const text = entry.searchText || buildSearchText(entry);
137
+ const vec = await this._embedding.embed(text);
138
+ if (vec.length > 0) {
139
+ entry.embedding = vec;
140
+ this._store.upsert(entry);
141
+ embedded++;
142
+ } else { failed++; }
143
+ } catch { failed++; }
144
+ }
145
+ return { embedded, skipped, failed };
146
+ }
147
+
148
+ // ── Usage tracking ──
149
+
150
+ /**
151
+ * Record tool usage.
152
+ * @param {string} name
153
+ * @param {boolean} success
154
+ */
155
+ recordUsage(name, success = true) {
156
+ const entry = this._cache.get(name);
157
+ if (!entry) return;
158
+ entry.usageCount++;
159
+ const alpha = 0.1;
160
+ entry.successRate = entry.successRate * (1 - alpha) + (success ? 1 : 0) * alpha;
161
+ entry.updatedAt = new Date().toISOString();
162
+ this._store.upsert(entry);
163
+ }
164
+
165
+ // ── Stats ──
166
+
167
+ /** @returns {object} */
168
+ stats() {
169
+ const bySource = {};
170
+ const byCategory = {};
171
+ let withEmbedding = 0;
172
+ for (const entry of this._cache.values()) {
173
+ bySource[entry.source] = (bySource[entry.source] || 0) + 1;
174
+ byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
175
+ if (entry.embedding) withEmbedding++;
176
+ }
177
+ return {
178
+ total: this._cache.size,
179
+ bySource,
180
+ byCategory,
181
+ withEmbedding,
182
+ embeddingCoverage: this._cache.size > 0
183
+ ? Math.round((withEmbedding / this._cache.size) * 100) + '%' : '0%',
184
+ };
185
+ }
186
+
187
+ // ── Internal ──
188
+
189
+ /** Convert SQLite row to tool entry */
190
+ _rowToEntry(row) {
191
+ return {
192
+ name: row.name,
193
+ description: row.description || '',
194
+ source: row.source || 'unknown',
195
+ category: row.category || 'misc',
196
+ provider: row.provider || row.plugin_name || '',
197
+ inputSchema: _parseJson(row.input_schema, null),
198
+ triggers: _parseJson(row.triggers, []),
199
+ tags: _parseJson(row.tags, []),
200
+ examples: _parseJson(row.examples, []),
201
+ endpoint: row.endpoint || '',
202
+ searchText: row.search_text || '',
203
+ embedding: _parseJson(row.embedding, null),
204
+ usageCount: row.usage_count || 0,
205
+ successRate: row.success_rate ?? 1.0,
206
+ registeredAt: row.registered_at,
207
+ updatedAt: row.updated_at,
208
+ };
209
+ }
210
+ }
211
+
212
+ function _parseJson(str, fallback) {
213
+ if (!str || str === '') return fallback;
214
+ try { return JSON.parse(str); } catch { return fallback; }
215
+ }
216
+
217
+ module.exports = { Registry };
package/lib/router.js ADDED
@@ -0,0 +1,160 @@
1
+ // QLN — L1 Router (3-Stage parallel search engine)
2
+ // Query → Stage1(Trigger) + Stage2(Keyword) + Stage3(Semantic) → Merge → Top-K
3
+ const { buildSearchText } = require('./schema');
4
+
5
+ /**
6
+ * 3-Stage search engine.
7
+ *
8
+ * Score formula:
9
+ * final = trigger×3.0 + keyword×1.0 + semantic×2.0
10
+ * + log2(usageCount+1)×0.5 + successRate×1.0
11
+ */
12
+ class Router {
13
+ /**
14
+ * @param {import('./registry').Registry} registry
15
+ * @param {import('./vector-index').VectorIndex} vectorIndex
16
+ * @param {import('./embedding').Embedding} [embedding]
17
+ */
18
+ constructor(registry, vectorIndex, embedding = null) {
19
+ this._registry = registry;
20
+ this._vectorIndex = vectorIndex;
21
+ this._embedding = embedding;
22
+ }
23
+
24
+ /**
25
+ * Route natural language query to tools.
26
+ * @param {string} query - Natural language (e.g. "take a screenshot")
27
+ * @param {{topK?: number, threshold?: number}} [options]
28
+ * @returns {Promise<{results: object[], timing: object}>}
29
+ */
30
+ async route(query, options = {}) {
31
+ const topK = options.topK || 5;
32
+ const threshold = options.threshold || 0.1;
33
+ const scores = new Map();
34
+ const timing = { stage1: 0, stage2: 0, stage3: 0, merge: 0, total: 0 };
35
+ const t0 = Date.now();
36
+
37
+ // Stage 1: Trigger exact match (fastest)
38
+ const t1 = Date.now();
39
+ this._stage1TriggerMatch(query, scores);
40
+ timing.stage1 = Date.now() - t1;
41
+
42
+ // Stage 2: Keyword match (search_text LIKE)
43
+ const t2 = Date.now();
44
+ this._stage2KeywordMatch(query, scores);
45
+ timing.stage2 = Date.now() - t2;
46
+
47
+ // Stage 3: Semantic vector search (when embedding available)
48
+ const t3 = Date.now();
49
+ await this._stage3SemanticSearch(query, scores);
50
+ timing.stage3 = Date.now() - t3;
51
+
52
+ // Merge: Calculate final scores
53
+ const t4 = Date.now();
54
+ const results = this._mergeAndRank(scores, topK, threshold);
55
+ timing.merge = Date.now() - t4;
56
+ timing.total = Date.now() - t0;
57
+
58
+ return { results, timing };
59
+ }
60
+
61
+ /** Stage 1: Trigger word exact match. Weight: 3.0 */
62
+ _stage1TriggerMatch(query, scores) {
63
+ const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 1);
64
+ for (const tool of this._registry.getAll()) {
65
+ const triggers = tool.triggers || [];
66
+ let hits = 0;
67
+ for (const word of queryWords) {
68
+ if (triggers.some(t => t === word || t.includes(word))) hits++;
69
+ }
70
+ if (hits > 0) {
71
+ this._getOrCreate(scores, tool.name).stage1 = hits * 3.0;
72
+ }
73
+ }
74
+ }
75
+
76
+ /** Stage 2: search_text keyword match. Weight: 1.0 */
77
+ _stage2KeywordMatch(query, scores) {
78
+ const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
79
+ for (const tool of this._registry.getAll()) {
80
+ const text = (tool.searchText || buildSearchText(tool)).toLowerCase();
81
+ let matchCount = 0;
82
+ for (const word of queryWords) {
83
+ if (text.includes(word)) matchCount++;
84
+ }
85
+ if (matchCount > 0) {
86
+ this._getOrCreate(scores, tool.name).stage2 =
87
+ (matchCount / Math.max(queryWords.length, 1)) * 1.0;
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Stage 3: Semantic vector search. Weight: 2.0 */
93
+ async _stage3SemanticSearch(query, scores) {
94
+ if (!this._embedding || !this._vectorIndex) return;
95
+ try {
96
+ const available = await this._embedding.isAvailable();
97
+ if (!available) return;
98
+ const queryVec = await this._embedding.embed(query);
99
+ if (!queryVec || queryVec.length === 0) return;
100
+ const semanticResults = this._vectorIndex.search(queryVec, 20);
101
+ for (const r of semanticResults) {
102
+ this._getOrCreate(scores, r.name).stage3 = r.score * 2.0;
103
+ }
104
+ } catch { /* graceful degradation */ }
105
+ }
106
+
107
+ /** Merge all stage results + usage/success bonus → ranking */
108
+ _mergeAndRank(scores, topK, threshold) {
109
+ const results = [];
110
+ for (const [name, s] of scores) {
111
+ const tool = this._registry.get(name);
112
+ if (!tool) continue;
113
+ const usageBonus = Math.log2((tool.usageCount || 0) + 1) * 0.5;
114
+ const successBonus = (tool.successRate ?? 1.0) * 1.0;
115
+ const finalScore = (s.stage1 || 0) + (s.stage2 || 0) + (s.stage3 || 0)
116
+ + usageBonus + successBonus;
117
+ if (finalScore >= threshold) {
118
+ results.push({
119
+ name,
120
+ score: Math.round(finalScore * 100) / 100,
121
+ stages: {
122
+ trigger: s.stage1 || 0,
123
+ keyword: s.stage2 || 0,
124
+ semantic: s.stage3 || 0,
125
+ usage: Math.round(usageBonus * 100) / 100,
126
+ success: Math.round(successBonus * 100) / 100,
127
+ },
128
+ description: tool.description,
129
+ source: tool.source,
130
+ category: tool.category,
131
+ inputSchema: tool.inputSchema,
132
+ });
133
+ }
134
+ }
135
+ results.sort((a, b) => b.score - a.score);
136
+ return results.slice(0, topK);
137
+ }
138
+
139
+ /** Build vector index */
140
+ buildIndex() {
141
+ return this._vectorIndex.build(this._registry.getAll());
142
+ }
143
+
144
+ /** @private */
145
+ _getOrCreate(scores, name) {
146
+ if (!scores.has(name)) scores.set(name, { stage1: 0, stage2: 0, stage3: 0 });
147
+ return scores.get(name);
148
+ }
149
+
150
+ /** @returns {object} */
151
+ stats() {
152
+ return {
153
+ registrySize: this._registry.size,
154
+ vectorIndex: this._vectorIndex.stats(),
155
+ embeddingAvailable: !!this._embedding,
156
+ };
157
+ }
158
+ }
159
+
160
+ module.exports = { Router };