@grafema/mcp 0.3.27 → 0.3.29

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.
@@ -0,0 +1,1114 @@
1
+ /**
2
+ * Enox Handlers — RFDB-backed knowledge graph for Grafema MCP server.
3
+ *
4
+ * Replaces the file-based knowledge-handlers.ts with a proper graph database.
5
+ * All knowledge lives in a separate RFDB database called "knowledge",
6
+ * sharing the same server socket as the code graph.
7
+ */
8
+
9
+ import { createHash } from 'crypto';
10
+ import { join, dirname } from 'path';
11
+ import { RFDBClient } from '@grafema/util';
12
+ import { textResult, errorResult, log } from '../utils.js';
13
+ import { getProjectPath, getSocketPathOverride } from '../state.js';
14
+ import { loadConfig } from '../config.js';
15
+ import type { MCPConfig } from '../config.js';
16
+ import type { ToolResult } from '../types.js';
17
+ import type { WireNode, WireEdge, EdgeType, AttrQuery } from '@grafema/types';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Key normalization
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function normalizeKey(name: string): string {
24
+ return name
25
+ .toLowerCase()
26
+ .replace(/\s*\(.*?\)\s*/g, '')
27
+ .replace(/[_\-]+/g, ' ')
28
+ .replace(/\s+/g, ' ')
29
+ .trim();
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Fact ID computation
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function computeFactId(from: string, rel: string, to: string): string {
37
+ return createHash('sha256').update(`${from}|${rel}|${to}`).digest('hex');
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Knowledge client singleton
42
+ // ---------------------------------------------------------------------------
43
+
44
+ let knowledgeClient: RFDBClient | null = null;
45
+ let knowledgeDbReady = false;
46
+
47
+ /**
48
+ * Derive the socket path using the same logic as RFDBServerBackend.
49
+ * Priority: --socket CLI override > config rfdb_socket > auto-derive from dbPath.
50
+ */
51
+ function deriveSocketPath(): string {
52
+ const projectPath = getProjectPath();
53
+ const config = loadConfig(projectPath) as MCPConfig;
54
+ const override = getSocketPathOverride();
55
+
56
+ if (override) return override;
57
+ if (config.rfdb_socket) return config.rfdb_socket;
58
+
59
+ const dbPath = join(projectPath, '.grafema', 'graph.rfdb');
60
+ const localSocket = join(dirname(dbPath), 'rfdb.sock');
61
+ const SUN_LEN = process.platform === 'darwin' ? 104 : 108;
62
+
63
+ if (Buffer.byteLength(localSocket) < SUN_LEN) {
64
+ return localSocket;
65
+ }
66
+
67
+ const hash = createHash('md5').update(dirname(dbPath)).digest('hex').slice(0, 12);
68
+ return join('/tmp', `grafema-${hash}.sock`);
69
+ }
70
+
71
+ /**
72
+ * Get or create the knowledge RFDB client.
73
+ * Connects to the same RFDB server socket as the code graph,
74
+ * but opens/creates a separate "knowledge" database.
75
+ */
76
+ async function getKnowledgeClient(): Promise<RFDBClient> {
77
+ if (knowledgeClient?.connected && knowledgeDbReady) {
78
+ return knowledgeClient;
79
+ }
80
+
81
+ // Connection died or never existed — (re)create
82
+ if (knowledgeClient) {
83
+ try { await knowledgeClient.close(); } catch { /* ignore */ }
84
+ }
85
+
86
+ const socketPath = deriveSocketPath();
87
+ log(`[Enox] Connecting to RFDB at ${socketPath}`);
88
+
89
+ knowledgeClient = new RFDBClient(socketPath, 'enox-knowledge');
90
+ await knowledgeClient.connect();
91
+ await knowledgeClient.hello();
92
+
93
+ // Create or open the "knowledge" database
94
+ try {
95
+ await knowledgeClient.createDatabase('knowledge');
96
+ log('[Enox] Created "knowledge" database');
97
+ } catch {
98
+ // Already exists — that's fine
99
+ }
100
+ await knowledgeClient.openDatabase('knowledge', 'rw');
101
+ knowledgeDbReady = true;
102
+ log('[Enox] Opened "knowledge" database');
103
+
104
+ return knowledgeClient;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Metadata helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function parseMeta(node: WireNode): Record<string, unknown> {
112
+ try {
113
+ return node.metadata ? JSON.parse(node.metadata) : {};
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ function parseEdgeMeta(edge: WireEdge): Record<string, unknown> {
120
+ try {
121
+ return edge.metadata ? JSON.parse(edge.metadata) : {};
122
+ } catch {
123
+ return {};
124
+ }
125
+ }
126
+
127
+ function nowISO(): string {
128
+ return new Date().toISOString();
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Node/edge collection helpers
133
+ // ---------------------------------------------------------------------------
134
+
135
+ async function collectNodes(client: RFDBClient, query: AttrQuery): Promise<WireNode[]> {
136
+ const nodes: WireNode[] = [];
137
+ for await (const node of client.queryNodes(query)) {
138
+ nodes.push(node);
139
+ }
140
+ return nodes;
141
+ }
142
+
143
+ async function findNodeByName(client: RFDBClient, name: string): Promise<WireNode | null> {
144
+ const key = normalizeKey(name);
145
+
146
+ // Try exact match on normalized key
147
+ const exact = await collectNodes(client, { name: key });
148
+ if (exact.length > 0) return exact[0];
149
+
150
+ // Try original name (might have dashes/underscores)
151
+ if (name !== key) {
152
+ const orig = await collectNodes(client, { name: name.toLowerCase() });
153
+ if (orig.length > 0) return orig[0];
154
+ }
155
+
156
+ // Substring fallback — try each word
157
+ const words = key.split(' ').filter(w => w.length > 2);
158
+ for (const word of words) {
159
+ const matches = await collectNodes(client, { name: word, substringMatch: true });
160
+ if (matches.length > 0) {
161
+ // Pick the best match (shortest name = most specific)
162
+ matches.sort((a, b) => a.name.length - b.name.length);
163
+ return matches[0];
164
+ }
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Ensure a node exists for the given entity name.
172
+ * Returns the node ID (normalized key).
173
+ */
174
+ async function ensureEntity(
175
+ client: RFDBClient,
176
+ name: string,
177
+ nodeType: string = 'ENTITY',
178
+ domain: string = 'general',
179
+ ): Promise<string> {
180
+ const id = normalizeKey(name);
181
+ const existing = await client.getNode(id);
182
+ if (existing) return id;
183
+
184
+ await client.addNodes([{
185
+ id,
186
+ nodeType: nodeType as WireNode['nodeType'],
187
+ name: id,
188
+ file: domain,
189
+ exported: false,
190
+ metadata: JSON.stringify({ created_at: nowISO(), domain }),
191
+ }]);
192
+
193
+ return id;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 1. handleRemember
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export interface RememberArgs {
201
+ subject: string;
202
+ fact: string;
203
+ domain?: string;
204
+ confidence?: number;
205
+ relation?: string;
206
+ }
207
+
208
+ export async function handleRemember(args: RememberArgs): Promise<ToolResult> {
209
+ try {
210
+ const client = await getKnowledgeClient();
211
+ const domain = args.domain || 'general';
212
+ const relation = args.relation || 'HAS_FACT';
213
+
214
+ // Ensure subject node
215
+ const subjectId = await ensureEntity(client, args.subject, 'ENTITY', domain);
216
+
217
+ // Create fact node
218
+ const factId = createHash('sha256')
219
+ .update(`${subjectId}|${args.fact}|${Date.now()}`)
220
+ .digest('hex')
221
+ .slice(0, 16);
222
+ const factNodeId = `fact:${factId}`;
223
+
224
+ await client.addNodes([{
225
+ id: factNodeId,
226
+ nodeType: 'FACT' as WireNode['nodeType'],
227
+ name: args.fact.slice(0, 120),
228
+ file: domain,
229
+ exported: false,
230
+ metadata: JSON.stringify({
231
+ content: args.fact,
232
+ domain,
233
+ created_at: nowISO(),
234
+ }),
235
+ }]);
236
+
237
+ // Create edge from subject to fact
238
+ await client.addEdges([{
239
+ src: subjectId,
240
+ dst: factNodeId,
241
+ edgeType: relation as EdgeType,
242
+ metadata: JSON.stringify({ created_at: nowISO() }),
243
+ }]);
244
+
245
+ await client.flush();
246
+
247
+ return textResult(
248
+ `Remembered:\n` +
249
+ ` Subject: ${subjectId}\n` +
250
+ ` Fact: ${factNodeId}\n` +
251
+ ` Relation: ${relation}\n` +
252
+ ` Domain: ${domain}`
253
+ );
254
+ } catch (error) {
255
+ const msg = error instanceof Error ? error.message : String(error);
256
+ return errorResult(`Failed to remember: ${msg}`);
257
+ }
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // 2. handleRecall
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export interface RecallArgs {
265
+ query: string;
266
+ depth?: number;
267
+ }
268
+
269
+ export async function handleRecall(args: RecallArgs): Promise<ToolResult> {
270
+ try {
271
+ const client = await getKnowledgeClient();
272
+ const depth = args.depth ?? 2;
273
+
274
+ // Find entities: try full query first, then individual words
275
+ let nodes = await collectNodes(client, {
276
+ name: args.query.toLowerCase(),
277
+ substringMatch: true,
278
+ });
279
+
280
+ if (nodes.length === 0) {
281
+ const words = args.query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
282
+ const seen = new Set<string>();
283
+ for (const word of words.sort((a, b) => b.length - a.length)) {
284
+ const matches = await collectNodes(client, { name: word, substringMatch: true });
285
+ for (const m of matches) {
286
+ if (!seen.has(m.id) && m.nodeType !== 'NPM_SYMBOL' && m.nodeType !== 'NPM_PACKAGE') {
287
+ seen.add(m.id);
288
+ nodes.push(m);
289
+ }
290
+ }
291
+ if (nodes.length >= 10) break;
292
+ }
293
+ }
294
+
295
+ if (nodes.length === 0) {
296
+ return textResult(`No knowledge found for "${args.query}".`);
297
+ }
298
+
299
+ const lines: string[] = [];
300
+
301
+ for (const node of nodes.slice(0, 10)) {
302
+ const meta = parseMeta(node);
303
+ lines.push(`## ${node.name} [${node.nodeType}]`);
304
+ if (meta.domain) lines.push(`Domain: ${meta.domain}`);
305
+ if (meta.content) lines.push(`Content: ${String(meta.content)}`);
306
+ if (meta.description) lines.push(`Description: ${String(meta.description)}`);
307
+ lines.push('');
308
+
309
+ // Traverse neighborhood
310
+ if (depth > 0) {
311
+ const outgoing = await client.getOutgoingEdges(node.id);
312
+ const incoming = await client.getIncomingEdges(node.id);
313
+
314
+ if (outgoing.length > 0) {
315
+ lines.push('### Outgoing:');
316
+ for (const edge of outgoing.slice(0, 20)) {
317
+ const target = await client.getNode(edge.dst);
318
+ const eMeta = parseEdgeMeta(edge);
319
+ const targetName = target?.name || edge.dst;
320
+ lines.push(` -[${edge.edgeType}]-> ${targetName}${eMeta.confidence ? ` (confidence: ${eMeta.confidence})` : ''}`);
321
+ }
322
+ lines.push('');
323
+ }
324
+
325
+ if (incoming.length > 0) {
326
+ lines.push('### Incoming:');
327
+ for (const edge of incoming.slice(0, 20)) {
328
+ const source = await client.getNode(edge.src);
329
+ const eMeta = parseEdgeMeta(edge);
330
+ const sourceName = source?.name || edge.src;
331
+ lines.push(` ${sourceName} -[${edge.edgeType}]-> this${eMeta.confidence ? ` (confidence: ${eMeta.confidence})` : ''}`);
332
+ }
333
+ lines.push('');
334
+ }
335
+ }
336
+ }
337
+
338
+ return textResult(lines.join('\n'));
339
+ } catch (error) {
340
+ const msg = error instanceof Error ? error.message : String(error);
341
+ return errorResult(`Failed to recall: ${msg}`);
342
+ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // 3. handleSemanticSearch
347
+ // ---------------------------------------------------------------------------
348
+
349
+ export interface SemanticSearchArgs {
350
+ query: string;
351
+ limit?: number;
352
+ domain?: string;
353
+ }
354
+
355
+ export async function handleSemanticSearch(args: SemanticSearchArgs): Promise<ToolResult> {
356
+ try {
357
+ const client = await getKnowledgeClient();
358
+ const limit = args.limit ?? 20;
359
+
360
+ // TODO: Wire to RFDB embedding engine when enabled.
361
+ // For now, fall back to substring search via queryNodes.
362
+ const querySpec: AttrQuery = {
363
+ name: args.query.toLowerCase(),
364
+ substringMatch: true,
365
+ };
366
+ if (args.domain) {
367
+ querySpec.file = args.domain; // domain stored in file field
368
+ }
369
+
370
+ const nodes = await collectNodes(client, querySpec);
371
+ const results = nodes.slice(0, limit);
372
+
373
+ if (results.length === 0) {
374
+ return textResult(`No results for semantic search: "${args.query}".`);
375
+ }
376
+
377
+ const lines: string[] = [`Found ${results.length} result(s) for "${args.query}":\n`];
378
+ for (let i = 0; i < results.length; i++) {
379
+ const node = results[i];
380
+ const meta = parseMeta(node);
381
+ // Pseudo-similarity score based on match quality (placeholder until embeddings)
382
+ const similarity = (1.0 - (i * 0.05)).toFixed(2);
383
+ lines.push(`${i + 1}. [${similarity}] ${node.name} (${node.nodeType})`);
384
+ if (meta.domain) lines.push(` Domain: ${meta.domain}`);
385
+ if (meta.content) lines.push(` ${String(meta.content).slice(0, 200)}`);
386
+ lines.push('');
387
+ }
388
+
389
+ return textResult(lines.join('\n'));
390
+ } catch (error) {
391
+ const msg = error instanceof Error ? error.message : String(error);
392
+ return errorResult(`Failed to search: ${msg}`);
393
+ }
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // 4. handleExploreEntity
398
+ // ---------------------------------------------------------------------------
399
+
400
+ export interface ExploreEntityArgs {
401
+ name: string;
402
+ depth?: number;
403
+ }
404
+
405
+ export async function handleExploreEntity(args: ExploreEntityArgs): Promise<ToolResult> {
406
+ try {
407
+ const client = await getKnowledgeClient();
408
+
409
+ const node = await findNodeByName(client, args.name);
410
+ if (!node) {
411
+ return errorResult(`Entity "${args.name}" not found. Use semantic_search to find similar entities.`);
412
+ }
413
+
414
+ const meta = parseMeta(node);
415
+ const lines: string[] = [
416
+ `# ${node.name}`,
417
+ `Type: ${node.nodeType}`,
418
+ `ID: ${node.id}`,
419
+ ];
420
+ if (meta.domain) lines.push(`Domain: ${meta.domain}`);
421
+ if (meta.description) lines.push(`Description: ${meta.description}`);
422
+ if (meta.content) lines.push(`Content: ${String(meta.content).slice(0, 500)}`);
423
+ if (meta.created_at) lines.push(`Created: ${meta.created_at}`);
424
+ lines.push('');
425
+
426
+ // Outgoing edges
427
+ const outgoing = await client.getOutgoingEdges(node.id);
428
+ if (outgoing.length > 0) {
429
+ lines.push(`## Outgoing (${outgoing.length}):`);
430
+ for (const edge of outgoing) {
431
+ const target = await client.getNode(edge.dst);
432
+ const eMeta = parseEdgeMeta(edge);
433
+ const targetName = target?.name || edge.dst;
434
+ let detail = ` -[${edge.edgeType}]-> ${targetName}`;
435
+ if (eMeta.confidence) detail += ` | confidence: ${eMeta.confidence}`;
436
+ if (eMeta.condition) detail += ` | condition: ${eMeta.condition}`;
437
+ if (eMeta.note) detail += ` | note: ${eMeta.note}`;
438
+ lines.push(detail);
439
+ }
440
+ lines.push('');
441
+ }
442
+
443
+ // Incoming edges
444
+ const incoming = await client.getIncomingEdges(node.id);
445
+ if (incoming.length > 0) {
446
+ lines.push(`## Incoming (${incoming.length}):`);
447
+ for (const edge of incoming) {
448
+ const source = await client.getNode(edge.src);
449
+ const eMeta = parseEdgeMeta(edge);
450
+ const sourceName = source?.name || edge.src;
451
+ let detail = ` ${sourceName} -[${edge.edgeType}]->`;
452
+ if (eMeta.confidence) detail += ` | confidence: ${eMeta.confidence}`;
453
+ if (eMeta.condition) detail += ` | condition: ${eMeta.condition}`;
454
+ if (eMeta.note) detail += ` | note: ${eMeta.note}`;
455
+ lines.push(detail);
456
+ }
457
+ lines.push('');
458
+ }
459
+
460
+ if (outgoing.length === 0 && incoming.length === 0) {
461
+ lines.push('No edges found (isolated node).');
462
+ }
463
+
464
+ return textResult(lines.join('\n'));
465
+ } catch (error) {
466
+ const msg = error instanceof Error ? error.message : String(error);
467
+ return errorResult(`Failed to explore: ${msg}`);
468
+ }
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // 5. handleAddAssertion
473
+ // ---------------------------------------------------------------------------
474
+
475
+ export interface AddAssertionArgs {
476
+ from: string;
477
+ relation: string;
478
+ to: string;
479
+ context?: string;
480
+ confidence?: number;
481
+ domain?: string;
482
+ note?: string;
483
+ condition?: string;
484
+ }
485
+
486
+ export async function handleAddAssertion(args: AddAssertionArgs): Promise<ToolResult> {
487
+ try {
488
+ const client = await getKnowledgeClient();
489
+ const domain = args.domain || 'general';
490
+
491
+ // Ensure from and to nodes exist
492
+ const fromId = await ensureEntity(client, args.from, 'ENTITY', domain);
493
+ const toId = await ensureEntity(client, args.to, 'ENTITY', domain);
494
+
495
+ const factId = computeFactId(fromId, args.relation, toId);
496
+
497
+ const edgeMeta: Record<string, unknown> = {
498
+ fact_id: factId,
499
+ domain,
500
+ created_at: nowISO(),
501
+ };
502
+ if (args.context) edgeMeta.context = args.context;
503
+ if (args.confidence !== undefined) edgeMeta.confidence = args.confidence;
504
+ if (args.note) edgeMeta.note = args.note;
505
+ if (args.condition) edgeMeta.condition = args.condition;
506
+
507
+ await client.addEdges([{
508
+ src: fromId,
509
+ dst: toId,
510
+ edgeType: args.relation as EdgeType,
511
+ metadata: JSON.stringify(edgeMeta),
512
+ }]);
513
+
514
+ await client.flush();
515
+
516
+ return textResult(
517
+ `Assertion added:\n` +
518
+ ` ${fromId} -[${args.relation}]-> ${toId}\n` +
519
+ ` fact_id: ${factId}\n` +
520
+ ` domain: ${domain}` +
521
+ (args.confidence !== undefined ? `\n confidence: ${args.confidence}` : '')
522
+ );
523
+ } catch (error) {
524
+ const msg = error instanceof Error ? error.message : String(error);
525
+ return errorResult(`Failed to add assertion: ${msg}`);
526
+ }
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // 6. handleUpdateAssertion
531
+ // ---------------------------------------------------------------------------
532
+
533
+ export interface UpdateAssertionArgs {
534
+ fact_id: string;
535
+ confidence?: number;
536
+ context?: string;
537
+ note?: string;
538
+ condition?: string;
539
+ domain?: string;
540
+ }
541
+
542
+ export async function handleUpdateAssertion(args: UpdateAssertionArgs): Promise<ToolResult> {
543
+ try {
544
+ const client = await getKnowledgeClient();
545
+
546
+ // Scan all edges to find the one with matching fact_id.
547
+ // This is expensive but necessary because RFDB doesn't index edge metadata.
548
+ const allEdges = await client.getAllEdges();
549
+ let found: (WireEdge & Record<string, unknown>) | null = null;
550
+
551
+ for (const edge of allEdges) {
552
+ const meta = parseEdgeMeta(edge);
553
+ if (meta.fact_id === args.fact_id) {
554
+ found = edge;
555
+ break;
556
+ }
557
+ }
558
+
559
+ if (!found) {
560
+ return errorResult(`Assertion with fact_id "${args.fact_id}" not found.`);
561
+ }
562
+
563
+ // Delete old edge, create new one with updated metadata
564
+ const oldMeta = parseEdgeMeta(found);
565
+ const newMeta: Record<string, unknown> = { ...oldMeta, updated_at: nowISO() };
566
+ if (args.confidence !== undefined) newMeta.confidence = args.confidence;
567
+ if (args.context !== undefined) newMeta.context = args.context;
568
+ if (args.note !== undefined) newMeta.note = args.note;
569
+ if (args.condition !== undefined) newMeta.condition = args.condition;
570
+ if (args.domain !== undefined) newMeta.domain = args.domain;
571
+
572
+ await client.deleteEdge(found.src, found.dst, found.edgeType as EdgeType);
573
+ await client.addEdges([{
574
+ src: found.src,
575
+ dst: found.dst,
576
+ edgeType: found.edgeType as EdgeType,
577
+ metadata: JSON.stringify(newMeta),
578
+ }]);
579
+
580
+ await client.flush();
581
+
582
+ return textResult(
583
+ `Assertion updated:\n` +
584
+ ` fact_id: ${args.fact_id}\n` +
585
+ ` ${found.src} -[${found.edgeType}]-> ${found.dst}`
586
+ );
587
+ } catch (error) {
588
+ const msg = error instanceof Error ? error.message : String(error);
589
+ return errorResult(`Failed to update assertion: ${msg}`);
590
+ }
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // 7. handleDeleteAssertion
595
+ // ---------------------------------------------------------------------------
596
+
597
+ export interface DeleteAssertionArgs {
598
+ fact_id: string;
599
+ }
600
+
601
+ export async function handleDeleteAssertion(args: DeleteAssertionArgs): Promise<ToolResult> {
602
+ try {
603
+ const client = await getKnowledgeClient();
604
+
605
+ const allEdges = await client.getAllEdges();
606
+ let found: (WireEdge & Record<string, unknown>) | null = null;
607
+
608
+ for (const edge of allEdges) {
609
+ const meta = parseEdgeMeta(edge);
610
+ if (meta.fact_id === args.fact_id) {
611
+ found = edge;
612
+ break;
613
+ }
614
+ }
615
+
616
+ if (!found) {
617
+ return errorResult(`Assertion with fact_id "${args.fact_id}" not found.`);
618
+ }
619
+
620
+ await client.deleteEdge(found.src, found.dst, found.edgeType as EdgeType);
621
+ await client.flush();
622
+
623
+ return textResult(
624
+ `Assertion deleted:\n` +
625
+ ` fact_id: ${args.fact_id}\n` +
626
+ ` ${found.src} -[${found.edgeType}]-> ${found.dst}`
627
+ );
628
+ } catch (error) {
629
+ const msg = error instanceof Error ? error.message : String(error);
630
+ return errorResult(`Failed to delete assertion: ${msg}`);
631
+ }
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // 8. handleEnoxQuery
636
+ // ---------------------------------------------------------------------------
637
+
638
+ export interface QueryGraphKnowledgeArgs {
639
+ type?: string;
640
+ domain?: string;
641
+ name?: string;
642
+ limit?: number;
643
+ }
644
+
645
+ export async function handleEnoxQuery(args: QueryGraphKnowledgeArgs): Promise<ToolResult> {
646
+ try {
647
+ const client = await getKnowledgeClient();
648
+ const limit = args.limit ?? 50;
649
+
650
+ const querySpec: AttrQuery = {};
651
+ if (args.type) querySpec.nodeType = args.type;
652
+ if (args.name) {
653
+ querySpec.name = args.name.toLowerCase();
654
+ querySpec.substringMatch = true;
655
+ }
656
+ if (args.domain) {
657
+ querySpec.file = args.domain; // domain stored in file field
658
+ }
659
+
660
+ const nodes = await collectNodes(client, querySpec);
661
+ const results = nodes.slice(0, limit);
662
+
663
+ if (results.length === 0) {
664
+ return textResult('No matching nodes in knowledge graph.');
665
+ }
666
+
667
+ const lines: string[] = [`Found ${results.length} node(s):\n`];
668
+ for (const node of results) {
669
+ const meta = parseMeta(node);
670
+ lines.push(`- ${node.id} | ${node.nodeType} | ${node.name}`);
671
+ if (meta.domain) lines.push(` domain: ${meta.domain}`);
672
+ if (meta.created_at) lines.push(` created: ${meta.created_at}`);
673
+ }
674
+
675
+ return textResult(lines.join('\n'));
676
+ } catch (error) {
677
+ const msg = error instanceof Error ? error.message : String(error);
678
+ return errorResult(`Failed to query knowledge graph: ${msg}`);
679
+ }
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // 9. handleEnoxTraverse
684
+ // ---------------------------------------------------------------------------
685
+
686
+ export interface TraverseArgs {
687
+ entity: string;
688
+ direction?: 'outgoing' | 'incoming' | 'both';
689
+ max_depth?: number;
690
+ edge_types?: string[];
691
+ }
692
+
693
+ export async function handleEnoxTraverse(args: TraverseArgs): Promise<ToolResult> {
694
+ try {
695
+ const client = await getKnowledgeClient();
696
+ const direction = args.direction ?? 'outgoing';
697
+ const maxDepth = args.max_depth ?? 3;
698
+
699
+ // Resolve entity to node ID
700
+ const node = await findNodeByName(client, args.entity);
701
+ if (!node) {
702
+ return errorResult(`Entity "${args.entity}" not found.`);
703
+ }
704
+
705
+ const edgeFilter = args.edge_types?.length
706
+ ? args.edge_types as EdgeType[]
707
+ : undefined;
708
+
709
+ // BFS traversal
710
+ const visited = new Map<string, number>(); // id -> depth
711
+ const queue: Array<{ id: string; depth: number }> = [{ id: node.id, depth: 0 }];
712
+ visited.set(node.id, 0);
713
+
714
+ while (queue.length > 0) {
715
+ const current = queue.shift()!;
716
+ if (current.depth >= maxDepth) continue;
717
+
718
+ const edges: (WireEdge & Record<string, unknown>)[] = [];
719
+
720
+ if (direction === 'outgoing' || direction === 'both') {
721
+ const out = await client.getOutgoingEdges(current.id, edgeFilter ?? null);
722
+ edges.push(...out);
723
+ }
724
+ if (direction === 'incoming' || direction === 'both') {
725
+ const inc = await client.getIncomingEdges(current.id, edgeFilter ?? null);
726
+ edges.push(...inc);
727
+ }
728
+
729
+ for (const edge of edges) {
730
+ const neighborId = direction === 'incoming' ? edge.src : edge.dst;
731
+ // For 'both', pick the other end
732
+ const otherId = edge.src === current.id ? edge.dst : edge.src;
733
+ const nextId = direction === 'both' ? otherId : neighborId;
734
+
735
+ if (!visited.has(nextId)) {
736
+ visited.set(nextId, current.depth + 1);
737
+ queue.push({ id: nextId, depth: current.depth + 1 });
738
+ }
739
+ }
740
+ }
741
+
742
+ // Format results
743
+ const lines: string[] = [`Traversal from "${node.name}" (${direction}, max_depth=${maxDepth}):\n`];
744
+ lines.push(`Visited ${visited.size} node(s):\n`);
745
+
746
+ for (const [id, depth] of Array.from(visited.entries())) {
747
+ const n = await client.getNode(id);
748
+ const name = n?.name || id;
749
+ const type = n?.nodeType || 'UNKNOWN';
750
+ const indent = ' '.repeat(depth);
751
+ lines.push(`${indent}[depth=${depth}] ${name} (${type})`);
752
+ }
753
+
754
+ return textResult(lines.join('\n'));
755
+ } catch (error) {
756
+ const msg = error instanceof Error ? error.message : String(error);
757
+ return errorResult(`Failed to traverse: ${msg}`);
758
+ }
759
+ }
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // 10. handleEnoxStats
763
+ // ---------------------------------------------------------------------------
764
+
765
+ export async function handleEnoxStats(): Promise<ToolResult> {
766
+ try {
767
+ const client = await getKnowledgeClient();
768
+
769
+ const [stats, nodesByType, edgesByType] = await Promise.all([
770
+ client.getStats(),
771
+ client.countNodesByType(),
772
+ client.countEdgesByType(),
773
+ ]);
774
+
775
+ const lines: string[] = [
776
+ '## Knowledge Graph Stats\n',
777
+ `Node count: ${stats.nodeCount}`,
778
+ `Edge count: ${stats.edgeCount}`,
779
+ ];
780
+
781
+ const nodeTypeEntries = Object.entries(nodesByType);
782
+ if (nodeTypeEntries.length > 0) {
783
+ lines.push('\n### Nodes by Type');
784
+ for (const [type, count] of nodeTypeEntries) {
785
+ lines.push(` ${type}: ${count}`);
786
+ }
787
+ }
788
+
789
+ const edgeTypeEntries = Object.entries(edgesByType);
790
+ if (edgeTypeEntries.length > 0) {
791
+ lines.push('\n### Edges by Type');
792
+ for (const [type, count] of edgeTypeEntries) {
793
+ lines.push(` ${type}: ${count}`);
794
+ }
795
+ }
796
+
797
+ return textResult(lines.join('\n'));
798
+ } catch (error) {
799
+ const msg = error instanceof Error ? error.message : String(error);
800
+ return errorResult(`Failed to get graph stats: ${msg}`);
801
+ }
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // 11. handleRecentActivity
806
+ // ---------------------------------------------------------------------------
807
+
808
+ export interface RecentActivityArgs {
809
+ since?: string; // ISO date string
810
+ limit?: number;
811
+ }
812
+
813
+ export async function handleRecentActivity(args: RecentActivityArgs): Promise<ToolResult> {
814
+ try {
815
+ const client = await getKnowledgeClient();
816
+ const limit = args.limit ?? 30;
817
+
818
+ // Get all nodes and sort by created_at from metadata
819
+ const allNodes = await client.getAllNodes();
820
+
821
+ // Parse created_at from metadata and sort descending
822
+ const withDates = allNodes
823
+ .map(node => {
824
+ const meta = parseMeta(node);
825
+ const createdAt = meta.created_at as string | undefined;
826
+ return { node, createdAt, meta };
827
+ })
828
+ .filter(item => item.createdAt !== undefined);
829
+
830
+ withDates.sort((a, b) => {
831
+ const da = new Date(a.createdAt!).getTime();
832
+ const db = new Date(b.createdAt!).getTime();
833
+ return db - da; // newest first
834
+ });
835
+
836
+ // Filter by since date if provided
837
+ let results = withDates;
838
+ if (args.since) {
839
+ const sinceTime = new Date(args.since).getTime();
840
+ results = withDates.filter(item => new Date(item.createdAt!).getTime() >= sinceTime);
841
+ }
842
+
843
+ results = results.slice(0, limit);
844
+
845
+ if (results.length === 0) {
846
+ return textResult('No recent activity found.');
847
+ }
848
+
849
+ const lines: string[] = [`Recent activity (${results.length} entries):\n`];
850
+ for (const { node, createdAt, meta } of results) {
851
+ lines.push(`- [${createdAt}] ${node.nodeType}: ${node.name}`);
852
+ if (meta.domain) lines.push(` domain: ${meta.domain}`);
853
+ }
854
+
855
+ return textResult(lines.join('\n'));
856
+ } catch (error) {
857
+ const msg = error instanceof Error ? error.message : String(error);
858
+ return errorResult(`Failed to get recent activity: ${msg}`);
859
+ }
860
+ }
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // 12. handleUpdateNode
864
+ // ---------------------------------------------------------------------------
865
+
866
+ export interface UpdateNodeArgs {
867
+ id: string;
868
+ name?: string;
869
+ domain?: string;
870
+ description?: string;
871
+ nodeType?: string;
872
+ }
873
+
874
+ export async function handleUpdateNode(args: UpdateNodeArgs): Promise<ToolResult> {
875
+ try {
876
+ const client = await getKnowledgeClient();
877
+
878
+ const existing = await client.getNode(args.id);
879
+ if (!existing) {
880
+ return errorResult(`Node "${args.id}" not found.`);
881
+ }
882
+
883
+ const meta = parseMeta(existing);
884
+ meta.updated_at = nowISO();
885
+ if (args.description !== undefined) meta.description = args.description;
886
+ if (args.domain !== undefined) meta.domain = args.domain;
887
+
888
+ const updatedNode: WireNode = {
889
+ id: existing.id,
890
+ nodeType: (args.nodeType as WireNode['nodeType']) || existing.nodeType,
891
+ name: args.name ?? existing.name,
892
+ file: args.domain ?? existing.file,
893
+ exported: existing.exported,
894
+ metadata: JSON.stringify(meta),
895
+ };
896
+
897
+ // Re-add node (addNodes upserts in RFDB)
898
+ await client.addNodes([updatedNode]);
899
+ await client.flush();
900
+
901
+ return textResult(
902
+ `Node updated:\n` +
903
+ ` ID: ${updatedNode.id}\n` +
904
+ ` Name: ${updatedNode.name}\n` +
905
+ ` Type: ${updatedNode.nodeType}\n` +
906
+ ` Domain: ${meta.domain || existing.file}`
907
+ );
908
+ } catch (error) {
909
+ const msg = error instanceof Error ? error.message : String(error);
910
+ return errorResult(`Failed to update node: ${msg}`);
911
+ }
912
+ }
913
+
914
+ // ---------------------------------------------------------------------------
915
+ // 13. handleCrawlEntity
916
+ // ---------------------------------------------------------------------------
917
+
918
+ export interface CrawlEntityArgs {
919
+ entity: string;
920
+ context?: string;
921
+ depth?: number;
922
+ }
923
+
924
+ /**
925
+ * Lightweight ontological crawl: query the code graph for an entity,
926
+ * generate graph-derived facts, and record them in the knowledge DB.
927
+ *
928
+ * No LLM, no child processes — pure graph introspection.
929
+ */
930
+ export async function handleCrawlEntity(args: CrawlEntityArgs): Promise<ToolResult> {
931
+ try {
932
+ const knowledgeClient = await getKnowledgeClient();
933
+ const depth = args.depth ?? 3;
934
+
935
+ // 1. Recall: check knowledge DB for existing facts
936
+ const existingNodes = await collectNodes(knowledgeClient, {
937
+ name: args.entity.toLowerCase(),
938
+ substringMatch: true,
939
+ });
940
+
941
+ const existingFacts: string[] = [];
942
+ for (const node of existingNodes.slice(0, 10)) {
943
+ const meta = parseMeta(node);
944
+ if (meta.content) existingFacts.push(String(meta.content));
945
+ }
946
+
947
+ // 2. Code graph scan: connect to default DB and find the entity
948
+ const socketPath = deriveSocketPath();
949
+ const codeClient = new RFDBClient(socketPath, 'crawl-entity');
950
+ await codeClient.connect();
951
+ await codeClient.hello();
952
+
953
+ const codeNodes = await collectNodes(codeClient, { name: args.entity, substringMatch: true });
954
+
955
+ if (codeNodes.length === 0) {
956
+ await codeClient.close();
957
+ return textResult(
958
+ `No code entity found matching "${args.entity}".\n` +
959
+ (existingFacts.length > 0
960
+ ? `\nExisting knowledge (${existingFacts.length} facts):\n${existingFacts.map(f => ` - ${f}`).join('\n')}`
961
+ : 'No existing knowledge either.')
962
+ );
963
+ }
964
+
965
+ // 3. Generate graph-derived facts for each matched node
966
+ const facts: string[] = [];
967
+ const processedNodes = codeNodes.slice(0, depth);
968
+
969
+ for (const node of processedNodes) {
970
+ const outgoing = await codeClient.getOutgoingEdges(node.id);
971
+ const incoming = await codeClient.getIncomingEdges(node.id);
972
+
973
+ facts.push(`${node.name} is a ${node.nodeType} in file ${node.file}`);
974
+
975
+ if (outgoing.length > 0 || incoming.length > 0) {
976
+ facts.push(`${node.name} has ${outgoing.length} outgoing and ${incoming.length} incoming edges`);
977
+ }
978
+
979
+ // Callers (incoming CALLS edges)
980
+ const callers: string[] = [];
981
+ for (const edge of incoming) {
982
+ if (edge.edgeType === 'CALLS' || edge.edgeType === 'INVOKES') {
983
+ const src = await codeClient.getNode(edge.src);
984
+ if (src) callers.push(src.name);
985
+ }
986
+ }
987
+ if (callers.length > 0) {
988
+ facts.push(`${node.name} is called by: ${callers.slice(0, 10).join(', ')}${callers.length > 10 ? ` (+${callers.length - 10} more)` : ''}`);
989
+ }
990
+
991
+ // Children (outgoing CONTAINS edges)
992
+ const children: string[] = [];
993
+ for (const edge of outgoing) {
994
+ if (edge.edgeType === 'CONTAINS' || edge.edgeType === 'HAS_MEMBER') {
995
+ const dst = await codeClient.getNode(edge.dst);
996
+ if (dst) children.push(`${dst.name} (${dst.nodeType})`);
997
+ }
998
+ }
999
+ if (children.length > 0) {
1000
+ facts.push(`${node.name} contains ${children.length} members: ${children.slice(0, 10).join(', ')}${children.length > 10 ? ` (+${children.length - 10} more)` : ''}`);
1001
+ }
1002
+
1003
+ // Callees (outgoing CALLS edges)
1004
+ const callees: string[] = [];
1005
+ for (const edge of outgoing) {
1006
+ if (edge.edgeType === 'CALLS' || edge.edgeType === 'INVOKES') {
1007
+ const dst = await codeClient.getNode(edge.dst);
1008
+ if (dst) callees.push(dst.name);
1009
+ }
1010
+ }
1011
+ if (callees.length > 0) {
1012
+ facts.push(`${node.name} calls: ${callees.slice(0, 10).join(', ')}${callees.length > 10 ? ` (+${callees.length - 10} more)` : ''}`);
1013
+ }
1014
+ }
1015
+
1016
+ await codeClient.close();
1017
+
1018
+ // 4. Return facts as context (NOT recorded — graph-derived data
1019
+ // is already in code graph, recording duplicates it)
1020
+ // Use remember() or add_assertion() to record interpretations.
1021
+ const lines: string[] = [
1022
+ `## Crawl: ${args.entity}`,
1023
+ `Code graph matches: ${codeNodes.length}`,
1024
+ `Facts generated: ${facts.length}`,
1025
+ `Existing knowledge: ${existingFacts.length} fact(s)`,
1026
+ '',
1027
+ '### Graph-derived facts:',
1028
+ ...facts.map(f => ` - ${f}`),
1029
+ ];
1030
+
1031
+ if (existingFacts.length > 0) {
1032
+ lines.push('', '### Prior knowledge:');
1033
+ for (const f of existingFacts.slice(0, 10)) {
1034
+ lines.push(` - ${f}`);
1035
+ }
1036
+ }
1037
+
1038
+ return textResult(lines.join('\n'));
1039
+ } catch (error) {
1040
+ const msg = error instanceof Error ? error.message : String(error);
1041
+ return errorResult(`Failed to crawl entity: ${msg}`);
1042
+ }
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // 14. handleSaveDocument
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ export interface SaveDocumentArgs {
1050
+ title: string;
1051
+ content: string;
1052
+ domain?: string;
1053
+ relates_to?: string[];
1054
+ }
1055
+
1056
+ export async function handleSaveDocument(args: SaveDocumentArgs): Promise<ToolResult> {
1057
+ try {
1058
+ const client = await getKnowledgeClient();
1059
+ const domain = args.domain || 'general';
1060
+
1061
+ const docId = `doc:${createHash('sha256')
1062
+ .update(`${args.title}|${Date.now()}`)
1063
+ .digest('hex')
1064
+ .slice(0, 16)}`;
1065
+
1066
+ await client.addNodes([{
1067
+ id: docId,
1068
+ nodeType: 'DOCUMENT' as WireNode['nodeType'],
1069
+ name: args.title,
1070
+ file: domain,
1071
+ exported: false,
1072
+ metadata: JSON.stringify({
1073
+ content: args.content,
1074
+ domain,
1075
+ created_at: nowISO(),
1076
+ }),
1077
+ }]);
1078
+
1079
+ // Create REFERENCES edges if relates_to is provided
1080
+ const relatedEdges: WireEdge[] = [];
1081
+ if (args.relates_to?.length) {
1082
+ for (const targetName of args.relates_to) {
1083
+ const targetId = await ensureEntity(client, targetName, 'ENTITY', domain);
1084
+ relatedEdges.push({
1085
+ src: docId,
1086
+ dst: targetId,
1087
+ edgeType: 'REFERENCES' as EdgeType,
1088
+ metadata: JSON.stringify({ created_at: nowISO() }),
1089
+ });
1090
+ }
1091
+ if (relatedEdges.length > 0) {
1092
+ await client.addEdges(relatedEdges);
1093
+ }
1094
+ }
1095
+
1096
+ await client.flush();
1097
+
1098
+ const lines: string[] = [
1099
+ `Document saved:`,
1100
+ ` ID: ${docId}`,
1101
+ ` Title: ${args.title}`,
1102
+ ` Domain: ${domain}`,
1103
+ ` Content: ${args.content.length} chars`,
1104
+ ];
1105
+ if (relatedEdges.length > 0) {
1106
+ lines.push(` References: ${relatedEdges.map(e => e.dst).join(', ')}`);
1107
+ }
1108
+
1109
+ return textResult(lines.join('\n'));
1110
+ } catch (error) {
1111
+ const msg = error instanceof Error ? error.message : String(error);
1112
+ return errorResult(`Failed to save document: ${msg}`);
1113
+ }
1114
+ }