@gamaze/hicortex 0.6.1 → 0.7.1

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/README.md CHANGED
@@ -45,13 +45,14 @@ openclaw gateway restart
45
45
 
46
46
  ## Agent Tools (MCP)
47
47
 
48
- 7 tools available via MCP:
48
+ 8 tools available via MCP:
49
49
 
50
50
  - **hicortex_search** — Semantic search across all stored memories
51
51
  - **hicortex_context** — Get recent decisions and project state
52
52
  - **hicortex_ingest** — Store a memory directly
53
53
  - **hicortex_lessons** — Get actionable lessons from reflection
54
54
  - **hicortex_index** — Get the knowledge domain index (what topics are stored)
55
+ - **hicortex_graph** — Graph traversal: neighbors, hubs, shortest paths
55
56
  - **hicortex_update** — Fix incorrect memories (re-embeds on content change)
56
57
  - **hicortex_delete** — Remove memories with cascade cleanup
57
58
 
@@ -43,11 +43,13 @@ exports.parseJsonLenient = parseJsonLenient;
43
43
  exports.runConsolidation = runConsolidation;
44
44
  exports.msUntilHour = msUntilHour;
45
45
  exports.scheduleConsolidation = scheduleConsolidation;
46
+ const types_js_1 = require("./types.js");
46
47
  const retrieval_js_1 = require("./retrieval.js");
47
48
  const storage = __importStar(require("./storage.js"));
48
49
  const prompts_js_1 = require("./prompts.js");
49
50
  const node_crypto_1 = require("node:crypto");
50
51
  const features_js_1 = require("./features.js");
52
+ const graph_js_1 = require("./graph.js");
51
53
  const state_js_1 = require("./state.js");
52
54
  // Default config constants (matching Python config.py)
53
55
  const CONSOLIDATE_MAX_LLM_CALLS = 200;
@@ -352,14 +354,49 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
352
354
  const totalLessons = lessonRows.reduce((s, r) => s + r.cnt, 0);
353
355
  let domains;
354
356
  if (!(0, features_js_1.isPro)()) {
355
- // OSS fallback: each project is its own domain
356
- domains = projectRows.map((r) => ({
357
- name: r.project,
358
- projects: [r.project],
359
- memoryCount: r.cnt,
360
- lessonCount: lessonsByProject.get(r.project) ?? 0,
361
- keywords: [],
362
- }));
357
+ // OSS: Louvain community detection on the memory_links graph (zero LLM cost)
358
+ const graph = (0, graph_js_1.louvainCommunities)(db);
359
+ if (graph.communities.length > 1 && graph.edgeCount >= 5) {
360
+ // Map communities to domains by finding the dominant project in each
361
+ // Pre-load all memory→project mappings in one query (avoids N+1)
362
+ const allProjectRows = db
363
+ .prepare("SELECT id, project FROM memories WHERE project IS NOT NULL")
364
+ .all();
365
+ const memProject = new Map(allProjectRows.map((r) => [r.id, r.project]));
366
+ domains = [];
367
+ for (const comm of graph.communities) {
368
+ const projectCounts = new Map();
369
+ for (const memId of comm.members) {
370
+ const proj = memProject.get(memId);
371
+ if (proj) {
372
+ projectCounts.set(proj, (projectCounts.get(proj) ?? 0) + 1);
373
+ }
374
+ }
375
+ const projects = [...projectCounts.keys()];
376
+ if (projects.length === 0)
377
+ continue;
378
+ // Name domain after the dominant project or combine top 2
379
+ const sorted = [...projectCounts.entries()].sort((a, b) => b[1] - a[1]);
380
+ const name = sorted.length >= 2 && sorted[1][1] > sorted[0][1] * 0.3
381
+ ? `${sorted[0][0]} + ${sorted[1][0]}`
382
+ : sorted[0][0];
383
+ const memoryCount = projects.reduce((s, p) => s + (projectRows.find((r) => r.project === p)?.cnt ?? 0), 0);
384
+ const lessonCount = projects.reduce((s, p) => s + (lessonsByProject.get(p) ?? 0), 0);
385
+ domains.push({ name, projects, memoryCount, lessonCount, keywords: [] });
386
+ }
387
+ domains.sort((a, b) => b.memoryCount - a.memoryCount);
388
+ console.log(`[hicortex] Louvain clustering: ${graph.communities.length} communities, modularity ${graph.modularity.toFixed(3)}`);
389
+ }
390
+ else {
391
+ // Not enough edges for meaningful clustering — fall back to project=domain
392
+ domains = projectRows.map((r) => ({
393
+ name: r.project,
394
+ projects: [r.project],
395
+ memoryCount: r.cnt,
396
+ lessonCount: lessonsByProject.get(r.project) ?? 0,
397
+ keywords: [],
398
+ }));
399
+ }
363
400
  }
364
401
  else {
365
402
  // Pro: LLM-curated domains
@@ -451,12 +488,17 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
451
488
  console.log(`[hicortex] Domain curation: ${domains.length} domains from ${projectRows.length} projects`);
452
489
  return { curated: true, domains: domains.length };
453
490
  }
454
- // ---------------------------------------------------------------------------
455
- // Stage 3: Link Discovery (vector similarity auto-link)
456
- // ---------------------------------------------------------------------------
457
- async function stageLinks(db, memories, embedFn, dryRun) {
491
+ /** Batch size for LLM edge classification calls. */
492
+ const EDGE_CLASSIFICATION_BATCH_SIZE = 8;
493
+ /** Valid relationship type set for fast lookup. */
494
+ const VALID_REL_SET = new Set(types_js_1.VALID_RELATIONSHIP_TYPES);
495
+ async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
458
496
  let autoLinked = 0;
497
+ let llmClassified = 0;
498
+ let heuristicFallback = 0;
459
499
  let failed = 0;
500
+ // Phase A: Discovery — collect candidates via vector similarity
501
+ const candidates = [];
460
502
  for (const mem of memories) {
461
503
  try {
462
504
  const embedding = await embedFn(mem.content);
@@ -464,27 +506,78 @@ async function stageLinks(db, memories, embedFn, dryRun) {
464
506
  for (const neighbor of neighbors) {
465
507
  const similarity = 1.0 - neighbor.distance;
466
508
  if (similarity > CONSOLIDATE_LINK_THRESHOLD) {
467
- const relationship = classifyRelationship(mem, neighbor, similarity);
468
- if (!dryRun) {
469
- try {
470
- storage.addLink(db, mem.id, neighbor.id, relationship, similarity);
471
- autoLinked++;
509
+ const heuristicType = classifyRelationship(mem, neighbor, similarity);
510
+ candidates.push({ source: mem, target: neighbor, similarity, heuristicType });
511
+ }
512
+ }
513
+ }
514
+ catch {
515
+ failed++;
516
+ }
517
+ }
518
+ if (candidates.length === 0) {
519
+ return { auto_linked: 0, llm_classified: 0, heuristic_fallback: 0, failed };
520
+ }
521
+ // Phase B: LLM batch classification
522
+ // Build batches and classify with LLM where budget allows
523
+ const classifiedTypes = new Array(candidates.length);
524
+ for (let i = 0; i < candidates.length; i += EDGE_CLASSIFICATION_BATCH_SIZE) {
525
+ const batch = candidates.slice(i, i + EDGE_CLASSIFICATION_BATCH_SIZE);
526
+ // Attempt LLM classification if budget allows
527
+ if (budget.use("edge_classification")) {
528
+ try {
529
+ const pairsBlock = batch.map((c, idx) => {
530
+ const srcContent = c.source.content.slice(0, 200);
531
+ const tgtContent = c.target.content.slice(0, 200);
532
+ return `[${idx}] SOURCE: ${c.source.memory_type} | ${c.source.project ?? "global"} | ${srcContent}\n TARGET: ${c.target.memory_type} | ${c.target.project ?? "global"} | ${tgtContent}\n similarity: ${c.similarity.toFixed(2)}`;
533
+ }).join("\n\n");
534
+ const prompt = (0, prompts_js_1.edgeClassification)(pairsBlock);
535
+ const raw = await llm.completeFast(prompt, 512);
536
+ const parsed = parseJsonLenient(raw, []);
537
+ if (Array.isArray(parsed) && parsed.length > 0) {
538
+ for (let j = 0; j < batch.length; j++) {
539
+ const llmType = parsed[j];
540
+ if (typeof llmType === "string" && VALID_REL_SET.has(llmType)) {
541
+ classifiedTypes[i + j] = llmType;
542
+ llmClassified++;
472
543
  }
473
- catch {
474
- failed++;
544
+ else {
545
+ // Invalid type from LLM — fall back to heuristic
546
+ classifiedTypes[i + j] = batch[j].heuristicType;
547
+ heuristicFallback++;
475
548
  }
476
549
  }
477
- else {
478
- autoLinked++;
479
- }
550
+ continue;
480
551
  }
481
552
  }
553
+ catch {
554
+ // LLM call failed — fall through to heuristic for this batch
555
+ }
482
556
  }
483
- catch {
484
- failed++;
557
+ // Budget exhausted or LLM failed — use heuristic for entire batch
558
+ for (let j = 0; j < batch.length; j++) {
559
+ classifiedTypes[i + j] = batch[j].heuristicType;
560
+ heuristicFallback++;
561
+ }
562
+ }
563
+ // Phase C: Store all classified links
564
+ for (let i = 0; i < candidates.length; i++) {
565
+ const c = candidates[i];
566
+ const relationship = classifiedTypes[i];
567
+ if (!dryRun) {
568
+ try {
569
+ storage.addLink(db, c.source.id, c.target.id, relationship, c.similarity);
570
+ autoLinked++;
571
+ }
572
+ catch {
573
+ failed++;
574
+ }
575
+ }
576
+ else {
577
+ autoLinked++;
485
578
  }
486
579
  }
487
- return { auto_linked: autoLinked, failed };
580
+ return { auto_linked: autoLinked, llm_classified: llmClassified, heuristic_fallback: heuristicFallback, failed };
488
581
  }
489
582
  /**
490
583
  * Classify the relationship between two memories based on type, temporal ordering, and similarity.
@@ -510,6 +603,35 @@ function classifyRelationship(source, target, similarity) {
510
603
  return "relates_to";
511
604
  }
512
605
  // ---------------------------------------------------------------------------
606
+ // Stage 3.5: Hub Detection & Strength Boost
607
+ // ---------------------------------------------------------------------------
608
+ const HUB_BOOST = 0.1;
609
+ const HUB_STRENGTH_CAP = 1.0;
610
+ function stageHubBoost(db, dryRun) {
611
+ const hubs = (0, graph_js_1.detectHubs)(db);
612
+ if (hubs.length === 0)
613
+ return { hubs_found: 0, boosted: 0 };
614
+ let boosted = 0;
615
+ if (!dryRun) {
616
+ const stmt = db.prepare("UPDATE memories SET base_strength = MIN(?, base_strength + ?) WHERE id = ? AND base_strength < ?");
617
+ const tx = db.transaction(() => {
618
+ for (const hub of hubs) {
619
+ const result = stmt.run(HUB_STRENGTH_CAP, HUB_BOOST, hub.id, HUB_STRENGTH_CAP);
620
+ if (result.changes > 0)
621
+ boosted++;
622
+ }
623
+ });
624
+ tx();
625
+ }
626
+ else {
627
+ boosted = hubs.length;
628
+ }
629
+ if (hubs.length > 0) {
630
+ console.log(`[hicortex] Hub detection: ${hubs.length} hubs found, ${boosted} boosted (+${HUB_BOOST})`);
631
+ }
632
+ return { hubs_found: hubs.length, boosted };
633
+ }
634
+ // ---------------------------------------------------------------------------
513
635
  // Stage 4: Decay & Prune
514
636
  // ---------------------------------------------------------------------------
515
637
  function stageDecayPrune(db, dryRun) {
@@ -595,8 +717,10 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
595
717
  }
596
718
  // Stage 2.7: Domain Curation
597
719
  report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
598
- // Stage 3: Link Discovery
599
- report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun);
720
+ // Stage 3: Link Discovery (with LLM-assisted edge classification)
721
+ report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
722
+ // Stage 3.5: Hub Detection — boost highly-connected memories
723
+ report.stages.hub_boost = stageHubBoost(db, dryRun);
600
724
  // Stage 4: Decay & Prune
601
725
  report.stages.decay_prune = stageDecayPrune(db, dryRun);
602
726
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Graph analysis for the memory link network.
3
+ *
4
+ * Pure-JS Louvain community detection + hub node identification.
5
+ * Operates on the memory_links table without external dependencies.
6
+ *
7
+ * Louvain algorithm: iteratively merges nodes into communities to maximize
8
+ * modularity. Produces quality comparable to Leiden for the graph sizes
9
+ * we deal with (hundreds to low thousands of nodes).
10
+ */
11
+ import type Database from "better-sqlite3";
12
+ export interface GraphCommunity {
13
+ id: number;
14
+ members: string[];
15
+ size: number;
16
+ }
17
+ export interface HubNode {
18
+ id: string;
19
+ linkCount: number;
20
+ project: string | null;
21
+ domain: string | null;
22
+ content: string;
23
+ }
24
+ export interface GraphAnalysis {
25
+ communities: GraphCommunity[];
26
+ hubs: HubNode[];
27
+ nodeCount: number;
28
+ edgeCount: number;
29
+ modularity: number;
30
+ }
31
+ /**
32
+ * Run Louvain community detection. Returns community assignments.
33
+ */
34
+ export declare function louvainCommunities(db: Database.Database): {
35
+ communities: GraphCommunity[];
36
+ modularity: number;
37
+ nodeCount: number;
38
+ edgeCount: number;
39
+ };
40
+ /**
41
+ * Find hub nodes — memories with link count significantly above median.
42
+ * Returns nodes with links > threshold (default: 2x median, minimum 3 links).
43
+ */
44
+ export declare function detectHubs(db: Database.Database, thresholdMultiplier?: number, minLinks?: number): HubNode[];
45
+ export interface GraphNeighbor {
46
+ id: string;
47
+ relationship: string;
48
+ strength: number;
49
+ direction: "outgoing" | "incoming";
50
+ content: string;
51
+ project: string | null;
52
+ }
53
+ export declare function getNeighbors(db: Database.Database, memoryId: string, limit?: number, relationship?: string): GraphNeighbor[];
54
+ export declare function shortestPath(db: Database.Database, fromId: string, toId: string, maxDepth?: number): string[] | null;
package/dist/graph.js ADDED
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ /**
3
+ * Graph analysis for the memory link network.
4
+ *
5
+ * Pure-JS Louvain community detection + hub node identification.
6
+ * Operates on the memory_links table without external dependencies.
7
+ *
8
+ * Louvain algorithm: iteratively merges nodes into communities to maximize
9
+ * modularity. Produces quality comparable to Leiden for the graph sizes
10
+ * we deal with (hundreds to low thousands of nodes).
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.louvainCommunities = louvainCommunities;
14
+ exports.detectHubs = detectHubs;
15
+ exports.getNeighbors = getNeighbors;
16
+ exports.shortestPath = shortestPath;
17
+ function loadGraph(db) {
18
+ const rows = db
19
+ .prepare("SELECT source_id, target_id, strength FROM memory_links")
20
+ .all();
21
+ const adj = new Map();
22
+ const nodes = new Set();
23
+ for (const { source_id, target_id, strength } of rows) {
24
+ nodes.add(source_id);
25
+ nodes.add(target_id);
26
+ const w = strength || 0.5;
27
+ if (!adj.has(source_id))
28
+ adj.set(source_id, []);
29
+ adj.get(source_id).push({ neighbor: target_id, weight: w });
30
+ if (!adj.has(target_id))
31
+ adj.set(target_id, []);
32
+ adj.get(target_id).push({ neighbor: source_id, weight: w });
33
+ }
34
+ return { adj, nodes, edgeCount: rows.length };
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Louvain community detection
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * Compute modularity of the current partition.
41
+ * Q = (1/2m) * sum_ij [ A_ij - k_i*k_j/(2m) ] * delta(c_i, c_j)
42
+ */
43
+ function computeModularity(adj, community, totalWeight) {
44
+ if (totalWeight === 0)
45
+ return 0;
46
+ const m2 = 2 * totalWeight;
47
+ let q = 0;
48
+ for (const [node, neighbors] of adj) {
49
+ const ki = neighbors.reduce((s, e) => s + e.weight, 0);
50
+ const ci = community.get(node);
51
+ for (const { neighbor, weight } of neighbors) {
52
+ const kj = adj.get(neighbor)?.reduce((s, e) => s + e.weight, 0) ?? 0;
53
+ const cj = community.get(neighbor);
54
+ if (ci === cj) {
55
+ q += weight - (ki * kj) / m2;
56
+ }
57
+ }
58
+ }
59
+ return q / m2;
60
+ }
61
+ /**
62
+ * Run Louvain community detection. Returns community assignments.
63
+ */
64
+ function louvainCommunities(db) {
65
+ const { adj, nodes, edgeCount } = loadGraph(db);
66
+ if (nodes.size === 0) {
67
+ return { communities: [], modularity: 0, nodeCount: 0, edgeCount: 0 };
68
+ }
69
+ const totalWeight = [...adj.values()]
70
+ .reduce((s, edges) => s + edges.reduce((s2, e) => s2 + e.weight, 0), 0) / 2;
71
+ // Initialize: each node in its own community
72
+ const community = new Map();
73
+ let nextId = 0;
74
+ for (const node of nodes) {
75
+ community.set(node, nextId++);
76
+ }
77
+ // Phase 1: local moves — repeatedly move nodes to neighbor community with best modularity gain
78
+ let improved = true;
79
+ let iterations = 0;
80
+ const MAX_ITERATIONS = 50;
81
+ while (improved && iterations < MAX_ITERATIONS) {
82
+ improved = false;
83
+ iterations++;
84
+ for (const node of nodes) {
85
+ const currentComm = community.get(node);
86
+ const neighbors = adj.get(node) ?? [];
87
+ // Compute weight to each neighbor community
88
+ const commWeights = new Map();
89
+ for (const { neighbor, weight } of neighbors) {
90
+ const nc = community.get(neighbor);
91
+ commWeights.set(nc, (commWeights.get(nc) ?? 0) + weight);
92
+ }
93
+ // Find best community to move to
94
+ let bestComm = currentComm;
95
+ let bestGain = 0;
96
+ const ki = neighbors.reduce((s, e) => s + e.weight, 0);
97
+ for (const [targetComm, weightToComm] of commWeights) {
98
+ if (targetComm === currentComm)
99
+ continue;
100
+ // Simplified modularity gain: delta_Q ~ weight_to_comm - ki * sum_comm / (2m)
101
+ const sumComm = [...community.entries()]
102
+ .filter(([, c]) => c === targetComm)
103
+ .reduce((s, [n]) => s + (adj.get(n)?.reduce((s2, e) => s2 + e.weight, 0) ?? 0), 0);
104
+ const gain = weightToComm - (ki * sumComm) / (2 * totalWeight);
105
+ if (gain > bestGain) {
106
+ bestGain = gain;
107
+ bestComm = targetComm;
108
+ }
109
+ }
110
+ if (bestComm !== currentComm) {
111
+ community.set(node, bestComm);
112
+ improved = true;
113
+ }
114
+ }
115
+ }
116
+ // Build community list
117
+ const commMembers = new Map();
118
+ for (const [node, comm] of community) {
119
+ if (!commMembers.has(comm))
120
+ commMembers.set(comm, []);
121
+ commMembers.get(comm).push(node);
122
+ }
123
+ // Renumber communities 0..N-1
124
+ const communities = [];
125
+ let idx = 0;
126
+ for (const [, members] of commMembers) {
127
+ if (members.length > 0) {
128
+ communities.push({ id: idx++, members, size: members.length });
129
+ }
130
+ }
131
+ // Sort by size desc
132
+ communities.sort((a, b) => b.size - a.size);
133
+ const modularity = computeModularity(adj, community, totalWeight);
134
+ return { communities, modularity, nodeCount: nodes.size, edgeCount };
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Hub node detection
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * Find hub nodes — memories with link count significantly above median.
141
+ * Returns nodes with links > threshold (default: 2x median, minimum 3 links).
142
+ */
143
+ function detectHubs(db, thresholdMultiplier = 2, minLinks = 3) {
144
+ const rows = db
145
+ .prepare(`SELECT id, cnt FROM (
146
+ SELECT id, COUNT(*) as cnt FROM (
147
+ SELECT source_id AS id FROM memory_links
148
+ UNION ALL
149
+ SELECT target_id AS id FROM memory_links
150
+ ) GROUP BY id
151
+ ) ORDER BY cnt DESC`)
152
+ .all();
153
+ if (rows.length === 0)
154
+ return [];
155
+ // Compute median link count
156
+ const sorted = rows.map((r) => r.cnt).sort((a, b) => a - b);
157
+ const median = sorted[Math.floor(sorted.length / 2)];
158
+ const threshold = Math.max(median * thresholdMultiplier, minLinks);
159
+ const hubs = [];
160
+ for (const { id, cnt } of rows) {
161
+ if (cnt < threshold)
162
+ break; // sorted desc, no more hubs
163
+ const mem = db
164
+ .prepare("SELECT content, project, domain FROM memories WHERE id = ?")
165
+ .get(id);
166
+ if (!mem)
167
+ continue;
168
+ hubs.push({
169
+ id,
170
+ linkCount: cnt,
171
+ project: mem.project,
172
+ domain: mem.domain,
173
+ content: mem.content.slice(0, 200),
174
+ });
175
+ }
176
+ return hubs;
177
+ }
178
+ function getNeighbors(db, memoryId, limit = 10, relationship) {
179
+ let sql = `SELECT source_id, target_id, relationship, strength
180
+ FROM memory_links
181
+ WHERE (source_id = ? OR target_id = ?)`;
182
+ const params = [memoryId, memoryId];
183
+ if (relationship) {
184
+ sql += ` AND relationship = ?`;
185
+ params.push(relationship);
186
+ }
187
+ sql += ` ORDER BY strength DESC LIMIT ?`;
188
+ params.push(limit);
189
+ const rows = db
190
+ .prepare(sql)
191
+ .all(...params);
192
+ const results = [];
193
+ for (const row of rows) {
194
+ const isOutgoing = row.source_id === memoryId;
195
+ const neighborId = isOutgoing ? row.target_id : row.source_id;
196
+ const mem = db
197
+ .prepare("SELECT content, project FROM memories WHERE id = ?")
198
+ .get(neighborId);
199
+ if (!mem)
200
+ continue;
201
+ results.push({
202
+ id: neighborId,
203
+ relationship: row.relationship,
204
+ strength: row.strength,
205
+ direction: isOutgoing ? "outgoing" : "incoming",
206
+ content: mem.content.slice(0, 200),
207
+ project: mem.project,
208
+ });
209
+ }
210
+ return results;
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Shortest path (for MCP tool)
214
+ // ---------------------------------------------------------------------------
215
+ function shortestPath(db, fromId, toId, maxDepth = 5) {
216
+ // BFS on the memory_links graph
217
+ const links = db
218
+ .prepare("SELECT source_id, target_id FROM memory_links")
219
+ .all();
220
+ const adj = new Map();
221
+ for (const { source_id, target_id } of links) {
222
+ if (!adj.has(source_id))
223
+ adj.set(source_id, new Set());
224
+ adj.get(source_id).add(target_id);
225
+ if (!adj.has(target_id))
226
+ adj.set(target_id, new Set());
227
+ adj.get(target_id).add(source_id);
228
+ }
229
+ if (!adj.has(fromId) || !adj.has(toId))
230
+ return null;
231
+ const visited = new Set([fromId]);
232
+ const parent = new Map();
233
+ const queue = [{ node: fromId, depth: 0 }];
234
+ while (queue.length > 0) {
235
+ const { node, depth } = queue.shift();
236
+ if (node === toId) {
237
+ // Reconstruct path
238
+ const path = [toId];
239
+ let current = toId;
240
+ while (parent.has(current)) {
241
+ current = parent.get(current);
242
+ path.unshift(current);
243
+ }
244
+ return path;
245
+ }
246
+ if (depth >= maxDepth)
247
+ continue;
248
+ for (const neighbor of adj.get(node) ?? []) {
249
+ if (!visited.has(neighbor)) {
250
+ visited.add(neighbor);
251
+ parent.set(neighbor, node);
252
+ queue.push({ node: neighbor, depth: depth + 1 });
253
+ }
254
+ }
255
+ }
256
+ return null;
257
+ }
@@ -59,6 +59,7 @@ const features_js_1 = require("./features.js");
59
59
  const state_js_1 = require("./state.js");
60
60
  const embedder_js_1 = require("./embedder.js");
61
61
  const storage = __importStar(require("./storage.js"));
62
+ const graph_js_1 = require("./graph.js");
62
63
  const retrieval = __importStar(require("./retrieval.js"));
63
64
  const consolidate_js_1 = require("./consolidate.js");
64
65
  const seed_lesson_js_1 = require("./seed-lesson.js");
@@ -243,6 +244,62 @@ function createMcpServer() {
243
244
  : "No memories yet.";
244
245
  return { content: [{ type: "text", text }] };
245
246
  });
247
+ // -- hicortex_graph --
248
+ server.tool("hicortex_graph", "Query the memory knowledge graph — find connected memories, hub nodes, or paths between memories.", {
249
+ operation: zod_1.z.enum(["neighbors", "hubs", "path"]).describe("Graph operation to perform"),
250
+ id: zod_1.z.string().optional().describe("Memory ID (required for neighbors and path operations)"),
251
+ target_id: zod_1.z.string().optional().describe("Target memory ID (required for path operation)"),
252
+ limit: zod_1.z.coerce.number().optional().describe("Max results (default 10)"),
253
+ domain: zod_1.z.string().optional().describe("Filter hubs by domain"),
254
+ relationship: zod_1.z.string().optional().describe("Filter neighbors by relationship type (e.g., CONTRADICTS, SUPERSEDES, derives)"),
255
+ }, async ({ operation, id, target_id, limit: resultLimit, domain: filterDomain, relationship: filterRelationship }) => {
256
+ if (!db)
257
+ return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
258
+ try {
259
+ if (operation === "neighbors") {
260
+ if (!id)
261
+ return { content: [{ type: "text", text: "id is required for neighbors operation" }], isError: true };
262
+ const resolvedId = resolveMemoryId(db, id);
263
+ if (!resolvedId)
264
+ return { content: [{ type: "text", text: `Memory not found: ${id}` }], isError: true };
265
+ const neighbors = (0, graph_js_1.getNeighbors)(db, resolvedId, resultLimit ?? 10, filterRelationship);
266
+ if (neighbors.length === 0)
267
+ return { content: [{ type: "text", text: "No connected memories found." }] };
268
+ const text = neighbors.map((n) => `[${n.direction}] ${n.relationship} (${n.strength.toFixed(2)})\n ${n.id.slice(0, 8)} | ${n.project ?? "global"} | ${n.content}`).join("\n\n");
269
+ return { content: [{ type: "text", text }] };
270
+ }
271
+ if (operation === "hubs") {
272
+ let hubs = (0, graph_js_1.detectHubs)(db);
273
+ if (filterDomain) {
274
+ hubs = hubs.filter((h) => h.domain === filterDomain || h.project === filterDomain);
275
+ }
276
+ if (hubs.length === 0)
277
+ return { content: [{ type: "text", text: "No hub memories found." }] };
278
+ const text = hubs.slice(0, resultLimit ?? 10).map((h) => `**${h.id.slice(0, 8)}** (${h.linkCount} links) | ${h.domain ?? h.project ?? "global"}\n ${h.content}`).join("\n\n");
279
+ return { content: [{ type: "text", text }] };
280
+ }
281
+ if (operation === "path") {
282
+ if (!id || !target_id)
283
+ return { content: [{ type: "text", text: "id and target_id are required for path operation" }], isError: true };
284
+ const fromId = resolveMemoryId(db, id);
285
+ const toId = resolveMemoryId(db, target_id);
286
+ if (!fromId || !toId)
287
+ return { content: [{ type: "text", text: "One or both memory IDs not found" }], isError: true };
288
+ const path = (0, graph_js_1.shortestPath)(db, fromId, toId);
289
+ if (!path)
290
+ return { content: [{ type: "text", text: "No path found between these memories." }] };
291
+ const text = path.map((nodeId, i) => {
292
+ const mem = storage.getMemory(db, nodeId);
293
+ return `${i + 1}. ${nodeId.slice(0, 8)} | ${mem?.project ?? "?"} | ${mem?.content.slice(0, 150) ?? "?"}`;
294
+ }).join("\n");
295
+ return { content: [{ type: "text", text: `Path (${path.length} hops):\n${text}` }] };
296
+ }
297
+ return { content: [{ type: "text", text: `Unknown operation: ${operation}` }], isError: true };
298
+ }
299
+ catch (err) {
300
+ return { content: [{ type: "text", text: `Graph query failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
301
+ }
302
+ });
246
303
  return server;
247
304
  }
248
305
  // ---------------------------------------------------------------------------
package/dist/prompts.d.ts CHANGED
@@ -19,3 +19,8 @@ export declare function distillation(projectName: string, date: string, transcri
19
19
  * Used during consolidation (Pro only, one call per nightly when projects change).
20
20
  */
21
21
  export declare function domainCuration(projectLines: string): string;
22
+ /**
23
+ * Edge classification prompt. Presents memory pairs and asks the LLM to
24
+ * choose the most specific relationship type for each.
25
+ */
26
+ export declare function edgeClassification(pairsBlock: string): string;
package/dist/prompts.js CHANGED
@@ -8,6 +8,7 @@ exports.importanceScoring = importanceScoring;
8
8
  exports.reflection = reflection;
9
9
  exports.distillation = distillation;
10
10
  exports.domainCuration = domainCuration;
11
+ exports.edgeClassification = edgeClassification;
11
12
  /**
12
13
  * Importance scoring prompt. Takes a {memories_block} with indexed memories.
13
14
  */
@@ -169,3 +170,31 @@ Rules:
169
170
 
170
171
  Respond with ONLY a JSON array. No explanations.`;
171
172
  }
173
+ /**
174
+ * Edge classification prompt. Presents memory pairs and asks the LLM to
175
+ * choose the most specific relationship type for each.
176
+ */
177
+ function edgeClassification(pairsBlock) {
178
+ return `You are a memory graph analyst. Classify the relationship between each memory pair.
179
+
180
+ VALID RELATIONSHIP TYPES:
181
+ - derives: A lesson or fact was derived from episodes (lesson ← episode)
182
+ - updates: A newer memory updates/replaces an older one on the same topic
183
+ - extends: Memory adds detail to another within the same project
184
+ - relates_to: Generic association (use ONLY when no specific type fits)
185
+ - CONTRADICTS: Memories give opposite advice or conflicting information
186
+ - SUPERSEDES: One memory fully replaces another (stronger than "updates")
187
+ - DEPENDS_ON: One memory's validity requires the other (prerequisite)
188
+ - CAUSED_BY: One event/decision directly caused the other
189
+ - VALIDATES: One memory confirms or provides evidence for the other
190
+
191
+ Choose the MOST SPECIFIC type. Prefer specific types over "relates_to".
192
+
193
+ MEMORY PAIRS:
194
+ ${pairsBlock}
195
+
196
+ Respond with ONLY a JSON array of relationship type strings, one per pair, in order.
197
+ Example for 3 pairs: ["CAUSED_BY", "extends", "VALIDATES"]
198
+
199
+ No explanations. Just the JSON array.`;
200
+ }
package/dist/types.d.ts CHANGED
@@ -27,6 +27,10 @@ export interface MemoryLink {
27
27
  strength: number;
28
28
  created_at: string;
29
29
  }
30
+ /** All valid relationship types for memory links.
31
+ * lowercase = heuristic (legacy), UPPER_SNAKE_CASE = LLM-classified (v0.7+). */
32
+ export declare const VALID_RELATIONSHIP_TYPES: readonly ["derives", "updates", "extends", "relates_to", "CONTRADICTS", "SUPERSEDES", "DEPENDS_ON", "CAUSED_BY", "VALIDATES"];
33
+ export type RelationshipType = typeof VALID_RELATIONSHIP_TYPES[number];
30
34
  /** A search result with scoring metadata. */
31
35
  export interface MemorySearchResult {
32
36
  id: string;
@@ -70,8 +74,14 @@ export interface ConsolidationReport {
70
74
  domains: number;
71
75
  reason?: string;
72
76
  };
77
+ hub_boost?: {
78
+ hubs_found: number;
79
+ boosted: number;
80
+ };
73
81
  links?: {
74
82
  auto_linked: number;
83
+ llm_classified?: number;
84
+ heuristic_fallback?: number;
75
85
  failed: number;
76
86
  };
77
87
  decay_prune?: {
package/dist/types.js CHANGED
@@ -4,3 +4,10 @@
4
4
  * Ported from the Python hicortex codebase.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.VALID_RELATIONSHIP_TYPES = void 0;
8
+ /** All valid relationship types for memory links.
9
+ * lowercase = heuristic (legacy), UPPER_SNAKE_CASE = LLM-classified (v0.7+). */
10
+ exports.VALID_RELATIONSHIP_TYPES = [
11
+ "derives", "updates", "extends", "relates_to",
12
+ "CONTRADICTS", "SUPERSEDES", "DEPENDS_ON", "CAUSED_BY", "VALIDATES",
13
+ ];
@@ -2,7 +2,7 @@
2
2
  "id": "hicortex",
3
3
  "name": "Hicortex — Long-term Memory That Learns",
4
4
  "description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
5
- "version": "0.6.1",
5
+ "version": "0.7.1",
6
6
  "kind": "lifecycle",
7
7
  "skills": ["./skills/hicortex-memory", "./skills/hicortex-learn", "./skills/hicortex-activate"],
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Human-like memory for self-improving AI agents. Automatic capturing, nightly reflection, and cross-agent learning. Works with Claude Code and OpenClaw.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {