zouroboros-memory 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/capture.d.ts +57 -0
- package/dist/capture.js +181 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +91 -0
- package/dist/conflict-resolver.d.ts +55 -0
- package/dist/conflict-resolver.js +221 -0
- package/dist/context-budget.d.ts +94 -0
- package/dist/context-budget.js +272 -0
- package/dist/cross-persona.d.ts +31 -0
- package/dist/cross-persona.js +188 -0
- package/dist/database.d.ts +35 -0
- package/dist/database.js +189 -0
- package/dist/embedding-benchmark.d.ts +12 -0
- package/dist/embedding-benchmark.js +224 -0
- package/dist/embeddings.d.ts +79 -0
- package/dist/embeddings.js +233 -0
- package/dist/episode-summarizer.d.ts +51 -0
- package/dist/episode-summarizer.js +285 -0
- package/dist/episodes.d.ts +41 -0
- package/dist/episodes.js +141 -0
- package/dist/facts.d.ts +60 -0
- package/dist/facts.js +263 -0
- package/dist/graph-traversal.d.ts +38 -0
- package/dist/graph-traversal.js +297 -0
- package/dist/graph.d.ts +51 -0
- package/dist/graph.js +221 -0
- package/dist/import-pipeline.d.ts +17 -0
- package/dist/import-pipeline.js +324 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +62 -0
- package/dist/mcp-server.d.ts +31 -0
- package/dist/mcp-server.js +285 -0
- package/dist/metrics.d.ts +63 -0
- package/dist/metrics.js +243 -0
- package/dist/multi-hop.d.ts +30 -0
- package/dist/multi-hop.js +238 -0
- package/dist/profiles.d.ts +51 -0
- package/dist/profiles.js +149 -0
- package/package.json +52 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* graph-traversal.ts — Enhanced Knowledge Graph Traversal
|
|
4
|
+
* MEM-105: Enhanced Knowledge Graph
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun graph-traversal.ts ancestors --entity <name>
|
|
8
|
+
* bun graph-traversal.ts descendants --entity <name>
|
|
9
|
+
* bun graph-traversal.ts cycles
|
|
10
|
+
* bun graph-traversal.ts infer --entity <name>
|
|
11
|
+
* bun graph-traversal.ts export-dot --entity <name> --output /tmp/graph.dot
|
|
12
|
+
*/
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { writeFileSync } from "fs";
|
|
15
|
+
const DB_PATH = process.env.ZO_MEMORY_DB || "/home/workspace/.zo/memory/shared-facts.db";
|
|
16
|
+
function getDb() {
|
|
17
|
+
const db = new Database(DB_PATH);
|
|
18
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
19
|
+
db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS relation_types (
|
|
21
|
+
relation TEXT PRIMARY KEY,
|
|
22
|
+
description TEXT,
|
|
23
|
+
inverse TEXT,
|
|
24
|
+
directed INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
category TEXT,
|
|
26
|
+
created_at INTEGER DEFAULT (strftime('%s','now'))
|
|
27
|
+
);
|
|
28
|
+
INSERT OR IGNORE INTO relation_types (relation, description, inverse, directed, category) VALUES
|
|
29
|
+
('depends_on', 'A requires B', 'required_by', 1, 'dependency'),
|
|
30
|
+
('supersedes', 'A replaces B', 'superseded_by', 1, 'temporal'),
|
|
31
|
+
('caused_by', 'A was caused by B', 'causes', 1, 'causal'),
|
|
32
|
+
('part_of', 'A is part of B', 'contains', 0, 'structural'),
|
|
33
|
+
('related_to', 'A is related to B', 'related_to', 0, 'general'),
|
|
34
|
+
('implements', 'A implements B', 'implemented_by', 1, 'dependency'),
|
|
35
|
+
('blocks', 'A blocks B', 'blocked_by', 1, 'dependency');
|
|
36
|
+
CREATE TABLE IF NOT EXISTS graph_cycles (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
entity_chain TEXT NOT NULL,
|
|
39
|
+
cycle_length INTEGER NOT NULL,
|
|
40
|
+
detected_at INTEGER DEFAULT (strftime('%s','now'))
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_graph_cycles_detected ON graph_cycles(detected_at);
|
|
43
|
+
`);
|
|
44
|
+
return db;
|
|
45
|
+
}
|
|
46
|
+
export const KNOWN_RELATIONS = new Set([
|
|
47
|
+
"depends_on", "required_by", "supersedes", "superseded_by",
|
|
48
|
+
"caused_by", "causes", "part_of", "contains", "related_to",
|
|
49
|
+
"implements", "implemented_by", "blocks", "blocked_by",
|
|
50
|
+
]);
|
|
51
|
+
export function getAncestors(db, entity, maxDepth = 10) {
|
|
52
|
+
const result = [];
|
|
53
|
+
const visited = new Set();
|
|
54
|
+
const queue = [{ entity, depth: 0, path: [entity] }];
|
|
55
|
+
visited.add(entity);
|
|
56
|
+
while (queue.length > 0) {
|
|
57
|
+
const { entity: current, depth, path } = queue.shift();
|
|
58
|
+
if (depth >= maxDepth)
|
|
59
|
+
continue;
|
|
60
|
+
// Get all incoming links (things this entity depends on / is caused by / etc.)
|
|
61
|
+
const rows = db.prepare(`
|
|
62
|
+
SELECT f.entity, fl.relation, f.id FROM fact_links fl
|
|
63
|
+
JOIN facts f ON fl.source_id = f.id
|
|
64
|
+
WHERE fl.target_id = (SELECT MIN(id) FROM facts WHERE entity = ? AND expires_at IS NULL LIMIT 1)
|
|
65
|
+
`).all(current);
|
|
66
|
+
// Also check via entity name directly
|
|
67
|
+
const inbound = db.prepare(`
|
|
68
|
+
SELECT f.entity, fl.relation FROM fact_links fl
|
|
69
|
+
JOIN facts f ON fl.source_id = f.id
|
|
70
|
+
JOIN facts tf ON fl.target_id = tf.id
|
|
71
|
+
WHERE tf.entity = ?
|
|
72
|
+
`).all(current);
|
|
73
|
+
const combined = [...rows, ...inbound];
|
|
74
|
+
for (const row of combined) {
|
|
75
|
+
const nextEntity = row.entity;
|
|
76
|
+
if (visited.has(nextEntity))
|
|
77
|
+
continue;
|
|
78
|
+
visited.add(nextEntity);
|
|
79
|
+
result.push({ entity: nextEntity, relation: row.relation, depth: depth + 1, path: [...path, nextEntity] });
|
|
80
|
+
queue.push({ entity: nextEntity, depth: depth + 1, path: [...path, nextEntity] });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
export function getDescendants(db, entity, maxDepth = 10) {
|
|
86
|
+
const result = [];
|
|
87
|
+
const visited = new Set();
|
|
88
|
+
const queue = [{ entity, depth: 0, path: [entity] }];
|
|
89
|
+
visited.add(entity);
|
|
90
|
+
while (queue.length > 0) {
|
|
91
|
+
const { entity: current, depth, path } = queue.shift();
|
|
92
|
+
if (depth >= maxDepth)
|
|
93
|
+
continue;
|
|
94
|
+
const outbound = db.prepare(`
|
|
95
|
+
SELECT f.entity, fl.relation FROM fact_links fl
|
|
96
|
+
JOIN facts f ON fl.target_id = f.id
|
|
97
|
+
JOIN facts sf ON fl.source_id = sf.id
|
|
98
|
+
WHERE sf.entity = ?
|
|
99
|
+
`).all(current);
|
|
100
|
+
for (const row of outbound) {
|
|
101
|
+
const nextEntity = row.entity;
|
|
102
|
+
if (visited.has(nextEntity))
|
|
103
|
+
continue;
|
|
104
|
+
visited.add(nextEntity);
|
|
105
|
+
result.push({ entity: nextEntity, relation: row.relation, depth: depth + 1, path: [...path, nextEntity] });
|
|
106
|
+
queue.push({ entity: nextEntity, depth: depth + 1, path: [...path, nextEntity] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
export function detectCycles(db) {
|
|
112
|
+
const allLinks = db.prepare("SELECT source_id, target_id FROM fact_links").all();
|
|
113
|
+
if (allLinks.length === 0)
|
|
114
|
+
return [];
|
|
115
|
+
const adj = new Map();
|
|
116
|
+
for (const { source_id, target_id } of allLinks) {
|
|
117
|
+
if (!adj.has(source_id))
|
|
118
|
+
adj.set(source_id, new Set());
|
|
119
|
+
adj.get(source_id).add(target_id);
|
|
120
|
+
}
|
|
121
|
+
const cycles = [];
|
|
122
|
+
const visited = new Set();
|
|
123
|
+
const recStack = new Set();
|
|
124
|
+
function dfs(node, path) {
|
|
125
|
+
visited.add(node);
|
|
126
|
+
recStack.add(node);
|
|
127
|
+
path.push(node);
|
|
128
|
+
for (const neighbor of adj.get(node) || []) {
|
|
129
|
+
if (!visited.has(neighbor)) {
|
|
130
|
+
dfs(neighbor, path);
|
|
131
|
+
}
|
|
132
|
+
else if (recStack.has(neighbor)) {
|
|
133
|
+
const cycleStart = path.indexOf(neighbor);
|
|
134
|
+
if (cycleStart >= 0) {
|
|
135
|
+
const cycle = path.slice(cycleStart);
|
|
136
|
+
cycles.push({ cycle, length: cycle.length });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
path.pop();
|
|
141
|
+
recStack.delete(node);
|
|
142
|
+
}
|
|
143
|
+
for (const node of adj.keys()) {
|
|
144
|
+
if (!visited.has(node))
|
|
145
|
+
dfs(node, []);
|
|
146
|
+
}
|
|
147
|
+
// Deduplicate symmetric cycles
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
return cycles.filter(c => {
|
|
150
|
+
const key = c.cycle.join("->");
|
|
151
|
+
const rev = [...c.cycle].reverse().join("->");
|
|
152
|
+
if (seen.has(key) || seen.has(rev))
|
|
153
|
+
return false;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export function inferRelations(db, entity) {
|
|
159
|
+
// Find facts with the same entity appearing in episodes together
|
|
160
|
+
const episodeRows = db.prepare(`
|
|
161
|
+
SELECT DISTINCT e.id FROM episodes e
|
|
162
|
+
JOIN episode_entities ee ON e.id = ee.episode_id
|
|
163
|
+
WHERE ee.entity = ?
|
|
164
|
+
`).all(entity);
|
|
165
|
+
if (episodeRows.length === 0)
|
|
166
|
+
return [];
|
|
167
|
+
const episodeIds = episodeRows.map(r => r.id);
|
|
168
|
+
const placeholders = episodeIds.map(() => "?").join(",");
|
|
169
|
+
const coEntities = db.prepare(`
|
|
170
|
+
SELECT DISTINCT ee2.entity, COUNT(DISTINCT ee2.episode_id) as co_occurrences
|
|
171
|
+
FROM episode_entities ee1
|
|
172
|
+
JOIN episode_entities ee2 ON ee1.episode_id = ee2.episode_id
|
|
173
|
+
WHERE ee1.entity = ? AND ee2.entity != ? AND ee2.episode_id IN (${placeholders})
|
|
174
|
+
GROUP BY ee2.entity
|
|
175
|
+
ORDER BY co_occurrences DESC
|
|
176
|
+
LIMIT 10
|
|
177
|
+
`).all(entity, entity, ...episodeIds);
|
|
178
|
+
const totalEpisodes = episodeRows.length;
|
|
179
|
+
return coEntities.map(r => ({
|
|
180
|
+
from: entity,
|
|
181
|
+
to: r.entity,
|
|
182
|
+
inferred_relation: "related_to",
|
|
183
|
+
confidence: r.co_occurrences / totalEpisodes,
|
|
184
|
+
reason: `Co-occurs in ${r.co_occurrences} episode(s) with "${entity}"`,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
export function exportDot(db, entity, outputPath) {
|
|
188
|
+
let links;
|
|
189
|
+
if (entity) {
|
|
190
|
+
const rows = db.prepare(`
|
|
191
|
+
SELECT fl.*, sf.entity as src_entity, tf.entity as tgt_entity
|
|
192
|
+
FROM fact_links fl
|
|
193
|
+
JOIN facts sf ON fl.source_id = sf.id
|
|
194
|
+
JOIN facts tf ON fl.target_id = tf.id
|
|
195
|
+
WHERE sf.entity = ? OR tf.entity = ?
|
|
196
|
+
`).all(entity, entity);
|
|
197
|
+
links = rows;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const rows = db.prepare(`
|
|
201
|
+
SELECT fl.*, sf.entity as src_entity, tf.entity as tgt_entity
|
|
202
|
+
FROM fact_links fl
|
|
203
|
+
JOIN facts sf ON fl.source_id = sf.id
|
|
204
|
+
JOIN facts tf ON fl.target_id = tf.id
|
|
205
|
+
`).all();
|
|
206
|
+
links = rows;
|
|
207
|
+
}
|
|
208
|
+
const nodes = new Set();
|
|
209
|
+
for (const l of links) {
|
|
210
|
+
nodes.add(l.src_entity);
|
|
211
|
+
nodes.add(l.tgt_entity);
|
|
212
|
+
}
|
|
213
|
+
const lines = ["digraph memory_graph {", " rankdir=LR;", " node [shape=box];"];
|
|
214
|
+
for (const node of nodes)
|
|
215
|
+
lines.push(` "${node}" [label="${node}"];`);
|
|
216
|
+
for (const l of links) {
|
|
217
|
+
lines.push(` "${l.src_entity}" -> "${l.tgt_entity}" [label="${l.relation}"];`);
|
|
218
|
+
}
|
|
219
|
+
lines.push("}");
|
|
220
|
+
const dot = lines.join("\n");
|
|
221
|
+
if (outputPath) {
|
|
222
|
+
writeFileSync(outputPath, dot);
|
|
223
|
+
}
|
|
224
|
+
return dot;
|
|
225
|
+
}
|
|
226
|
+
async function main() {
|
|
227
|
+
const args = process.argv.slice(2);
|
|
228
|
+
if (args.length === 0) {
|
|
229
|
+
console.log("Graph Traversal CLI\n\nCommands:\n ancestors --entity <name> Find all upstream dependencies\n descendants --entity <name> Find all downstream dependents\n cycles Detect dependency cycles\n infer --entity <name> Infer new relations from co-occurrence\n export-dot --entity <name> --output <path> Export to DOT format");
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
232
|
+
const flags = {};
|
|
233
|
+
for (let i = 0; i < args.length; i++)
|
|
234
|
+
if (args[i].startsWith("--"))
|
|
235
|
+
flags[args[i].slice(2)] = args[i + 1] || "";
|
|
236
|
+
const command = args[0];
|
|
237
|
+
const db = getDb();
|
|
238
|
+
if (command === "ancestors") {
|
|
239
|
+
if (!flags.entity) {
|
|
240
|
+
console.error("--entity required");
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const ancestors = getAncestors(db, flags.entity);
|
|
244
|
+
if (ancestors.length === 0) {
|
|
245
|
+
console.log(`No ancestors for "${flags.entity}".`);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
ancestors.forEach(a => console.log(` ${" ".repeat(a.depth - 1)}--${a.relation}--> ${a.entity}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else if (command === "descendants") {
|
|
252
|
+
if (!flags.entity) {
|
|
253
|
+
console.error("--entity required");
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
const descendants = getDescendants(db, flags.entity);
|
|
257
|
+
if (descendants.length === 0) {
|
|
258
|
+
console.log(`No descendants for "${flags.entity}".`);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
descendants.forEach(d => console.log(` ${" ".repeat(d.depth - 1)}${d.entity} --${d.relation}-->`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (command === "cycles") {
|
|
265
|
+
const cycles = detectCycles(db);
|
|
266
|
+
if (cycles.length === 0) {
|
|
267
|
+
console.log("No cycles detected.");
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
console.log(`${cycles.length} cycle(s) found:\n`);
|
|
271
|
+
cycles.forEach((c, i) => console.log(` ${i + 1}. ${c.cycle.join(" → ")} (${c.length} hops)`));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (command === "infer") {
|
|
275
|
+
if (!flags.entity) {
|
|
276
|
+
console.error("--entity required");
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
const inferred = inferRelations(db, flags.entity);
|
|
280
|
+
if (inferred.length === 0) {
|
|
281
|
+
console.log("No inferred relations.");
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
inferred.forEach(r => console.log(` ${r.from} --${r.inferred_relation}--> ${r.to} (${(r.confidence * 100).toFixed(0)}% conf)\n ${r.reason}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (command === "export-dot") {
|
|
288
|
+
const dot = exportDot(db, flags.entity || undefined, flags.output);
|
|
289
|
+
if (flags.output)
|
|
290
|
+
console.log(`Exported to ${flags.output}`);
|
|
291
|
+
else
|
|
292
|
+
console.log(dot);
|
|
293
|
+
}
|
|
294
|
+
db.close();
|
|
295
|
+
}
|
|
296
|
+
if (import.meta.main)
|
|
297
|
+
main();
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph-boosted search v2
|
|
3
|
+
*
|
|
4
|
+
* Builds an implicit entity graph from facts and episodes,
|
|
5
|
+
* then uses graph traversal to boost search results for
|
|
6
|
+
* entities that are closely related to the query context.
|
|
7
|
+
*/
|
|
8
|
+
import type { GraphNode, GraphEdge, MemorySearchResult } from 'zouroboros-core';
|
|
9
|
+
/**
|
|
10
|
+
* Invalidate the graph cache. Call after mutations (fact store/delete, episode create).
|
|
11
|
+
*/
|
|
12
|
+
export declare function invalidateGraphCache(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Build a graph of entity co-occurrences from episodes.
|
|
15
|
+
* Two entities are connected if they appear in the same episode.
|
|
16
|
+
* Results are cached with a 60-second TTL.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildEntityGraph(): {
|
|
19
|
+
nodes: GraphNode[];
|
|
20
|
+
edges: GraphEdge[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Get entities related to a given entity via graph traversal.
|
|
24
|
+
* Returns entities within `depth` hops, scored by connection strength.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getRelatedEntities(entity: string, options?: {
|
|
27
|
+
depth?: number;
|
|
28
|
+
limit?: number;
|
|
29
|
+
}): {
|
|
30
|
+
entity: string;
|
|
31
|
+
score: number;
|
|
32
|
+
}[];
|
|
33
|
+
/**
|
|
34
|
+
* Graph-boosted search: augments keyword/vector results with
|
|
35
|
+
* graph-adjacent facts from related entities.
|
|
36
|
+
*
|
|
37
|
+
* Uses RRF fusion across three signals:
|
|
38
|
+
* 1. Exact/keyword matches (weight: exactWeight)
|
|
39
|
+
* 2. Vector/semantic matches (weight: vectorWeight)
|
|
40
|
+
* 3. Graph-adjacent facts (weight: graphWeight)
|
|
41
|
+
*/
|
|
42
|
+
export declare function searchFactsGraphBoosted(baseResults: MemorySearchResult[], queryEntities: string[], options?: {
|
|
43
|
+
limit?: number;
|
|
44
|
+
graphWeight?: number;
|
|
45
|
+
graphDepth?: number;
|
|
46
|
+
}): MemorySearchResult[];
|
|
47
|
+
/**
|
|
48
|
+
* Extract entity names from a query string.
|
|
49
|
+
* Simple heuristic: capitalized words, quoted strings, known entities.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractQueryEntities(query: string): string[];
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph-boosted search v2
|
|
3
|
+
*
|
|
4
|
+
* Builds an implicit entity graph from facts and episodes,
|
|
5
|
+
* then uses graph traversal to boost search results for
|
|
6
|
+
* entities that are closely related to the query context.
|
|
7
|
+
*/
|
|
8
|
+
import { getDatabase } from './database.js';
|
|
9
|
+
/** Cached graph with TTL to avoid rebuilding on every search */
|
|
10
|
+
let _graphCache = null;
|
|
11
|
+
const GRAPH_CACHE_TTL_MS = 60_000; // 1 minute
|
|
12
|
+
/**
|
|
13
|
+
* Invalidate the graph cache. Call after mutations (fact store/delete, episode create).
|
|
14
|
+
*/
|
|
15
|
+
export function invalidateGraphCache() {
|
|
16
|
+
_graphCache = null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build a graph of entity co-occurrences from episodes.
|
|
20
|
+
* Two entities are connected if they appear in the same episode.
|
|
21
|
+
* Results are cached with a 60-second TTL.
|
|
22
|
+
*/
|
|
23
|
+
export function buildEntityGraph() {
|
|
24
|
+
if (_graphCache && Date.now() - _graphCache.builtAt < GRAPH_CACHE_TTL_MS) {
|
|
25
|
+
return { nodes: _graphCache.nodes, edges: _graphCache.edges };
|
|
26
|
+
}
|
|
27
|
+
const db = getDatabase();
|
|
28
|
+
// Get all entities that appear in episodes
|
|
29
|
+
const entityRows = db.query('SELECT DISTINCT entity FROM episode_entities').all();
|
|
30
|
+
const nodes = entityRows.map(r => ({
|
|
31
|
+
id: r.entity,
|
|
32
|
+
type: 'entity',
|
|
33
|
+
label: r.entity,
|
|
34
|
+
properties: {},
|
|
35
|
+
}));
|
|
36
|
+
// Build edges from co-occurrence in episodes
|
|
37
|
+
const edgeMap = new Map();
|
|
38
|
+
const episodes = db.query('SELECT id FROM episodes').all();
|
|
39
|
+
// For each episode, get all entities and create edges between them
|
|
40
|
+
const stmt = db.query('SELECT entity FROM episode_entities WHERE episode_id = ?');
|
|
41
|
+
for (const ep of episodes) {
|
|
42
|
+
const entities = stmt.all(ep.id).map(r => r.entity);
|
|
43
|
+
for (let i = 0; i < entities.length; i++) {
|
|
44
|
+
for (let j = i + 1; j < entities.length; j++) {
|
|
45
|
+
const key = [entities[i], entities[j]].sort().join('||');
|
|
46
|
+
edgeMap.set(key, (edgeMap.get(key) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Also build edges from facts sharing the same entity
|
|
51
|
+
const factEntities = db.query('SELECT DISTINCT entity FROM facts').all();
|
|
52
|
+
for (const fe of factEntities) {
|
|
53
|
+
const existing = nodes.find(n => n.id === fe.entity);
|
|
54
|
+
if (!existing) {
|
|
55
|
+
nodes.push({
|
|
56
|
+
id: fe.entity,
|
|
57
|
+
type: 'entity',
|
|
58
|
+
label: fe.entity,
|
|
59
|
+
properties: {},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const edges = Array.from(edgeMap.entries()).map(([key, weight]) => {
|
|
64
|
+
const [source, target] = key.split('||');
|
|
65
|
+
return { source, target, relation: 'co-occurs', weight };
|
|
66
|
+
});
|
|
67
|
+
_graphCache = { nodes, edges, builtAt: Date.now() };
|
|
68
|
+
return { nodes, edges };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get entities related to a given entity via graph traversal.
|
|
72
|
+
* Returns entities within `depth` hops, scored by connection strength.
|
|
73
|
+
*/
|
|
74
|
+
export function getRelatedEntities(entity, options = {}) {
|
|
75
|
+
const { depth = 2, limit = 20 } = options;
|
|
76
|
+
const { edges } = buildEntityGraph();
|
|
77
|
+
// BFS with decay
|
|
78
|
+
const visited = new Map(); // entity → score
|
|
79
|
+
const queue = [
|
|
80
|
+
{ entity, score: 1.0, hop: 0 },
|
|
81
|
+
];
|
|
82
|
+
visited.set(entity, 1.0);
|
|
83
|
+
while (queue.length > 0) {
|
|
84
|
+
const current = queue.shift();
|
|
85
|
+
if (current.hop >= depth)
|
|
86
|
+
continue;
|
|
87
|
+
// Find connected entities
|
|
88
|
+
for (const edge of edges) {
|
|
89
|
+
let neighbor = null;
|
|
90
|
+
if (edge.source === current.entity)
|
|
91
|
+
neighbor = edge.target;
|
|
92
|
+
else if (edge.target === current.entity)
|
|
93
|
+
neighbor = edge.source;
|
|
94
|
+
if (neighbor && !visited.has(neighbor)) {
|
|
95
|
+
const decayFactor = 1 / (current.hop + 2); // decay with distance
|
|
96
|
+
const score = current.score * decayFactor * Math.min(edge.weight, 5) / 5;
|
|
97
|
+
visited.set(neighbor, score);
|
|
98
|
+
queue.push({ entity: neighbor, score, hop: current.hop + 1 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Remove self, sort by score
|
|
103
|
+
visited.delete(entity);
|
|
104
|
+
return Array.from(visited.entries())
|
|
105
|
+
.map(([entity, score]) => ({ entity, score }))
|
|
106
|
+
.sort((a, b) => b.score - a.score)
|
|
107
|
+
.slice(0, limit);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Graph-boosted search: augments keyword/vector results with
|
|
111
|
+
* graph-adjacent facts from related entities.
|
|
112
|
+
*
|
|
113
|
+
* Uses RRF fusion across three signals:
|
|
114
|
+
* 1. Exact/keyword matches (weight: exactWeight)
|
|
115
|
+
* 2. Vector/semantic matches (weight: vectorWeight)
|
|
116
|
+
* 3. Graph-adjacent facts (weight: graphWeight)
|
|
117
|
+
*/
|
|
118
|
+
export function searchFactsGraphBoosted(baseResults, queryEntities, options = {}) {
|
|
119
|
+
const { limit = 10, graphWeight = 0.2, graphDepth = 2 } = options;
|
|
120
|
+
const db = getDatabase();
|
|
121
|
+
// Collect related entities from graph
|
|
122
|
+
const relatedEntities = new Map();
|
|
123
|
+
for (const entity of queryEntities) {
|
|
124
|
+
for (const related of getRelatedEntities(entity, { depth: graphDepth })) {
|
|
125
|
+
const existing = relatedEntities.get(related.entity) ?? 0;
|
|
126
|
+
relatedEntities.set(related.entity, Math.max(existing, related.score));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (relatedEntities.size === 0)
|
|
130
|
+
return baseResults;
|
|
131
|
+
// Fetch facts for related entities
|
|
132
|
+
const entityList = Array.from(relatedEntities.keys());
|
|
133
|
+
const placeholders = entityList.map(() => '?').join(',');
|
|
134
|
+
const graphRows = db.query(`
|
|
135
|
+
SELECT id, entity, key, value, category, decay_class as decayClass,
|
|
136
|
+
importance, created_at as createdAt
|
|
137
|
+
FROM facts
|
|
138
|
+
WHERE entity IN (${placeholders})
|
|
139
|
+
AND (expires_at IS NULL OR expires_at > strftime('%s', 'now'))
|
|
140
|
+
ORDER BY importance DESC
|
|
141
|
+
LIMIT 50
|
|
142
|
+
`).all(...entityList);
|
|
143
|
+
// Build graph results scored by entity relationship strength
|
|
144
|
+
const graphResults = graphRows.map(row => ({
|
|
145
|
+
entry: {
|
|
146
|
+
id: row.id,
|
|
147
|
+
entity: row.entity,
|
|
148
|
+
key: row.key,
|
|
149
|
+
value: row.value,
|
|
150
|
+
decay: row.decayClass,
|
|
151
|
+
createdAt: new Date(row.createdAt * 1000).toISOString(),
|
|
152
|
+
updatedAt: new Date(row.createdAt * 1000).toISOString(),
|
|
153
|
+
tags: row.category ? [row.category] : undefined,
|
|
154
|
+
},
|
|
155
|
+
score: relatedEntities.get(row.entity) ?? 0,
|
|
156
|
+
matchType: 'graph',
|
|
157
|
+
}));
|
|
158
|
+
// RRF fusion of base results + graph results
|
|
159
|
+
const k = 60;
|
|
160
|
+
const scores = new Map();
|
|
161
|
+
const baseWeight = 1 - graphWeight;
|
|
162
|
+
baseResults.forEach((result, rank) => {
|
|
163
|
+
const rrfScore = (1 / (k + rank + 1)) * baseWeight;
|
|
164
|
+
scores.set(result.entry.id, {
|
|
165
|
+
entry: result.entry,
|
|
166
|
+
score: rrfScore,
|
|
167
|
+
matchType: result.matchType,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
graphResults
|
|
171
|
+
.sort((a, b) => b.score - a.score)
|
|
172
|
+
.forEach((result, rank) => {
|
|
173
|
+
const rrfScore = (1 / (k + rank + 1)) * graphWeight;
|
|
174
|
+
const existing = scores.get(result.entry.id);
|
|
175
|
+
if (existing) {
|
|
176
|
+
existing.score += rrfScore;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
scores.set(result.entry.id, {
|
|
180
|
+
entry: result.entry,
|
|
181
|
+
score: rrfScore,
|
|
182
|
+
matchType: 'graph',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return Array.from(scores.values())
|
|
187
|
+
.sort((a, b) => b.score - a.score)
|
|
188
|
+
.slice(0, limit)
|
|
189
|
+
.map(r => ({
|
|
190
|
+
entry: r.entry,
|
|
191
|
+
score: r.score,
|
|
192
|
+
matchType: r.matchType,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Extract entity names from a query string.
|
|
197
|
+
* Simple heuristic: capitalized words, quoted strings, known entities.
|
|
198
|
+
*/
|
|
199
|
+
export function extractQueryEntities(query) {
|
|
200
|
+
const db = getDatabase();
|
|
201
|
+
const words = query.split(/\s+/);
|
|
202
|
+
// Check which words match known entities in facts
|
|
203
|
+
const knownEntities = new Set();
|
|
204
|
+
const entityRows = db.query('SELECT DISTINCT entity FROM facts').all();
|
|
205
|
+
const entitySet = new Set(entityRows.map(r => r.entity.toLowerCase()));
|
|
206
|
+
// Match individual words and bigrams
|
|
207
|
+
for (let i = 0; i < words.length; i++) {
|
|
208
|
+
const word = words[i].replace(/[^a-zA-Z0-9_-]/g, '');
|
|
209
|
+
if (entitySet.has(word.toLowerCase())) {
|
|
210
|
+
knownEntities.add(word);
|
|
211
|
+
}
|
|
212
|
+
// Try bigrams
|
|
213
|
+
if (i < words.length - 1) {
|
|
214
|
+
const bigram = `${word} ${words[i + 1].replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
215
|
+
if (entitySet.has(bigram.toLowerCase())) {
|
|
216
|
+
knownEntities.add(bigram);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return Array.from(knownEntities);
|
|
221
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* zo-memory-system Import Pipeline v1.0
|
|
4
|
+
*
|
|
5
|
+
* Import facts from external sources into the memory system.
|
|
6
|
+
*
|
|
7
|
+
* Supported sources:
|
|
8
|
+
* chatgpt — ChatGPT JSON export (conversations format)
|
|
9
|
+
* obsidian — Obsidian vault (markdown files with frontmatter)
|
|
10
|
+
* markdown — Generic markdown files
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* bun import.ts --source chatgpt --path ~/export.json
|
|
14
|
+
* bun import.ts --source obsidian --path ~/Vault --dry-run
|
|
15
|
+
* bun import.ts --source markdown --path ~/notes/file.md
|
|
16
|
+
*/
|
|
17
|
+
export {};
|