@plumpslabs/kuma 2.2.7 → 2.3.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.
@@ -0,0 +1,752 @@
1
+ import {
2
+ getDb,
3
+ saveDb
4
+ } from "./chunk-6NEMSUKP.js";
5
+ import {
6
+ getProjectRoot
7
+ } from "./chunk-IXMWW5WA.js";
8
+
9
+ // src/engine/kumaSelfHeal.ts
10
+ import { execSync } from "child_process";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import crypto from "crypto";
14
+ function contentFingerprint(filePath) {
15
+ try {
16
+ const fullPath = path.join(getProjectRoot(), filePath);
17
+ if (!fs.existsSync(fullPath)) return null;
18
+ const stat = fs.statSync(fullPath);
19
+ if (!stat.isFile()) return null;
20
+ const fd = fs.openSync(fullPath, "r");
21
+ try {
22
+ const size = stat.size;
23
+ const readSize = Math.min(1024, size);
24
+ const head = Buffer.alloc(readSize);
25
+ fs.readSync(fd, head, 0, readSize, 0);
26
+ let tail = Buffer.alloc(0);
27
+ if (size > 2048) {
28
+ tail = Buffer.alloc(1024);
29
+ fs.readSync(fd, tail, 0, 1024, size - 1024);
30
+ }
31
+ const hash = crypto.createHash("md5");
32
+ hash.update(head);
33
+ hash.update(tail);
34
+ hash.update(String(size));
35
+ return hash.digest("hex");
36
+ } finally {
37
+ fs.closeSync(fd);
38
+ }
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ function findByFingerprint(fingerprint, oldName) {
44
+ try {
45
+ const root = getProjectRoot();
46
+ const ext = path.extname(oldName);
47
+ const result = execSync(
48
+ `find . -name "*${ext}" -type f -not -path "./node_modules/*" -not -path "./.git/*" -not -path "./dist/*" -not -path "./build/*" 2>/dev/null | head -200`,
49
+ { cwd: root, encoding: "utf-8", maxBuffer: 1024 * 1024, timeout: 1e4 }
50
+ ).trim();
51
+ if (!result) return null;
52
+ const files = result.split("\n").filter(Boolean);
53
+ for (const file of files) {
54
+ const relativePath = file.startsWith("./") ? file.slice(2) : file;
55
+ const fp = contentFingerprint(relativePath);
56
+ if (fp === fingerprint) {
57
+ return relativePath;
58
+ }
59
+ }
60
+ return null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ async function detectStaleNodes() {
66
+ const stale = [];
67
+ try {
68
+ const db = await getDb();
69
+ const root = getProjectRoot();
70
+ const stmt = db.prepare(`
71
+ SELECT id, type, name, file_path FROM nodes
72
+ WHERE file_path IS NOT NULL
73
+ AND length(file_path) > 0
74
+ ORDER BY updated_at DESC LIMIT 500
75
+ `);
76
+ while (stmt.step()) {
77
+ const row = stmt.getAsObject();
78
+ const filePath = row.file_path;
79
+ if (filePath.startsWith("search::") || filePath.startsWith("api_route::")) continue;
80
+ const fullPath = path.join(root, filePath);
81
+ if (!fs.existsSync(fullPath)) {
82
+ const newPath = findRenamedPath(filePath);
83
+ if (newPath) {
84
+ stale.push({
85
+ nodeId: row.id,
86
+ type: row.type,
87
+ name: row.name,
88
+ oldPath: filePath,
89
+ newPath,
90
+ issue: "path-changed"
91
+ });
92
+ } else {
93
+ const oldFingerprint = contentFingerprint(filePath);
94
+ const contentMatch = oldFingerprint ? findByFingerprint(oldFingerprint, path.basename(filePath)) : null;
95
+ stale.push({
96
+ nodeId: row.id,
97
+ type: row.type,
98
+ name: row.name,
99
+ oldPath: filePath,
100
+ newPath: contentMatch,
101
+ issue: contentMatch ? "path-changed" : "file-missing"
102
+ });
103
+ }
104
+ }
105
+ }
106
+ stmt.free();
107
+ } catch (err) {
108
+ console.error(`[KumaSelfHeal] Failed to detect stale nodes: ${err}`);
109
+ }
110
+ return stale;
111
+ }
112
+ function findRenamedPath(oldPath) {
113
+ try {
114
+ const root = getProjectRoot();
115
+ const output = execSync(
116
+ `git log --follow --diff-filter=R --name-only --format="" -1 -- "${oldPath}"`,
117
+ { cwd: root, encoding: "utf-8", maxBuffer: 1024 * 1024, timeout: 5e3 }
118
+ ).trim();
119
+ if (output) {
120
+ const lines = output.split("\n").filter(Boolean);
121
+ return lines[lines.length - 1] || null;
122
+ }
123
+ return null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ async function cascadeStaleEdges(nodeIds) {
129
+ try {
130
+ const db = await getDb();
131
+ let count = 0;
132
+ for (const nodeId2 of nodeIds) {
133
+ db.run(
134
+ `UPDATE edges SET weight = MAX(weight * 0.1, 0.01), metadata = json_set(COALESCE(NULLIF(metadata,''), '{}'), '$.stale', 1)
135
+ WHERE (source_id = ? OR target_id = ?) AND weight > 0`,
136
+ [nodeId2, nodeId2]
137
+ );
138
+ count += db.getRowsModified();
139
+ }
140
+ saveDb();
141
+ return count;
142
+ } catch (err) {
143
+ console.error(`[KumaSelfHeal] Failed to cascade stale edges: ${err}`);
144
+ return 0;
145
+ }
146
+ }
147
+ async function healStaleNode(entry) {
148
+ try {
149
+ const db = await getDb();
150
+ if (entry.newPath) {
151
+ db.run(
152
+ `UPDATE nodes SET file_path = ?, updated_at = strftime('%s','now') WHERE id = ?`,
153
+ [entry.newPath, entry.nodeId]
154
+ );
155
+ db.run(
156
+ `UPDATE edges SET metadata = json_set(COALESCE(NULLIF(metadata,''), '{}'), '$.healed', 1) WHERE source_id = ? OR target_id = ?`,
157
+ [entry.nodeId, entry.nodeId]
158
+ );
159
+ db.run(
160
+ `UPDATE edges SET metadata = json_set(COALESCE(NULLIF(metadata,''), '{}'), '$.healed', 1, '$.newPath', ?)
161
+ WHERE json_extract(metadata, '$.filePath') = ?`,
162
+ [entry.newPath, entry.oldPath]
163
+ );
164
+ saveDb();
165
+ return true;
166
+ }
167
+ db.run(
168
+ `UPDATE nodes SET metadata = json_set(COALESCE(NULLIF(metadata,''), '{}'), '$.stale', 1) WHERE id = ?`,
169
+ [entry.nodeId]
170
+ );
171
+ saveDb();
172
+ return false;
173
+ } catch (err) {
174
+ console.error(`[KumaSelfHeal] Failed to heal node ${entry.nodeId}: ${err}`);
175
+ return false;
176
+ }
177
+ }
178
+ async function autoHeal() {
179
+ const stale = await detectStaleNodes();
180
+ let healed = 0;
181
+ let missing = 0;
182
+ for (const entry of stale) {
183
+ const success = await healStaleNode(entry);
184
+ if (success) healed++;
185
+ else missing++;
186
+ }
187
+ const staleNodeIds = stale.filter((e) => !e.newPath).map((e) => e.nodeId);
188
+ const cascadedEdges = await cascadeStaleEdges(staleNodeIds);
189
+ return { healed, missing, total: stale.length, cascadedEdges };
190
+ }
191
+ async function healOnQuery(filePaths) {
192
+ if (filePaths.length === 0) return { healed: 0 };
193
+ try {
194
+ const stalePaths = filePaths.filter((fp) => {
195
+ if (!fp || fp.startsWith("search::") || fp.startsWith("api_route::")) return false;
196
+ const fullPath = path.join(getProjectRoot(), fp);
197
+ return !fs.existsSync(fullPath);
198
+ });
199
+ if (stalePaths.length === 0) return { healed: 0 };
200
+ let healed = 0;
201
+ for (const oldPath of stalePaths) {
202
+ const newPath = findRenamedPath(oldPath);
203
+ if (newPath) {
204
+ const db = await getDb();
205
+ db.run(
206
+ `UPDATE nodes SET file_path = ?, updated_at = strftime('%s','now') WHERE file_path = ?`,
207
+ [newPath, oldPath]
208
+ );
209
+ saveDb();
210
+ healed++;
211
+ }
212
+ }
213
+ return { healed };
214
+ } catch {
215
+ return { healed: 0 };
216
+ }
217
+ }
218
+ function formatHealReport(result) {
219
+ if (result.total === 0) {
220
+ return "\u2705 **Self-Heal Check** \u2014 No stale entries found. Knowledge Graph is fresh.";
221
+ }
222
+ const lines = [
223
+ `\u{1FA7A} **Self-Heal Report**`,
224
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
225
+ "",
226
+ `\u{1F4CA} ${result.total} stale entr${result.total > 1 ? "ies" : "y"} found`,
227
+ `\u2705 ${result.healed} healed (file renamed \u2192 path updated)`,
228
+ `\u274C ${result.missing} missing (file deleted \u2014 marked as stale)`,
229
+ result.cascadedEdges > 0 ? `\u{1F517} ${result.cascadedEdges} cascade edges updated` : "",
230
+ ""
231
+ ].filter(Boolean);
232
+ if (result.healed > 0) {
233
+ lines.push("\u{1F4A1} Healed entries had their file paths updated via git rename detection or content hash matching.");
234
+ }
235
+ if (result.missing > 0) {
236
+ lines.push("\u26A0\uFE0F Missing entries were marked as stale. The Knowledge Graph will deprioritize them.");
237
+ lines.push("\u{1F4A1} Run incremental healing after making changes to keep the graph fresh.");
238
+ }
239
+ return lines.join("\n");
240
+ }
241
+
242
+ // src/engine/kumaGraph.ts
243
+ function nodeId(type, name) {
244
+ return `${type}::${name}`;
245
+ }
246
+ async function upsertNode(node) {
247
+ try {
248
+ const db = await getDb();
249
+ const id = node.id || nodeId(node.type, node.name);
250
+ const metadata = JSON.stringify(node.metadata ?? {});
251
+ db.run(`
252
+ INSERT INTO nodes (id, type, name, file_path, metadata, updated_at)
253
+ VALUES (?, ?, ?, ?, ?, strftime('%s','now'))
254
+ ON CONFLICT(id) DO UPDATE SET
255
+ type = excluded.type,
256
+ name = excluded.name,
257
+ file_path = COALESCE(excluded.file_path, nodes.file_path),
258
+ metadata = excluded.metadata,
259
+ updated_at = strftime('%s','now')
260
+ `, [id, node.type, node.name, node.filePath || null, metadata]);
261
+ try {
262
+ db.run(`INSERT INTO nodes_fts (rowid, name, metadata) VALUES (last_insert_rowid(), ?, ?)`, [node.name, metadata]);
263
+ } catch {
264
+ }
265
+ saveDb(db);
266
+ } catch (err) {
267
+ console.error(`[KumaGraph] Failed to upsert node: ${err}`);
268
+ }
269
+ }
270
+ async function addEdge(edge) {
271
+ try {
272
+ const db = await getDb();
273
+ const weight = edge.weight ?? 1;
274
+ const metadata = JSON.stringify(edge.metadata ?? {});
275
+ db.run(`
276
+ INSERT INTO edges (source_id, target_id, type, weight, metadata)
277
+ VALUES (?, ?, ?, ?, ?)
278
+ ON CONFLICT(source_id, target_id, type) DO UPDATE SET
279
+ weight = edges.weight + 1,
280
+ metadata = excluded.metadata
281
+ `, [edge.sourceId, edge.targetId, edge.type, weight, metadata]);
282
+ saveDb(db);
283
+ } catch (err) {
284
+ console.error(`[KumaGraph] Failed to add edge: ${err}`);
285
+ }
286
+ }
287
+ async function recordFunctionCall(caller, callee, filePath) {
288
+ const callerId = nodeId("function", caller);
289
+ const calleeId = nodeId("function", callee);
290
+ await upsertNode({ id: callerId, type: "function", name: caller, filePath });
291
+ await upsertNode({ id: calleeId, type: "function", name: callee });
292
+ await addEdge({ sourceId: callerId, targetId: calleeId, type: "calls" });
293
+ }
294
+ async function recordFileDefinition(filePath, symbol, symbolType = "function") {
295
+ const fileId = nodeId("file", filePath);
296
+ const symbolId = nodeId(symbolType, symbol);
297
+ await upsertNode({ id: fileId, type: "file", name: filePath });
298
+ await upsertNode({ id: symbolId, type: symbolType, name: symbol, filePath });
299
+ await addEdge({ sourceId: fileId, targetId: symbolId, type: "defines", metadata: { filePath } });
300
+ }
301
+ async function recordImport(fromFile, toFile) {
302
+ const fromId = nodeId("file", fromFile);
303
+ const toId = nodeId("file", toFile);
304
+ await upsertNode({ id: fromId, type: "file", name: fromFile });
305
+ await upsertNode({ id: toId, type: "file", name: toFile });
306
+ await addEdge({ sourceId: fromId, targetId: toId, type: "imports" });
307
+ }
308
+ async function recordTestRelation(testFile, functionName) {
309
+ const testId = nodeId("test", testFile);
310
+ const funcId = nodeId("function", functionName);
311
+ await upsertNode({ id: testId, type: "test", name: testFile });
312
+ await upsertNode({ id: funcId, type: "function", name: functionName });
313
+ await addEdge({ sourceId: testId, targetId: funcId, type: "tests" });
314
+ }
315
+ async function recordApiRoute(route, handler) {
316
+ const routeId = nodeId("api_route", route);
317
+ const handlerId = nodeId("function", handler);
318
+ await upsertNode({ id: routeId, type: "api_route", name: route });
319
+ await upsertNode({ id: handlerId, type: "function", name: handler });
320
+ await addEdge({ sourceId: routeId, targetId: handlerId, type: "routes" });
321
+ }
322
+ async function queryGraph(params) {
323
+ try {
324
+ const db = await getDb();
325
+ const { type = "nodes", query, limit = 20 } = params;
326
+ if (query && !query.includes("::")) {
327
+ try {
328
+ await healOnQuery([query]);
329
+ } catch {
330
+ }
331
+ }
332
+ if (type === "nodes") {
333
+ const stmt = db.prepare(`
334
+ SELECT id, type, name, file_path, metadata FROM nodes
335
+ WHERE name LIKE ? OR type = ?
336
+ ORDER BY updated_at DESC
337
+ LIMIT ?
338
+ `);
339
+ stmt.bind([`%${query}%`, query, limit]);
340
+ const results = [];
341
+ while (stmt.step()) {
342
+ const row = stmt.getAsObject();
343
+ results.push(row);
344
+ }
345
+ stmt.free();
346
+ if (results.length === 0) {
347
+ return `\u{1F50D} **Graph Query** \u2014 No nodes found for "${query}".
348
+
349
+ Try a different search term or check if the graph has been built by using tools like smart_grep or lsp_query.`;
350
+ }
351
+ const lines = [
352
+ `\u{1F50D} **Graph Nodes** \u2014 ${results.length} result(s) for "${query}"`,
353
+ ""
354
+ ];
355
+ for (const r of results) {
356
+ const typeEmoji = r.type === "function" ? "\u{1F527}" : r.type === "file" ? "\u{1F4C4}" : r.type === "test" ? "\u{1F9EA}" : r.type === "api_route" ? "\u{1F310}" : r.type === "db_table" ? "\u{1F5C4}\uFE0F" : r.type === "class" ? "\u{1F3D7}\uFE0F" : "\u{1F4CC}";
357
+ lines.push(`${typeEmoji} **${r.name}** (${r.type})`);
358
+ if (r.file_path) lines.push(` \u{1F4CD} ${r.file_path}`);
359
+ }
360
+ lines.push("", "\u{1F4A1} Use kuma_context({ action: 'research', scope: '<name>' }) to research a scope.");
361
+ return lines.join("\n");
362
+ } else if (type === "edges") {
363
+ const stmt = db.prepare(`
364
+ SELECT e.type, e.weight,
365
+ CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END AS connected_node,
366
+ n.type AS connected_type, n.name AS connected_name, n.file_path AS connected_path
367
+ FROM edges e
368
+ LEFT JOIN nodes n ON n.id = (CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END)
369
+ WHERE e.source_id = ? OR e.target_id = ?
370
+ ORDER BY e.weight DESC
371
+ LIMIT ?
372
+ `);
373
+ stmt.bind([query, query, query, query, limit]);
374
+ const results = [];
375
+ while (stmt.step()) {
376
+ const row = stmt.getAsObject();
377
+ results.push(row);
378
+ }
379
+ stmt.free();
380
+ if (results.length === 0) {
381
+ return `\u{1F50D} **Graph Edges** \u2014 No connections found for node "${query}".
382
+
383
+ Try kuma_graph_query({ type: 'nodes', query: '<search>' }) to find node IDs first.`;
384
+ }
385
+ const lines = [
386
+ `\u{1F517} **Graph Edges** \u2014 ${results.length} connection(s) for "${query}"`,
387
+ ""
388
+ ];
389
+ for (const r of results) {
390
+ const edgeEmoji = r.type === "calls" ? "\u27A1\uFE0F" : r.type === "imports" ? "\u{1F4E5}" : r.type === "defines" ? "\u{1F4DD}" : r.type === "tests" ? "\u{1F9EA}" : r.type === "routes" ? "\u{1F310}" : "\u{1F517}";
391
+ const typeLabel = r.connected_type ? ` (${r.connected_type})` : "";
392
+ lines.push(`${edgeEmoji} **${r.connected_node}**${typeLabel} \u2014 ${r.type} (weight: ${r.weight})`);
393
+ if (r.connected_path) lines.push(` \u{1F4CD} ${r.connected_path}`);
394
+ }
395
+ return lines.join("\n");
396
+ } else if (type === "paths") {
397
+ const parts = query.split("\u2192").map((s) => s.trim());
398
+ if (parts.length !== 2) {
399
+ return `\u26A0\uFE0F For path queries, use format: "sourceNodeID \u2192 targetNodeID"
400
+ Example: kuma_graph_query({ type: 'paths', query: 'function::login \u2192 function::validatePassword' })`;
401
+ }
402
+ const [sourceId, targetId] = parts;
403
+ const visited = /* @__PURE__ */ new Set();
404
+ const maxDepth = 10;
405
+ const queue = [{ nodeId: sourceId, path: [sourceId] }];
406
+ visited.add(sourceId);
407
+ let foundPath = null;
408
+ while (queue.length > 0 && !foundPath && queue[0].path.length <= maxDepth) {
409
+ const current = queue.shift();
410
+ const stmt = db.prepare(`
411
+ SELECT CASE WHEN source_id = ? THEN target_id ELSE source_id END AS neighbor
412
+ FROM edges WHERE (source_id = ? OR target_id = ?)
413
+ `);
414
+ stmt.bind([current.nodeId, current.nodeId, current.nodeId]);
415
+ while (stmt.step()) {
416
+ const row = stmt.getAsObject();
417
+ const neighbor = row.neighbor;
418
+ if (neighbor === targetId) {
419
+ foundPath = [...current.path, neighbor];
420
+ break;
421
+ }
422
+ if (!visited.has(neighbor)) {
423
+ visited.add(neighbor);
424
+ queue.push({ nodeId: neighbor, path: [...current.path, neighbor] });
425
+ }
426
+ }
427
+ stmt.free();
428
+ }
429
+ if (!foundPath) {
430
+ return `\u{1F50D} **Path Query** \u2014 No path found between "${sourceId}" and "${targetId}".
431
+
432
+ The two nodes may not be connected in the knowledge graph yet. Use more tools to build the graph.`;
433
+ }
434
+ const names = [];
435
+ for (const nodeId2 of foundPath) {
436
+ const stmt2 = db.prepare("SELECT name, type FROM nodes WHERE id = ?");
437
+ stmt2.bind([nodeId2]);
438
+ if (stmt2.step()) {
439
+ const row = stmt2.getAsObject();
440
+ names.push(`${row.name} (${row.type})`);
441
+ } else {
442
+ names.push(nodeId2);
443
+ }
444
+ stmt2.free();
445
+ }
446
+ return [
447
+ `\u{1F6E4}\uFE0F **Graph Path** \u2014 ${names.length} node(s)`,
448
+ "",
449
+ ...names.map((n, i) => ` ${i > 0 ? "\u2192" : " "} ${n}`),
450
+ "",
451
+ "\u{1F4A1} This path was discovered by traversing the knowledge graph built from AI tool calls."
452
+ ].join("\n");
453
+ }
454
+ return `\u26A0\uFE0F Unknown query type "${type}". Use "nodes", "edges", or "paths".`;
455
+ } catch (err) {
456
+ return `Error querying graph: ${err}`;
457
+ }
458
+ }
459
+ async function buildFromSessionMemory() {
460
+ try {
461
+ const { sessionMemory } = await import("./sessionMemory-52ECPXZ6.js");
462
+ const toolCalls = sessionMemory.getToolCallHistory(50);
463
+ let edgeCount = 0;
464
+ for (const call of toolCalls) {
465
+ const params = call.params;
466
+ if (call.toolName === "kuma_research" || call.toolName === "research") {
467
+ const scope = params.scope;
468
+ if (scope) {
469
+ await upsertNode({
470
+ id: `research::${scope}`,
471
+ type: "variable",
472
+ name: `research:${scope}`,
473
+ metadata: { confidence: params.confidence || 0 }
474
+ });
475
+ edgeCount++;
476
+ }
477
+ }
478
+ const filePath = params.filePath || params.files?.[0];
479
+ if (filePath) {
480
+ await upsertNode({
481
+ id: nodeId("file", filePath),
482
+ type: "file",
483
+ name: filePath
484
+ });
485
+ edgeCount++;
486
+ }
487
+ }
488
+ return edgeCount;
489
+ } catch {
490
+ return 0;
491
+ }
492
+ }
493
+ async function searchGraph(query, limit = 20) {
494
+ try {
495
+ const db = await getDb();
496
+ try {
497
+ const stmt2 = db.prepare(`
498
+ SELECT n.id, n.type, n.name, n.file_path
499
+ FROM nodes_fts f
500
+ JOIN nodes n ON n.rowid = f.rowid
501
+ WHERE nodes_fts MATCH ?
502
+ LIMIT ?
503
+ `);
504
+ stmt2.bind([query, limit]);
505
+ const results2 = [];
506
+ while (stmt2.step()) {
507
+ results2.push(stmt2.getAsObject());
508
+ }
509
+ stmt2.free();
510
+ if (results2.length > 0) {
511
+ const lines2 = [
512
+ `\u{1F50D} **Graph Search** \u2014 ${results2.length} result(s) for "${query}"`,
513
+ ""
514
+ ];
515
+ for (const r of results2) {
516
+ lines2.push(` \u2022 **${r.name}** (${r.type})${r.file_path ? ` \u2014 ${r.file_path}` : ""}`);
517
+ }
518
+ return lines2.join("\n");
519
+ }
520
+ } catch {
521
+ }
522
+ const stmt = db.prepare(`
523
+ SELECT id, type, name, file_path FROM nodes
524
+ WHERE name LIKE ? OR file_path LIKE ?
525
+ ORDER BY updated_at DESC
526
+ LIMIT ?
527
+ `);
528
+ stmt.bind([`%${query}%`, `%${query}%`, limit]);
529
+ const results = [];
530
+ while (stmt.step()) {
531
+ results.push(stmt.getAsObject());
532
+ }
533
+ stmt.free();
534
+ if (results.length === 0) {
535
+ return `\u{1F50D} **Graph Search** \u2014 No results for "${query}". Try a different search term.`;
536
+ }
537
+ const lines = [
538
+ `\u{1F50D} **Graph Search** \u2014 ${results.length} result(s) for "${query}"`,
539
+ ""
540
+ ];
541
+ for (const r of results) {
542
+ lines.push(` \u2022 **${r.name}** (${r.type})${r.file_path ? ` \u2014 ${r.file_path}` : ""}`);
543
+ }
544
+ return lines.join("\n");
545
+ } catch (err) {
546
+ return `Error searching graph: ${err}`;
547
+ }
548
+ }
549
+ async function getGraphStats() {
550
+ try {
551
+ const db = await getDb();
552
+ const nodeCount = db.exec("SELECT COUNT(*) as c FROM nodes")[0]?.values[0][0] ?? 0;
553
+ const edgeCount = db.exec("SELECT COUNT(*) as c FROM edges")[0]?.values[0][0] ?? 0;
554
+ const typeCounts = [];
555
+ try {
556
+ const stmt = db.prepare("SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type ORDER BY cnt DESC");
557
+ while (stmt.step()) {
558
+ typeCounts.push(stmt.getAsObject());
559
+ }
560
+ stmt.free();
561
+ } catch {
562
+ }
563
+ const lines = [
564
+ `\u{1F4CA} **Knowledge Graph Stats**`,
565
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
566
+ "",
567
+ `\u{1F4CA} **${nodeCount} nodes** | **${edgeCount} edges**`,
568
+ ""
569
+ ];
570
+ if (typeCounts.length > 0) {
571
+ lines.push("**Node Types:**");
572
+ for (const t of typeCounts) {
573
+ const emoji = t.type === "function" ? "\u{1F527}" : t.type === "file" ? "\u{1F4C4}" : t.type === "test" ? "\u{1F9EA}" : t.type === "api_route" ? "\u{1F310}" : t.type === "db_table" ? "\u{1F5C4}\uFE0F" : t.type === "class" ? "\u{1F3D7}\uFE0F" : "\u{1F4CC}";
574
+ lines.push(` ${emoji} ${t.type}: ${t.cnt}`);
575
+ }
576
+ }
577
+ lines.push(
578
+ "",
579
+ "\u{1F4A1} Nodes are built from research and code changes:",
580
+ " \u2022 kuma_context research \u2192 research nodes",
581
+ " \u2022 File modifications \u2192 file change edges",
582
+ " \u2022 Graph queries \u2192 connection edges"
583
+ );
584
+ return lines.join("\n");
585
+ } catch (err) {
586
+ return `Error getting graph stats: ${err}`;
587
+ }
588
+ }
589
+ async function analyzeImpact(target) {
590
+ try {
591
+ const db = await getDb();
592
+ const nodeStmt = db.prepare(`
593
+ SELECT id, name, type, file_path FROM nodes
594
+ WHERE name LIKE ? OR file_path = ? OR id = ?
595
+ LIMIT 1
596
+ `);
597
+ nodeStmt.bind([`%${target}%`, target, target]);
598
+ let nodeId2 = "";
599
+ let nodeName = "";
600
+ if (nodeStmt.step()) {
601
+ const row = nodeStmt.getAsObject();
602
+ nodeId2 = row.id;
603
+ nodeName = row.name;
604
+ }
605
+ nodeStmt.free();
606
+ if (!nodeId2) {
607
+ return { symbol: target, references: 0, files: 0, testFiles: 0, entryPoints: [], risk: "low" };
608
+ }
609
+ const refStmt = db.prepare(`
610
+ SELECT COUNT(*) as cnt, COUNT(DISTINCT CASE WHEN e.source_id != ? THEN e.source_id ELSE e.target_id END) as file_count
611
+ FROM edges e WHERE e.source_id = ? OR e.target_id = ?
612
+ `);
613
+ refStmt.bind([nodeId2, nodeId2, nodeId2]);
614
+ let references = 0;
615
+ let files = 0;
616
+ if (refStmt.step()) {
617
+ const row = refStmt.getAsObject();
618
+ references = row.cnt || 0;
619
+ files = row.file_count || 0;
620
+ }
621
+ refStmt.free();
622
+ let testFiles = 0;
623
+ try {
624
+ const testStmt = db.prepare(
625
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON n.id = e.source_id WHERE (e.source_id = ? OR e.target_id = ?) AND n.type = 'test'`
626
+ );
627
+ testStmt.bind([nodeId2, nodeId2]);
628
+ if (testStmt.step()) {
629
+ testFiles = testStmt.getAsObject().cnt || 0;
630
+ }
631
+ testStmt.free();
632
+ } catch {
633
+ }
634
+ const entryStmt = db.prepare(
635
+ `SELECT n.name, COUNT(*) as cnt FROM edges e JOIN nodes n ON n.id = e.target_id WHERE e.target_id = ? GROUP BY n.name ORDER BY cnt DESC LIMIT 5`
636
+ );
637
+ entryStmt.bind([nodeId2]);
638
+ const entryRows = [];
639
+ while (entryStmt.step()) {
640
+ entryRows.push(entryStmt.getAsObject());
641
+ }
642
+ entryStmt.free();
643
+ const entryPoints = [];
644
+ for (const row of entryRows) {
645
+ entryPoints.push(`${row.name} (${row.cnt} refs)`);
646
+ }
647
+ const risk = references > 20 ? "high" : references > 5 ? "medium" : "low";
648
+ return { symbol: nodeName, references, files, testFiles, entryPoints, risk };
649
+ } catch (err) {
650
+ console.error(`[KumaGraph] Impact analysis failed: ${err}`);
651
+ return { symbol: target, references: 0, files: 0, testFiles: 0, entryPoints: [], risk: "low" };
652
+ }
653
+ }
654
+ function formatImpact(result) {
655
+ const icon = result.risk === "high" ? "\u{1F534}" : result.risk === "medium" ? "\u{1F7E1}" : "\u{1F7E2}";
656
+ const lines = [
657
+ `${icon} **Impact Analysis** \u2014 ${result.symbol}`,
658
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
659
+ "",
660
+ `\u{1F4CA} ${result.references} reference(s) across ${result.files} file(s)`,
661
+ `\u{1F9EA} ${result.testFiles} test file(s)`,
662
+ result.entryPoints.length > 0 ? `
663
+ \u{1F3AF} Entry Points:
664
+ ${result.entryPoints.join("\n ")}` : "",
665
+ "",
666
+ `\u26A0\uFE0F Risk Level: **${result.risk.toUpperCase()}**`,
667
+ result.risk === "high" ? "\u{1F4A1} High impact \u2014 review thoroughly before changing." : ""
668
+ ].filter(Boolean);
669
+ return lines.join("\n");
670
+ }
671
+ async function traceFlow(entryPoint, maxDepth = 10) {
672
+ const steps = [];
673
+ try {
674
+ const db = await getDb();
675
+ const nodeStmt = db.prepare("SELECT id FROM nodes WHERE name LIKE ? OR id = ? LIMIT 1");
676
+ nodeStmt.bind([`%${entryPoint}%`, entryPoint]);
677
+ let nodeId2 = "";
678
+ if (nodeStmt.step()) {
679
+ nodeId2 = nodeStmt.getAsObject().id;
680
+ }
681
+ nodeStmt.free();
682
+ if (!nodeId2) return steps;
683
+ const visited = /* @__PURE__ */ new Set();
684
+ const queue = [{ id: nodeId2, depth: 0 }];
685
+ visited.add(nodeId2);
686
+ while (queue.length > 0) {
687
+ const current = queue.shift();
688
+ if (current.depth >= maxDepth) continue;
689
+ const edgeStmt = db.prepare(`
690
+ SELECT e.type, n.id, n.name FROM edges e
691
+ JOIN nodes n ON n.id = e.target_id
692
+ WHERE e.source_id = ? AND e.type IN ('calls','routes','imports')
693
+ ORDER BY e.weight DESC LIMIT 10
694
+ `);
695
+ edgeStmt.bind([current.id]);
696
+ while (edgeStmt.step()) {
697
+ const row = edgeStmt.getAsObject();
698
+ const targetId = row.id;
699
+ const targetName = row.name;
700
+ const edgeType = row.type;
701
+ if (!visited.has(targetId)) {
702
+ visited.add(targetId);
703
+ steps.push({ from: current.id, to: targetName, edgeType });
704
+ queue.push({ id: targetId, depth: current.depth + 1 });
705
+ }
706
+ }
707
+ edgeStmt.free();
708
+ }
709
+ return steps;
710
+ } catch (err) {
711
+ console.error(`[KumaGraph] Flow trace failed: ${err}`);
712
+ return steps;
713
+ }
714
+ }
715
+ function formatFlow(entryPoint, steps) {
716
+ if (steps.length === 0) {
717
+ return `\u{1F50D} No flow found for "${entryPoint}". Research the scope first with kuma_context({ action: 'research' }).`;
718
+ }
719
+ const lines = [
720
+ `\u{1F6E4}\uFE0F **Flow: ${entryPoint}**`,
721
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
722
+ ""
723
+ ];
724
+ const edgeEmojis = { calls: "\u27A1\uFE0F", routes: "\u{1F310}", imports: "\u{1F4E5}" };
725
+ for (const step of steps) {
726
+ const emoji = edgeEmojis[step.edgeType] || "\u{1F517}";
727
+ lines.push(` ${emoji} ${step.to} (${step.edgeType})`);
728
+ }
729
+ lines.push("", `\u{1F4A1} ${steps.length} step(s) traversed.`);
730
+ return lines.join("\n");
731
+ }
732
+
733
+ export {
734
+ detectStaleNodes,
735
+ autoHeal,
736
+ formatHealReport,
737
+ upsertNode,
738
+ addEdge,
739
+ recordFunctionCall,
740
+ recordFileDefinition,
741
+ recordImport,
742
+ recordTestRelation,
743
+ recordApiRoute,
744
+ queryGraph,
745
+ buildFromSessionMemory,
746
+ searchGraph,
747
+ getGraphStats,
748
+ analyzeImpact,
749
+ formatImpact,
750
+ traceFlow,
751
+ formatFlow
752
+ };