openclaw-node-harness 2.0.4 → 2.1.1
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.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @openclaw/mcp-knowledge — Full Test Suite
|
|
4
|
+
*
|
|
5
|
+
* 12 groups covering every exported function + HTTP transport.
|
|
6
|
+
* Zero dependencies. Run: node test.mjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pipeline } from '@huggingface/transformers';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import {
|
|
12
|
+
readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync,
|
|
13
|
+
chmodSync, existsSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
scanMarkdownFiles,
|
|
23
|
+
chunkMarkdown,
|
|
24
|
+
splitOversized,
|
|
25
|
+
splitByParagraphs,
|
|
26
|
+
hashContent,
|
|
27
|
+
initDatabase,
|
|
28
|
+
embed,
|
|
29
|
+
embedBatch,
|
|
30
|
+
indexWorkspace,
|
|
31
|
+
semanticSearch,
|
|
32
|
+
findRelated,
|
|
33
|
+
getStats,
|
|
34
|
+
createKnowledgeEngine,
|
|
35
|
+
EMBEDDING_DIM,
|
|
36
|
+
MAX_CHUNK_CHARS,
|
|
37
|
+
MODEL_NAME,
|
|
38
|
+
SNIPPET_LENGTH,
|
|
39
|
+
} from './core.mjs';
|
|
40
|
+
|
|
41
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
42
|
+
const __dirname = dirname(__filename);
|
|
43
|
+
|
|
44
|
+
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
let passed = 0;
|
|
47
|
+
let failed = 0;
|
|
48
|
+
let currentGroup = '';
|
|
49
|
+
|
|
50
|
+
function group(name) {
|
|
51
|
+
currentGroup = name;
|
|
52
|
+
process.stdout.write(`\n=== ${name} ===\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ok(val, msg) {
|
|
56
|
+
if (val) {
|
|
57
|
+
passed++;
|
|
58
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
59
|
+
} else {
|
|
60
|
+
failed++;
|
|
61
|
+
process.stdout.write(` FAIL: ${msg} [got falsy]\n`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function eq(actual, expected, msg) {
|
|
66
|
+
if (actual === expected) {
|
|
67
|
+
passed++;
|
|
68
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
69
|
+
} else {
|
|
70
|
+
failed++;
|
|
71
|
+
process.stdout.write(` FAIL: ${msg} [expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}]\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function gt(a, b, msg) {
|
|
76
|
+
if (a > b) {
|
|
77
|
+
passed++;
|
|
78
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
79
|
+
} else {
|
|
80
|
+
failed++;
|
|
81
|
+
process.stdout.write(` FAIL: ${msg} [expected ${a} > ${b}]\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function gte(a, b, msg) {
|
|
86
|
+
if (a >= b) {
|
|
87
|
+
passed++;
|
|
88
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
89
|
+
} else {
|
|
90
|
+
failed++;
|
|
91
|
+
process.stdout.write(` FAIL: ${msg} [expected ${a} >= ${b}]\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function lt(a, b, msg) {
|
|
96
|
+
if (a < b) {
|
|
97
|
+
passed++;
|
|
98
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
99
|
+
} else {
|
|
100
|
+
failed++;
|
|
101
|
+
process.stdout.write(` FAIL: ${msg} [expected ${a} < ${b}]\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function includes(arr, val, msg) {
|
|
106
|
+
const has = Array.isArray(arr) ? arr.includes(val) : typeof arr === 'string' && arr.includes(val);
|
|
107
|
+
if (has) {
|
|
108
|
+
passed++;
|
|
109
|
+
process.stdout.write(` PASS: ${msg}\n`);
|
|
110
|
+
} else {
|
|
111
|
+
failed++;
|
|
112
|
+
process.stdout.write(` FAIL: ${msg} [value not found]\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Workspace Helpers ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function createTestWorkspace() {
|
|
119
|
+
const dir = join(tmpdir(), `mcp-k-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
120
|
+
const docsDir = join(dir, 'docs');
|
|
121
|
+
mkdirSync(docsDir, { recursive: true });
|
|
122
|
+
|
|
123
|
+
writeFileSync(join(docsDir, 'oracle.md'), `# Oracle Threat Model
|
|
124
|
+
|
|
125
|
+
## GPS Spoofing Attacks
|
|
126
|
+
|
|
127
|
+
The primary risk to biome-based gameplay is GPS spoofing. Players could fake their location to harvest mana from biomes they haven't physically visited.
|
|
128
|
+
|
|
129
|
+
## Mitigation Strategies
|
|
130
|
+
|
|
131
|
+
We use Nodle's DePIN network for location verification. The NodleLocationOracle contract cross-references device attestations with on-chain proofs.
|
|
132
|
+
`);
|
|
133
|
+
|
|
134
|
+
writeFileSync(join(docsDir, 'factions.md'), `# Faction Lore
|
|
135
|
+
|
|
136
|
+
## The Verdant Pact
|
|
137
|
+
|
|
138
|
+
The Verdant Pact draws power from natural biomes — forests, rivers, mountains. Their mana harvesting is strongest in green zones.
|
|
139
|
+
|
|
140
|
+
## The Iron Syndicate
|
|
141
|
+
|
|
142
|
+
The Iron Syndicate operates in urban environments. Their technology-augmented spells work best near cell towers and data centers.
|
|
143
|
+
`);
|
|
144
|
+
|
|
145
|
+
writeFileSync(join(docsDir, 'architecture.md'), `# Technical Architecture
|
|
146
|
+
|
|
147
|
+
## Smart Contract Stack
|
|
148
|
+
|
|
149
|
+
The core contracts are: ArcaneKernel (entry point), ManaWell (resource management), BiomeOracle (location verification), and NodeController (network governance).
|
|
150
|
+
|
|
151
|
+
## Mobile Client
|
|
152
|
+
|
|
153
|
+
The Unity-based mobile client handles AR rendering, GPS tracking, and wallet integration via WalletConnect.
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
// Non-md files that should be ignored
|
|
157
|
+
writeFileSync(join(docsDir, 'data.json'), '{"key": "value"}');
|
|
158
|
+
writeFileSync(join(docsDir, 'contract.sol'), 'pragma solidity ^0.8.0;');
|
|
159
|
+
writeFileSync(join(docsDir, 'image.png'), 'fakepng');
|
|
160
|
+
|
|
161
|
+
// Excluded file
|
|
162
|
+
writeFileSync(join(dir, 'active-tasks.md'), '# Active Tasks\n\nstuff');
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
dir,
|
|
166
|
+
docsDir,
|
|
167
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createIndexedDb(ws, extractor) {
|
|
172
|
+
const dbPath = join(ws.dir, 'test.db');
|
|
173
|
+
const db = initDatabase(dbPath);
|
|
174
|
+
|
|
175
|
+
const files = [];
|
|
176
|
+
for (const name of readdirSync(ws.docsDir)) {
|
|
177
|
+
if (name.endsWith('.md')) {
|
|
178
|
+
files.push({ path: join(ws.docsDir, name), rel: `docs/${name}` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const insertDoc = db.prepare('INSERT OR REPLACE INTO documents (path, content_hash, last_indexed, chunk_count) VALUES (?, ?, ?, ?)');
|
|
183
|
+
const insertChunk = db.prepare('INSERT INTO chunks (doc_path, section, text, snippet) VALUES (?, ?, ?, ?)');
|
|
184
|
+
|
|
185
|
+
return { db, dbPath, files, insertDoc, insertChunk };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Group 1: Scanner ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function testScanner() {
|
|
191
|
+
group('1. Scanner');
|
|
192
|
+
const ws = createTestWorkspace();
|
|
193
|
+
|
|
194
|
+
// Scans .md files in included directories
|
|
195
|
+
const files = scanMarkdownFiles(ws.dir, ['docs/'], []);
|
|
196
|
+
ok(files.length >= 3, `Finds .md files in docs/ (got ${files.length})`);
|
|
197
|
+
|
|
198
|
+
// Only .md files (not .json, .sol, .png)
|
|
199
|
+
const exts = files.map(f => f.rel);
|
|
200
|
+
ok(exts.every(r => r.endsWith('.md')), 'All results are .md files');
|
|
201
|
+
|
|
202
|
+
// Exclude patterns filter files
|
|
203
|
+
const filesExcluded = scanMarkdownFiles(ws.dir, ['docs/', 'active-tasks.md'], [/active-tasks\.md$/]);
|
|
204
|
+
const hasActive = filesExcluded.some(f => f.rel.includes('active-tasks'));
|
|
205
|
+
eq(hasActive, false, 'Exclude pattern filters active-tasks.md');
|
|
206
|
+
|
|
207
|
+
// Missing include directory handled gracefully
|
|
208
|
+
const filesNoDir = scanMarkdownFiles(ws.dir, ['nonexistent/'], []);
|
|
209
|
+
eq(filesNoDir.length, 0, 'Missing directory returns empty array');
|
|
210
|
+
|
|
211
|
+
// Individual file path as include
|
|
212
|
+
writeFileSync(join(ws.dir, 'SOUL.md'), '# Soul\n\nIdentity file.');
|
|
213
|
+
const filesSingle = scanMarkdownFiles(ws.dir, ['SOUL.md'], []);
|
|
214
|
+
eq(filesSingle.length, 1, 'Individual file path works as include');
|
|
215
|
+
eq(filesSingle[0].rel, 'SOUL.md', 'Relative path is correct');
|
|
216
|
+
|
|
217
|
+
// Empty directory
|
|
218
|
+
const emptyDir = join(ws.dir, 'empty');
|
|
219
|
+
mkdirSync(emptyDir);
|
|
220
|
+
const filesEmpty = scanMarkdownFiles(ws.dir, ['empty/'], []);
|
|
221
|
+
eq(filesEmpty.length, 0, 'Empty directory returns empty array');
|
|
222
|
+
|
|
223
|
+
// Path object has both path and rel
|
|
224
|
+
ok(files[0].path && files[0].rel, 'File objects have path and rel properties');
|
|
225
|
+
|
|
226
|
+
ws.cleanup();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Group 2: Chunker ────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function testChunker() {
|
|
232
|
+
group('2. Chunker');
|
|
233
|
+
|
|
234
|
+
// Simple doc with H1 + H2
|
|
235
|
+
const doc1 = '# Title\n\nIntro.\n\n## Section A\n\nBody A.\n\n## Section B\n\nBody B.';
|
|
236
|
+
const c1 = chunkMarkdown(doc1);
|
|
237
|
+
eq(c1.length, 3, 'Simple doc → 3 chunks');
|
|
238
|
+
eq(c1[0].section, '# Title', 'First chunk section label');
|
|
239
|
+
eq(c1[1].section, '## Section A', 'Second chunk section label');
|
|
240
|
+
eq(c1[2].section, '## Section B', 'Third chunk section label');
|
|
241
|
+
|
|
242
|
+
// Headerless doc
|
|
243
|
+
const c2 = chunkMarkdown('Plain text, no headings.');
|
|
244
|
+
eq(c2.length, 1, 'Headerless → 1 chunk');
|
|
245
|
+
eq(c2[0].section, '(top)', 'Headerless section is "(top)"');
|
|
246
|
+
|
|
247
|
+
// Empty doc
|
|
248
|
+
const c3 = chunkMarkdown('');
|
|
249
|
+
eq(c3.length, 1, 'Empty doc → 1 chunk');
|
|
250
|
+
|
|
251
|
+
// Oversized section splits on paragraphs
|
|
252
|
+
const bigPara = 'A'.repeat(800);
|
|
253
|
+
const bigDoc = `# Big\n\n${[bigPara, bigPara, bigPara].join('\n\n')}`;
|
|
254
|
+
const c4 = chunkMarkdown(bigDoc);
|
|
255
|
+
gte(c4.length, 2, `Oversized section splits via paragraphs (got ${c4.length})`);
|
|
256
|
+
|
|
257
|
+
// Recursive sub-heading split (H2 → H3)
|
|
258
|
+
const sub = (ch, n) => ch.repeat(600);
|
|
259
|
+
const h3Doc = `## Parent\n\n### Sub A\n\n${sub('B')}\n\n### Sub B\n\n${sub('C')}\n\n### Sub C\n\n${sub('D')}`;
|
|
260
|
+
const c5 = chunkMarkdown(h3Doc);
|
|
261
|
+
gte(c5.length, 3, `H2 splits on H3 boundaries (got ${c5.length})`);
|
|
262
|
+
ok(c5.some(c => c.section === '### Sub A'), 'Sub-heading label preserved: ### Sub A');
|
|
263
|
+
|
|
264
|
+
// All chunks respect MAX_CHUNK_CHARS (invariant)
|
|
265
|
+
const allChunks = [...c1, ...c2, ...c3, ...c4, ...c5];
|
|
266
|
+
const oversized = allChunks.filter(c => c.text.length > MAX_CHUNK_CHARS + 50); // small tolerance for section header
|
|
267
|
+
eq(oversized.length, 0, `No chunk exceeds MAX_CHUNK_CHARS (${MAX_CHUNK_CHARS})`);
|
|
268
|
+
|
|
269
|
+
// Deep nesting: H2 > H3 > H4
|
|
270
|
+
const deepDoc = `## L2\n\n### L3\n\n#### L4a\n\nContent A.\n\n#### L4b\n\nContent B.`;
|
|
271
|
+
const c6 = chunkMarkdown(deepDoc);
|
|
272
|
+
ok(c6.length >= 1, `Deep nesting produces chunks (got ${c6.length})`);
|
|
273
|
+
|
|
274
|
+
// Section label [part N] suffix on paragraph splits
|
|
275
|
+
const partDoc = `# Parts\n\n${[bigPara, bigPara, bigPara].join('\n\n')}`;
|
|
276
|
+
const c7 = chunkMarkdown(partDoc);
|
|
277
|
+
if (c7.length > 1) {
|
|
278
|
+
ok(c7.some(c => c.section.includes('[part')), 'Paragraph splits get [part N] suffix');
|
|
279
|
+
} else {
|
|
280
|
+
ok(true, 'Paragraph split suffix (skipped — single chunk)');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Mixed heading levels
|
|
284
|
+
const mixedDoc = '# H1\n\nA.\n\n### H3\n\nB.\n\n## H2\n\nC.';
|
|
285
|
+
const c8 = chunkMarkdown(mixedDoc);
|
|
286
|
+
eq(c8.length, 3, 'Mixed heading levels → 3 chunks');
|
|
287
|
+
|
|
288
|
+
// Unicode content preserved
|
|
289
|
+
const unicodeDoc = '# Titre\n\nContenu en français avec des accents: é, è, ê, ë, à, ü, ö.';
|
|
290
|
+
const c9 = chunkMarkdown(unicodeDoc);
|
|
291
|
+
ok(c9[0].text.includes('français'), 'Unicode content preserved');
|
|
292
|
+
|
|
293
|
+
// Frontmatter treated as body (not header)
|
|
294
|
+
const fmDoc = '---\ntitle: Test\n---\n\n# Real Header\n\nBody.';
|
|
295
|
+
const c10 = chunkMarkdown(fmDoc);
|
|
296
|
+
ok(c10.some(c => c.text.includes('---')), 'YAML frontmatter treated as body text');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Group 3: Content Hashing ────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function testHashing() {
|
|
302
|
+
group('3. Content Hashing');
|
|
303
|
+
|
|
304
|
+
// Same content → same hash
|
|
305
|
+
eq(hashContent('hello'), hashContent('hello'), 'Same content → same hash');
|
|
306
|
+
|
|
307
|
+
// Different content → different hash
|
|
308
|
+
ok(hashContent('hello') !== hashContent('world'), 'Different content → different hash');
|
|
309
|
+
|
|
310
|
+
// Empty string → valid hash
|
|
311
|
+
const emptyHash = hashContent('');
|
|
312
|
+
eq(emptyHash.length, 64, 'Empty string → valid 64-char SHA-256 hash');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Group 4: Database ──────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
function testDatabase() {
|
|
318
|
+
group('4. Database');
|
|
319
|
+
|
|
320
|
+
const db = initDatabase(':memory:');
|
|
321
|
+
|
|
322
|
+
// Tables exist
|
|
323
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
|
|
324
|
+
includes(tables, 'documents', 'documents table exists');
|
|
325
|
+
includes(tables, 'chunks', 'chunks table exists');
|
|
326
|
+
includes(tables, 'meta', 'meta table exists');
|
|
327
|
+
|
|
328
|
+
// Virtual table
|
|
329
|
+
const vtables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%vec0%'").all();
|
|
330
|
+
ok(vtables.length > 0 || tables.includes('chunk_vectors'), 'chunk_vectors virtual table exists');
|
|
331
|
+
|
|
332
|
+
// WAL mode (in-memory returns 'memory', so test with file-based DB)
|
|
333
|
+
const ws = createTestWorkspace();
|
|
334
|
+
const fileDb = initDatabase(join(ws.dir, 'wal-test.db'));
|
|
335
|
+
const journal = fileDb.pragma('journal_mode', { simple: true });
|
|
336
|
+
eq(journal, 'wal', 'WAL mode enabled');
|
|
337
|
+
fileDb.close();
|
|
338
|
+
ws.cleanup();
|
|
339
|
+
|
|
340
|
+
// Index exists
|
|
341
|
+
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_chunks_doc_path'").all();
|
|
342
|
+
eq(indexes.length, 1, 'idx_chunks_doc_path index exists');
|
|
343
|
+
|
|
344
|
+
db.close();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── Group 5: sqlite-vec Integration ─────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
function testSqliteVec() {
|
|
350
|
+
group('5. sqlite-vec Integration');
|
|
351
|
+
|
|
352
|
+
const db = initDatabase(':memory:');
|
|
353
|
+
|
|
354
|
+
// Extension loads
|
|
355
|
+
const ver = db.prepare('SELECT vec_version() as v').get();
|
|
356
|
+
ok(ver.v, `sqlite-vec loaded: ${ver.v}`);
|
|
357
|
+
|
|
358
|
+
// Insert + self-match
|
|
359
|
+
const v1 = new Float32Array(EMBEDDING_DIM);
|
|
360
|
+
v1[0] = 1.0;
|
|
361
|
+
const b1 = Buffer.from(v1.buffer);
|
|
362
|
+
db.prepare('INSERT INTO chunk_vectors VALUES (1, ?)').run(b1);
|
|
363
|
+
|
|
364
|
+
const r1 = db.prepare('SELECT rowid, distance FROM chunk_vectors WHERE embedding MATCH ? AND k = 5').all(b1);
|
|
365
|
+
eq(r1.length, 1, 'Self-match returns 1 result');
|
|
366
|
+
lt(r1[0].distance, 0.001, `Self-match distance ≈ 0 (got ${r1[0].distance})`);
|
|
367
|
+
|
|
368
|
+
// Orthogonal vector → larger distance
|
|
369
|
+
const v2 = new Float32Array(EMBEDDING_DIM);
|
|
370
|
+
v2[1] = 1.0;
|
|
371
|
+
const b2 = Buffer.from(v2.buffer);
|
|
372
|
+
db.prepare('INSERT INTO chunk_vectors VALUES (2, ?)').run(b2);
|
|
373
|
+
|
|
374
|
+
const r2 = db.prepare('SELECT rowid, distance FROM chunk_vectors WHERE embedding MATCH ? AND k = 5').all(b1);
|
|
375
|
+
eq(r2.length, 2, 'KNN returns 2 results after 2 inserts');
|
|
376
|
+
eq(r2[0].rowid, 1, 'Nearest neighbor is self (rowid=1)');
|
|
377
|
+
gt(r2[1].distance, r2[0].distance, 'Orthogonal vector has larger distance');
|
|
378
|
+
|
|
379
|
+
db.close();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Group 6: Embedding Pipeline ─────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
async function testEmbedding() {
|
|
385
|
+
group('6. Embedding Pipeline');
|
|
386
|
+
|
|
387
|
+
process.stdout.write(' Loading model...\n');
|
|
388
|
+
const extractor = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'fp32' });
|
|
389
|
+
|
|
390
|
+
// Dimension
|
|
391
|
+
const v1 = await embed('Hello world');
|
|
392
|
+
eq(v1.length, EMBEDDING_DIM, `Output dimension is ${EMBEDDING_DIM}`);
|
|
393
|
+
|
|
394
|
+
// Normalization
|
|
395
|
+
let norm = 0;
|
|
396
|
+
for (let i = 0; i < v1.length; i++) norm += v1[i] * v1[i];
|
|
397
|
+
norm = Math.sqrt(norm);
|
|
398
|
+
lt(Math.abs(norm - 1.0), 0.01, `Normalized (L2 norm=${norm.toFixed(4)})`);
|
|
399
|
+
|
|
400
|
+
// Semantic similarity
|
|
401
|
+
const vSimilar = await embed('Greetings planet');
|
|
402
|
+
const vDifferent = await embed('Database schema migration');
|
|
403
|
+
function cosine(a, b) {
|
|
404
|
+
let d = 0;
|
|
405
|
+
for (let i = 0; i < a.length; i++) d += a[i] * b[i];
|
|
406
|
+
return d;
|
|
407
|
+
}
|
|
408
|
+
const simClose = cosine(v1, vSimilar);
|
|
409
|
+
const simFar = cosine(v1, vDifferent);
|
|
410
|
+
gt(simClose, simFar, `"Hello world" closer to "Greetings planet" (${simClose.toFixed(3)}) than "DB migration" (${simFar.toFixed(3)})`);
|
|
411
|
+
|
|
412
|
+
// embedBatch count
|
|
413
|
+
const batch = await embedBatch(['one', 'two', 'three']);
|
|
414
|
+
eq(batch.length, 3, 'embedBatch returns correct count');
|
|
415
|
+
|
|
416
|
+
// Truncation (long text doesn't crash)
|
|
417
|
+
const longText = 'word '.repeat(2000);
|
|
418
|
+
const vLong = await embed(longText);
|
|
419
|
+
eq(vLong.length, EMBEDDING_DIM, 'Long text embedding succeeds (truncated)');
|
|
420
|
+
|
|
421
|
+
return extractor;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Group 7: Indexer ────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
async function testIndexer(extractor) {
|
|
427
|
+
group('7. Indexer');
|
|
428
|
+
|
|
429
|
+
// --- Test: indexes new files ---
|
|
430
|
+
const ws = createTestWorkspace();
|
|
431
|
+
const dbPath = join(ws.dir, 'idx-test.db');
|
|
432
|
+
const db = initDatabase(dbPath);
|
|
433
|
+
|
|
434
|
+
const result = await indexWorkspace(db, ws.dir, { force: false });
|
|
435
|
+
// We wrote 3 .md files in docs/ + SOUL.md doesn't exist yet in this workspace
|
|
436
|
+
// But the include paths won't match our test workspace (they're hardcoded to memory/, projects/, etc.)
|
|
437
|
+
// So we need to use the scanner with custom includes
|
|
438
|
+
// Actually, indexWorkspace uses INCLUDE_DIRS from core.mjs which are workspace-specific.
|
|
439
|
+
// For testing, we need to call with a workspace that has matching paths.
|
|
440
|
+
// Let's restructure: create files that match default includes.
|
|
441
|
+
|
|
442
|
+
db.close();
|
|
443
|
+
ws.cleanup();
|
|
444
|
+
|
|
445
|
+
// Create a workspace that matches default include paths
|
|
446
|
+
const ws2 = createTestWorkspace();
|
|
447
|
+
const memDir = join(ws2.dir, 'memory');
|
|
448
|
+
mkdirSync(memDir, { recursive: true });
|
|
449
|
+
writeFileSync(join(memDir, 'day1.md'), '# Day 1\n\nBuilt the knowledge server.');
|
|
450
|
+
writeFileSync(join(memDir, 'day2.md'), '# Day 2\n\nFixed sqlite-vec quirk.');
|
|
451
|
+
writeFileSync(join(ws2.dir, 'SOUL.md'), '# Soul\n\nI am Daedalus.');
|
|
452
|
+
|
|
453
|
+
// Use custom INCLUDE_DIRS for test via environment or direct scanner call
|
|
454
|
+
// We'll use indexWorkspace with a custom root and test via the DB state
|
|
455
|
+
const dbPath2 = join(ws2.dir, 'idx2.db');
|
|
456
|
+
const db2 = initDatabase(dbPath2);
|
|
457
|
+
|
|
458
|
+
// Index with custom scanner — call indexWorkspace which uses INCLUDE_DIRS
|
|
459
|
+
// Since INCLUDE_DIRS includes 'memory/' and 'SOUL.md', our test workspace matches
|
|
460
|
+
const r2 = await indexWorkspace(db2, ws2.dir);
|
|
461
|
+
gte(r2.indexed, 2, `Indexes new files (got ${r2.indexed})`);
|
|
462
|
+
eq(r2.total, r2.indexed + r2.skipped, 'total = indexed + skipped');
|
|
463
|
+
|
|
464
|
+
// Verify DB state
|
|
465
|
+
const docCount = db2.prepare('SELECT COUNT(*) as c FROM documents').get().c;
|
|
466
|
+
gte(docCount, 2, `Documents in DB after index (got ${docCount})`);
|
|
467
|
+
const chunkCount = db2.prepare('SELECT COUNT(*) as c FROM chunks').get().c;
|
|
468
|
+
const vecCount = db2.prepare('SELECT COUNT(*) as c FROM chunk_vectors').get().c;
|
|
469
|
+
eq(chunkCount, vecCount, `Chunk count == vector count (${chunkCount} == ${vecCount})`);
|
|
470
|
+
|
|
471
|
+
// --- Test: skips unchanged files ---
|
|
472
|
+
const r3 = await indexWorkspace(db2, ws2.dir);
|
|
473
|
+
eq(r3.indexed, 0, 'Second index skips unchanged files');
|
|
474
|
+
eq(r3.skipped, r2.indexed, `Skipped count matches previous indexed (${r3.skipped})`);
|
|
475
|
+
|
|
476
|
+
// --- Test: re-indexes changed files ---
|
|
477
|
+
writeFileSync(join(memDir, 'day1.md'), '# Day 1 Updated\n\nCompletely new content for testing re-index.');
|
|
478
|
+
const r4 = await indexWorkspace(db2, ws2.dir);
|
|
479
|
+
gte(r4.indexed, 1, `Re-indexes changed file (got ${r4.indexed})`);
|
|
480
|
+
|
|
481
|
+
// --- Test: detects deleted files ---
|
|
482
|
+
rmSync(join(memDir, 'day2.md'));
|
|
483
|
+
const r5 = await indexWorkspace(db2, ws2.dir);
|
|
484
|
+
gte(r5.deleted, 1, `Detects deleted file (got ${r5.deleted})`);
|
|
485
|
+
|
|
486
|
+
// Verify deleted file's chunks are gone
|
|
487
|
+
const deletedChunks = db2.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = 'memory/day2.md'").get().c;
|
|
488
|
+
eq(deletedChunks, 0, 'Deleted file chunks removed from DB');
|
|
489
|
+
|
|
490
|
+
// --- Test: force re-indexes everything ---
|
|
491
|
+
const r6 = await indexWorkspace(db2, ws2.dir, { force: true });
|
|
492
|
+
gte(r6.indexed, 1, `Force re-indexes all files (got ${r6.indexed})`);
|
|
493
|
+
eq(r6.skipped, 0, 'Force mode skips nothing');
|
|
494
|
+
|
|
495
|
+
// --- Test: last_index_time updated ---
|
|
496
|
+
const meta = db2.prepare("SELECT value FROM meta WHERE key = 'last_index_time'").get();
|
|
497
|
+
ok(meta && parseInt(meta.value) > 0, 'last_index_time updated in meta table');
|
|
498
|
+
|
|
499
|
+
// --- Test: snippet generation ---
|
|
500
|
+
const snippet = db2.prepare('SELECT snippet FROM chunks LIMIT 1').get();
|
|
501
|
+
ok(snippet && snippet.snippet.length > 0, 'Snippets are generated');
|
|
502
|
+
ok(snippet && snippet.snippet.length <= SNIPPET_LENGTH, `Snippet <= ${SNIPPET_LENGTH} chars`);
|
|
503
|
+
ok(snippet && !snippet.snippet.includes('\n'), 'Snippet has no newlines');
|
|
504
|
+
|
|
505
|
+
db2.close();
|
|
506
|
+
ws2.cleanup();
|
|
507
|
+
|
|
508
|
+
// --- Test: empty workspace ---
|
|
509
|
+
const wsEmpty = { dir: join(tmpdir(), `mcp-k-empty-${Date.now()}`), cleanup: null };
|
|
510
|
+
mkdirSync(wsEmpty.dir, { recursive: true });
|
|
511
|
+
const dbEmpty = initDatabase(join(wsEmpty.dir, 'empty.db'));
|
|
512
|
+
const rEmpty = await indexWorkspace(dbEmpty, wsEmpty.dir);
|
|
513
|
+
eq(rEmpty.indexed, 0, 'Empty workspace → 0 indexed');
|
|
514
|
+
eq(rEmpty.skipped, 0, 'Empty workspace → 0 skipped');
|
|
515
|
+
dbEmpty.close();
|
|
516
|
+
rmSync(wsEmpty.dir, { recursive: true, force: true });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ─── Group 8: semanticSearch ─────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
async function testSearch(extractor) {
|
|
522
|
+
group('8. Semantic Search');
|
|
523
|
+
|
|
524
|
+
// Build indexed DB
|
|
525
|
+
const ws = createTestWorkspace();
|
|
526
|
+
const memDir = join(ws.dir, 'memory');
|
|
527
|
+
mkdirSync(memDir, { recursive: true });
|
|
528
|
+
writeFileSync(join(memDir, 'oracle.md'), readFileSync(join(ws.docsDir, 'oracle.md'), 'utf-8'));
|
|
529
|
+
writeFileSync(join(memDir, 'factions.md'), readFileSync(join(ws.docsDir, 'factions.md'), 'utf-8'));
|
|
530
|
+
writeFileSync(join(memDir, 'arch.md'), readFileSync(join(ws.docsDir, 'architecture.md'), 'utf-8'));
|
|
531
|
+
|
|
532
|
+
const dbPath = join(ws.dir, 'search.db');
|
|
533
|
+
const db = initDatabase(dbPath);
|
|
534
|
+
await indexWorkspace(db, ws.dir);
|
|
535
|
+
|
|
536
|
+
// Relevance ranking
|
|
537
|
+
const r1 = await semanticSearch(db, 'GPS spoofing location attacks', 5);
|
|
538
|
+
ok(r1.length > 0, 'Search returns results');
|
|
539
|
+
eq(r1[0].path, 'memory/oracle.md', 'Top result for GPS spoofing is oracle.md');
|
|
540
|
+
|
|
541
|
+
// Result shape
|
|
542
|
+
ok(r1[0].path && r1[0].section && r1[0].snippet, 'Result has path, section, snippet');
|
|
543
|
+
ok(typeof r1[0].score === 'number', 'Score is a number');
|
|
544
|
+
ok(r1[0].score >= -1 && r1[0].score <= 1, `Score in valid range (got ${r1[0].score})`);
|
|
545
|
+
|
|
546
|
+
// Limit parameter
|
|
547
|
+
const r2 = await semanticSearch(db, 'any query', 2);
|
|
548
|
+
ok(r2.length <= 2, `Limit=2 respected (got ${r2.length})`);
|
|
549
|
+
|
|
550
|
+
// Empty index returns empty
|
|
551
|
+
const emptyDb = initDatabase(':memory:');
|
|
552
|
+
const rEmpty = await semanticSearch(emptyDb, 'test', 5);
|
|
553
|
+
eq(rEmpty.length, 0, 'Empty index returns empty array');
|
|
554
|
+
emptyDb.close();
|
|
555
|
+
|
|
556
|
+
// Cross-domain query still returns results (top-K regardless)
|
|
557
|
+
const r3 = await semanticSearch(db, 'quantum physics black holes', 5);
|
|
558
|
+
ok(r3.length > 0, 'Unrelated query still returns top-K results');
|
|
559
|
+
|
|
560
|
+
db.close();
|
|
561
|
+
ws.cleanup();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ─── Group 9: findRelated ────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
async function testFindRelated(extractor) {
|
|
567
|
+
group('9. findRelated');
|
|
568
|
+
|
|
569
|
+
const ws = createTestWorkspace();
|
|
570
|
+
const memDir = join(ws.dir, 'memory');
|
|
571
|
+
mkdirSync(memDir, { recursive: true });
|
|
572
|
+
writeFileSync(join(memDir, 'oracle.md'), readFileSync(join(ws.docsDir, 'oracle.md'), 'utf-8'));
|
|
573
|
+
writeFileSync(join(memDir, 'factions.md'), readFileSync(join(ws.docsDir, 'factions.md'), 'utf-8'));
|
|
574
|
+
writeFileSync(join(memDir, 'arch.md'), readFileSync(join(ws.docsDir, 'architecture.md'), 'utf-8'));
|
|
575
|
+
|
|
576
|
+
const dbPath = join(ws.dir, 'related.db');
|
|
577
|
+
const db = initDatabase(dbPath);
|
|
578
|
+
await indexWorkspace(db, ws.dir);
|
|
579
|
+
|
|
580
|
+
// Finds related docs (excludes self)
|
|
581
|
+
const r1 = await findRelated(db, 'memory/oracle.md', 5);
|
|
582
|
+
ok(Array.isArray(r1), 'Returns array');
|
|
583
|
+
ok(r1.length > 0, 'Returns related documents');
|
|
584
|
+
ok(!r1.some(r => r.path === 'memory/oracle.md'), 'Excludes self from results');
|
|
585
|
+
|
|
586
|
+
// Non-existent doc
|
|
587
|
+
const r2 = await findRelated(db, 'nonexistent.md', 5);
|
|
588
|
+
ok(r2.error, 'Non-existent doc returns error object');
|
|
589
|
+
|
|
590
|
+
// Deduplication (one entry per doc)
|
|
591
|
+
const paths = r1.map(r => r.path);
|
|
592
|
+
const uniquePaths = [...new Set(paths)];
|
|
593
|
+
eq(paths.length, uniquePaths.length, 'Results deduplicated by document');
|
|
594
|
+
|
|
595
|
+
// Limit respected
|
|
596
|
+
const r3 = await findRelated(db, 'memory/oracle.md', 1);
|
|
597
|
+
ok(r3.length <= 1, `Limit=1 respected (got ${r3.length})`);
|
|
598
|
+
|
|
599
|
+
// Result shape
|
|
600
|
+
if (r1.length > 0) {
|
|
601
|
+
ok(r1[0].path && r1[0].section && r1[0].snippet, 'Result has path, section, snippet');
|
|
602
|
+
ok(typeof r1[0].score === 'number', 'Score is a number');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
db.close();
|
|
606
|
+
ws.cleanup();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ─── Group 10: getStats ──────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
async function testGetStats(extractor) {
|
|
612
|
+
group('10. getStats');
|
|
613
|
+
|
|
614
|
+
const ws = createTestWorkspace();
|
|
615
|
+
const memDir = join(ws.dir, 'memory');
|
|
616
|
+
mkdirSync(memDir, { recursive: true });
|
|
617
|
+
writeFileSync(join(memDir, 'test.md'), '# Test\n\nBody.');
|
|
618
|
+
|
|
619
|
+
const dbPath = join(ws.dir, 'stats.db');
|
|
620
|
+
const db = initDatabase(dbPath);
|
|
621
|
+
await indexWorkspace(db, ws.dir);
|
|
622
|
+
|
|
623
|
+
const stats = getStats(db);
|
|
624
|
+
gte(stats.documents, 1, `Document count >= 1 (got ${stats.documents})`);
|
|
625
|
+
gte(stats.chunks, 1, `Chunk count >= 1 (got ${stats.chunks})`);
|
|
626
|
+
eq(stats.embedding_dim, EMBEDDING_DIM, 'Embedding dim matches');
|
|
627
|
+
eq(stats.model, MODEL_NAME, 'Model name matches');
|
|
628
|
+
ok(stats.last_indexed, 'last_indexed timestamp present');
|
|
629
|
+
|
|
630
|
+
db.close();
|
|
631
|
+
ws.cleanup();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ─── Group 11: Engine Factory ────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
async function testEngine() {
|
|
637
|
+
group('11. Engine Factory');
|
|
638
|
+
|
|
639
|
+
const ws = createTestWorkspace();
|
|
640
|
+
const memDir = join(ws.dir, 'memory');
|
|
641
|
+
mkdirSync(memDir, { recursive: true });
|
|
642
|
+
writeFileSync(join(memDir, 'engine-test.md'), '# Engine Test\n\nSemantic search engine testing document.');
|
|
643
|
+
|
|
644
|
+
const dbPath = join(ws.dir, 'engine.db');
|
|
645
|
+
const engine = await createKnowledgeEngine({
|
|
646
|
+
workspace: ws.dir,
|
|
647
|
+
dbPath,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Interface check
|
|
651
|
+
ok(typeof engine.search === 'function', 'engine.search is a function');
|
|
652
|
+
ok(typeof engine.related === 'function', 'engine.related is a function');
|
|
653
|
+
ok(typeof engine.reindex === 'function', 'engine.reindex is a function');
|
|
654
|
+
ok(typeof engine.stats === 'function', 'engine.stats is a function');
|
|
655
|
+
|
|
656
|
+
// Search through engine
|
|
657
|
+
const results = await engine.search('engine testing', 5);
|
|
658
|
+
ok(Array.isArray(results), 'engine.search returns array');
|
|
659
|
+
|
|
660
|
+
// Stats through engine
|
|
661
|
+
const stats = engine.stats();
|
|
662
|
+
gte(stats.documents, 1, 'engine.stats shows indexed docs');
|
|
663
|
+
|
|
664
|
+
// Reindex through engine
|
|
665
|
+
const reResult = await engine.reindex(true);
|
|
666
|
+
ok(typeof reResult.indexed === 'number', 'engine.reindex returns result object');
|
|
667
|
+
|
|
668
|
+
engine.db.close();
|
|
669
|
+
ws.cleanup();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ─── Group 12: HTTP Transport ────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
async function testHttpTransport() {
|
|
675
|
+
group('12. HTTP Transport');
|
|
676
|
+
|
|
677
|
+
const ws = createTestWorkspace();
|
|
678
|
+
const memDir = join(ws.dir, 'memory');
|
|
679
|
+
mkdirSync(memDir, { recursive: true });
|
|
680
|
+
writeFileSync(join(memDir, 'http-test.md'), '# HTTP Test\n\nDocument for HTTP transport testing.');
|
|
681
|
+
|
|
682
|
+
const port = 3199 + Math.floor(Math.random() * 100);
|
|
683
|
+
const dbPath = join(ws.dir, 'http.db');
|
|
684
|
+
|
|
685
|
+
const serverProc = spawn('node', [join(__dirname, 'server.mjs')], {
|
|
686
|
+
env: {
|
|
687
|
+
...process.env,
|
|
688
|
+
KNOWLEDGE_ROOT: ws.dir,
|
|
689
|
+
KNOWLEDGE_DB: dbPath,
|
|
690
|
+
KNOWLEDGE_PORT: String(port),
|
|
691
|
+
KNOWLEDGE_POLL_MS: '0',
|
|
692
|
+
},
|
|
693
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Wait for readiness by watching stderr for the "listening" message
|
|
697
|
+
let ready = false;
|
|
698
|
+
let stderr = '';
|
|
699
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
700
|
+
const timeout = setTimeout(() => reject(new Error('HTTP server startup timeout (60s)')), 60000);
|
|
701
|
+
serverProc.stderr.on('data', (data) => {
|
|
702
|
+
stderr += data.toString();
|
|
703
|
+
if (stderr.includes('HTTP MCP server listening')) {
|
|
704
|
+
ready = true;
|
|
705
|
+
clearTimeout(timeout);
|
|
706
|
+
resolve();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
serverProc.on('exit', (code) => {
|
|
710
|
+
if (!ready) {
|
|
711
|
+
clearTimeout(timeout);
|
|
712
|
+
reject(new Error(`Server exited with code ${code} before ready. stderr: ${stderr}`));
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
await readyPromise;
|
|
719
|
+
|
|
720
|
+
// GET /health
|
|
721
|
+
const healthRes = await fetch(`http://127.0.0.1:${port}/health`);
|
|
722
|
+
const health = await healthRes.json();
|
|
723
|
+
eq(healthRes.status, 200, 'GET /health returns 200');
|
|
724
|
+
eq(health.status, 'ok', 'Health status is "ok"');
|
|
725
|
+
ok(typeof health.uptime === 'number', 'Health has uptime field');
|
|
726
|
+
|
|
727
|
+
// POST /mcp with initialize
|
|
728
|
+
const mcpRes = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
729
|
+
method: 'POST',
|
|
730
|
+
headers: {
|
|
731
|
+
'Content-Type': 'application/json',
|
|
732
|
+
'Accept': 'text/event-stream, application/json',
|
|
733
|
+
},
|
|
734
|
+
body: JSON.stringify({
|
|
735
|
+
jsonrpc: '2.0',
|
|
736
|
+
method: 'initialize',
|
|
737
|
+
params: {
|
|
738
|
+
protocolVersion: '2025-03-26',
|
|
739
|
+
capabilities: {},
|
|
740
|
+
clientInfo: { name: 'test', version: '0.1.0' },
|
|
741
|
+
},
|
|
742
|
+
id: 1,
|
|
743
|
+
}),
|
|
744
|
+
});
|
|
745
|
+
eq(mcpRes.status, 200, 'POST /mcp initialize returns 200');
|
|
746
|
+
const mcpBody = await mcpRes.text();
|
|
747
|
+
ok(mcpBody.includes('@openclaw/mcp-knowledge'), 'MCP response contains server name');
|
|
748
|
+
|
|
749
|
+
// GET /mcp → 405
|
|
750
|
+
const getRes = await fetch(`http://127.0.0.1:${port}/mcp`);
|
|
751
|
+
eq(getRes.status, 405, 'GET /mcp returns 405');
|
|
752
|
+
|
|
753
|
+
// GET /unknown → 404
|
|
754
|
+
const notFoundRes = await fetch(`http://127.0.0.1:${port}/unknown`);
|
|
755
|
+
eq(notFoundRes.status, 404, 'GET /unknown returns 404');
|
|
756
|
+
|
|
757
|
+
// MCP initialize response has tool capabilities
|
|
758
|
+
ok(mcpBody.includes('"tools"'), 'Initialize response declares tool capabilities');
|
|
759
|
+
|
|
760
|
+
// DELETE /mcp → 405
|
|
761
|
+
const delRes = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'DELETE' });
|
|
762
|
+
eq(delRes.status, 405, 'DELETE /mcp returns 405');
|
|
763
|
+
|
|
764
|
+
} finally {
|
|
765
|
+
serverProc.kill('SIGTERM');
|
|
766
|
+
ws.cleanup();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ─── Run All ─────────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
async function main() {
|
|
773
|
+
process.stdout.write('╔══════════════════════════════════════════════════╗\n');
|
|
774
|
+
process.stdout.write('║ @openclaw/mcp-knowledge — Full Test Suite ║\n');
|
|
775
|
+
process.stdout.write('╚══════════════════════════════════════════════════╝\n');
|
|
776
|
+
|
|
777
|
+
// Sync tests (no model needed)
|
|
778
|
+
testScanner();
|
|
779
|
+
testChunker();
|
|
780
|
+
testHashing();
|
|
781
|
+
testDatabase();
|
|
782
|
+
testSqliteVec();
|
|
783
|
+
|
|
784
|
+
// Async tests (model needed)
|
|
785
|
+
const extractor = await testEmbedding();
|
|
786
|
+
await testIndexer(extractor);
|
|
787
|
+
await testSearch(extractor);
|
|
788
|
+
await testFindRelated(extractor);
|
|
789
|
+
await testGetStats(extractor);
|
|
790
|
+
await testEngine();
|
|
791
|
+
await testHttpTransport();
|
|
792
|
+
|
|
793
|
+
process.stdout.write(`\n${'═'.repeat(50)}\n`);
|
|
794
|
+
process.stdout.write(`Results: ${passed} passed, ${failed} failed\n`);
|
|
795
|
+
process.stdout.write(`${'═'.repeat(50)}\n`);
|
|
796
|
+
setTimeout(() => process.exit(failed > 0 ? 1 : 0), 200);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
main().catch(err => {
|
|
800
|
+
process.stderr.write(`Test error: ${err.message}\n${err.stack}\n`);
|
|
801
|
+
process.exit(1);
|
|
802
|
+
});
|