@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 +2 -1
- package/dist/consolidate.js +151 -27
- package/dist/graph.d.ts +54 -0
- package/dist/graph.js +257 -0
- package/dist/mcp-server.js +57 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +29 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.js +7 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,13 +45,14 @@ openclaw gateway restart
|
|
|
45
45
|
|
|
46
46
|
## Agent Tools (MCP)
|
|
47
47
|
|
|
48
|
-
|
|
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
|
|
package/dist/consolidate.js
CHANGED
|
@@ -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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
}
|
package/dist/graph.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
+
];
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|