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 +6 -0
- package/package.json +4 -2
- package/src/core/GraphEngine.ts +252 -0
- package/src/core/GraphGardener.ts +244 -0
- package/src/core/VectorEngine.ts +4 -1
- package/src/daemon/sonar-agent.ts +179 -859
- package/src/daemon/sonar-inference.ts +116 -0
- package/src/daemon/sonar-logic.ts +662 -0
- package/src/daemon/sonar-strategies.ts +187 -0
- package/src/daemon/sonar-types.ts +68 -0
- package/src/mcp/index.ts +20 -5
- package/src/pipeline/AmalfaIngestor.ts +2 -2
- package/src/resonance/db.ts +9 -2
- package/src/resonance/schema.ts +15 -2
- package/src/utils/TagInjector.ts +90 -0
- package/src/utils/sonar-client.ts +17 -0
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.
|
|
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.
|
|
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
|
+
}
|
package/src/core/VectorEngine.ts
CHANGED
|
@@ -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
|
}
|