amalfa 1.0.28 → 1.0.30

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
@@ -388,3 +388,9 @@ Amalfa evolved from patterns discovered in the [PolyVis](https://github.com/pjsv
388
388
  ---
389
389
 
390
390
  **Built with ❤️ by developers frustrated with context loss.**
391
+
392
+ ---
393
+
394
+ ## Acknowledgments
395
+
396
+ AMALFA leverages the powerful [Graphology](https://graphology.github.io/) library for in-memory graph analysis. Graphology is published on Zenodo with a DOI ([10.5281/zenodo.5681257](https://doi.org/10.5281/zenodo.5681257)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalfa",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "Local-first knowledge graph engine for AI agents. Transforms markdown into searchable memory with MCP protocol.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pjsvis/amalfa#readme",
@@ -62,8 +62,10 @@
62
62
  "publish": "npm publish"
63
63
  },
64
64
  "dependencies": {
65
- "@modelcontextprotocol/sdk": "1.25.0",
65
+ "@modelcontextprotocol/sdk": "1.25.2",
66
66
  "fastembed": "2.0.0",
67
+ "graphology": "^0.26.0",
68
+ "graphology-library": "^0.8.0",
67
69
  "pino": "^10.1.0"
68
70
  }
69
71
  }
@@ -0,0 +1,252 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { MultiDirectedGraph } from "graphology";
3
+ import { bidirectional as shortestPath } from "graphology-shortest-path/unweighted";
4
+ import communitiesLouvain from "graphology-communities-louvain";
5
+ import pagerank from "graphology-metrics/centrality/pagerank";
6
+ import betweenness from "graphology-metrics/centrality/betweenness";
7
+ import { connectedComponents } from "graphology-components";
8
+ import { getLogger } from "@src/utils/Logger";
9
+
10
+ const log = getLogger("GraphEngine");
11
+
12
+ export interface GraphNode {
13
+ id: string;
14
+ type?: string;
15
+ title?: string;
16
+ domain?: string;
17
+ layer?: string;
18
+ date?: string;
19
+ }
20
+
21
+ export interface GraphEdge {
22
+ source: string;
23
+ target: string;
24
+ type?: string;
25
+ }
26
+
27
+ /**
28
+ * GraphEngine - In-memory graph management using Graphology.
29
+ * Provides fast traversal and analysis without database overhead.
30
+ *
31
+ * CITATION:
32
+ * Graphology is published on Zenodo with a DOI (10.5281/zenodo.5681257).
33
+ * Academic users are encouraged to cite it accordingly.
34
+ */
35
+ export class GraphEngine {
36
+ private graph: MultiDirectedGraph;
37
+
38
+ constructor() {
39
+ this.graph = new MultiDirectedGraph({ allowSelfLoops: true });
40
+ }
41
+
42
+ /**
43
+ * Load the graph from ResonanceDB (SQLite)
44
+ * Uses "hollow nodes" - only structural metadata, no embeddings.
45
+ */
46
+ async load(db: Database): Promise<void> {
47
+ log.info("Loading graph into memory...");
48
+ const start = Date.now();
49
+
50
+ this.graph.clear();
51
+
52
+ // 1. Load Nodes
53
+ const nodes = db
54
+ .query("SELECT id, type, title, domain, layer, date FROM nodes")
55
+ .all() as GraphNode[];
56
+ for (const node of nodes) {
57
+ this.graph.addNode(node.id, {
58
+ type: node.type,
59
+ title: node.title,
60
+ domain: node.domain,
61
+ layer: node.layer,
62
+ date: node.date,
63
+ });
64
+ }
65
+
66
+ // 2. Load Edges
67
+ const edges = db
68
+ .query("SELECT source, target, type FROM edges")
69
+ .all() as GraphEdge[];
70
+ for (const edge of edges) {
71
+ try {
72
+ if (
73
+ this.graph.hasNode(edge.source) &&
74
+ this.graph.hasNode(edge.target)
75
+ ) {
76
+ this.graph.addEdge(edge.source, edge.target, {
77
+ type: edge.type,
78
+ });
79
+ }
80
+ } catch (err) {
81
+ log.debug(
82
+ { source: edge.source, target: edge.target, error: err },
83
+ "Failed to add edge",
84
+ );
85
+ }
86
+ }
87
+
88
+ log.info(
89
+ {
90
+ nodes: this.graph.order,
91
+ edges: this.graph.size,
92
+ elapsedMs: Date.now() - start,
93
+ },
94
+ "Graph loaded successfully",
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Get neighbors of a node
100
+ */
101
+ getNeighbors(nodeId: string): string[] {
102
+ if (!this.graph.hasNode(nodeId)) return [];
103
+ return this.graph.neighbors(nodeId);
104
+ }
105
+
106
+ /**
107
+ * Get attributes of a specific node
108
+ */
109
+ getNodeAttributes(nodeId: string): GraphNode | null {
110
+ if (!this.graph.hasNode(nodeId)) return null;
111
+ return this.graph.getNodeAttributes(nodeId) as GraphNode;
112
+ }
113
+
114
+ /**
115
+ * Find shortest path between two nodes
116
+ */
117
+ findShortestPath(sourceId: string, targetId: string): string[] | null {
118
+ if (!this.graph.hasNode(sourceId) || !this.graph.hasNode(targetId)) {
119
+ return null;
120
+ }
121
+ return shortestPath(this.graph, sourceId, targetId);
122
+ }
123
+
124
+ /**
125
+ * Run Louvain community detection
126
+ */
127
+ detectCommunities(): Record<string, number> {
128
+ return communitiesLouvain(this.graph);
129
+ }
130
+
131
+ /**
132
+ * Get PageRank for all nodes
133
+ */
134
+ getPagerank(): Record<string, number> {
135
+ return pagerank(this.graph);
136
+ }
137
+
138
+ /**
139
+ * Get Betweenness Centrality for all nodes
140
+ */
141
+ getBetweenness(): Record<string, number> {
142
+ return betweenness(this.graph);
143
+ }
144
+
145
+ /**
146
+ * Get connected components
147
+ */
148
+ getComponents(): string[][] {
149
+ return connectedComponents(this.graph);
150
+ }
151
+
152
+ /**
153
+ * Get a summary of all metrics
154
+ */
155
+ getMetrics() {
156
+ const pr = this.getPagerank();
157
+ const bc = this.getBetweenness();
158
+ const communities = this.detectCommunities();
159
+
160
+ return {
161
+ pagerank: pr,
162
+ betweenness: bc,
163
+ communities: communities,
164
+ components: this.getComponents().length,
165
+ stats: this.getStats(),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Calculate Adamic-Adar index for two nodes.
171
+ * Higher score = more likely to have a logical relationship based on specific shared neighbors.
172
+ * AA(u, v) = Sum of (1 / log(degree(w))) for each shared neighbor w.
173
+ */
174
+ getAdamicAdar(nodeA: string, nodeB: string): number {
175
+ if (!this.graph.hasNode(nodeA) || !this.graph.hasNode(nodeB)) return 0;
176
+ if (nodeA === nodeB) return 0;
177
+
178
+ const neighborsA = new Set(this.graph.neighbors(nodeA));
179
+ const neighborsB = this.graph.neighbors(nodeB);
180
+ let score = 0;
181
+
182
+ for (const neighbor of neighborsB) {
183
+ if (neighborsA.has(neighbor)) {
184
+ const degree = this.graph.degree(neighbor);
185
+ // degree > 1 because it's a shared neighbor (connected to at least A and B)
186
+ if (degree > 1) {
187
+ score += 1 / Math.log(degree);
188
+ }
189
+ }
190
+ }
191
+
192
+ return score;
193
+ }
194
+
195
+ /**
196
+ * Find structural candidates for new edges using Adamic-Adar.
197
+ * Identifies nodes that are not linked but share many specific neighbors.
198
+ */
199
+ findStructuralCandidates(
200
+ limit = 10,
201
+ ): { source: string; target: string; score: number }[] {
202
+ const candidates: { source: string; target: string; score: number }[] = [];
203
+ const nodes = this.graph.nodes();
204
+ const seen = new Set<string>();
205
+
206
+ for (const u of nodes) {
207
+ const neighborsU = new Set(this.graph.neighbors(u));
208
+ const twoHopPotential = new Set<string>();
209
+
210
+ // Find nodes v that share at least one neighbor w with u
211
+ for (const w of neighborsU) {
212
+ for (const v of this.graph.neighbors(w)) {
213
+ if (v !== u && !neighborsU.has(v)) {
214
+ twoHopPotential.add(v);
215
+ }
216
+ }
217
+ }
218
+
219
+ for (const v of twoHopPotential) {
220
+ const pairId = [u, v].sort().join("|");
221
+ if (seen.has(pairId)) continue;
222
+ seen.add(pairId);
223
+
224
+ const score = this.getAdamicAdar(u, v);
225
+ if (score > 0) {
226
+ candidates.push({ source: u, target: v, score });
227
+ }
228
+ }
229
+ }
230
+
231
+ return candidates.sort((a, b) => b.score - a.score).slice(0, limit);
232
+ }
233
+
234
+ /**
235
+ * Get graph statistics
236
+ */
237
+ getStats() {
238
+ return {
239
+ nodes: this.graph.order,
240
+ edges: this.graph.size,
241
+ density:
242
+ this.graph.size / (this.graph.order * (this.graph.order - 1) || 1),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Export for external tools (e.g. visualization)
248
+ */
249
+ getInternalGraph(): MultiDirectedGraph {
250
+ return this.graph;
251
+ }
252
+ }
@@ -0,0 +1,244 @@
1
+ import type { ResonanceDB } from "@src/resonance/db";
2
+ import { getLogger } from "@src/utils/Logger";
3
+ import type { GraphEngine } from "./GraphEngine";
4
+ import type { VectorEngine } from "./VectorEngine";
5
+
6
+ const log = getLogger("GraphGardener");
7
+
8
+ export interface BridgeSuggestion {
9
+ sourceId: string;
10
+ targetId: string;
11
+ reason: string;
12
+ similarity: number;
13
+ }
14
+
15
+ export interface ClusterInsight {
16
+ clusterId: number;
17
+ nodes: string[];
18
+ suggestedTag?: string;
19
+ }
20
+
21
+ /**
22
+ * GraphGardener - Semantic Graph Optimization
23
+ * Exploits the duality of Vector Search (Semantic) and Graphology (Structural)
24
+ * to find missing links and optimize knowledge topology.
25
+ */
26
+ export class GraphGardener {
27
+ constructor(
28
+ private db: ResonanceDB,
29
+ private graph: GraphEngine,
30
+ private vector: VectorEngine,
31
+ ) {}
32
+
33
+ /**
34
+ * findGaps: Finds nodes that are semantically similar but structurally distant.
35
+ * These are "hidden relationships" that should probably be links or shared tags.
36
+ */
37
+ async findGaps(
38
+ limit = 10,
39
+ similarityThreshold = 0.82,
40
+ ): Promise<BridgeSuggestion[]> {
41
+ log.info("Analyzing graph for semantic gaps...");
42
+ const candidates = this.db
43
+ .getRawDb()
44
+ .query(
45
+ "SELECT id, embedding FROM nodes WHERE embedding IS NOT NULL LIMIT 100",
46
+ )
47
+ .all() as { id: string; embedding: Uint8Array }[];
48
+
49
+ const suggestions: BridgeSuggestion[] = [];
50
+
51
+ for (const node of candidates) {
52
+ if (!node.embedding) continue;
53
+
54
+ // Convert blob to Float32Array for search
55
+ const queryFloats = new Float32Array(
56
+ node.embedding.buffer,
57
+ node.embedding.byteOffset,
58
+ node.embedding.byteLength / 4,
59
+ );
60
+
61
+ // 1. Get Vector Neighbors using stored embedding (FAFCAS Dot Product)
62
+ const semanticNeighbors = await this.vector.searchByVector(
63
+ queryFloats,
64
+ 5,
65
+ );
66
+
67
+ // 2. Get Graph Neighbors
68
+ const graphNeighbors = new Set(this.graph.getNeighbors(node.id));
69
+
70
+ for (const sn of semanticNeighbors) {
71
+ if (sn.id === node.id) continue;
72
+ if (sn.score < similarityThreshold) continue;
73
+
74
+ // 3. If semantically close but NOT a graph neighbor -> Potential GAP
75
+ if (!graphNeighbors.has(sn.id)) {
76
+ // Check if inverse already in suggestions
77
+ if (
78
+ !suggestions.find(
79
+ (s) =>
80
+ (s.sourceId === node.id && s.targetId === sn.id) ||
81
+ (s.sourceId === sn.id && s.targetId === node.id),
82
+ )
83
+ ) {
84
+ suggestions.push({
85
+ sourceId: node.id,
86
+ targetId: sn.id,
87
+ reason: "Semantic proximity without structural link",
88
+ similarity: sn.score,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return suggestions
96
+ .sort((a, b) => b.similarity - a.similarity)
97
+ .slice(0, limit);
98
+ }
99
+
100
+ /**
101
+ * findStructuralGaps: Finds nodes that share significant structural context (Adamic-Adar)
102
+ * but are not directly linked. This is pure topological link prediction.
103
+ */
104
+ findStructuralGaps(limit = 10): BridgeSuggestion[] {
105
+ log.info("Analyzing graph for structural gaps (Adamic-Adar)...");
106
+ const candidates = this.graph.findStructuralCandidates(limit);
107
+
108
+ return candidates.map((c) => ({
109
+ sourceId: c.source,
110
+ targetId: c.target,
111
+ reason: `Structural Adamic-Adar overlap (score: ${c.score.toFixed(2)})`,
112
+ similarity: c.score,
113
+ }));
114
+ }
115
+
116
+ /**
117
+ * analyzeCommunities: Provides insights into detected clusters.
118
+ */
119
+ analyzeCommunities(): ClusterInsight[] {
120
+ const communities = this.graph.detectCommunities();
121
+ const clusters: Map<number, string[]> = new Map();
122
+
123
+ for (const [nodeId, clusterId] of Object.entries(communities)) {
124
+ if (!clusters.has(clusterId)) clusters.set(clusterId, []);
125
+ clusters.get(clusterId)?.push(nodeId);
126
+ }
127
+
128
+ return Array.from(clusters.entries()).map(([clusterId, nodes]) => ({
129
+ clusterId,
130
+ nodes,
131
+ }));
132
+ }
133
+
134
+ /**
135
+ * summarizeCluster: Logic resides in agent calling LLM,
136
+ * but helper to get representative nodes is useful.
137
+ */
138
+ getClusterRepresentatives(nodes: string[], top = 3): string[] {
139
+ // Get highest PageRank nodes within this set
140
+ const pr = this.graph.getPagerank();
141
+ return nodes.sort((a, b) => (pr[b] || 0) - (pr[a] || 0)).slice(0, top);
142
+ }
143
+
144
+ /**
145
+ * findRelated: Search for nodes related to a query.
146
+ */
147
+ async findRelated(query: string, limit = 5) {
148
+ return await this.vector.search(query, limit);
149
+ }
150
+
151
+ /**
152
+ * weaveTimeline: Proposes FOLLOWS/PRECEDES edges based on date metadata.
153
+ */
154
+ weaveTimeline(): BridgeSuggestion[] {
155
+ const nodes = this.db
156
+ .getRawDb()
157
+ .query("SELECT id, date FROM nodes WHERE date IS NOT NULL")
158
+ .all() as { id: string; date: string }[];
159
+
160
+ if (nodes.length < 2) return [];
161
+
162
+ // Sort by date
163
+ nodes.sort((a, b) => a.date.localeCompare(b.date));
164
+
165
+ const clusters = this.analyzeCommunities();
166
+ const nodeToCluster = new Map<string, number>();
167
+ for (const c of clusters) {
168
+ for (const nodeId of c.nodes) {
169
+ nodeToCluster.set(nodeId, c.clusterId);
170
+ }
171
+ }
172
+
173
+ const suggestions: BridgeSuggestion[] = [];
174
+ const lastInCluster = new Map<number, { id: string; date: string }>();
175
+
176
+ for (const node of nodes) {
177
+ const clusterId = nodeToCluster.get(node.id);
178
+ if (clusterId === undefined) continue;
179
+
180
+ const last = lastInCluster.get(clusterId);
181
+ if (last) {
182
+ suggestions.push({
183
+ sourceId: last.id,
184
+ targetId: node.id,
185
+ reason: `Temporal sequence (${last.date} -> ${node.date}) in community ${clusterId}`,
186
+ similarity: 1.0,
187
+ });
188
+ }
189
+
190
+ lastInCluster.set(clusterId, node);
191
+ }
192
+
193
+ return suggestions;
194
+ }
195
+
196
+ /**
197
+ * identifyHubs: Finds nodes with high betweenness or pagerank that lack tags.
198
+ */
199
+ /**
200
+ * resolveSource: Returns the absolute file path for a node ID.
201
+ */
202
+ resolveSource(nodeId: string): string | null {
203
+ const row = this.db
204
+ .getRawDb()
205
+ .query("SELECT meta FROM nodes WHERE id = ?")
206
+ .get(nodeId) as { meta: string } | null;
207
+ if (row?.meta) {
208
+ try {
209
+ const meta = JSON.parse(row.meta);
210
+ return meta.source || null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * getContent: Reads the raw markdown content for a node.
220
+ */
221
+ async getContent(nodeId: string): Promise<string | null> {
222
+ const sourcePath = this.resolveSource(nodeId);
223
+ if (!sourcePath) return null;
224
+ try {
225
+ return await Bun.file(sourcePath).text();
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ identifyHubs(top = 5) {
232
+ const pr = this.graph.getPagerank();
233
+ const bc = this.graph.getBetweenness();
234
+
235
+ const nodes = Object.keys(pr).map((id) => ({
236
+ id,
237
+ pagerank: pr[id],
238
+ betweenness: bc[id],
239
+ score: (pr[id] || 0) + (bc[id] || 0),
240
+ }));
241
+
242
+ return nodes.sort((a, b) => b.score - a.score).slice(0, top);
243
+ }
244
+ }
@@ -7,6 +7,7 @@ export interface SearchResult {
7
7
  score: number;
8
8
  content: string;
9
9
  title?: string;
10
+ date?: string;
10
11
  }
11
12
 
12
13
  /**
@@ -191,7 +192,7 @@ export class VectorEngine {
191
192
  // Note: Hollow Nodes have content=NULL, use meta.source to read from filesystem if needed
192
193
  const results: SearchResult[] = [];
193
194
  const contentStmt = this.db.prepare(
194
- "SELECT title, content, meta FROM nodes WHERE id = ?",
195
+ "SELECT title, content, meta, date FROM nodes WHERE id = ?",
195
196
  );
196
197
 
197
198
  for (const item of topK) {
@@ -199,6 +200,7 @@ export class VectorEngine {
199
200
  title: string;
200
201
  content: string | null;
201
202
  meta: string | null;
203
+ date: string | null;
202
204
  };
203
205
  if (row) {
204
206
  // For hollow nodes, extract a preview from title or meta
@@ -217,6 +219,7 @@ export class VectorEngine {
217
219
  score: item.score,
218
220
  title: row.title,
219
221
  content: content,
222
+ date: row.date || undefined,
220
223
  });
221
224
  }
222
225
  }