@kernel.chat/kbot 3.41.0 → 3.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +5 -5
  2. package/dist/agent-teams.d.ts +1 -1
  3. package/dist/agent-teams.d.ts.map +1 -1
  4. package/dist/agent-teams.js +36 -3
  5. package/dist/agent-teams.js.map +1 -1
  6. package/dist/agents/specialists.d.ts.map +1 -1
  7. package/dist/agents/specialists.js +20 -0
  8. package/dist/agents/specialists.js.map +1 -1
  9. package/dist/auth.d.ts +5 -1
  10. package/dist/auth.d.ts.map +1 -1
  11. package/dist/auth.js +1 -1
  12. package/dist/auth.js.map +1 -1
  13. package/dist/channels/kbot-channel.js +8 -31
  14. package/dist/channels/kbot-channel.js.map +1 -1
  15. package/dist/cli.js +44 -11
  16. package/dist/cli.js.map +1 -1
  17. package/dist/completions.d.ts.map +1 -1
  18. package/dist/completions.js +7 -0
  19. package/dist/completions.js.map +1 -1
  20. package/dist/digest.js +1 -1
  21. package/dist/digest.js.map +1 -1
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +132 -92
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/doctor.test.d.ts +2 -0
  26. package/dist/doctor.test.d.ts.map +1 -0
  27. package/dist/doctor.test.js +432 -0
  28. package/dist/doctor.test.js.map +1 -0
  29. package/dist/email-service.d.ts.map +1 -1
  30. package/dist/email-service.js +1 -2
  31. package/dist/email-service.js.map +1 -1
  32. package/dist/episodic-memory.d.ts.map +1 -1
  33. package/dist/episodic-memory.js +14 -0
  34. package/dist/episodic-memory.js.map +1 -1
  35. package/dist/learned-router.d.ts.map +1 -1
  36. package/dist/learned-router.js +29 -0
  37. package/dist/learned-router.js.map +1 -1
  38. package/dist/tools/email.d.ts.map +1 -1
  39. package/dist/tools/email.js +2 -3
  40. package/dist/tools/email.js.map +1 -1
  41. package/dist/tools/hypothesis-engine.d.ts +2 -0
  42. package/dist/tools/hypothesis-engine.d.ts.map +1 -0
  43. package/dist/tools/hypothesis-engine.js +2276 -0
  44. package/dist/tools/hypothesis-engine.js.map +1 -0
  45. package/dist/tools/index.d.ts.map +1 -1
  46. package/dist/tools/index.js +11 -1
  47. package/dist/tools/index.js.map +1 -1
  48. package/dist/tools/lab-bio.d.ts +2 -0
  49. package/dist/tools/lab-bio.d.ts.map +1 -0
  50. package/dist/tools/lab-bio.js +1392 -0
  51. package/dist/tools/lab-bio.js.map +1 -0
  52. package/dist/tools/lab-chem.d.ts +2 -0
  53. package/dist/tools/lab-chem.d.ts.map +1 -0
  54. package/dist/tools/lab-chem.js +1257 -0
  55. package/dist/tools/lab-chem.js.map +1 -0
  56. package/dist/tools/lab-core.d.ts +2 -0
  57. package/dist/tools/lab-core.d.ts.map +1 -0
  58. package/dist/tools/lab-core.js +2452 -0
  59. package/dist/tools/lab-core.js.map +1 -0
  60. package/dist/tools/lab-data.d.ts +2 -0
  61. package/dist/tools/lab-data.d.ts.map +1 -0
  62. package/dist/tools/lab-data.js +2464 -0
  63. package/dist/tools/lab-data.js.map +1 -0
  64. package/dist/tools/lab-earth.d.ts +2 -0
  65. package/dist/tools/lab-earth.d.ts.map +1 -0
  66. package/dist/tools/lab-earth.js +1124 -0
  67. package/dist/tools/lab-earth.js.map +1 -0
  68. package/dist/tools/lab-math.d.ts +2 -0
  69. package/dist/tools/lab-math.d.ts.map +1 -0
  70. package/dist/tools/lab-math.js +3021 -0
  71. package/dist/tools/lab-math.js.map +1 -0
  72. package/dist/tools/lab-physics.d.ts +2 -0
  73. package/dist/tools/lab-physics.d.ts.map +1 -0
  74. package/dist/tools/lab-physics.js +2423 -0
  75. package/dist/tools/lab-physics.js.map +1 -0
  76. package/dist/tools/research-notebook.d.ts +2 -0
  77. package/dist/tools/research-notebook.d.ts.map +1 -0
  78. package/dist/tools/research-notebook.js +1165 -0
  79. package/dist/tools/research-notebook.js.map +1 -0
  80. package/dist/tools/research-pipeline.d.ts +2 -0
  81. package/dist/tools/research-pipeline.d.ts.map +1 -0
  82. package/dist/tools/research-pipeline.js +1094 -0
  83. package/dist/tools/research-pipeline.js.map +1 -0
  84. package/dist/tools/science-graph.d.ts +2 -0
  85. package/dist/tools/science-graph.d.ts.map +1 -0
  86. package/dist/tools/science-graph.js +995 -0
  87. package/dist/tools/science-graph.js.map +1 -0
  88. package/package.json +2 -3
@@ -0,0 +1,995 @@
1
+ // kbot Science Knowledge Graph — Connects entities across kbot's science tools
2
+ // Stores entities and relationships discovered during research sessions.
3
+ // Graph persists at ~/.kbot/science-graph.json. No external dependencies.
4
+ import { registerTool, getTool } from './index.js';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ // ─── Domain Classification ──────────────────────────────────────────────────
9
+ const TYPE_TO_DOMAIN = {
10
+ gene: 'biology',
11
+ protein: 'biology',
12
+ disease: 'biology',
13
+ pathway: 'biology',
14
+ species: 'biology',
15
+ compound: 'chemistry',
16
+ element: 'chemistry',
17
+ material: 'chemistry',
18
+ paper: 'math', // papers span all domains; default to math as neutral
19
+ concept: 'physics', // concepts span all domains; default to physics as neutral
20
+ };
21
+ const DOMAIN_TYPES = {
22
+ biology: ['gene', 'protein', 'disease', 'pathway', 'species'],
23
+ chemistry: ['compound', 'element', 'material'],
24
+ physics: ['concept'],
25
+ earth: ['species'], // species can also be earth science
26
+ math: ['paper'],
27
+ };
28
+ // ─── Graph Storage ──────────────────────────────────────────────────────────
29
+ const GRAPH_PATH = join(homedir(), '.kbot', 'science-graph.json');
30
+ function ensureDir() {
31
+ const dir = join(homedir(), '.kbot');
32
+ if (!existsSync(dir)) {
33
+ mkdirSync(dir, { recursive: true });
34
+ }
35
+ }
36
+ function loadGraph() {
37
+ try {
38
+ if (existsSync(GRAPH_PATH)) {
39
+ const raw = readFileSync(GRAPH_PATH, 'utf-8');
40
+ return JSON.parse(raw);
41
+ }
42
+ }
43
+ catch {
44
+ // corrupted file — start fresh
45
+ }
46
+ const now = new Date().toISOString();
47
+ return {
48
+ entities: {},
49
+ relations: [],
50
+ metadata: { created: now, lastModified: now, entityCount: 0, relationCount: 0 },
51
+ };
52
+ }
53
+ function saveGraph(graph) {
54
+ ensureDir();
55
+ graph.metadata.lastModified = new Date().toISOString();
56
+ graph.metadata.entityCount = Object.keys(graph.entities).length;
57
+ graph.metadata.relationCount = graph.relations.length;
58
+ writeFileSync(GRAPH_PATH, JSON.stringify(graph, null, 2), 'utf-8');
59
+ }
60
+ // ─── ID Generation ──────────────────────────────────────────────────────────
61
+ function entityId(name, type) {
62
+ return `${type}:${name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}`;
63
+ }
64
+ // ─── Fuzzy Match ────────────────────────────────────────────────────────────
65
+ function fuzzyMatch(needle, haystack) {
66
+ const n = needle.toLowerCase();
67
+ const h = haystack.toLowerCase();
68
+ if (h.includes(n) || n.includes(h))
69
+ return true;
70
+ // Levenshtein for short strings
71
+ if (n.length <= 20 && h.length <= 20) {
72
+ const dist = levenshtein(n, h);
73
+ return dist <= Math.max(1, Math.floor(Math.min(n.length, h.length) * 0.3));
74
+ }
75
+ return false;
76
+ }
77
+ function levenshtein(a, b) {
78
+ const m = a.length, n = b.length;
79
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
80
+ for (let i = 0; i <= m; i++)
81
+ dp[i][0] = i;
82
+ for (let j = 0; j <= n; j++)
83
+ dp[0][j] = j;
84
+ for (let i = 1; i <= m; i++) {
85
+ for (let j = 1; j <= n; j++) {
86
+ dp[i][j] = a[i - 1] === b[j - 1]
87
+ ? dp[i - 1][j - 1]
88
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
89
+ }
90
+ }
91
+ return dp[m][n];
92
+ }
93
+ // ─── Graph Algorithms ───────────────────────────────────────────────────────
94
+ /** Build adjacency list from relations */
95
+ function buildAdjacency(graph) {
96
+ const adj = new Map();
97
+ for (const rel of graph.relations) {
98
+ if (!adj.has(rel.from))
99
+ adj.set(rel.from, []);
100
+ if (!adj.has(rel.to))
101
+ adj.set(rel.to, []);
102
+ adj.get(rel.from).push({ neighbor: rel.to, relation: rel });
103
+ adj.get(rel.to).push({ neighbor: rel.from, relation: rel });
104
+ }
105
+ return adj;
106
+ }
107
+ /** BFS shortest path between two entity IDs */
108
+ function bfsPath(adj, startId, endId, maxDepth) {
109
+ if (startId === endId)
110
+ return [{ entity: startId, relation: null }];
111
+ const visited = new Set([startId]);
112
+ const parent = new Map();
113
+ const queue = [{ id: startId, depth: 0 }];
114
+ while (queue.length > 0) {
115
+ const { id, depth } = queue.shift();
116
+ if (depth >= maxDepth)
117
+ continue;
118
+ const neighbors = adj.get(id) || [];
119
+ for (const { neighbor, relation } of neighbors) {
120
+ if (visited.has(neighbor))
121
+ continue;
122
+ visited.add(neighbor);
123
+ parent.set(neighbor, { from: id, relation });
124
+ if (neighbor === endId) {
125
+ // reconstruct path
126
+ const path = [];
127
+ let cur = endId;
128
+ while (cur !== startId) {
129
+ const p = parent.get(cur);
130
+ path.unshift({ entity: cur, relation: p.relation });
131
+ cur = p.from;
132
+ }
133
+ path.unshift({ entity: startId, relation: null });
134
+ return path;
135
+ }
136
+ queue.push({ id: neighbor, depth: depth + 1 });
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ /** BFS to find all entities within maxDepth of a start entity */
142
+ function bfsNeighbors(adj, startId, maxDepth) {
143
+ const result = new Map();
144
+ const visited = new Set([startId]);
145
+ const queue = [{ id: startId, depth: 0 }];
146
+ while (queue.length > 0) {
147
+ const { id, depth } = queue.shift();
148
+ if (depth >= maxDepth)
149
+ continue;
150
+ const neighbors = adj.get(id) || [];
151
+ for (const { neighbor, relation } of neighbors) {
152
+ if (visited.has(neighbor))
153
+ continue;
154
+ visited.add(neighbor);
155
+ result.set(neighbor, { depth: depth + 1, relations: [relation] });
156
+ queue.push({ id: neighbor, depth: depth + 1 });
157
+ }
158
+ }
159
+ return result;
160
+ }
161
+ /** Compute degree centrality for all entities */
162
+ function degreeCentrality(graph) {
163
+ const degrees = new Map();
164
+ for (const id of Object.keys(graph.entities)) {
165
+ degrees.set(id, 0);
166
+ }
167
+ for (const rel of graph.relations) {
168
+ degrees.set(rel.from, (degrees.get(rel.from) || 0) + 1);
169
+ degrees.set(rel.to, (degrees.get(rel.to) || 0) + 1);
170
+ }
171
+ return degrees;
172
+ }
173
+ /** Find entity ID by fuzzy name match */
174
+ function findEntityByName(graph, name) {
175
+ // exact ID match first
176
+ const id = Object.keys(graph.entities).find(k => k === name);
177
+ if (id)
178
+ return graph.entities[id];
179
+ // exact name match
180
+ const exact = Object.values(graph.entities).find(e => e.name.toLowerCase() === name.toLowerCase());
181
+ if (exact)
182
+ return exact;
183
+ // fuzzy match
184
+ const fuzzy = Object.values(graph.entities).find(e => fuzzyMatch(name, e.name));
185
+ return fuzzy || null;
186
+ }
187
+ // ─── Entity Type Validation ─────────────────────────────────────────────────
188
+ const VALID_ENTITY_TYPES = ['gene', 'protein', 'compound', 'disease', 'pathway', 'species', 'element', 'material', 'paper', 'concept'];
189
+ const VALID_RELATION_TYPES = ['targets', 'inhibits', 'causes', 'treats', 'contains', 'catalyzes', 'encodes', 'interacts_with', 'associated_with', 'cites', 'similar_to'];
190
+ // ─── Registration ───────────────────────────────────────────────────────────
191
+ export function registerScienceGraphTools() {
192
+ // ════════════════════════════════════════════════════════════════════════════
193
+ // 1. graph_add_entity — Add a scientific entity to the knowledge graph
194
+ // ════════════════════════════════════════════════════════════════════════════
195
+ registerTool({
196
+ name: 'graph_add_entity',
197
+ description: 'Add a scientific entity to the Science Knowledge Graph. Entities represent genes, proteins, compounds, diseases, pathways, species, elements, materials, papers, or concepts discovered during research.',
198
+ parameters: {
199
+ name: { type: 'string', description: 'Entity name (e.g., "TP53", "Aspirin", "Alzheimer\'s Disease")', required: true },
200
+ type: { type: 'string', description: 'Entity type: gene, protein, compound, disease, pathway, species, element, material, paper, concept', required: true },
201
+ properties: { type: 'string', description: 'JSON string of additional properties (e.g., \'{"symbol":"TP53","chromosome":"17"}\')' },
202
+ source: { type: 'string', description: 'Which tool or source discovered this entity (e.g., "gene_lookup", "pubmed_search", "manual")', required: true },
203
+ },
204
+ tier: 'free',
205
+ async execute(args) {
206
+ const name = String(args.name).trim();
207
+ const type = String(args.type).trim().toLowerCase();
208
+ const source = String(args.source || 'manual').trim();
209
+ if (!name)
210
+ return '**Error**: Entity name is required.';
211
+ if (!VALID_ENTITY_TYPES.includes(type)) {
212
+ return `**Error**: Invalid entity type "${type}". Valid types: ${VALID_ENTITY_TYPES.join(', ')}`;
213
+ }
214
+ let properties = {};
215
+ if (args.properties) {
216
+ try {
217
+ properties = JSON.parse(String(args.properties));
218
+ }
219
+ catch {
220
+ return '**Error**: Invalid JSON in properties parameter.';
221
+ }
222
+ }
223
+ const graph = loadGraph();
224
+ const id = entityId(name, type);
225
+ const now = new Date().toISOString();
226
+ if (graph.entities[id]) {
227
+ // Update existing entity
228
+ const existing = graph.entities[id];
229
+ existing.lastSeen = now;
230
+ existing.references++;
231
+ existing.properties = { ...existing.properties, ...properties };
232
+ if (source && source !== existing.source) {
233
+ existing.source = `${existing.source}, ${source}`;
234
+ }
235
+ saveGraph(graph);
236
+ return `## Entity Updated\n\n| Field | Value |\n|---|---|\n| **ID** | \`${id}\` |\n| **Name** | ${existing.name} |\n| **Type** | ${existing.type} |\n| **References** | ${existing.references} |\n| **Sources** | ${existing.source} |\n| **Properties** | ${Object.keys(existing.properties).length} fields |\n\nEntity already existed — incremented reference count and merged properties.`;
237
+ }
238
+ const entity = {
239
+ id,
240
+ name,
241
+ type,
242
+ properties,
243
+ source,
244
+ created: now,
245
+ lastSeen: now,
246
+ references: 1,
247
+ };
248
+ graph.entities[id] = entity;
249
+ saveGraph(graph);
250
+ return `## Entity Added\n\n| Field | Value |\n|---|---|\n| **ID** | \`${id}\` |\n| **Name** | ${name} |\n| **Type** | ${type} |\n| **Source** | ${source} |\n| **Properties** | ${Object.keys(properties).length} fields |\n\nGraph now contains **${graph.metadata.entityCount}** entities and **${graph.metadata.relationCount}** relations.`;
251
+ },
252
+ });
253
+ // ════════════════════════════════════════════════════════════════════════════
254
+ // 2. graph_add_relation — Add a relationship between two entities
255
+ // ════════════════════════════════════════════════════════════════════════════
256
+ registerTool({
257
+ name: 'graph_add_relation',
258
+ description: 'Add a relationship between two entities in the Science Knowledge Graph. Both entities must already exist. Relation types: targets, inhibits, causes, treats, contains, catalyzes, encodes, interacts_with, associated_with, cites, similar_to.',
259
+ parameters: {
260
+ from_entity: { type: 'string', description: 'Name or ID of the source entity', required: true },
261
+ to_entity: { type: 'string', description: 'Name or ID of the target entity', required: true },
262
+ relation_type: { type: 'string', description: 'Relationship type: targets, inhibits, causes, treats, contains, catalyzes, encodes, interacts_with, associated_with, cites, similar_to', required: true },
263
+ confidence: { type: 'number', description: 'Confidence score 0-1 (default 0.8)' },
264
+ evidence: { type: 'string', description: 'Supporting evidence or reference for this relationship', required: true },
265
+ },
266
+ tier: 'free',
267
+ async execute(args) {
268
+ const fromName = String(args.from_entity).trim();
269
+ const toName = String(args.to_entity).trim();
270
+ const relType = String(args.relation_type).trim().toLowerCase();
271
+ const confidence = typeof args.confidence === 'number' ? Math.max(0, Math.min(1, args.confidence)) : 0.8;
272
+ const evidence = String(args.evidence || '').trim();
273
+ if (!fromName || !toName)
274
+ return '**Error**: Both from_entity and to_entity are required.';
275
+ if (!VALID_RELATION_TYPES.includes(relType)) {
276
+ return `**Error**: Invalid relation type "${relType}". Valid types: ${VALID_RELATION_TYPES.join(', ')}`;
277
+ }
278
+ const graph = loadGraph();
279
+ const fromEntity = findEntityByName(graph, fromName);
280
+ const toEntity = findEntityByName(graph, toName);
281
+ if (!fromEntity)
282
+ return `**Error**: Entity "${fromName}" not found in graph. Add it first with graph_add_entity.`;
283
+ if (!toEntity)
284
+ return `**Error**: Entity "${toName}" not found in graph. Add it first with graph_add_entity.`;
285
+ // Check for duplicate relation
286
+ const duplicate = graph.relations.find(r => r.from === fromEntity.id && r.to === toEntity.id && r.type === relType);
287
+ if (duplicate) {
288
+ // Update confidence if higher
289
+ if (confidence > duplicate.confidence) {
290
+ duplicate.confidence = confidence;
291
+ duplicate.evidence = evidence || duplicate.evidence;
292
+ saveGraph(graph);
293
+ return `## Relation Updated\n\n**${fromEntity.name}** --[${relType}]--> **${toEntity.name}**\n\nConfidence upgraded to **${confidence}**. Evidence: ${evidence || duplicate.evidence}`;
294
+ }
295
+ return `## Relation Already Exists\n\n**${fromEntity.name}** --[${relType}]--> **${toEntity.name}** (confidence: ${duplicate.confidence})\n\nExisting relation unchanged (new confidence ${confidence} <= existing ${duplicate.confidence}).`;
296
+ }
297
+ const relation = {
298
+ from: fromEntity.id,
299
+ to: toEntity.id,
300
+ type: relType,
301
+ confidence,
302
+ evidence,
303
+ source: 'manual',
304
+ created: new Date().toISOString(),
305
+ };
306
+ graph.relations.push(relation);
307
+ saveGraph(graph);
308
+ return `## Relation Added\n\n**${fromEntity.name}** --[${relType}]--> **${toEntity.name}**\n\n| Field | Value |\n|---|---|\n| **Confidence** | ${confidence} |\n| **Evidence** | ${evidence || 'None provided'} |\n\nGraph now contains **${graph.metadata.entityCount}** entities and **${graph.metadata.relationCount}** relations.`;
309
+ },
310
+ });
311
+ // ════════════════════════════════════════════════════════════════════════════
312
+ // 3. graph_query — Query the knowledge graph
313
+ // ════════════════════════════════════════════════════════════════════════════
314
+ registerTool({
315
+ name: 'graph_query',
316
+ description: 'Query the Science Knowledge Graph. Find entities by name (fuzzy), trace shortest paths between entities (BFS), find neighbors within depth, list all entities of a type, or dump all entities.',
317
+ parameters: {
318
+ query: { type: 'string', description: 'Search term: entity name for entity/neighbors, "A -> B" for path finding, type name for type queries', required: true },
319
+ query_type: { type: 'string', description: 'Query mode: entity (fuzzy name search), path (shortest path between two entities separated by "->"), neighbors (connected entities within depth), type (all entities of a given type), all (full listing)', required: true },
320
+ max_depth: { type: 'number', description: 'Maximum search depth for path/neighbors queries (default 2, max 5)' },
321
+ },
322
+ tier: 'free',
323
+ async execute(args) {
324
+ const query = String(args.query).trim();
325
+ const queryType = String(args.query_type).trim().toLowerCase();
326
+ const maxDepth = Math.min(typeof args.max_depth === 'number' ? args.max_depth : 2, 5);
327
+ const graph = loadGraph();
328
+ if (Object.keys(graph.entities).length === 0) {
329
+ return '## Empty Graph\n\nThe Science Knowledge Graph is empty. Use `graph_add_entity` to add entities.';
330
+ }
331
+ const adj = buildAdjacency(graph);
332
+ // ── entity: fuzzy search
333
+ if (queryType === 'entity') {
334
+ const matches = Object.values(graph.entities).filter(e => fuzzyMatch(query, e.name) || fuzzyMatch(query, e.id));
335
+ if (matches.length === 0)
336
+ return `## No Matches\n\nNo entities matching "${query}" found in the graph.`;
337
+ const lines = matches.map(e => {
338
+ const rels = graph.relations.filter(r => r.from === e.id || r.to === e.id);
339
+ return `### ${e.name} (\`${e.type}\`)\n- **ID**: \`${e.id}\`\n- **Source**: ${e.source}\n- **References**: ${e.references}\n- **Relations**: ${rels.length}\n- **Properties**: ${JSON.stringify(e.properties)}`;
340
+ });
341
+ return `## Entity Search: "${query}"\n\nFound **${matches.length}** matching entities:\n\n${lines.join('\n\n')}`;
342
+ }
343
+ // ── path: BFS shortest path
344
+ if (queryType === 'path') {
345
+ const parts = query.split('->').map(s => s.trim());
346
+ if (parts.length !== 2)
347
+ return '**Error**: Path query requires format "Entity A -> Entity B".';
348
+ const startEntity = findEntityByName(graph, parts[0]);
349
+ const endEntity = findEntityByName(graph, parts[1]);
350
+ if (!startEntity)
351
+ return `**Error**: Entity "${parts[0]}" not found.`;
352
+ if (!endEntity)
353
+ return `**Error**: Entity "${parts[1]}" not found.`;
354
+ const path = bfsPath(adj, startEntity.id, endEntity.id, maxDepth);
355
+ if (!path)
356
+ return `## No Path Found\n\nNo path between **${startEntity.name}** and **${endEntity.name}** within depth ${maxDepth}.`;
357
+ const steps = path.map((step, i) => {
358
+ const entity = graph.entities[step.entity];
359
+ const entityName = entity ? entity.name : step.entity;
360
+ if (i === 0)
361
+ return `1. **${entityName}** (\`${entity?.type}\`)`;
362
+ const rel = step.relation;
363
+ const relLabel = rel ? `--[${rel.type}, confidence: ${rel.confidence}]-->` : '-->';
364
+ return `${i + 1}. ${relLabel} **${entityName}** (\`${entity?.type}\`)`;
365
+ });
366
+ return `## Path: ${startEntity.name} → ${endEntity.name}\n\nShortest path (${path.length - 1} hops):\n\n${steps.join('\n')}`;
367
+ }
368
+ // ── neighbors: BFS within depth
369
+ if (queryType === 'neighbors') {
370
+ const entity = findEntityByName(graph, query);
371
+ if (!entity)
372
+ return `**Error**: Entity "${query}" not found.`;
373
+ const neighbors = bfsNeighbors(adj, entity.id, maxDepth);
374
+ if (neighbors.size === 0)
375
+ return `## No Neighbors\n\n**${entity.name}** has no connections within depth ${maxDepth}.`;
376
+ const byDepth = new Map();
377
+ for (const [nId, info] of neighbors) {
378
+ const n = graph.entities[nId];
379
+ if (!n)
380
+ continue;
381
+ if (!byDepth.has(info.depth))
382
+ byDepth.set(info.depth, []);
383
+ const relDesc = info.relations.map(r => r.type).join(', ');
384
+ byDepth.get(info.depth).push(`- **${n.name}** (\`${n.type}\`) via ${relDesc}`);
385
+ }
386
+ const sections = Array.from(byDepth.entries())
387
+ .sort((a, b) => a[0] - b[0])
388
+ .map(([depth, items]) => `### Depth ${depth}\n${items.join('\n')}`);
389
+ return `## Neighbors of ${entity.name}\n\nFound **${neighbors.size}** connected entities within depth ${maxDepth}:\n\n${sections.join('\n\n')}`;
390
+ }
391
+ // ── type: list all entities of a given type
392
+ if (queryType === 'type') {
393
+ const typeName = query.toLowerCase();
394
+ const matches = Object.values(graph.entities).filter(e => e.type === typeName);
395
+ if (matches.length === 0)
396
+ return `## No Entities of Type "${query}"\n\nNo entities of type \`${typeName}\` found.`;
397
+ const lines = matches
398
+ .sort((a, b) => b.references - a.references)
399
+ .map(e => `| ${e.name} | ${e.source} | ${e.references} | ${e.created.split('T')[0]} |`);
400
+ return `## Entities of Type: ${typeName}\n\nFound **${matches.length}** entities:\n\n| Name | Source | References | Created |\n|---|---|---|---|\n${lines.join('\n')}`;
401
+ }
402
+ // ── all: full listing
403
+ if (queryType === 'all') {
404
+ const entities = Object.values(graph.entities);
405
+ const byType = new Map();
406
+ for (const e of entities) {
407
+ if (!byType.has(e.type))
408
+ byType.set(e.type, []);
409
+ byType.get(e.type).push(e);
410
+ }
411
+ const sections = Array.from(byType.entries()).map(([type, ents]) => {
412
+ const rows = ents.slice(0, 20).map(e => `| ${e.name} | ${e.references} | ${e.source} |`);
413
+ return `### ${type} (${ents.length})\n| Name | Refs | Source |\n|---|---|---|\n${rows.join('\n')}`;
414
+ });
415
+ return `## Full Knowledge Graph\n\n**${graph.metadata.entityCount}** entities, **${graph.metadata.relationCount}** relations\n\n${sections.join('\n\n')}`;
416
+ }
417
+ return `**Error**: Unknown query_type "${queryType}". Use: entity, path, neighbors, type, all.`;
418
+ },
419
+ });
420
+ // ════════════════════════════════════════════════════════════════════════════
421
+ // 4. graph_connect — Discover connections between two entities
422
+ // ════════════════════════════════════════════════════════════════════════════
423
+ registerTool({
424
+ name: 'graph_connect',
425
+ description: 'Automatically discover connections between two entities by searching for direct relations, shared neighbors, indirect paths (up to depth 3), and common properties.',
426
+ parameters: {
427
+ entity_a: { type: 'string', description: 'First entity name or ID', required: true },
428
+ entity_b: { type: 'string', description: 'Second entity name or ID', required: true },
429
+ },
430
+ tier: 'free',
431
+ async execute(args) {
432
+ const nameA = String(args.entity_a).trim();
433
+ const nameB = String(args.entity_b).trim();
434
+ const graph = loadGraph();
435
+ const entityA = findEntityByName(graph, nameA);
436
+ const entityB = findEntityByName(graph, nameB);
437
+ if (!entityA)
438
+ return `**Error**: Entity "${nameA}" not found.`;
439
+ if (!entityB)
440
+ return `**Error**: Entity "${nameB}" not found.`;
441
+ const adj = buildAdjacency(graph);
442
+ const sections = [];
443
+ // ── Direct relations
444
+ const directRels = graph.relations.filter(r => (r.from === entityA.id && r.to === entityB.id) ||
445
+ (r.from === entityB.id && r.to === entityA.id));
446
+ if (directRels.length > 0) {
447
+ const lines = directRels.map(r => {
448
+ const fromName = graph.entities[r.from]?.name || r.from;
449
+ const toName = graph.entities[r.to]?.name || r.to;
450
+ return `- **${fromName}** --[${r.type}]--> **${toName}** (confidence: ${r.confidence})`;
451
+ });
452
+ sections.push(`### Direct Relations\n${lines.join('\n')}`);
453
+ }
454
+ else {
455
+ sections.push('### Direct Relations\nNone found.');
456
+ }
457
+ // ── Shared neighbors
458
+ const neighborsA = bfsNeighbors(adj, entityA.id, 1);
459
+ const neighborsB = bfsNeighbors(adj, entityB.id, 1);
460
+ const sharedNeighborIds = [...neighborsA.keys()].filter(id => neighborsB.has(id));
461
+ if (sharedNeighborIds.length > 0) {
462
+ const lines = sharedNeighborIds.map(id => {
463
+ const n = graph.entities[id];
464
+ return n ? `- **${n.name}** (\`${n.type}\`)` : `- \`${id}\``;
465
+ });
466
+ sections.push(`### Shared Neighbors (${sharedNeighborIds.length})\n${lines.join('\n')}`);
467
+ }
468
+ else {
469
+ sections.push('### Shared Neighbors\nNone found.');
470
+ }
471
+ // ── Shortest path (up to depth 3)
472
+ if (directRels.length === 0) {
473
+ const path = bfsPath(adj, entityA.id, entityB.id, 3);
474
+ if (path && path.length > 2) {
475
+ const steps = path.map((step, i) => {
476
+ const entity = graph.entities[step.entity];
477
+ const name = entity ? entity.name : step.entity;
478
+ if (i === 0)
479
+ return `**${name}**`;
480
+ return `--[${step.relation?.type}]--> **${name}**`;
481
+ });
482
+ sections.push(`### Indirect Path (${path.length - 1} hops)\n${steps.join(' ')}`);
483
+ }
484
+ else if (!path) {
485
+ sections.push('### Indirect Path\nNo path found within 3 hops.');
486
+ }
487
+ }
488
+ // ── Common properties
489
+ const propsA = Object.keys(entityA.properties);
490
+ const propsB = Object.keys(entityB.properties);
491
+ const commonKeys = propsA.filter(k => propsB.includes(k));
492
+ if (commonKeys.length > 0) {
493
+ const lines = commonKeys.map(k => `- **${k}**: ${JSON.stringify(entityA.properties[k])} (A) / ${JSON.stringify(entityB.properties[k])} (B)`);
494
+ sections.push(`### Common Properties (${commonKeys.length})\n${lines.join('\n')}`);
495
+ }
496
+ // ── Same type?
497
+ if (entityA.type === entityB.type) {
498
+ sections.push(`### Same Type\nBoth entities are of type \`${entityA.type}\`, suggesting they may be comparable.`);
499
+ }
500
+ else {
501
+ sections.push(`### Cross-Type\n**${entityA.name}** is \`${entityA.type}\` (${TYPE_TO_DOMAIN[entityA.type]}), **${entityB.name}** is \`${entityB.type}\` (${TYPE_TO_DOMAIN[entityB.type]}).`);
502
+ }
503
+ return `## Connections: ${entityA.name} ↔ ${entityB.name}\n\n${sections.join('\n\n')}`;
504
+ },
505
+ });
506
+ // ════════════════════════════════════════════════════════════════════════════
507
+ // 5. graph_enrich — Auto-enrich an entity using science tools
508
+ // ════════════════════════════════════════════════════════════════════════════
509
+ registerTool({
510
+ name: 'graph_enrich',
511
+ description: 'Auto-enrich a knowledge graph entity by calling relevant science tools based on its type. For example, a gene entity triggers gene_lookup + pathway_search; a compound triggers compound_search + compound_properties. Discovered relations are stored in the graph automatically.',
512
+ parameters: {
513
+ entity_name: { type: 'string', description: 'Name of the entity to enrich (must already exist in the graph)', required: true },
514
+ },
515
+ tier: 'free',
516
+ async execute(args) {
517
+ const name = String(args.entity_name).trim();
518
+ const graph = loadGraph();
519
+ const entity = findEntityByName(graph, name);
520
+ if (!entity)
521
+ return `**Error**: Entity "${name}" not found. Add it first with graph_add_entity.`;
522
+ const toolCalls = [];
523
+ // Determine which tools to call based on entity type
524
+ switch (entity.type) {
525
+ case 'gene':
526
+ toolCalls.push({ toolName: 'gene_lookup', args: { gene: entity.name }, purpose: 'gene information' }, { toolName: 'pathway_search', args: { query: entity.name }, purpose: 'associated pathways' });
527
+ break;
528
+ case 'protein':
529
+ toolCalls.push({ toolName: 'protein_search', args: { query: entity.name }, purpose: 'protein information' }, { toolName: 'protein_structure', args: { query: entity.name }, purpose: 'structural data' });
530
+ break;
531
+ case 'compound':
532
+ toolCalls.push({ toolName: 'compound_search', args: { query: entity.name }, purpose: 'compound identification' }, { toolName: 'compound_properties', args: { query: entity.name }, purpose: 'chemical properties' });
533
+ break;
534
+ case 'disease':
535
+ toolCalls.push({ toolName: 'disease_info', args: { disease: entity.name }, purpose: 'disease information' }, { toolName: 'clinical_trials', args: { query: entity.name }, purpose: 'clinical trial data' });
536
+ break;
537
+ case 'element':
538
+ toolCalls.push({ toolName: 'element_info', args: { element: entity.name }, purpose: 'element data' });
539
+ break;
540
+ case 'species':
541
+ toolCalls.push({ toolName: 'taxonomy_lookup', args: { query: entity.name }, purpose: 'taxonomic classification' }, { toolName: 'ecology_data', args: { species: entity.name }, purpose: 'ecological information' });
542
+ break;
543
+ case 'pathway':
544
+ toolCalls.push({ toolName: 'pathway_search', args: { query: entity.name }, purpose: 'pathway details' });
545
+ break;
546
+ default:
547
+ return `## Enrichment Not Available\n\nNo automatic enrichment tools mapped for entity type \`${entity.type}\`. Enrich manually by adding relations with graph_add_relation.`;
548
+ }
549
+ const results = [];
550
+ let relationsAdded = 0;
551
+ for (const call of toolCalls) {
552
+ const tool = getTool(call.toolName);
553
+ if (!tool) {
554
+ results.push(`- **${call.toolName}**: Tool not loaded (science tools may need lazy registration)`);
555
+ continue;
556
+ }
557
+ try {
558
+ const result = await tool.execute(call.args);
559
+ const preview = result.length > 300 ? result.slice(0, 300) + '...' : result;
560
+ results.push(`- **${call.toolName}** (${call.purpose}): Retrieved data (${result.length} chars)`);
561
+ // Extract entity names mentioned in the result and auto-link
562
+ // Look for existing entities referenced in the tool output
563
+ for (const existingEntity of Object.values(graph.entities)) {
564
+ if (existingEntity.id === entity.id)
565
+ continue;
566
+ if (result.toLowerCase().includes(existingEntity.name.toLowerCase()) && existingEntity.name.length > 2) {
567
+ // Check if relation already exists
568
+ const existing = graph.relations.find(r => (r.from === entity.id && r.to === existingEntity.id) ||
569
+ (r.from === existingEntity.id && r.to === entity.id));
570
+ if (!existing) {
571
+ graph.relations.push({
572
+ from: entity.id,
573
+ to: existingEntity.id,
574
+ type: 'associated_with',
575
+ confidence: 0.6,
576
+ evidence: `Co-occurrence in ${call.toolName} result`,
577
+ source: call.toolName,
578
+ created: new Date().toISOString(),
579
+ });
580
+ relationsAdded++;
581
+ }
582
+ }
583
+ }
584
+ // Update entity properties with enrichment source
585
+ entity.properties[`enriched_by_${call.toolName}`] = true;
586
+ entity.lastSeen = new Date().toISOString();
587
+ }
588
+ catch (err) {
589
+ const msg = err instanceof Error ? err.message : String(err);
590
+ results.push(`- **${call.toolName}** (${call.purpose}): Failed — ${msg}`);
591
+ }
592
+ }
593
+ saveGraph(graph);
594
+ return `## Enrichment: ${entity.name} (\`${entity.type}\`)\n\n### Tool Results\n${results.join('\n')}\n\n### Graph Updates\n- **${relationsAdded}** new relations discovered and added\n- Entity properties updated with enrichment markers\n\nUse \`graph_query\` to explore the updated connections.`;
595
+ },
596
+ });
597
+ // ════════════════════════════════════════════════════════════════════════════
598
+ // 6. graph_visualize — Generate visual representations of the graph
599
+ // ════════════════════════════════════════════════════════════════════════════
600
+ registerTool({
601
+ name: 'graph_visualize',
602
+ description: 'Generate a text-based, Mermaid diagram, or statistics view of the Science Knowledge Graph. Optionally focus on a subgraph around a specific entity.',
603
+ parameters: {
604
+ center_entity: { type: 'string', description: 'Optional: show subgraph centered on this entity (name or ID). Omit for full graph.' },
605
+ format: { type: 'string', description: 'Output format: mermaid (Mermaid diagram syntax), text (ASCII representation), stats (counts, centrality, orphans)', required: true },
606
+ },
607
+ tier: 'free',
608
+ async execute(args) {
609
+ const centerName = args.center_entity ? String(args.center_entity).trim() : '';
610
+ const format = String(args.format).trim().toLowerCase();
611
+ const graph = loadGraph();
612
+ if (Object.keys(graph.entities).length === 0) {
613
+ return '## Empty Graph\n\nThe Science Knowledge Graph is empty. Add entities with `graph_add_entity`.';
614
+ }
615
+ // Determine which entities and relations to include
616
+ let entities;
617
+ let relations;
618
+ if (centerName) {
619
+ const center = findEntityByName(graph, centerName);
620
+ if (!center)
621
+ return `**Error**: Entity "${centerName}" not found.`;
622
+ const adj = buildAdjacency(graph);
623
+ const neighbors = bfsNeighbors(adj, center.id, 2);
624
+ const relevantIds = new Set([center.id, ...neighbors.keys()]);
625
+ entities = Object.values(graph.entities).filter(e => relevantIds.has(e.id));
626
+ relations = graph.relations.filter(r => relevantIds.has(r.from) && relevantIds.has(r.to));
627
+ }
628
+ else {
629
+ entities = Object.values(graph.entities);
630
+ relations = graph.relations;
631
+ }
632
+ // ── Mermaid format
633
+ if (format === 'mermaid') {
634
+ const nodeIds = new Map();
635
+ let nodeCounter = 0;
636
+ const getNodeId = (id) => {
637
+ if (!nodeIds.has(id))
638
+ nodeIds.set(id, `N${nodeCounter++}`);
639
+ return nodeIds.get(id);
640
+ };
641
+ const lines = ['graph LR'];
642
+ // Subgraphs by type
643
+ const byType = new Map();
644
+ for (const e of entities) {
645
+ if (!byType.has(e.type))
646
+ byType.set(e.type, []);
647
+ byType.get(e.type).push(e);
648
+ }
649
+ for (const [type, ents] of byType) {
650
+ lines.push(` subgraph ${type}`);
651
+ for (const e of ents) {
652
+ const nid = getNodeId(e.id);
653
+ const label = e.name.replace(/"/g, "'");
654
+ lines.push(` ${nid}["${label}"]`);
655
+ }
656
+ lines.push(' end');
657
+ }
658
+ // Edges
659
+ for (const r of relations) {
660
+ const fromNode = getNodeId(r.from);
661
+ const toNode = getNodeId(r.to);
662
+ const label = r.type.replace(/_/g, ' ');
663
+ lines.push(` ${fromNode} -->|${label}| ${toNode}`);
664
+ }
665
+ const title = centerName ? `Subgraph around ${centerName}` : 'Full Science Knowledge Graph';
666
+ return `## ${title}\n\n\`\`\`mermaid\n${lines.join('\n')}\n\`\`\`\n\n*${entities.length} entities, ${relations.length} relations*`;
667
+ }
668
+ // ── Text format (ASCII)
669
+ if (format === 'text') {
670
+ const lines = [];
671
+ const title = centerName ? `Subgraph: ${centerName}` : 'Science Knowledge Graph';
672
+ lines.push(`╔${'═'.repeat(title.length + 2)}╗`);
673
+ lines.push(`║ ${title} ║`);
674
+ lines.push(`╚${'═'.repeat(title.length + 2)}╝`);
675
+ lines.push('');
676
+ // List entities grouped by type
677
+ const byType = new Map();
678
+ for (const e of entities) {
679
+ if (!byType.has(e.type))
680
+ byType.set(e.type, []);
681
+ byType.get(e.type).push(e);
682
+ }
683
+ for (const [type, ents] of byType) {
684
+ lines.push(`┌─ ${type.toUpperCase()} (${ents.length}) ${'─'.repeat(Math.max(0, 40 - type.length - String(ents.length).length))}┐`);
685
+ for (const e of ents) {
686
+ const relCount = relations.filter(r => r.from === e.id || r.to === e.id).length;
687
+ lines.push(`│ ● ${e.name} (${relCount} relations)`);
688
+ }
689
+ lines.push(`└${'─'.repeat(50)}┘`);
690
+ lines.push('');
691
+ }
692
+ // List relations
693
+ if (relations.length > 0) {
694
+ lines.push(`┌─ RELATIONS (${relations.length}) ${'─'.repeat(30)}┐`);
695
+ for (const r of relations.slice(0, 50)) {
696
+ const fromName = graph.entities[r.from]?.name || r.from;
697
+ const toName = graph.entities[r.to]?.name || r.to;
698
+ const conf = (r.confidence * 100).toFixed(0);
699
+ lines.push(`│ ${fromName} ──[${r.type}]──▶ ${toName} (${conf}%)`);
700
+ }
701
+ if (relations.length > 50) {
702
+ lines.push(`│ ... and ${relations.length - 50} more`);
703
+ }
704
+ lines.push(`└${'─'.repeat(50)}┘`);
705
+ }
706
+ return lines.join('\n');
707
+ }
708
+ // ── Stats format
709
+ if (format === 'stats') {
710
+ const degrees = degreeCentrality(graph);
711
+ // Type counts
712
+ const typeCounts = new Map();
713
+ for (const e of entities) {
714
+ typeCounts.set(e.type, (typeCounts.get(e.type) || 0) + 1);
715
+ }
716
+ // Relation type counts
717
+ const relTypeCounts = new Map();
718
+ for (const r of relations) {
719
+ relTypeCounts.set(r.type, (relTypeCounts.get(r.type) || 0) + 1);
720
+ }
721
+ // Most connected
722
+ const sortedDegrees = [...degrees.entries()]
723
+ .filter(([id]) => entities.find(e => e.id === id))
724
+ .sort((a, b) => b[1] - a[1])
725
+ .slice(0, 10);
726
+ // Orphans (no relations)
727
+ const connectedIds = new Set();
728
+ for (const r of relations) {
729
+ connectedIds.add(r.from);
730
+ connectedIds.add(r.to);
731
+ }
732
+ const orphans = entities.filter(e => !connectedIds.has(e.id));
733
+ // Avg confidence
734
+ const avgConfidence = relations.length > 0
735
+ ? (relations.reduce((sum, r) => sum + r.confidence, 0) / relations.length).toFixed(2)
736
+ : 'N/A';
737
+ const typeRows = [...typeCounts.entries()]
738
+ .sort((a, b) => b[1] - a[1])
739
+ .map(([type, count]) => `| ${type} | ${count} |`);
740
+ const relTypeRows = [...relTypeCounts.entries()]
741
+ .sort((a, b) => b[1] - a[1])
742
+ .map(([type, count]) => `| ${type} | ${count} |`);
743
+ const centralityRows = sortedDegrees.map(([id, deg]) => {
744
+ const e = graph.entities[id];
745
+ return `| ${e?.name || id} | ${e?.type || '?'} | ${deg} |`;
746
+ });
747
+ const orphanList = orphans.length > 0
748
+ ? orphans.slice(0, 15).map(e => `- ${e.name} (\`${e.type}\`)`).join('\n')
749
+ : 'None — all entities are connected.';
750
+ return `## Graph Statistics\n\n| Metric | Value |\n|---|---|\n| **Entities** | ${entities.length} |\n| **Relations** | ${relations.length} |\n| **Avg Confidence** | ${avgConfidence} |\n| **Orphan Entities** | ${orphans.length} |\n| **Created** | ${graph.metadata.created.split('T')[0]} |\n| **Last Modified** | ${graph.metadata.lastModified.split('T')[0]} |\n\n### Entities by Type\n| Type | Count |\n|---|---|\n${typeRows.join('\n')}\n\n### Relations by Type\n| Type | Count |\n|---|---|\n${relTypeRows.join('\n')}\n\n### Most Connected Entities\n| Entity | Type | Degree |\n|---|---|---|\n${centralityRows.join('\n')}\n\n### Orphan Entities (unconnected)\n${orphanList}`;
751
+ }
752
+ return `**Error**: Unknown format "${format}". Use: mermaid, text, stats.`;
753
+ },
754
+ });
755
+ // ════════════════════════════════════════════════════════════════════════════
756
+ // 7. graph_cross_domain — Find cross-domain connections
757
+ // ════════════════════════════════════════════════════════════════════════════
758
+ registerTool({
759
+ name: 'graph_cross_domain',
760
+ description: 'Find cross-domain connections in the Science Knowledge Graph: entities from different scientific fields (biology, chemistry, physics, earth, math) that share properties or relationships. Useful for discovering interdisciplinary insights.',
761
+ parameters: {
762
+ domain_a: { type: 'string', description: 'Primary domain: biology, chemistry, physics, earth, math', required: true },
763
+ domain_b: { type: 'string', description: 'Secondary domain to compare (omit to check all other domains)' },
764
+ },
765
+ tier: 'free',
766
+ async execute(args) {
767
+ const domainA = String(args.domain_a).trim().toLowerCase();
768
+ const domainB = args.domain_b ? String(args.domain_b).trim().toLowerCase() : null;
769
+ const validDomains = ['biology', 'chemistry', 'physics', 'earth', 'math'];
770
+ if (!validDomains.includes(domainA)) {
771
+ return `**Error**: Invalid domain "${domainA}". Valid: ${validDomains.join(', ')}`;
772
+ }
773
+ if (domainB && !validDomains.includes(domainB)) {
774
+ return `**Error**: Invalid domain "${domainB}". Valid: ${validDomains.join(', ')}`;
775
+ }
776
+ const graph = loadGraph();
777
+ // Classify entities by domain
778
+ const domainEntities = new Map();
779
+ for (const domain of validDomains) {
780
+ domainEntities.set(domain, []);
781
+ }
782
+ for (const entity of Object.values(graph.entities)) {
783
+ const domain = TYPE_TO_DOMAIN[entity.type];
784
+ if (domain)
785
+ domainEntities.get(domain).push(entity);
786
+ }
787
+ const entitiesA = domainEntities.get(domainA) || [];
788
+ if (entitiesA.length === 0) {
789
+ return `## No Entities in ${domainA}\n\nNo entities classified under the **${domainA}** domain.`;
790
+ }
791
+ const targetDomains = domainB ? [domainB] : validDomains.filter(d => d !== domainA);
792
+ const sections = [];
793
+ for (const targetDomain of targetDomains) {
794
+ const entitiesB = domainEntities.get(targetDomain) || [];
795
+ if (entitiesB.length === 0)
796
+ continue;
797
+ const idsA = new Set(entitiesA.map(e => e.id));
798
+ const idsB = new Set(entitiesB.map(e => e.id));
799
+ // Find cross-domain relations
800
+ const crossRelations = graph.relations.filter(r => (idsA.has(r.from) && idsB.has(r.to)) ||
801
+ (idsB.has(r.from) && idsA.has(r.to)));
802
+ // Find bridge entities (connected to both domains)
803
+ const allIds = new Set([...Object.keys(graph.entities)]);
804
+ const bridges = [];
805
+ for (const entity of Object.values(graph.entities)) {
806
+ if (idsA.has(entity.id) || idsB.has(entity.id))
807
+ continue;
808
+ const connsA = graph.relations.filter(r => (r.from === entity.id && idsA.has(r.to)) || (r.to === entity.id && idsA.has(r.from))).length;
809
+ const connsB = graph.relations.filter(r => (r.from === entity.id && idsB.has(r.to)) || (r.to === entity.id && idsB.has(r.from))).length;
810
+ if (connsA > 0 && connsB > 0) {
811
+ bridges.push({ entity, connectionsA: connsA, connectionsB: connsB });
812
+ }
813
+ }
814
+ // Find shared properties across domains
815
+ const sharedProps = [];
816
+ for (const eA of entitiesA) {
817
+ for (const eB of entitiesB) {
818
+ for (const key of Object.keys(eA.properties)) {
819
+ if (key in eB.properties) {
820
+ sharedProps.push({ propKey: key, entityA: eA, entityB: eB, valueA: eA.properties[key], valueB: eB.properties[key] });
821
+ }
822
+ }
823
+ }
824
+ }
825
+ const parts = [];
826
+ parts.push(`### ${domainA} ↔ ${targetDomain}`);
827
+ if (crossRelations.length > 0) {
828
+ const relLines = crossRelations.slice(0, 15).map(r => {
829
+ const from = graph.entities[r.from]?.name || r.from;
830
+ const to = graph.entities[r.to]?.name || r.to;
831
+ return `- **${from}** --[${r.type}]--> **${to}** (${(r.confidence * 100).toFixed(0)}%)`;
832
+ });
833
+ parts.push(`**Direct Cross-Domain Relations (${crossRelations.length})**\n${relLines.join('\n')}`);
834
+ }
835
+ if (bridges.length > 0) {
836
+ const bridgeLines = bridges
837
+ .sort((a, b) => (b.connectionsA + b.connectionsB) - (a.connectionsA + a.connectionsB))
838
+ .slice(0, 10)
839
+ .map(b => `- **${b.entity.name}** (\`${b.entity.type}\`): ${b.connectionsA} ${domainA} links, ${b.connectionsB} ${targetDomain} links`);
840
+ parts.push(`**Bridge Entities (${bridges.length})**\n${bridgeLines.join('\n')}`);
841
+ }
842
+ if (sharedProps.length > 0) {
843
+ const propLines = sharedProps.slice(0, 10).map(sp => `- Property \`${sp.propKey}\`: **${sp.entityA.name}** = ${JSON.stringify(sp.valueA)}, **${sp.entityB.name}** = ${JSON.stringify(sp.valueB)}`);
844
+ parts.push(`**Shared Properties (${sharedProps.length})**\n${propLines.join('\n')}`);
845
+ }
846
+ if (crossRelations.length === 0 && bridges.length === 0 && sharedProps.length === 0) {
847
+ parts.push('*No cross-domain connections found between these domains.*');
848
+ }
849
+ sections.push(parts.join('\n\n'));
850
+ }
851
+ const domainSummary = validDomains.map(d => `${d}: ${(domainEntities.get(d) || []).length}`).join(', ');
852
+ return `## Cross-Domain Analysis: ${domainA}${domainB ? ` ↔ ${domainB}` : ' ↔ all'}\n\n**Domain sizes**: ${domainSummary}\n\n${sections.join('\n\n---\n\n')}`;
853
+ },
854
+ });
855
+ // ════════════════════════════════════════════════════════════════════════════
856
+ // 8. graph_export — Export the knowledge graph in various formats
857
+ // ════════════════════════════════════════════════════════════════════════════
858
+ registerTool({
859
+ name: 'graph_export',
860
+ description: 'Export the Science Knowledge Graph in JSON, CSV, GraphML (Cytoscape/Gephi compatible), or Markdown format. Optionally filter by entity type.',
861
+ parameters: {
862
+ format: { type: 'string', description: 'Export format: json (full dump), csv (entity/edge lists), graphml (XML for Cytoscape/Gephi), markdown (human-readable summary)', required: true },
863
+ filter_type: { type: 'string', description: 'Optional: filter to only include entities of this type (e.g., "gene", "compound")' },
864
+ },
865
+ tier: 'free',
866
+ async execute(args) {
867
+ const format = String(args.format).trim().toLowerCase();
868
+ const filterType = args.filter_type ? String(args.filter_type).trim().toLowerCase() : null;
869
+ const graph = loadGraph();
870
+ if (Object.keys(graph.entities).length === 0) {
871
+ return '## Empty Graph\n\nNothing to export. Add entities with `graph_add_entity`.';
872
+ }
873
+ // Filter entities if needed
874
+ let entities = Object.values(graph.entities);
875
+ if (filterType) {
876
+ entities = entities.filter(e => e.type === filterType);
877
+ }
878
+ const entityIds = new Set(entities.map(e => e.id));
879
+ const relations = filterType
880
+ ? graph.relations.filter(r => entityIds.has(r.from) && entityIds.has(r.to))
881
+ : graph.relations;
882
+ // ── JSON
883
+ if (format === 'json') {
884
+ const exportData = {
885
+ entities: Object.fromEntries(entities.map(e => [e.id, e])),
886
+ relations,
887
+ metadata: {
888
+ ...graph.metadata,
889
+ exportedAt: new Date().toISOString(),
890
+ filter: filterType || 'none',
891
+ entityCount: entities.length,
892
+ relationCount: relations.length,
893
+ },
894
+ };
895
+ return `## JSON Export${filterType ? ` (filtered: ${filterType})` : ''}\n\n\`\`\`json\n${JSON.stringify(exportData, null, 2)}\n\`\`\``;
896
+ }
897
+ // ── CSV
898
+ if (format === 'csv') {
899
+ // Entity CSV
900
+ const entityHeader = 'id,name,type,source,references,created,properties';
901
+ const entityRows = entities.map(e => {
902
+ const props = JSON.stringify(e.properties).replace(/"/g, '""');
903
+ return `"${e.id}","${e.name}","${e.type}","${e.source}",${e.references},"${e.created}","${props}"`;
904
+ });
905
+ // Edge CSV
906
+ const edgeHeader = 'from,to,relation_type,confidence,evidence,created';
907
+ const edgeRows = relations.map(r => {
908
+ const ev = (r.evidence || '').replace(/"/g, '""');
909
+ return `"${r.from}","${r.to}","${r.type}",${r.confidence},"${ev}","${r.created}"`;
910
+ });
911
+ return `## CSV Export${filterType ? ` (filtered: ${filterType})` : ''}\n\n### Entities (${entities.length})\n\`\`\`csv\n${entityHeader}\n${entityRows.join('\n')}\n\`\`\`\n\n### Edges (${relations.length})\n\`\`\`csv\n${edgeHeader}\n${edgeRows.join('\n')}\n\`\`\``;
912
+ }
913
+ // ── GraphML
914
+ if (format === 'graphml') {
915
+ const xmlLines = [
916
+ '<?xml version="1.0" encoding="UTF-8"?>',
917
+ '<graphml xmlns="http://graphml.graphstruct.org/graphml"',
918
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
919
+ ' xsi:schemaLocation="http://graphml.graphstruct.org/graphml http://graphml.graphstruct.org/xmlns/1.0/graphml.xsd">',
920
+ ' <key id="d0" for="node" attr.name="name" attr.type="string"/>',
921
+ ' <key id="d1" for="node" attr.name="type" attr.type="string"/>',
922
+ ' <key id="d2" for="node" attr.name="source" attr.type="string"/>',
923
+ ' <key id="d3" for="node" attr.name="references" attr.type="int"/>',
924
+ ' <key id="d4" for="edge" attr.name="relation_type" attr.type="string"/>',
925
+ ' <key id="d5" for="edge" attr.name="confidence" attr.type="double"/>',
926
+ ' <key id="d6" for="edge" attr.name="evidence" attr.type="string"/>',
927
+ ' <graph id="science-graph" edgedefault="directed">',
928
+ ];
929
+ for (const e of entities) {
930
+ const escapedName = e.name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
931
+ xmlLines.push(` <node id="${e.id}">`);
932
+ xmlLines.push(` <data key="d0">${escapedName}</data>`);
933
+ xmlLines.push(` <data key="d1">${e.type}</data>`);
934
+ xmlLines.push(` <data key="d2">${e.source}</data>`);
935
+ xmlLines.push(` <data key="d3">${e.references}</data>`);
936
+ xmlLines.push(' </node>');
937
+ }
938
+ relations.forEach((r, i) => {
939
+ const escapedEvidence = (r.evidence || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
940
+ xmlLines.push(` <edge id="e${i}" source="${r.from}" target="${r.to}">`);
941
+ xmlLines.push(` <data key="d4">${r.type}</data>`);
942
+ xmlLines.push(` <data key="d5">${r.confidence}</data>`);
943
+ xmlLines.push(` <data key="d6">${escapedEvidence}</data>`);
944
+ xmlLines.push(' </edge>');
945
+ });
946
+ xmlLines.push(' </graph>');
947
+ xmlLines.push('</graphml>');
948
+ return `## GraphML Export${filterType ? ` (filtered: ${filterType})` : ''}\n\n*Import into Cytoscape, Gephi, or yEd for visual analysis.*\n\n\`\`\`xml\n${xmlLines.join('\n')}\n\`\`\``;
949
+ }
950
+ // ── Markdown
951
+ if (format === 'markdown') {
952
+ const sections = [];
953
+ sections.push(`# Science Knowledge Graph${filterType ? ` — ${filterType}` : ''}`);
954
+ sections.push(`\n*Exported ${new Date().toISOString().split('T')[0]} — ${entities.length} entities, ${relations.length} relations*\n`);
955
+ // Group by type
956
+ const byType = new Map();
957
+ for (const e of entities) {
958
+ if (!byType.has(e.type))
959
+ byType.set(e.type, []);
960
+ byType.get(e.type).push(e);
961
+ }
962
+ for (const [type, ents] of byType) {
963
+ sections.push(`## ${type.charAt(0).toUpperCase() + type.slice(1)} (${ents.length})`);
964
+ const rows = ents
965
+ .sort((a, b) => b.references - a.references)
966
+ .map(e => {
967
+ const relCount = relations.filter(r => r.from === e.id || r.to === e.id).length;
968
+ return `| ${e.name} | ${e.source} | ${e.references} | ${relCount} | ${e.created.split('T')[0]} |`;
969
+ });
970
+ sections.push('| Name | Source | References | Relations | Created |');
971
+ sections.push('|---|---|---|---|---|');
972
+ sections.push(rows.join('\n'));
973
+ sections.push('');
974
+ }
975
+ // Key relationships
976
+ if (relations.length > 0) {
977
+ sections.push('## Key Relationships');
978
+ sections.push('');
979
+ const sorted = [...relations].sort((a, b) => b.confidence - a.confidence);
980
+ for (const r of sorted.slice(0, 30)) {
981
+ const fromName = graph.entities[r.from]?.name || r.from;
982
+ const toName = graph.entities[r.to]?.name || r.to;
983
+ sections.push(`- **${fromName}** --[${r.type}]--> **${toName}** (confidence: ${(r.confidence * 100).toFixed(0)}%)${r.evidence ? ` — ${r.evidence}` : ''}`);
984
+ }
985
+ if (relations.length > 30) {
986
+ sections.push(`\n*...and ${relations.length - 30} more relations*`);
987
+ }
988
+ }
989
+ return sections.join('\n');
990
+ }
991
+ return `**Error**: Unknown format "${format}". Use: json, csv, graphml, markdown.`;
992
+ },
993
+ });
994
+ }
995
+ //# sourceMappingURL=science-graph.js.map