n2-qln 3.1.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +33 -0
- package/README.md +33 -0
- package/index.js +17 -2
- package/lib/config.js +6 -0
- package/lib/provider-loader.js +126 -0
- package/lib/registry.js +2 -0
- package/lib/router.js +34 -3
- package/lib/schema.js +1 -0
- package/lib/store.js +5 -3
- package/package.json +8 -2
package/README.ko.md
CHANGED
|
@@ -349,6 +349,39 @@ module.exports = {
|
|
|
349
349
|
| **기본** (Ollama 없음) | Stage 1 + 2 | ⭐⭐⭐⭐ 훌륭 | 없음 |
|
|
350
350
|
| **Ollama 포함** | Stage 1 + 2 + 3 | ⭐⭐⭐⭐⭐ 완벽 | Ollama 실행 필요 |
|
|
351
351
|
|
|
352
|
+
### 다국어 사용자
|
|
353
|
+
|
|
354
|
+
`nomic-embed-text`는 영어에 최적화되어 있습니다. **한국어, 일본어, 중국어** 등 다른 언어를 사용한다면 다국어 모델로 교체하세요:
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
ollama pull bge-m3
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
```javascript
|
|
361
|
+
// config.local.js
|
|
362
|
+
module.exports = {
|
|
363
|
+
embedding: {
|
|
364
|
+
enabled: true,
|
|
365
|
+
model: 'bge-m3', // 다국어 지원 (100개 이상 언어)
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
코드 수정 없이 config의 모델명만 바꾸면 됩니다.
|
|
371
|
+
|
|
372
|
+
### 클라우드 동기화
|
|
373
|
+
|
|
374
|
+
도구 인덱스를 여러 기기에서 동기화하고 싶다면 `dataDir`을 클라우드 폴더로 지정하세요:
|
|
375
|
+
|
|
376
|
+
```javascript
|
|
377
|
+
// config.local.js
|
|
378
|
+
module.exports = {
|
|
379
|
+
dataDir: 'G:/My Drive/n2-qln', // Google Drive, OneDrive, Dropbox, NAS...
|
|
380
|
+
};
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
[n2-soul 클라우드 스토리지](https://github.com/choihyunsus/n2-soul#%EF%B8%8F-cloud-storage--store-your-ai-memory-anywhere)와 동일한 방식입니다. SQLite 파일이 해당 폴더에 저장되고, 동기화 서비스가 나머지를 처리합니다.
|
|
384
|
+
|
|
352
385
|
---
|
|
353
386
|
|
|
354
387
|
## 프로젝트 구조
|
package/README.md
CHANGED
|
@@ -369,6 +369,39 @@ module.exports = {
|
|
|
369
369
|
| **Default** (no Ollama) | Stage 1 + 2 | ⭐⭐⭐⭐ Great | None |
|
|
370
370
|
| **With Ollama** | Stage 1 + 2 + 3 | ⭐⭐⭐⭐⭐ Perfect | Ollama running |
|
|
371
371
|
|
|
372
|
+
### Multilingual Users
|
|
373
|
+
|
|
374
|
+
`nomic-embed-text` is optimized for English. For **Korean, Japanese, Chinese**, or other languages, swap to a multilingual model:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
ollama pull bge-m3
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
```javascript
|
|
381
|
+
// config.local.js
|
|
382
|
+
module.exports = {
|
|
383
|
+
embedding: {
|
|
384
|
+
enabled: true,
|
|
385
|
+
model: 'bge-m3', // multilingual (100+ languages)
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
No code changes needed — just swap the model name in config.
|
|
391
|
+
|
|
392
|
+
### Cloud Sync
|
|
393
|
+
|
|
394
|
+
Want your tool index synced across machines? Point `dataDir` to a cloud folder:
|
|
395
|
+
|
|
396
|
+
```javascript
|
|
397
|
+
// config.local.js
|
|
398
|
+
module.exports = {
|
|
399
|
+
dataDir: 'G:/My Drive/n2-qln', // Google Drive, OneDrive, Dropbox, NAS...
|
|
400
|
+
};
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Same approach as [n2-soul Cloud Storage](https://github.com/choihyunsus/n2-soul#%EF%B8%8F-cloud-storage--store-your-ai-memory-anywhere). SQLite file lives in that folder — your sync service handles the rest.
|
|
404
|
+
|
|
372
405
|
---
|
|
373
406
|
|
|
374
407
|
## Project Structure
|
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// QLN —
|
|
2
|
+
// QLN — Query Layer Network MCP server entry point
|
|
3
3
|
// Semantic tool dispatcher: route 1000 tools through 1 router
|
|
4
4
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
5
5
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
|
|
8
8
|
// Core
|
|
9
|
+
const path = require('path');
|
|
9
10
|
const { loadConfig } = require('./lib/config');
|
|
10
11
|
const { Store } = require('./lib/store');
|
|
11
12
|
const { Embedding } = require('./lib/embedding');
|
|
@@ -13,6 +14,7 @@ const { Registry } = require('./lib/registry');
|
|
|
13
14
|
const { VectorIndex } = require('./lib/vector-index');
|
|
14
15
|
const { Router } = require('./lib/router');
|
|
15
16
|
const { Executor } = require('./lib/executor');
|
|
17
|
+
const { loadProviders } = require('./lib/provider-loader');
|
|
16
18
|
|
|
17
19
|
// MCP Tool (unified)
|
|
18
20
|
const { registerQlnCall } = require('./tools/qln-call');
|
|
@@ -31,6 +33,18 @@ async function main() {
|
|
|
31
33
|
const registry = new Registry(store, embedding);
|
|
32
34
|
registry.load();
|
|
33
35
|
|
|
36
|
+
// 1.5. Provider auto-indexing (providers/*.json → registry)
|
|
37
|
+
if (config.providers?.enabled !== false) {
|
|
38
|
+
const provDir = config.providers?.dir || path.join(__dirname, 'providers');
|
|
39
|
+
const provResult = loadProviders(provDir, registry);
|
|
40
|
+
if (provResult.loaded > 0) {
|
|
41
|
+
console.error(`[QLN] Providers: ${provResult.loaded} tools from ${provResult.details.filter(d => d.status === 'loaded').length} files`);
|
|
42
|
+
}
|
|
43
|
+
if (provResult.failed > 0) {
|
|
44
|
+
console.error(`[QLN] Provider warnings: ${provResult.failed} files failed to load`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
const vectorIndex = new VectorIndex();
|
|
35
49
|
const router = new Router(registry, vectorIndex, embedding);
|
|
36
50
|
const executor = new Executor(config.executor || {});
|
|
@@ -46,9 +60,10 @@ async function main() {
|
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
// 3. Create MCP server
|
|
63
|
+
const pkg = require('./package.json');
|
|
49
64
|
const server = new McpServer({
|
|
50
65
|
name: 'n2-qln',
|
|
51
|
-
version:
|
|
66
|
+
version: pkg.version,
|
|
52
67
|
});
|
|
53
68
|
|
|
54
69
|
// 4. Register unified MCP tool (1 tool, 5 actions)
|
package/lib/config.js
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// QLN — Provider manifest loader (providers/*.json → registry auto-registration)
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { inferCategory } = require('./schema');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Required fields for a valid provider manifest.
|
|
8
|
+
* @type {string[]}
|
|
9
|
+
*/
|
|
10
|
+
const REQUIRED_MANIFEST_FIELDS = ['provider', 'tools'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Required fields for each tool entry within a manifest.
|
|
14
|
+
* @type {string[]}
|
|
15
|
+
*/
|
|
16
|
+
const REQUIRED_TOOL_FIELDS = ['name', 'description'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load all provider manifests from a directory and register their tools.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} providersDir - Absolute path to providers/ directory
|
|
22
|
+
* @param {import('./registry').Registry} registry - QLN registry instance
|
|
23
|
+
* @returns {{ loaded: number, skipped: number, failed: number, details: object[] }}
|
|
24
|
+
*/
|
|
25
|
+
function loadProviders(providersDir, registry) {
|
|
26
|
+
const result = { loaded: 0, skipped: 0, failed: 0, details: [] };
|
|
27
|
+
|
|
28
|
+
if (!fs.existsSync(providersDir)) return result;
|
|
29
|
+
|
|
30
|
+
/** @type {string[]} */
|
|
31
|
+
const files = fs.readdirSync(providersDir)
|
|
32
|
+
.filter(f => f.endsWith('.json'));
|
|
33
|
+
|
|
34
|
+
if (files.length === 0) return result;
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const filePath = path.join(providersDir, file);
|
|
38
|
+
try {
|
|
39
|
+
const manifest = _parseManifest(filePath);
|
|
40
|
+
if (!manifest) {
|
|
41
|
+
result.skipped++;
|
|
42
|
+
result.details.push({ file, status: 'skipped', reason: 'invalid manifest' });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tools = _normalizeTools(manifest);
|
|
47
|
+
if (tools.length === 0) {
|
|
48
|
+
result.skipped++;
|
|
49
|
+
result.details.push({ file, status: 'skipped', reason: 'no valid tools' });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Idempotent: purge old entries from this provider before re-registering
|
|
54
|
+
registry.purgeBySource(`provider:${manifest.provider}`);
|
|
55
|
+
|
|
56
|
+
const count = registry.registerBatch(tools);
|
|
57
|
+
result.loaded += count;
|
|
58
|
+
result.details.push({
|
|
59
|
+
file,
|
|
60
|
+
status: 'loaded',
|
|
61
|
+
provider: manifest.provider,
|
|
62
|
+
toolCount: count,
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
result.failed++;
|
|
66
|
+
result.details.push({ file, status: 'failed', reason: err.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse and validate a manifest JSON file.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} filePath - Absolute path to JSON file
|
|
77
|
+
* @returns {object|null} Parsed manifest or null if invalid
|
|
78
|
+
*/
|
|
79
|
+
function _parseManifest(filePath) {
|
|
80
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
81
|
+
const manifest = JSON.parse(raw);
|
|
82
|
+
|
|
83
|
+
// Validate required fields
|
|
84
|
+
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
|
85
|
+
if (!manifest[field]) return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// tools must be a non-empty array
|
|
89
|
+
if (!Array.isArray(manifest.tools)) return null;
|
|
90
|
+
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize tool entries from a manifest for registry registration.
|
|
96
|
+
* Injects provider metadata and assigns source = "provider:{name}".
|
|
97
|
+
*
|
|
98
|
+
* @param {object} manifest - Validated manifest object
|
|
99
|
+
* @returns {object[]} Array of normalized tool entries ready for registerBatch()
|
|
100
|
+
*/
|
|
101
|
+
function _normalizeTools(manifest) {
|
|
102
|
+
const providerName = manifest.provider;
|
|
103
|
+
const tools = [];
|
|
104
|
+
|
|
105
|
+
for (const raw of manifest.tools) {
|
|
106
|
+
// Skip tools missing required fields
|
|
107
|
+
if (!raw.name || !raw.description) continue;
|
|
108
|
+
|
|
109
|
+
tools.push({
|
|
110
|
+
name: raw.name,
|
|
111
|
+
description: raw.description,
|
|
112
|
+
source: `provider:${providerName}`,
|
|
113
|
+
category: raw.category || inferCategory(raw.name, 'provider'),
|
|
114
|
+
provider: providerName,
|
|
115
|
+
inputSchema: raw.inputSchema || null,
|
|
116
|
+
triggers: raw.triggers || undefined, // let schema.js extract
|
|
117
|
+
tags: raw.tags || [],
|
|
118
|
+
examples: raw.examples || [],
|
|
119
|
+
endpoint: raw.endpoint || '',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return tools;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { loadProviders };
|
package/lib/registry.js
CHANGED
|
@@ -156,6 +156,7 @@ class Registry {
|
|
|
156
156
|
const entry = this._cache.get(name);
|
|
157
157
|
if (!entry) return;
|
|
158
158
|
entry.usageCount++;
|
|
159
|
+
entry.lastUsedAt = new Date().toISOString();
|
|
159
160
|
const alpha = 0.1;
|
|
160
161
|
entry.successRate = entry.successRate * (1 - alpha) + (success ? 1 : 0) * alpha;
|
|
161
162
|
entry.updatedAt = new Date().toISOString();
|
|
@@ -203,6 +204,7 @@ class Registry {
|
|
|
203
204
|
embedding: _parseJson(row.embedding, null),
|
|
204
205
|
usageCount: row.usage_count || 0,
|
|
205
206
|
successRate: row.success_rate ?? 1.0,
|
|
207
|
+
lastUsedAt: row.last_used_at || null,
|
|
206
208
|
registeredAt: row.registered_at,
|
|
207
209
|
updatedAt: row.updated_at,
|
|
208
210
|
};
|
package/lib/router.js
CHANGED
|
@@ -104,13 +104,20 @@ class Router {
|
|
|
104
104
|
} catch { /* graceful degradation */ }
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Merge all stage results + usage/success bonus → ranking */
|
|
107
|
+
/** Merge all stage results + usage/success bonus + recency decay → ranking */
|
|
108
108
|
_mergeAndRank(scores, topK, threshold) {
|
|
109
109
|
const results = [];
|
|
110
110
|
for (const [name, s] of scores) {
|
|
111
111
|
const tool = this._registry.get(name);
|
|
112
112
|
if (!tool) continue;
|
|
113
|
-
|
|
113
|
+
|
|
114
|
+
// Recency Decay: usage bonus fades over 30-day half-life
|
|
115
|
+
const daysSinceUse = tool.lastUsedAt
|
|
116
|
+
? (Date.now() - new Date(tool.lastUsedAt).getTime()) / 86400000
|
|
117
|
+
: 0;
|
|
118
|
+
const recencyFactor = tool.lastUsedAt ? Math.exp(-daysSinceUse / 30) : 1.0;
|
|
119
|
+
const usageBonus = Math.log2((tool.usageCount || 0) + 1) * 0.5 * recencyFactor;
|
|
120
|
+
|
|
114
121
|
const successBonus = (tool.successRate ?? 1.0) * 1.0;
|
|
115
122
|
const finalScore = (s.stage1 || 0) + (s.stage2 || 0) + (s.stage3 || 0)
|
|
116
123
|
+ usageBonus + successBonus;
|
|
@@ -124,6 +131,7 @@ class Router {
|
|
|
124
131
|
semantic: s.stage3 || 0,
|
|
125
132
|
usage: Math.round(usageBonus * 100) / 100,
|
|
126
133
|
success: Math.round(successBonus * 100) / 100,
|
|
134
|
+
recencyFactor: Math.round(recencyFactor * 1000) / 1000,
|
|
127
135
|
},
|
|
128
136
|
description: tool.description,
|
|
129
137
|
source: tool.source,
|
|
@@ -133,7 +141,30 @@ class Router {
|
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
results.sort((a, b) => b.score - a.score);
|
|
136
|
-
|
|
144
|
+
const ranked = results.slice(0, topK);
|
|
145
|
+
|
|
146
|
+
// 5% Explorer: inject least-used tool into last slot
|
|
147
|
+
if (ranked.length >= topK && topK >= 2) {
|
|
148
|
+
const resultNames = new Set(ranked.map(r => r.name));
|
|
149
|
+
const allTools = this._registry.getAll()
|
|
150
|
+
.filter(t => !resultNames.has(t.name))
|
|
151
|
+
.sort((a, b) => (a.usageCount || 0) - (b.usageCount || 0));
|
|
152
|
+
if (allTools.length > 0) {
|
|
153
|
+
const explorer = allTools[0];
|
|
154
|
+
ranked[ranked.length - 1] = {
|
|
155
|
+
name: explorer.name,
|
|
156
|
+
score: 0,
|
|
157
|
+
stages: { trigger: 0, keyword: 0, semantic: 0, usage: 0, success: 0, recencyFactor: 0 },
|
|
158
|
+
description: explorer.description,
|
|
159
|
+
source: explorer.source,
|
|
160
|
+
category: explorer.category,
|
|
161
|
+
inputSchema: explorer.inputSchema,
|
|
162
|
+
explorer: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return ranked;
|
|
137
168
|
}
|
|
138
169
|
|
|
139
170
|
/** Build vector index */
|
package/lib/schema.js
CHANGED
|
@@ -21,6 +21,7 @@ function createToolEntry(raw) {
|
|
|
21
21
|
searchText: '',
|
|
22
22
|
usageCount: raw.usageCount || 0,
|
|
23
23
|
successRate: typeof raw.successRate === 'number' ? raw.successRate : 1.0,
|
|
24
|
+
lastUsedAt: raw.lastUsedAt || null,
|
|
24
25
|
embedding: raw.embedding || null,
|
|
25
26
|
registeredAt: raw.registeredAt || new Date().toISOString(),
|
|
26
27
|
updatedAt: new Date().toISOString(),
|
package/lib/store.js
CHANGED
|
@@ -104,6 +104,7 @@ class Store {
|
|
|
104
104
|
addCol('tools', 'provider', 'TEXT', "''");
|
|
105
105
|
addCol('tools', 'examples', 'TEXT', "'[]'");
|
|
106
106
|
addCol('tools', 'endpoint', 'TEXT', "''");
|
|
107
|
+
addCol('tools', 'last_used_at', 'TEXT', 'NULL');
|
|
107
108
|
|
|
108
109
|
// Copy plugin_name → provider (if old schema has plugin_name)
|
|
109
110
|
try {
|
|
@@ -130,10 +131,10 @@ class Store {
|
|
|
130
131
|
this._db.run(`
|
|
131
132
|
INSERT INTO tools (name, description, source, category, provider,
|
|
132
133
|
input_schema, triggers, tags, examples, endpoint, search_text, embedding,
|
|
133
|
-
usage_count, success_rate, updated_at)
|
|
134
|
+
usage_count, success_rate, last_used_at, updated_at)
|
|
134
135
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
135
136
|
?, COALESCE(?, (SELECT embedding FROM tools WHERE name = ?)),
|
|
136
|
-
?, ?, datetime('now'))
|
|
137
|
+
?, ?, ?, datetime('now'))
|
|
137
138
|
ON CONFLICT(name) DO UPDATE SET
|
|
138
139
|
description = excluded.description,
|
|
139
140
|
source = excluded.source,
|
|
@@ -148,6 +149,7 @@ class Store {
|
|
|
148
149
|
embedding = COALESCE(excluded.embedding, tools.embedding),
|
|
149
150
|
usage_count = excluded.usage_count,
|
|
150
151
|
success_rate = excluded.success_rate,
|
|
152
|
+
last_used_at = excluded.last_used_at,
|
|
151
153
|
updated_at = excluded.updated_at
|
|
152
154
|
`, [
|
|
153
155
|
entry.name, entry.description, entry.source, entry.category, entry.provider,
|
|
@@ -155,7 +157,7 @@ class Store {
|
|
|
155
157
|
JSON.stringify(entry.tags), JSON.stringify(entry.examples), entry.endpoint || '',
|
|
156
158
|
entry.searchText,
|
|
157
159
|
entry.embedding ? JSON.stringify(entry.embedding) : null, entry.name,
|
|
158
|
-
entry.usageCount, entry.successRate,
|
|
160
|
+
entry.usageCount, entry.successRate, entry.lastUsedAt || null,
|
|
159
161
|
]);
|
|
160
162
|
this._persist();
|
|
161
163
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n2-qln",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Query Layer Network — Semantic tool dispatcher for MCP. Route 1000 tools through 1 router.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
"sql.js": "^1.12.0",
|
|
16
16
|
"zod": "~3.24.1"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"ai",
|
|
21
|
+
"tool-routing",
|
|
22
|
+
"semantic-search",
|
|
23
|
+
"llm"
|
|
24
|
+
],
|
|
19
25
|
"author": "N2",
|
|
20
26
|
"license": "Apache-2.0",
|
|
21
27
|
"repository": {
|