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