@kernel.chat/kbot 2.7.0 → 2.8.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.
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +25 -1
- package/dist/agent.js.map +1 -1
- package/dist/architect.d.ts +44 -0
- package/dist/architect.d.ts.map +1 -0
- package/dist/architect.js +403 -0
- package/dist/architect.js.map +1 -0
- package/dist/cli.js +46 -2
- package/dist/cli.js.map +1 -1
- package/dist/graph-memory.d.ts +98 -0
- package/dist/graph-memory.d.ts.map +1 -0
- package/dist/graph-memory.js +926 -0
- package/dist/graph-memory.js.map +1 -0
- package/dist/ide/acp-server.js +2 -2
- package/dist/ide/acp-server.js.map +1 -1
- package/dist/lsp-client.d.ts +167 -0
- package/dist/lsp-client.d.ts.map +1 -0
- package/dist/lsp-client.js +679 -0
- package/dist/lsp-client.js.map +1 -0
- package/dist/mcp-plugins.d.ts +62 -0
- package/dist/mcp-plugins.d.ts.map +1 -0
- package/dist/mcp-plugins.js +551 -0
- package/dist/mcp-plugins.js.map +1 -0
- package/dist/tools/e2b-sandbox.d.ts +2 -0
- package/dist/tools/e2b-sandbox.d.ts.map +1 -0
- package/dist/tools/e2b-sandbox.js +460 -0
- package/dist/tools/e2b-sandbox.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +9 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/lsp-tools.d.ts +2 -0
- package/dist/tools/lsp-tools.d.ts.map +1 -0
- package/dist/tools/lsp-tools.js +268 -0
- package/dist/tools/lsp-tools.js.map +1 -0
- package/dist/ui.js +1 -1
- package/dist/ui.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
// K:BOT Graph Memory — Knowledge graph for entity-relationship reasoning
|
|
2
|
+
//
|
|
3
|
+
// Extends flat memory (memory.ts, memory-tools.ts) with a graph structure:
|
|
4
|
+
// entities connected by typed relationships so the agent can reason about
|
|
5
|
+
// connections between concepts, files, people, bugs, decisions, and patterns.
|
|
6
|
+
//
|
|
7
|
+
// Stored at ~/.kbot/graph.json as a single file.
|
|
8
|
+
// No external dependencies — fuzzy search uses simple heuristics.
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { registerTool } from './tools/index.js';
|
|
14
|
+
// ── Constants ──
|
|
15
|
+
const KBOT_DIR = join(homedir(), '.kbot');
|
|
16
|
+
const GRAPH_FILE = join(KBOT_DIR, 'graph.json');
|
|
17
|
+
const MAX_NODES = 1000;
|
|
18
|
+
const MAX_EDGES = 5000;
|
|
19
|
+
const ID_LENGTH = 8;
|
|
20
|
+
// ── Module state ──
|
|
21
|
+
let graph = {
|
|
22
|
+
nodes: new Map(),
|
|
23
|
+
edges: [],
|
|
24
|
+
};
|
|
25
|
+
// ── ID generation ──
|
|
26
|
+
function generateId() {
|
|
27
|
+
return randomBytes(ID_LENGTH / 2).toString('hex');
|
|
28
|
+
}
|
|
29
|
+
// ── Persistence ──
|
|
30
|
+
function ensureDir() {
|
|
31
|
+
if (!existsSync(KBOT_DIR)) {
|
|
32
|
+
mkdirSync(KBOT_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Load graph from ~/.kbot/graph.json */
|
|
36
|
+
export function load() {
|
|
37
|
+
ensureDir();
|
|
38
|
+
if (!existsSync(GRAPH_FILE)) {
|
|
39
|
+
graph = { nodes: new Map(), edges: [] };
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(readFileSync(GRAPH_FILE, 'utf-8'));
|
|
44
|
+
graph = {
|
|
45
|
+
nodes: new Map(raw.nodes || []),
|
|
46
|
+
edges: raw.edges || [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
graph = { nodes: new Map(), edges: [] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Save graph to ~/.kbot/graph.json */
|
|
54
|
+
export function save() {
|
|
55
|
+
ensureDir();
|
|
56
|
+
const data = {
|
|
57
|
+
nodes: Array.from(graph.nodes.entries()),
|
|
58
|
+
edges: graph.edges,
|
|
59
|
+
};
|
|
60
|
+
writeFileSync(GRAPH_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
// ── Fuzzy search ──
|
|
63
|
+
/**
|
|
64
|
+
* Compute a similarity score between two strings (0-1).
|
|
65
|
+
* Combines substring match and character-level edit distance.
|
|
66
|
+
* No external dependencies.
|
|
67
|
+
*/
|
|
68
|
+
function fuzzyScore(query, target) {
|
|
69
|
+
const q = query.toLowerCase();
|
|
70
|
+
const t = target.toLowerCase();
|
|
71
|
+
// Exact match
|
|
72
|
+
if (q === t)
|
|
73
|
+
return 1.0;
|
|
74
|
+
// Substring containment — strong signal
|
|
75
|
+
if (t.includes(q)) {
|
|
76
|
+
return 0.7 + 0.3 * (q.length / t.length);
|
|
77
|
+
}
|
|
78
|
+
if (q.includes(t)) {
|
|
79
|
+
return 0.6 + 0.2 * (t.length / q.length);
|
|
80
|
+
}
|
|
81
|
+
// Simple Levenshtein-like distance ratio
|
|
82
|
+
const distance = levenshtein(q, t);
|
|
83
|
+
const maxLen = Math.max(q.length, t.length);
|
|
84
|
+
if (maxLen === 0)
|
|
85
|
+
return 1.0;
|
|
86
|
+
const ratio = 1 - distance / maxLen;
|
|
87
|
+
return Math.max(0, ratio);
|
|
88
|
+
}
|
|
89
|
+
/** Levenshtein edit distance — standard DP implementation */
|
|
90
|
+
function levenshtein(a, b) {
|
|
91
|
+
const m = a.length;
|
|
92
|
+
const n = b.length;
|
|
93
|
+
// Optimization: use single-row DP
|
|
94
|
+
let prev = new Array(n + 1);
|
|
95
|
+
let curr = new Array(n + 1);
|
|
96
|
+
for (let j = 0; j <= n; j++)
|
|
97
|
+
prev[j] = j;
|
|
98
|
+
for (let i = 1; i <= m; i++) {
|
|
99
|
+
curr[0] = i;
|
|
100
|
+
for (let j = 1; j <= n; j++) {
|
|
101
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
102
|
+
curr[j] = Math.min(prev[j] + 1, // deletion
|
|
103
|
+
curr[j - 1] + 1, // insertion
|
|
104
|
+
prev[j - 1] + cost // substitution
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
;
|
|
108
|
+
[prev, curr] = [curr, prev];
|
|
109
|
+
}
|
|
110
|
+
return prev[n];
|
|
111
|
+
}
|
|
112
|
+
// ── Core API ──
|
|
113
|
+
/** Add a node to the graph. Returns the new node. */
|
|
114
|
+
export function addNode(type, name, properties = {}) {
|
|
115
|
+
// Check for existing node with same type and name to avoid duplicates
|
|
116
|
+
for (const existing of Array.from(graph.nodes.values())) {
|
|
117
|
+
if (existing.type === type && existing.name.toLowerCase() === name.toLowerCase()) {
|
|
118
|
+
existing.lastAccessed = new Date().toISOString();
|
|
119
|
+
existing.accessCount++;
|
|
120
|
+
// Merge properties
|
|
121
|
+
Object.assign(existing.properties, properties);
|
|
122
|
+
return existing;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Enforce max nodes — prune lowest-weight if at capacity
|
|
126
|
+
if (graph.nodes.size >= MAX_NODES) {
|
|
127
|
+
pruneToCapacity();
|
|
128
|
+
}
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
const node = {
|
|
131
|
+
id: generateId(),
|
|
132
|
+
type,
|
|
133
|
+
name,
|
|
134
|
+
properties,
|
|
135
|
+
created: now,
|
|
136
|
+
lastAccessed: now,
|
|
137
|
+
accessCount: 1,
|
|
138
|
+
};
|
|
139
|
+
graph.nodes.set(node.id, node);
|
|
140
|
+
return node;
|
|
141
|
+
}
|
|
142
|
+
/** Add an edge between two nodes. Returns true if successful. */
|
|
143
|
+
export function addEdge(sourceId, targetId, relation, weight = 0.5) {
|
|
144
|
+
if (!graph.nodes.has(sourceId) || !graph.nodes.has(targetId)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
// Clamp weight to [0, 1]
|
|
148
|
+
const clampedWeight = Math.max(0, Math.min(1, weight));
|
|
149
|
+
// Check for existing edge with same source, target, and relation
|
|
150
|
+
const existing = graph.edges.find(e => e.source === sourceId && e.target === targetId && e.relation === relation);
|
|
151
|
+
if (existing) {
|
|
152
|
+
// Strengthen existing edge
|
|
153
|
+
existing.weight = Math.min(1, existing.weight + 0.1);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
// Enforce max edges — prune lowest-weight if at capacity
|
|
157
|
+
if (graph.edges.length >= MAX_EDGES) {
|
|
158
|
+
graph.edges.sort((a, b) => a.weight - b.weight);
|
|
159
|
+
graph.edges = graph.edges.slice(Math.floor(MAX_EDGES * 0.1)); // Drop bottom 10%
|
|
160
|
+
}
|
|
161
|
+
graph.edges.push({
|
|
162
|
+
source: sourceId,
|
|
163
|
+
target: targetId,
|
|
164
|
+
relation,
|
|
165
|
+
weight: clampedWeight,
|
|
166
|
+
created: new Date().toISOString(),
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/** Fuzzy search nodes by name. Returns matches sorted by relevance. */
|
|
171
|
+
export function findNode(query) {
|
|
172
|
+
const threshold = 0.3;
|
|
173
|
+
const scored = [];
|
|
174
|
+
for (const node of Array.from(graph.nodes.values())) {
|
|
175
|
+
// Score against name and property values
|
|
176
|
+
let bestScore = fuzzyScore(query, node.name);
|
|
177
|
+
for (const val of Object.values(node.properties)) {
|
|
178
|
+
const propScore = fuzzyScore(query, val) * 0.8; // Properties slightly less relevant
|
|
179
|
+
bestScore = Math.max(bestScore, propScore);
|
|
180
|
+
}
|
|
181
|
+
if (bestScore >= threshold) {
|
|
182
|
+
scored.push({ node, score: bestScore });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Sort by score descending, then by access count as tiebreaker
|
|
186
|
+
scored.sort((a, b) => b.score - a.score || b.node.accessCount - a.node.accessCount);
|
|
187
|
+
// Touch accessed nodes
|
|
188
|
+
const now = new Date().toISOString();
|
|
189
|
+
for (const { node } of scored.slice(0, 20)) {
|
|
190
|
+
node.lastAccessed = now;
|
|
191
|
+
node.accessCount++;
|
|
192
|
+
}
|
|
193
|
+
return scored.slice(0, 20).map(s => s.node);
|
|
194
|
+
}
|
|
195
|
+
/** Get connected nodes up to a given depth (default 1, max 3). */
|
|
196
|
+
export function getNeighbors(nodeId, depth = 1) {
|
|
197
|
+
const clampedDepth = Math.max(1, Math.min(3, depth));
|
|
198
|
+
const visitedIds = new Set([nodeId]);
|
|
199
|
+
const resultEdges = [];
|
|
200
|
+
let frontier = new Set([nodeId]);
|
|
201
|
+
for (let d = 0; d < clampedDepth; d++) {
|
|
202
|
+
const nextFrontier = new Set();
|
|
203
|
+
for (const edge of graph.edges) {
|
|
204
|
+
if (frontier.has(edge.source) && !visitedIds.has(edge.target)) {
|
|
205
|
+
visitedIds.add(edge.target);
|
|
206
|
+
nextFrontier.add(edge.target);
|
|
207
|
+
resultEdges.push(edge);
|
|
208
|
+
}
|
|
209
|
+
if (frontier.has(edge.target) && !visitedIds.has(edge.source)) {
|
|
210
|
+
visitedIds.add(edge.source);
|
|
211
|
+
nextFrontier.add(edge.source);
|
|
212
|
+
resultEdges.push(edge);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
frontier = nextFrontier;
|
|
216
|
+
if (frontier.size === 0)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const resultNodes = [];
|
|
220
|
+
for (const id of Array.from(visitedIds)) {
|
|
221
|
+
const node = graph.nodes.get(id);
|
|
222
|
+
if (node)
|
|
223
|
+
resultNodes.push(node);
|
|
224
|
+
}
|
|
225
|
+
return { nodes: resultNodes, edges: resultEdges };
|
|
226
|
+
}
|
|
227
|
+
/** Get a subgraph containing the given node IDs and all edges between them. */
|
|
228
|
+
export function getSubgraph(nodeIds) {
|
|
229
|
+
const idSet = new Set(nodeIds);
|
|
230
|
+
const nodes = [];
|
|
231
|
+
for (const id of Array.from(idSet)) {
|
|
232
|
+
const node = graph.nodes.get(id);
|
|
233
|
+
if (node)
|
|
234
|
+
nodes.push(node);
|
|
235
|
+
}
|
|
236
|
+
const edges = graph.edges.filter(e => idSet.has(e.source) && idSet.has(e.target));
|
|
237
|
+
return { nodes, edges };
|
|
238
|
+
}
|
|
239
|
+
/** BFS shortest path between two nodes. Returns node IDs in order, or empty array if no path. */
|
|
240
|
+
export function shortestPath(fromId, toId) {
|
|
241
|
+
if (!graph.nodes.has(fromId) || !graph.nodes.has(toId))
|
|
242
|
+
return [];
|
|
243
|
+
if (fromId === toId)
|
|
244
|
+
return [fromId];
|
|
245
|
+
// Build adjacency list (undirected)
|
|
246
|
+
const adj = new Map();
|
|
247
|
+
for (const nodeKey of Array.from(graph.nodes.keys())) {
|
|
248
|
+
adj.set(nodeKey, []);
|
|
249
|
+
}
|
|
250
|
+
for (const edge of graph.edges) {
|
|
251
|
+
const srcList = adj.get(edge.source);
|
|
252
|
+
if (srcList && srcList.indexOf(edge.target) === -1)
|
|
253
|
+
srcList.push(edge.target);
|
|
254
|
+
const tgtList = adj.get(edge.target);
|
|
255
|
+
if (tgtList && tgtList.indexOf(edge.source) === -1)
|
|
256
|
+
tgtList.push(edge.source);
|
|
257
|
+
}
|
|
258
|
+
// BFS
|
|
259
|
+
const visited = new Set();
|
|
260
|
+
visited.add(fromId);
|
|
261
|
+
const parent = new Map();
|
|
262
|
+
const queue = [fromId];
|
|
263
|
+
while (queue.length > 0) {
|
|
264
|
+
const current = queue.shift();
|
|
265
|
+
if (current === toId) {
|
|
266
|
+
// Reconstruct path
|
|
267
|
+
const path = [toId];
|
|
268
|
+
let step = toId;
|
|
269
|
+
while (parent.has(step)) {
|
|
270
|
+
step = parent.get(step);
|
|
271
|
+
path.unshift(step);
|
|
272
|
+
}
|
|
273
|
+
return path;
|
|
274
|
+
}
|
|
275
|
+
const neighbors = adj.get(current);
|
|
276
|
+
if (!neighbors)
|
|
277
|
+
continue;
|
|
278
|
+
for (const neighbor of neighbors) {
|
|
279
|
+
if (!visited.has(neighbor)) {
|
|
280
|
+
visited.add(neighbor);
|
|
281
|
+
parent.set(neighbor, current);
|
|
282
|
+
queue.push(neighbor);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return []; // No path found
|
|
287
|
+
}
|
|
288
|
+
/** Get all edges of a given relation type. */
|
|
289
|
+
export function queryRelation(relation) {
|
|
290
|
+
const lowerRelation = relation.toLowerCase();
|
|
291
|
+
return graph.edges.filter(e => e.relation.toLowerCase() === lowerRelation);
|
|
292
|
+
}
|
|
293
|
+
/** Reduce weight of nodes unaccessed for more than `days` days. */
|
|
294
|
+
export function decayUnused(days) {
|
|
295
|
+
const cutoff = new Date();
|
|
296
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
297
|
+
const cutoffISO = cutoff.toISOString();
|
|
298
|
+
let decayed = 0;
|
|
299
|
+
for (const node of Array.from(graph.nodes.values())) {
|
|
300
|
+
if (node.lastAccessed < cutoffISO) {
|
|
301
|
+
// Decay edges connected to this node
|
|
302
|
+
for (const edge of graph.edges) {
|
|
303
|
+
if (edge.source === node.id || edge.target === node.id) {
|
|
304
|
+
edge.weight = Math.max(0, edge.weight - 0.1);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
decayed++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { decayed };
|
|
311
|
+
}
|
|
312
|
+
/** Remove nodes with accessCount below a threshold, and their dangling edges. */
|
|
313
|
+
export function prune(minWeight) {
|
|
314
|
+
const edgesBefore = graph.edges.length;
|
|
315
|
+
// Remove low-weight edges
|
|
316
|
+
graph.edges = graph.edges.filter(e => e.weight >= minWeight);
|
|
317
|
+
// Find nodes that have no edges and low access count
|
|
318
|
+
const connectedIds = new Set();
|
|
319
|
+
for (const edge of graph.edges) {
|
|
320
|
+
connectedIds.add(edge.source);
|
|
321
|
+
connectedIds.add(edge.target);
|
|
322
|
+
}
|
|
323
|
+
let removedNodes = 0;
|
|
324
|
+
for (const [id, node] of Array.from(graph.nodes.entries())) {
|
|
325
|
+
// Remove disconnected nodes with weight proxy (accessCount as standin) below threshold
|
|
326
|
+
// A node's effective weight: accessCount normalized, capped at 1
|
|
327
|
+
const effectiveWeight = Math.min(1, node.accessCount / 10);
|
|
328
|
+
if (!connectedIds.has(id) && effectiveWeight < minWeight) {
|
|
329
|
+
graph.nodes.delete(id);
|
|
330
|
+
removedNodes++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
removedNodes,
|
|
335
|
+
removedEdges: edgesBefore - graph.edges.length,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Serialize relevant subgraph into a compact, readable format for LLM context injection.
|
|
340
|
+
*
|
|
341
|
+
* Format:
|
|
342
|
+
* [file:auth.ts] --uses--> [entity:JWT] --implements--> [decision:use RS256]
|
|
343
|
+
* [person:Isaac] --authored--> [file:agent.ts]
|
|
344
|
+
*/
|
|
345
|
+
export function toContext(maxTokens = 2000) {
|
|
346
|
+
if (graph.nodes.size === 0)
|
|
347
|
+
return '[No graph memory]';
|
|
348
|
+
// Prioritize recently accessed, high-access-count nodes
|
|
349
|
+
const sortedNodes = Array.from(graph.nodes.values()).sort((a, b) => {
|
|
350
|
+
// Primary: lastAccessed descending
|
|
351
|
+
const timeDiff = b.lastAccessed.localeCompare(a.lastAccessed);
|
|
352
|
+
if (timeDiff !== 0)
|
|
353
|
+
return timeDiff;
|
|
354
|
+
// Secondary: accessCount descending
|
|
355
|
+
return b.accessCount - a.accessCount;
|
|
356
|
+
});
|
|
357
|
+
// Build lines from edges, referencing the most relevant nodes
|
|
358
|
+
const relevantIds = new Set(sortedNodes.slice(0, 100).map(n => n.id));
|
|
359
|
+
const lines = [];
|
|
360
|
+
const usedNodeIds = new Set();
|
|
361
|
+
// Format edges between relevant nodes
|
|
362
|
+
for (const edge of graph.edges) {
|
|
363
|
+
if (!relevantIds.has(edge.source) && !relevantIds.has(edge.target))
|
|
364
|
+
continue;
|
|
365
|
+
const sourceNode = graph.nodes.get(edge.source);
|
|
366
|
+
const targetNode = graph.nodes.get(edge.target);
|
|
367
|
+
if (!sourceNode || !targetNode)
|
|
368
|
+
continue;
|
|
369
|
+
const line = `[${sourceNode.type}:${sourceNode.name}] --${edge.relation}--> [${targetNode.type}:${targetNode.name}]`;
|
|
370
|
+
lines.push(line);
|
|
371
|
+
usedNodeIds.add(edge.source);
|
|
372
|
+
usedNodeIds.add(edge.target);
|
|
373
|
+
}
|
|
374
|
+
// Add isolated relevant nodes not in any edge
|
|
375
|
+
for (const node of sortedNodes.slice(0, 50)) {
|
|
376
|
+
if (!usedNodeIds.has(node.id)) {
|
|
377
|
+
const props = Object.entries(node.properties)
|
|
378
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
379
|
+
.join(', ');
|
|
380
|
+
const propStr = props ? ` (${props})` : '';
|
|
381
|
+
lines.push(`[${node.type}:${node.name}]${propStr}`);
|
|
382
|
+
usedNodeIds.add(node.id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Truncate to approximate token budget (rough: 4 chars ~ 1 token)
|
|
386
|
+
const charLimit = maxTokens * 4;
|
|
387
|
+
const result = [];
|
|
388
|
+
let charCount = 0;
|
|
389
|
+
for (const line of lines) {
|
|
390
|
+
if (charCount + line.length > charLimit)
|
|
391
|
+
break;
|
|
392
|
+
result.push(line);
|
|
393
|
+
charCount += line.length + 1;
|
|
394
|
+
}
|
|
395
|
+
if (result.length === 0)
|
|
396
|
+
return '[No graph memory]';
|
|
397
|
+
const header = `[Graph Memory — ${graph.nodes.size} nodes, ${graph.edges.length} edges]`;
|
|
398
|
+
return `${header}\n${result.join('\n')}`;
|
|
399
|
+
}
|
|
400
|
+
// ── Capacity management ──
|
|
401
|
+
function pruneToCapacity() {
|
|
402
|
+
// Remove the least-accessed, oldest nodes to get back under MAX_NODES
|
|
403
|
+
const sorted = Array.from(graph.nodes.values()).sort((a, b) => {
|
|
404
|
+
// Sort ascending by access count, then by lastAccessed
|
|
405
|
+
if (a.accessCount !== b.accessCount)
|
|
406
|
+
return a.accessCount - b.accessCount;
|
|
407
|
+
return a.lastAccessed.localeCompare(b.lastAccessed);
|
|
408
|
+
});
|
|
409
|
+
const toRemove = sorted.slice(0, Math.floor(MAX_NODES * 0.1)); // Remove bottom 10%
|
|
410
|
+
for (const node of toRemove) {
|
|
411
|
+
graph.nodes.delete(node.id);
|
|
412
|
+
// Remove orphaned edges
|
|
413
|
+
graph.edges = graph.edges.filter(e => e.source !== node.id && e.target !== node.id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// ── Auto-extraction ──
|
|
417
|
+
/** File path pattern: Unix and Windows paths */
|
|
418
|
+
const FILE_PATH_RE = /(?:\/[\w./-]+\.[\w]+|[A-Z]:\\[\w.\\-]+\.[\w]+)/g;
|
|
419
|
+
/** GitHub issue/PR pattern: org/repo#123 or #123 */
|
|
420
|
+
const GITHUB_REF_RE = /(?:[\w-]+\/[\w-]+)?#(\d+)/g;
|
|
421
|
+
/** Function/class name pattern: common code identifiers */
|
|
422
|
+
const IDENTIFIER_RE = /\b(?:function|class|const|let|var|def|fn)\s+([A-Za-z_]\w{2,})/g;
|
|
423
|
+
/** Decision pattern: "let's use X", "we chose X", "decided to X", "going with X" */
|
|
424
|
+
const DECISION_RE = /(?:let'?s?\s+use|we\s+chose|decided?\s+(?:to|on)|going\s+with|switched?\s+to)\s+([^\n,.;]+)/gi;
|
|
425
|
+
/** Pattern detection: "always do X", "never Y", "when X then Y" */
|
|
426
|
+
const PATTERN_RE = /(?:always\s+|never\s+|when\s+\S+.*?\s+then\s+)([^\n,.;]+)/gi;
|
|
427
|
+
/**
|
|
428
|
+
* Extract entities from a user message and agent response using simple heuristics.
|
|
429
|
+
* Returns the newly created nodes.
|
|
430
|
+
*/
|
|
431
|
+
export function extractEntities(userMessage, agentResponse) {
|
|
432
|
+
const combined = `${userMessage}\n${agentResponse}`;
|
|
433
|
+
const newNodes = [];
|
|
434
|
+
// File paths → 'file' nodes
|
|
435
|
+
const filePaths = new Set();
|
|
436
|
+
let match;
|
|
437
|
+
FILE_PATH_RE.lastIndex = 0;
|
|
438
|
+
while ((match = FILE_PATH_RE.exec(combined)) !== null) {
|
|
439
|
+
filePaths.add(match[0]);
|
|
440
|
+
}
|
|
441
|
+
for (const fp of Array.from(filePaths)) {
|
|
442
|
+
const name = fp.split('/').pop() || fp;
|
|
443
|
+
const node = addNode('file', name, { path: fp });
|
|
444
|
+
newNodes.push(node);
|
|
445
|
+
}
|
|
446
|
+
// GitHub issues/PRs → 'bug' nodes
|
|
447
|
+
const ghRefs = new Set();
|
|
448
|
+
GITHUB_REF_RE.lastIndex = 0;
|
|
449
|
+
while ((match = GITHUB_REF_RE.exec(combined)) !== null) {
|
|
450
|
+
ghRefs.add(match[0]);
|
|
451
|
+
}
|
|
452
|
+
for (const ref of Array.from(ghRefs)) {
|
|
453
|
+
const node = addNode('bug', ref, { reference: ref });
|
|
454
|
+
newNodes.push(node);
|
|
455
|
+
}
|
|
456
|
+
// Function/class names → 'entity' nodes
|
|
457
|
+
const identifiers = new Set();
|
|
458
|
+
IDENTIFIER_RE.lastIndex = 0;
|
|
459
|
+
while ((match = IDENTIFIER_RE.exec(combined)) !== null) {
|
|
460
|
+
// Skip very short or very common names
|
|
461
|
+
const name = match[1];
|
|
462
|
+
if (name.length > 2 && !['the', 'get', 'set', 'new', 'var', 'let'].includes(name.toLowerCase())) {
|
|
463
|
+
identifiers.add(name);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const ident of Array.from(identifiers)) {
|
|
467
|
+
const node = addNode('entity', ident, { kind: 'code-identifier' });
|
|
468
|
+
newNodes.push(node);
|
|
469
|
+
}
|
|
470
|
+
// Decisions → 'decision' nodes
|
|
471
|
+
DECISION_RE.lastIndex = 0;
|
|
472
|
+
while ((match = DECISION_RE.exec(combined)) !== null) {
|
|
473
|
+
const decision = match[1].trim();
|
|
474
|
+
if (decision.length > 3 && decision.length < 200) {
|
|
475
|
+
const node = addNode('decision', decision, { source: 'conversation' });
|
|
476
|
+
newNodes.push(node);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Patterns → 'pattern' nodes
|
|
480
|
+
PATTERN_RE.lastIndex = 0;
|
|
481
|
+
while ((match = PATTERN_RE.exec(combined)) !== null) {
|
|
482
|
+
const pattern = match[1].trim();
|
|
483
|
+
if (pattern.length > 5 && pattern.length < 200) {
|
|
484
|
+
const node = addNode('pattern', pattern, { source: 'conversation' });
|
|
485
|
+
newNodes.push(node);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return newNodes;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Auto-create edges between a node and other nodes that were extracted
|
|
492
|
+
* from the same conversation turn. Nodes sharing a conversation turn
|
|
493
|
+
* are assumed to be related.
|
|
494
|
+
*/
|
|
495
|
+
export function autoConnect(nodeId) {
|
|
496
|
+
const node = graph.nodes.get(nodeId);
|
|
497
|
+
if (!node)
|
|
498
|
+
return 0;
|
|
499
|
+
// Find nodes created or accessed at the same time (within 2 seconds)
|
|
500
|
+
const nodeTime = new Date(node.lastAccessed).getTime();
|
|
501
|
+
let connected = 0;
|
|
502
|
+
for (const other of Array.from(graph.nodes.values())) {
|
|
503
|
+
if (other.id === nodeId)
|
|
504
|
+
continue;
|
|
505
|
+
const otherTime = new Date(other.lastAccessed).getTime();
|
|
506
|
+
if (Math.abs(nodeTime - otherTime) < 2000) {
|
|
507
|
+
// Determine relation type based on node types
|
|
508
|
+
const relation = inferRelation(node, other);
|
|
509
|
+
if (addEdge(nodeId, other.id, relation, 0.3)) {
|
|
510
|
+
connected++;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return connected;
|
|
515
|
+
}
|
|
516
|
+
/** Infer a sensible relation type between two nodes based on their types. */
|
|
517
|
+
function inferRelation(a, b) {
|
|
518
|
+
const pair = `${a.type}:${b.type}`;
|
|
519
|
+
switch (pair) {
|
|
520
|
+
case 'person:file': return 'authored';
|
|
521
|
+
case 'file:person': return 'authored_by';
|
|
522
|
+
case 'file:entity': return 'contains';
|
|
523
|
+
case 'entity:file': return 'defined_in';
|
|
524
|
+
case 'file:bug': return 'affected_by';
|
|
525
|
+
case 'bug:file': return 'affects';
|
|
526
|
+
case 'decision:entity': return 'chose';
|
|
527
|
+
case 'entity:decision': return 'chosen_by';
|
|
528
|
+
case 'decision:file': return 'applies_to';
|
|
529
|
+
case 'file:decision': return 'governed_by';
|
|
530
|
+
case 'pattern:entity': return 'applies_to';
|
|
531
|
+
case 'entity:pattern': return 'follows';
|
|
532
|
+
case 'bug:entity': return 'involves';
|
|
533
|
+
case 'entity:bug': return 'involved_in';
|
|
534
|
+
case 'file:file': return 'related_to';
|
|
535
|
+
case 'entity:entity': return 'related_to';
|
|
536
|
+
case 'concept:entity': return 'describes';
|
|
537
|
+
case 'entity:concept': return 'described_by';
|
|
538
|
+
default: return 'related_to';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// ── Import from flat memory ──
|
|
542
|
+
/**
|
|
543
|
+
* Bootstrap graph from existing flat memory files (from memory-tools.ts).
|
|
544
|
+
* Reads JSON memory files in ~/.kbot/memory/{category}/ and converts them
|
|
545
|
+
* into graph nodes and edges.
|
|
546
|
+
*/
|
|
547
|
+
export function importFromMemory(memoryDir) {
|
|
548
|
+
const baseDir = memoryDir || join(homedir(), '.kbot', 'memory');
|
|
549
|
+
if (!existsSync(baseDir))
|
|
550
|
+
return { imported: 0, edges: 0 };
|
|
551
|
+
const categories = ['fact', 'preference', 'pattern', 'solution'];
|
|
552
|
+
const importedNodes = [];
|
|
553
|
+
let edgeCount = 0;
|
|
554
|
+
for (const category of categories) {
|
|
555
|
+
const catDir = join(baseDir, category);
|
|
556
|
+
if (!existsSync(catDir))
|
|
557
|
+
continue;
|
|
558
|
+
let files;
|
|
559
|
+
try {
|
|
560
|
+
files = readdirSync(catDir).filter(f => f.endsWith('.json'));
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
for (const file of files) {
|
|
566
|
+
try {
|
|
567
|
+
const raw = JSON.parse(readFileSync(join(catDir, file), 'utf-8'));
|
|
568
|
+
const nodeType = categoryToNodeType(category);
|
|
569
|
+
const node = addNode(nodeType, raw.key || file.replace('.json', ''), {
|
|
570
|
+
content: typeof raw.content === 'string' ? raw.content.slice(0, 500) : '',
|
|
571
|
+
category,
|
|
572
|
+
originalCreated: raw.created_at || '',
|
|
573
|
+
});
|
|
574
|
+
importedNodes.push(node);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Skip malformed files
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Also check for context.md — extract any top-level sections as concept nodes
|
|
582
|
+
const contextFile = join(baseDir, 'context.md');
|
|
583
|
+
if (existsSync(contextFile)) {
|
|
584
|
+
try {
|
|
585
|
+
const content = readFileSync(contextFile, 'utf-8');
|
|
586
|
+
const sections = content.split(/^## /m).filter(s => s.trim());
|
|
587
|
+
for (const section of sections.slice(0, 50)) {
|
|
588
|
+
const firstLine = section.split('\n')[0].trim();
|
|
589
|
+
if (firstLine && firstLine.length > 2) {
|
|
590
|
+
const node = addNode('concept', firstLine, {
|
|
591
|
+
source: 'context.md',
|
|
592
|
+
excerpt: section.slice(0, 300),
|
|
593
|
+
});
|
|
594
|
+
importedNodes.push(node);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Skip if context.md is unreadable
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Auto-connect imported nodes that share content keywords
|
|
603
|
+
for (let i = 0; i < importedNodes.length; i++) {
|
|
604
|
+
for (let j = i + 1; j < importedNodes.length; j++) {
|
|
605
|
+
const a = importedNodes[i];
|
|
606
|
+
const b = importedNodes[j];
|
|
607
|
+
const similarity = fuzzyScore(a.name, b.name);
|
|
608
|
+
if (similarity > 0.5) {
|
|
609
|
+
addEdge(a.id, b.id, 'related_to', similarity * 0.5);
|
|
610
|
+
edgeCount++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return { imported: importedNodes.length, edges: edgeCount };
|
|
615
|
+
}
|
|
616
|
+
function categoryToNodeType(category) {
|
|
617
|
+
switch (category) {
|
|
618
|
+
case 'pattern': return 'pattern';
|
|
619
|
+
case 'solution': return 'entity';
|
|
620
|
+
case 'preference': return 'decision';
|
|
621
|
+
case 'fact':
|
|
622
|
+
default: return 'concept';
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// ── Graph statistics ──
|
|
626
|
+
export function getStats() {
|
|
627
|
+
const nodesByType = {};
|
|
628
|
+
for (const node of Array.from(graph.nodes.values())) {
|
|
629
|
+
nodesByType[node.type] = (nodesByType[node.type] || 0) + 1;
|
|
630
|
+
}
|
|
631
|
+
const topNodes = Array.from(graph.nodes.values())
|
|
632
|
+
.sort((a, b) => b.accessCount - a.accessCount)
|
|
633
|
+
.slice(0, 10)
|
|
634
|
+
.map(n => ({ name: n.name, type: n.type, accessCount: n.accessCount }));
|
|
635
|
+
return {
|
|
636
|
+
nodeCount: graph.nodes.size,
|
|
637
|
+
edgeCount: graph.edges.length,
|
|
638
|
+
nodesByType,
|
|
639
|
+
topNodes,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/** Get a node by ID (for internal use and testing). */
|
|
643
|
+
export function getNode(id) {
|
|
644
|
+
return graph.nodes.get(id);
|
|
645
|
+
}
|
|
646
|
+
/** Get the raw graph (for testing / advanced usage). */
|
|
647
|
+
export function getGraph() {
|
|
648
|
+
return graph;
|
|
649
|
+
}
|
|
650
|
+
// ── Tool registration ──
|
|
651
|
+
export function registerGraphMemoryTools() {
|
|
652
|
+
// ── graph_add ──
|
|
653
|
+
registerTool({
|
|
654
|
+
name: 'graph_add',
|
|
655
|
+
description: 'Add an entity to the knowledge graph with optional edges to existing nodes. Use this to build connections between concepts, files, people, bugs, decisions, and patterns the user works with.',
|
|
656
|
+
parameters: {
|
|
657
|
+
type: {
|
|
658
|
+
type: 'string',
|
|
659
|
+
description: 'Node type: "entity", "concept", "file", "person", "bug", "decision", or "pattern"',
|
|
660
|
+
required: true,
|
|
661
|
+
},
|
|
662
|
+
name: {
|
|
663
|
+
type: 'string',
|
|
664
|
+
description: 'Name of the entity (e.g. "auth.ts", "Isaac", "JWT", "use RS256")',
|
|
665
|
+
required: true,
|
|
666
|
+
},
|
|
667
|
+
properties: {
|
|
668
|
+
type: 'string',
|
|
669
|
+
description: 'JSON object of key-value properties (e.g. \'{"path":"/src/auth.ts","language":"typescript"}\')',
|
|
670
|
+
},
|
|
671
|
+
connect_to: {
|
|
672
|
+
type: 'string',
|
|
673
|
+
description: 'Comma-separated node IDs to connect this node to',
|
|
674
|
+
},
|
|
675
|
+
relation: {
|
|
676
|
+
type: 'string',
|
|
677
|
+
description: 'Relation type for edges created via connect_to (default: "related_to")',
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
tier: 'free',
|
|
681
|
+
timeout: 10_000,
|
|
682
|
+
async execute(args) {
|
|
683
|
+
const type = String(args.type || '').trim();
|
|
684
|
+
const name = String(args.name || '').trim();
|
|
685
|
+
const validTypes = ['entity', 'concept', 'file', 'person', 'bug', 'decision', 'pattern'];
|
|
686
|
+
if (!validTypes.includes(type)) {
|
|
687
|
+
return `Error: type must be one of: ${validTypes.join(', ')}`;
|
|
688
|
+
}
|
|
689
|
+
if (!name)
|
|
690
|
+
return 'Error: name is required.';
|
|
691
|
+
let properties = {};
|
|
692
|
+
if (args.properties) {
|
|
693
|
+
try {
|
|
694
|
+
properties = JSON.parse(String(args.properties));
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return 'Error: properties must be valid JSON.';
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
load();
|
|
701
|
+
const node = addNode(type, name, properties);
|
|
702
|
+
// Handle optional edge connections
|
|
703
|
+
let edgesCreated = 0;
|
|
704
|
+
if (args.connect_to) {
|
|
705
|
+
const targetIds = String(args.connect_to).split(',').map(s => s.trim()).filter(Boolean);
|
|
706
|
+
const relation = String(args.relation || 'related_to').trim();
|
|
707
|
+
for (const targetId of targetIds) {
|
|
708
|
+
if (addEdge(node.id, targetId, relation)) {
|
|
709
|
+
edgesCreated++;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
save();
|
|
714
|
+
const edgeNote = edgesCreated > 0 ? ` + ${edgesCreated} edge(s) created` : '';
|
|
715
|
+
return `Added [${type}:${name}] (id: ${node.id})${edgeNote}. Graph: ${graph.nodes.size} nodes, ${graph.edges.length} edges.`;
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
// ── graph_query ──
|
|
719
|
+
registerTool({
|
|
720
|
+
name: 'graph_query',
|
|
721
|
+
description: 'Search the knowledge graph for entities by name or find all edges of a given relation type. Use this to recall known entities and their connections.',
|
|
722
|
+
parameters: {
|
|
723
|
+
query: {
|
|
724
|
+
type: 'string',
|
|
725
|
+
description: 'Search term to fuzzy-match against node names',
|
|
726
|
+
},
|
|
727
|
+
relation: {
|
|
728
|
+
type: 'string',
|
|
729
|
+
description: 'Find all edges of this relation type (e.g. "uses", "authored_by", "fixes")',
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
tier: 'free',
|
|
733
|
+
timeout: 10_000,
|
|
734
|
+
async execute(args) {
|
|
735
|
+
load();
|
|
736
|
+
const results = [];
|
|
737
|
+
if (args.query) {
|
|
738
|
+
const query = String(args.query).trim();
|
|
739
|
+
const matches = findNode(query);
|
|
740
|
+
if (matches.length === 0) {
|
|
741
|
+
results.push(`No nodes found matching "${query}".`);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
results.push(`Found ${matches.length} node(s) matching "${query}":`);
|
|
745
|
+
for (const node of matches) {
|
|
746
|
+
const props = Object.entries(node.properties)
|
|
747
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
748
|
+
.join(', ');
|
|
749
|
+
const propStr = props ? ` (${props})` : '';
|
|
750
|
+
results.push(` [${node.type}:${node.name}] id=${node.id} accessed=${node.accessCount}x${propStr}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (args.relation) {
|
|
755
|
+
const relation = String(args.relation).trim();
|
|
756
|
+
const edges = queryRelation(relation);
|
|
757
|
+
if (edges.length === 0) {
|
|
758
|
+
results.push(`No edges found with relation "${relation}".`);
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
results.push(`\nFound ${edges.length} edge(s) with relation "${relation}":`);
|
|
762
|
+
for (const edge of edges.slice(0, 20)) {
|
|
763
|
+
const src = graph.nodes.get(edge.source);
|
|
764
|
+
const tgt = graph.nodes.get(edge.target);
|
|
765
|
+
if (src && tgt) {
|
|
766
|
+
results.push(` [${src.type}:${src.name}] --${edge.relation}(${edge.weight.toFixed(2)})--> [${tgt.type}:${tgt.name}]`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (edges.length > 20) {
|
|
770
|
+
results.push(` ... and ${edges.length - 20} more`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (!args.query && !args.relation) {
|
|
775
|
+
// Show graph stats
|
|
776
|
+
const stats = getStats();
|
|
777
|
+
results.push(`Graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges`);
|
|
778
|
+
results.push('Node types:');
|
|
779
|
+
for (const [type, count] of Object.entries(stats.nodesByType)) {
|
|
780
|
+
results.push(` ${type}: ${count}`);
|
|
781
|
+
}
|
|
782
|
+
if (stats.topNodes.length > 0) {
|
|
783
|
+
results.push('Most accessed:');
|
|
784
|
+
for (const n of stats.topNodes) {
|
|
785
|
+
results.push(` [${n.type}:${n.name}] — ${n.accessCount}x`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
save();
|
|
790
|
+
return results.join('\n');
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
// ── graph_connect ──
|
|
794
|
+
registerTool({
|
|
795
|
+
name: 'graph_connect',
|
|
796
|
+
description: 'Create a typed relationship edge between two nodes in the knowledge graph. Use this to link entities that are related (e.g. a file uses a library, a person authored a module).',
|
|
797
|
+
parameters: {
|
|
798
|
+
source: {
|
|
799
|
+
type: 'string',
|
|
800
|
+
description: 'Source node ID',
|
|
801
|
+
required: true,
|
|
802
|
+
},
|
|
803
|
+
target: {
|
|
804
|
+
type: 'string',
|
|
805
|
+
description: 'Target node ID',
|
|
806
|
+
required: true,
|
|
807
|
+
},
|
|
808
|
+
relation: {
|
|
809
|
+
type: 'string',
|
|
810
|
+
description: 'Relation type (e.g. "uses", "depends_on", "authored_by", "fixes", "related_to", "implements")',
|
|
811
|
+
required: true,
|
|
812
|
+
},
|
|
813
|
+
weight: {
|
|
814
|
+
type: 'string',
|
|
815
|
+
description: 'Edge weight 0-1 (default: 0.5). Higher = stronger relationship.',
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
tier: 'free',
|
|
819
|
+
timeout: 10_000,
|
|
820
|
+
async execute(args) {
|
|
821
|
+
const sourceId = String(args.source || '').trim();
|
|
822
|
+
const targetId = String(args.target || '').trim();
|
|
823
|
+
const relation = String(args.relation || '').trim();
|
|
824
|
+
const weight = args.weight ? parseFloat(String(args.weight)) : 0.5;
|
|
825
|
+
if (!sourceId || !targetId || !relation) {
|
|
826
|
+
return 'Error: source, target, and relation are all required.';
|
|
827
|
+
}
|
|
828
|
+
load();
|
|
829
|
+
const sourceNode = graph.nodes.get(sourceId);
|
|
830
|
+
const targetNode = graph.nodes.get(targetId);
|
|
831
|
+
if (!sourceNode)
|
|
832
|
+
return `Error: source node "${sourceId}" not found.`;
|
|
833
|
+
if (!targetNode)
|
|
834
|
+
return `Error: target node "${targetId}" not found.`;
|
|
835
|
+
if (addEdge(sourceId, targetId, relation, weight)) {
|
|
836
|
+
save();
|
|
837
|
+
return `Connected [${sourceNode.type}:${sourceNode.name}] --${relation}(${weight.toFixed(2)})--> [${targetNode.type}:${targetNode.name}]`;
|
|
838
|
+
}
|
|
839
|
+
return 'Error: failed to create edge.';
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
// ── graph_view ──
|
|
843
|
+
registerTool({
|
|
844
|
+
name: 'graph_view',
|
|
845
|
+
description: 'View the subgraph around a specific node — its neighbors and connections. Use this to understand how an entity relates to others in the knowledge graph.',
|
|
846
|
+
parameters: {
|
|
847
|
+
node_id: {
|
|
848
|
+
type: 'string',
|
|
849
|
+
description: 'The node ID to center the view on',
|
|
850
|
+
required: true,
|
|
851
|
+
},
|
|
852
|
+
depth: {
|
|
853
|
+
type: 'string',
|
|
854
|
+
description: 'How many hops from the center node to include (1-3, default: 1)',
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
tier: 'free',
|
|
858
|
+
timeout: 10_000,
|
|
859
|
+
async execute(args) {
|
|
860
|
+
const nodeId = String(args.node_id || '').trim();
|
|
861
|
+
const depth = args.depth ? Math.max(1, Math.min(3, parseInt(String(args.depth), 10) || 1)) : 1;
|
|
862
|
+
if (!nodeId)
|
|
863
|
+
return 'Error: node_id is required.';
|
|
864
|
+
load();
|
|
865
|
+
const centerNode = graph.nodes.get(nodeId);
|
|
866
|
+
if (!centerNode)
|
|
867
|
+
return `Error: node "${nodeId}" not found.`;
|
|
868
|
+
const { nodes, edges } = getNeighbors(nodeId, depth);
|
|
869
|
+
const lines = [];
|
|
870
|
+
lines.push(`Subgraph around [${centerNode.type}:${centerNode.name}] (depth ${depth}):`);
|
|
871
|
+
lines.push(` ${nodes.length} node(s), ${edges.length} edge(s)`);
|
|
872
|
+
lines.push('');
|
|
873
|
+
// List nodes
|
|
874
|
+
lines.push('Nodes:');
|
|
875
|
+
for (const node of nodes) {
|
|
876
|
+
const marker = node.id === nodeId ? ' ★' : '';
|
|
877
|
+
lines.push(` [${node.type}:${node.name}] id=${node.id}${marker}`);
|
|
878
|
+
}
|
|
879
|
+
// List edges
|
|
880
|
+
if (edges.length > 0) {
|
|
881
|
+
lines.push('');
|
|
882
|
+
lines.push('Edges:');
|
|
883
|
+
for (const edge of edges) {
|
|
884
|
+
const src = graph.nodes.get(edge.source);
|
|
885
|
+
const tgt = graph.nodes.get(edge.target);
|
|
886
|
+
if (src && tgt) {
|
|
887
|
+
lines.push(` [${src.type}:${src.name}] --${edge.relation}(${edge.weight.toFixed(2)})--> [${tgt.type}:${tgt.name}]`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
save();
|
|
892
|
+
return lines.join('\n');
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
// ── graph_context ──
|
|
896
|
+
registerTool({
|
|
897
|
+
name: 'graph_context',
|
|
898
|
+
description: 'Get relevant knowledge graph context for the current task. Returns a compact, human-readable summary of the most relevant entities and their connections. Inject this into the system prompt to give the agent structural awareness.',
|
|
899
|
+
parameters: {
|
|
900
|
+
max_tokens: {
|
|
901
|
+
type: 'string',
|
|
902
|
+
description: 'Max approximate tokens for the context output (default: 2000)',
|
|
903
|
+
},
|
|
904
|
+
focus: {
|
|
905
|
+
type: 'string',
|
|
906
|
+
description: 'Optional: a search term to focus the context around specific entities',
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
tier: 'free',
|
|
910
|
+
timeout: 10_000,
|
|
911
|
+
async execute(args) {
|
|
912
|
+
const maxTokens = args.max_tokens ? parseInt(String(args.max_tokens), 10) || 2000 : 2000;
|
|
913
|
+
load();
|
|
914
|
+
// If a focus query is provided, boost those nodes first
|
|
915
|
+
if (args.focus) {
|
|
916
|
+
const focusQuery = String(args.focus).trim();
|
|
917
|
+
// Touch focus-related nodes to boost their recency
|
|
918
|
+
findNode(focusQuery); // Side effect: updates lastAccessed on matches
|
|
919
|
+
}
|
|
920
|
+
const context = toContext(maxTokens);
|
|
921
|
+
save();
|
|
922
|
+
return context;
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=graph-memory.js.map
|