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.
Files changed (134) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/lane-watchdog.js +23 -2
  4. package/bin/mesh-agent.js +439 -28
  5. package/bin/mesh-bridge.js +69 -3
  6. package/bin/mesh-health-publisher.js +41 -1
  7. package/bin/mesh-task-daemon.js +821 -26
  8. package/bin/mesh.js +411 -20
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +296 -10
  29. package/lib/agent-activity.js +2 -2
  30. package/lib/circling-parser.js +119 -0
  31. package/lib/exec-safety.js +105 -0
  32. package/lib/hyperagent-store.mjs +652 -0
  33. package/lib/kanban-io.js +24 -31
  34. package/lib/llm-providers.js +16 -0
  35. package/lib/mcp-knowledge/bench.mjs +118 -0
  36. package/lib/mcp-knowledge/core.mjs +530 -0
  37. package/lib/mcp-knowledge/package.json +25 -0
  38. package/lib/mcp-knowledge/server.mjs +252 -0
  39. package/lib/mcp-knowledge/test.mjs +802 -0
  40. package/lib/memory-budget.mjs +261 -0
  41. package/lib/mesh-collab.js +483 -165
  42. package/lib/mesh-harness.js +427 -0
  43. package/lib/mesh-plans.js +79 -50
  44. package/lib/mesh-tasks.js +132 -49
  45. package/lib/nats-resolve.js +4 -4
  46. package/lib/plan-templates.js +226 -0
  47. package/lib/pre-compression-flush.mjs +322 -0
  48. package/lib/role-loader.js +292 -0
  49. package/lib/rule-loader.js +358 -0
  50. package/lib/session-store.mjs +461 -0
  51. package/lib/transcript-parser.mjs +292 -0
  52. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  53. package/mission-control/drizzle.config.ts +1 -4
  54. package/mission-control/package-lock.json +1571 -83
  55. package/mission-control/package.json +6 -2
  56. package/mission-control/scripts/gen-chronology.js +3 -3
  57. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  58. package/mission-control/scripts/import-pipeline.js +0 -15
  59. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  60. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  61. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  62. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  63. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  64. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  65. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  66. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  67. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  68. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  69. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  70. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  71. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  72. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  73. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  74. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  75. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  76. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  77. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
  78. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  79. package/mission-control/src/app/api/tasks/route.ts +21 -30
  80. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  81. package/mission-control/src/app/cowork/page.tsx +261 -0
  82. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  83. package/mission-control/src/app/graph/page.tsx +26 -0
  84. package/mission-control/src/app/memory/page.tsx +1 -1
  85. package/mission-control/src/app/obsidian/page.tsx +36 -6
  86. package/mission-control/src/app/roadmap/page.tsx +24 -0
  87. package/mission-control/src/app/souls/page.tsx +2 -2
  88. package/mission-control/src/components/board/execution-config.tsx +431 -0
  89. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  90. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  91. package/mission-control/src/components/board/task-card.tsx +55 -2
  92. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  93. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  94. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  95. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  96. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  97. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  98. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  99. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  100. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  101. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  102. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  103. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  104. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  105. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  106. package/mission-control/src/lib/config.ts +67 -0
  107. package/mission-control/src/lib/db/index.ts +85 -1
  108. package/mission-control/src/lib/db/schema.ts +61 -3
  109. package/mission-control/src/lib/hooks.ts +309 -0
  110. package/mission-control/src/lib/memory/entities.ts +3 -2
  111. package/mission-control/src/lib/memory/extract.ts +2 -1
  112. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  113. package/mission-control/src/lib/nats.ts +66 -1
  114. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  115. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  116. package/mission-control/src/lib/scheduler.ts +12 -11
  117. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  118. package/mission-control/src/lib/sync/tasks.ts +23 -1
  119. package/mission-control/src/lib/task-id.ts +32 -0
  120. package/mission-control/src/lib/tts/index.ts +33 -9
  121. package/mission-control/src/middleware.ts +82 -0
  122. package/mission-control/tsconfig.json +2 -1
  123. package/mission-control/vitest.config.ts +14 -0
  124. package/package.json +15 -2
  125. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  126. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  127. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  128. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  129. package/services/service-manifest.json +1 -1
  130. package/skills/cc-godmode/references/agents.md +8 -8
  131. package/uninstall.sh +37 -9
  132. package/workspace-bin/memory-daemon.mjs +199 -5
  133. package/workspace-bin/session-search.mjs +204 -0
  134. 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
+ });