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/LICENSE +191 -0
- package/README.md +413 -0
- package/docs/README.md +2 -0
- package/docs/architecture.png +0 -0
- package/index.js +65 -0
- package/lib/config.js +65 -0
- package/lib/embedding.js +131 -0
- package/lib/executor.js +104 -0
- package/lib/registry.js +217 -0
- package/lib/router.js +160 -0
- package/lib/schema.js +95 -0
- package/lib/store.js +215 -0
- package/lib/validator.js +171 -0
- package/lib/vector-index.js +151 -0
- package/package.json +28 -0
- package/tools/qln-call.js +244 -0
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 };
|
package/lib/embedding.js
ADDED
|
@@ -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 };
|
package/lib/executor.js
ADDED
|
@@ -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 };
|
package/lib/registry.js
ADDED
|
@@ -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 };
|