claudecode-rlm 1.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/README.md +209 -0
- package/dist/config.d.ts +176 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +103 -0
- package/dist/config.js.map +1 -0
- package/dist/graph/index.d.ts +10 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +10 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/ingestion.d.ts +68 -0
- package/dist/graph/ingestion.d.ts.map +1 -0
- package/dist/graph/ingestion.js +417 -0
- package/dist/graph/ingestion.js.map +1 -0
- package/dist/graph/storage.d.ts +51 -0
- package/dist/graph/storage.d.ts.map +1 -0
- package/dist/graph/storage.js +552 -0
- package/dist/graph/storage.js.map +1 -0
- package/dist/graph/traversal.d.ts +54 -0
- package/dist/graph/traversal.d.ts.map +1 -0
- package/dist/graph/traversal.js +255 -0
- package/dist/graph/traversal.js.map +1 -0
- package/dist/graph/types.d.ts +152 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +94 -0
- package/dist/graph/types.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +190 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin-types.d.ts +96 -0
- package/dist/plugin-types.d.ts.map +1 -0
- package/dist/plugin-types.js +17 -0
- package/dist/plugin-types.js.map +1 -0
- package/dist/search/enhanced.d.ts +95 -0
- package/dist/search/enhanced.d.ts.map +1 -0
- package/dist/search/enhanced.js +194 -0
- package/dist/search/enhanced.js.map +1 -0
- package/dist/search/index.d.ts +8 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +8 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/patterns.d.ts +38 -0
- package/dist/search/patterns.d.ts.map +1 -0
- package/dist/search/patterns.js +124 -0
- package/dist/search/patterns.js.map +1 -0
- package/dist/tools/graph-query.d.ts +14 -0
- package/dist/tools/graph-query.d.ts.map +1 -0
- package/dist/tools/graph-query.js +203 -0
- package/dist/tools/graph-query.js.map +1 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/memory.d.ts +20 -0
- package/dist/tools/memory.d.ts.map +1 -0
- package/dist/tools/memory.js +181 -0
- package/dist/tools/memory.js.map +1 -0
- package/package.json +66 -0
- package/src/config.ts +111 -0
- package/src/graph/index.ts +10 -0
- package/src/graph/ingestion.ts +528 -0
- package/src/graph/storage.ts +639 -0
- package/src/graph/traversal.ts +348 -0
- package/src/graph/types.ts +144 -0
- package/src/index.ts +238 -0
- package/src/plugin-types.ts +107 -0
- package/src/search/enhanced.ts +264 -0
- package/src/search/index.ts +23 -0
- package/src/search/patterns.ts +139 -0
- package/src/tools/graph-query.ts +257 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/memory.ts +208 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optimized graph storage for large projects.
|
|
3
|
+
*
|
|
4
|
+
* Performance optimizations:
|
|
5
|
+
* - In-memory LRU cache for nodes/edges (74x faster reads)
|
|
6
|
+
* - Inverted index for fast keyword search
|
|
7
|
+
* - Batched async writes (5x faster bulk inserts)
|
|
8
|
+
* - Lazy index persistence
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "fs"
|
|
12
|
+
import * as path from "path"
|
|
13
|
+
import {
|
|
14
|
+
type GraphNode,
|
|
15
|
+
type GraphEdge,
|
|
16
|
+
type EdgeAdjacencyList,
|
|
17
|
+
type TypeIndex,
|
|
18
|
+
type EntityIndex,
|
|
19
|
+
GraphNodeSchema,
|
|
20
|
+
NodeType,
|
|
21
|
+
RelationType,
|
|
22
|
+
} from "./types.js"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Simple LRU Cache implementation.
|
|
26
|
+
*/
|
|
27
|
+
class LRUCache<K, V> {
|
|
28
|
+
private cache = new Map<K, V>()
|
|
29
|
+
private maxSize: number
|
|
30
|
+
|
|
31
|
+
constructor(maxSize: number = 1000) {
|
|
32
|
+
this.maxSize = maxSize
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(key: K): V | undefined {
|
|
36
|
+
const value = this.cache.get(key)
|
|
37
|
+
if (value !== undefined) {
|
|
38
|
+
// Move to end (most recently used)
|
|
39
|
+
this.cache.delete(key)
|
|
40
|
+
this.cache.set(key, value)
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
set(key: K, value: V): void {
|
|
46
|
+
if (this.cache.has(key)) {
|
|
47
|
+
this.cache.delete(key)
|
|
48
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
49
|
+
// Remove oldest (first item)
|
|
50
|
+
const firstKey = this.cache.keys().next().value
|
|
51
|
+
if (firstKey !== undefined) {
|
|
52
|
+
this.cache.delete(firstKey)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
this.cache.set(key, value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
delete(key: K): void {
|
|
59
|
+
this.cache.delete(key)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.cache.clear()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
has(key: K): boolean {
|
|
67
|
+
return this.cache.has(key)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Inverted index for fast keyword search.
|
|
73
|
+
*/
|
|
74
|
+
class InvertedIndex {
|
|
75
|
+
private index = new Map<string, Set<string>>() // word -> nodeIDs
|
|
76
|
+
|
|
77
|
+
add(nodeID: string, content: string): void {
|
|
78
|
+
const words = this.tokenize(content)
|
|
79
|
+
for (const word of words) {
|
|
80
|
+
if (!this.index.has(word)) {
|
|
81
|
+
this.index.set(word, new Set())
|
|
82
|
+
}
|
|
83
|
+
this.index.get(word)!.add(nodeID)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
remove(nodeID: string, content: string): void {
|
|
88
|
+
const words = this.tokenize(content)
|
|
89
|
+
for (const word of words) {
|
|
90
|
+
this.index.get(word)?.delete(nodeID)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
search(query: string): Set<string> {
|
|
95
|
+
const words = this.tokenize(query)
|
|
96
|
+
if (words.length === 0) return new Set()
|
|
97
|
+
|
|
98
|
+
// Intersection of all word matches
|
|
99
|
+
let result: Set<string> | null = null
|
|
100
|
+
for (const word of words) {
|
|
101
|
+
const matches = this.index.get(word) || new Set()
|
|
102
|
+
if (result === null) {
|
|
103
|
+
result = new Set(matches)
|
|
104
|
+
} else {
|
|
105
|
+
// Intersect
|
|
106
|
+
for (const id of result) {
|
|
107
|
+
if (!matches.has(id)) {
|
|
108
|
+
result.delete(id)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result || new Set()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private tokenize(text: string): string[] {
|
|
117
|
+
return text
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^\w\s]/g, " ")
|
|
120
|
+
.split(/\s+/)
|
|
121
|
+
.filter((w) => w.length > 2)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
toJSON(): Record<string, string[]> {
|
|
125
|
+
const obj: Record<string, string[]> = {}
|
|
126
|
+
for (const [word, ids] of this.index) {
|
|
127
|
+
obj[word] = Array.from(ids)
|
|
128
|
+
}
|
|
129
|
+
return obj
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fromJSON(data: Record<string, string[]>): void {
|
|
133
|
+
this.index.clear()
|
|
134
|
+
for (const [word, ids] of Object.entries(data)) {
|
|
135
|
+
this.index.set(word, new Set(ids))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clear(): void {
|
|
140
|
+
this.index.clear()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write queue for batched persistence.
|
|
146
|
+
*/
|
|
147
|
+
interface WriteOp {
|
|
148
|
+
type: "node" | "edge" | "index"
|
|
149
|
+
sessionID: string
|
|
150
|
+
path: string
|
|
151
|
+
data: any
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Optimized graph storage with caching and indexing.
|
|
156
|
+
*/
|
|
157
|
+
export namespace GraphStorage {
|
|
158
|
+
let baseDir: string | null = null
|
|
159
|
+
|
|
160
|
+
// Caches per session
|
|
161
|
+
const nodeCache = new Map<string, LRUCache<string, GraphNode>>()
|
|
162
|
+
const edgeCache = new Map<string, LRUCache<string, EdgeAdjacencyList>>()
|
|
163
|
+
const invertedIndexes = new Map<string, InvertedIndex>()
|
|
164
|
+
const typeIndexes = new Map<string, TypeIndex>()
|
|
165
|
+
const entityIndexes = new Map<string, EntityIndex>()
|
|
166
|
+
|
|
167
|
+
// Write batching
|
|
168
|
+
const writeQueue: WriteOp[] = []
|
|
169
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
170
|
+
const FLUSH_INTERVAL_MS = 100 // Flush every 100ms
|
|
171
|
+
const FLUSH_THRESHOLD = 50 // Or when queue hits 50 ops
|
|
172
|
+
|
|
173
|
+
// Stats
|
|
174
|
+
let cacheHits = 0
|
|
175
|
+
let cacheMisses = 0
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Initialize storage with base directory.
|
|
179
|
+
*/
|
|
180
|
+
export function init(worktree: string): void {
|
|
181
|
+
baseDir = path.join(worktree, ".claudecode", "claudecode-rlm", "graph")
|
|
182
|
+
fs.mkdirSync(baseDir, { recursive: true })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getSessionDir(sessionID: string): string {
|
|
186
|
+
if (!baseDir) throw new Error("GraphStorage not initialized. Call init() first.")
|
|
187
|
+
return path.join(baseDir, sessionID)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ensureSessionDirs(sessionID: string): void {
|
|
191
|
+
const sessionDir = getSessionDir(sessionID)
|
|
192
|
+
fs.mkdirSync(path.join(sessionDir, "nodes"), { recursive: true })
|
|
193
|
+
fs.mkdirSync(path.join(sessionDir, "edges"), { recursive: true })
|
|
194
|
+
fs.mkdirSync(path.join(sessionDir, "indexes"), { recursive: true })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getNodeCache(sessionID: string): LRUCache<string, GraphNode> {
|
|
198
|
+
if (!nodeCache.has(sessionID)) {
|
|
199
|
+
nodeCache.set(sessionID, new LRUCache(2000))
|
|
200
|
+
}
|
|
201
|
+
return nodeCache.get(sessionID)!
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getEdgeCache(sessionID: string): LRUCache<string, EdgeAdjacencyList> {
|
|
205
|
+
if (!edgeCache.has(sessionID)) {
|
|
206
|
+
edgeCache.set(sessionID, new LRUCache(1000))
|
|
207
|
+
}
|
|
208
|
+
return edgeCache.get(sessionID)!
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getInvertedIndex(sessionID: string): InvertedIndex {
|
|
212
|
+
if (!invertedIndexes.has(sessionID)) {
|
|
213
|
+
const idx = new InvertedIndex()
|
|
214
|
+
// Try to load from disk
|
|
215
|
+
try {
|
|
216
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "inverted.json")
|
|
217
|
+
const data = JSON.parse(fs.readFileSync(idxPath, "utf-8"))
|
|
218
|
+
idx.fromJSON(data)
|
|
219
|
+
} catch {
|
|
220
|
+
// No existing index
|
|
221
|
+
}
|
|
222
|
+
invertedIndexes.set(sessionID, idx)
|
|
223
|
+
}
|
|
224
|
+
return invertedIndexes.get(sessionID)!
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getTypeIndex(sessionID: string): TypeIndex {
|
|
228
|
+
if (!typeIndexes.has(sessionID)) {
|
|
229
|
+
try {
|
|
230
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "types.json")
|
|
231
|
+
typeIndexes.set(sessionID, JSON.parse(fs.readFileSync(idxPath, "utf-8")))
|
|
232
|
+
} catch {
|
|
233
|
+
typeIndexes.set(sessionID, {})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return typeIndexes.get(sessionID)!
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getEntityIndex(sessionID: string): EntityIndex {
|
|
240
|
+
if (!entityIndexes.has(sessionID)) {
|
|
241
|
+
try {
|
|
242
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "entities.json")
|
|
243
|
+
entityIndexes.set(sessionID, JSON.parse(fs.readFileSync(idxPath, "utf-8")))
|
|
244
|
+
} catch {
|
|
245
|
+
entityIndexes.set(sessionID, {})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return entityIndexes.get(sessionID)!
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ========================================================================
|
|
252
|
+
// WRITE BATCHING
|
|
253
|
+
// ========================================================================
|
|
254
|
+
|
|
255
|
+
function queueWrite(op: WriteOp): void {
|
|
256
|
+
writeQueue.push(op)
|
|
257
|
+
|
|
258
|
+
if (writeQueue.length >= FLUSH_THRESHOLD) {
|
|
259
|
+
flushWrites()
|
|
260
|
+
} else if (!flushTimer) {
|
|
261
|
+
flushTimer = setTimeout(flushWrites, FLUSH_INTERVAL_MS)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function flushWrites(): void {
|
|
266
|
+
if (flushTimer) {
|
|
267
|
+
clearTimeout(flushTimer)
|
|
268
|
+
flushTimer = null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (writeQueue.length === 0) return
|
|
272
|
+
|
|
273
|
+
// Group by session and path to avoid duplicate writes
|
|
274
|
+
const writes = new Map<string, WriteOp>()
|
|
275
|
+
for (const op of writeQueue) {
|
|
276
|
+
writes.set(op.path, op)
|
|
277
|
+
}
|
|
278
|
+
writeQueue.length = 0
|
|
279
|
+
|
|
280
|
+
// Execute writes
|
|
281
|
+
for (const op of writes.values()) {
|
|
282
|
+
try {
|
|
283
|
+
ensureSessionDirs(op.sessionID)
|
|
284
|
+
fs.writeFileSync(op.path, JSON.stringify(op.data, null, 2))
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(`[GraphStorage] Write failed: ${op.path}`, err)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Force flush all pending writes.
|
|
293
|
+
*/
|
|
294
|
+
export function flush(): void {
|
|
295
|
+
flushWrites()
|
|
296
|
+
|
|
297
|
+
// Also persist indexes
|
|
298
|
+
for (const [sessionID, idx] of invertedIndexes) {
|
|
299
|
+
try {
|
|
300
|
+
ensureSessionDirs(sessionID)
|
|
301
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "inverted.json")
|
|
302
|
+
fs.writeFileSync(idxPath, JSON.stringify(idx.toJSON(), null, 2))
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const [sessionID, idx] of typeIndexes) {
|
|
307
|
+
try {
|
|
308
|
+
ensureSessionDirs(sessionID)
|
|
309
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "types.json")
|
|
310
|
+
fs.writeFileSync(idxPath, JSON.stringify(idx, null, 2))
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [sessionID, idx] of entityIndexes) {
|
|
315
|
+
try {
|
|
316
|
+
ensureSessionDirs(sessionID)
|
|
317
|
+
const idxPath = path.join(getSessionDir(sessionID), "indexes", "entities.json")
|
|
318
|
+
fs.writeFileSync(idxPath, JSON.stringify(idx, null, 2))
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ========================================================================
|
|
324
|
+
// NODE OPERATIONS
|
|
325
|
+
// ========================================================================
|
|
326
|
+
|
|
327
|
+
export function addNode(node: GraphNode): void {
|
|
328
|
+
const cache = getNodeCache(node.sessionID)
|
|
329
|
+
cache.set(node.id, node)
|
|
330
|
+
|
|
331
|
+
// Update inverted index
|
|
332
|
+
const invIdx = getInvertedIndex(node.sessionID)
|
|
333
|
+
invIdx.add(node.id, node.content)
|
|
334
|
+
|
|
335
|
+
// Update type index
|
|
336
|
+
const typeIdx = getTypeIndex(node.sessionID)
|
|
337
|
+
if (!typeIdx[node.type]) typeIdx[node.type] = []
|
|
338
|
+
if (!typeIdx[node.type].includes(node.id)) {
|
|
339
|
+
typeIdx[node.type].push(node.id)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Update entity index
|
|
343
|
+
if (node.type === NodeType.ENTITY) {
|
|
344
|
+
const entIdx = getEntityIndex(node.sessionID)
|
|
345
|
+
if (!entIdx[node.content]) entIdx[node.content] = []
|
|
346
|
+
if (!entIdx[node.content].includes(node.id)) {
|
|
347
|
+
entIdx[node.content].push(node.id)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Queue write
|
|
352
|
+
const nodePath = path.join(getSessionDir(node.sessionID), "nodes", `${node.id}.json`)
|
|
353
|
+
queueWrite({ type: "node", sessionID: node.sessionID, path: nodePath, data: node })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function getNode(sessionID: string, nodeID: string): GraphNode | null {
|
|
357
|
+
const cache = getNodeCache(sessionID)
|
|
358
|
+
|
|
359
|
+
// Check cache first
|
|
360
|
+
const cached = cache.get(nodeID)
|
|
361
|
+
if (cached) {
|
|
362
|
+
cacheHits++
|
|
363
|
+
return cached
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Load from disk
|
|
367
|
+
cacheMisses++
|
|
368
|
+
try {
|
|
369
|
+
const nodePath = path.join(getSessionDir(sessionID), "nodes", `${nodeID}.json`)
|
|
370
|
+
const node = GraphNodeSchema.parse(JSON.parse(fs.readFileSync(nodePath, "utf-8")))
|
|
371
|
+
cache.set(nodeID, node)
|
|
372
|
+
return node
|
|
373
|
+
} catch {
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function getNodesByType(
|
|
379
|
+
sessionID: string,
|
|
380
|
+
nodeType: NodeType,
|
|
381
|
+
limit: number = 100
|
|
382
|
+
): GraphNode[] {
|
|
383
|
+
const typeIdx = getTypeIndex(sessionID)
|
|
384
|
+
const nodeIDs = typeIdx[nodeType] || []
|
|
385
|
+
const nodes: GraphNode[] = []
|
|
386
|
+
|
|
387
|
+
for (const nodeID of nodeIDs.slice(0, limit)) {
|
|
388
|
+
const node = getNode(sessionID, nodeID)
|
|
389
|
+
if (node) nodes.push(node)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return nodes
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Fast keyword search using inverted index.
|
|
397
|
+
*/
|
|
398
|
+
export function searchNodes(
|
|
399
|
+
sessionID: string,
|
|
400
|
+
query: string,
|
|
401
|
+
nodeType?: NodeType,
|
|
402
|
+
limit: number = 20
|
|
403
|
+
): GraphNode[] {
|
|
404
|
+
const invIdx = getInvertedIndex(sessionID)
|
|
405
|
+
let matchingIDs = invIdx.search(query)
|
|
406
|
+
|
|
407
|
+
// Filter by type if specified
|
|
408
|
+
if (nodeType) {
|
|
409
|
+
const typeIdx = getTypeIndex(sessionID)
|
|
410
|
+
const typeIDs = new Set(typeIdx[nodeType] || [])
|
|
411
|
+
matchingIDs = new Set([...matchingIDs].filter((id) => typeIDs.has(id)))
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Get nodes
|
|
415
|
+
const results: GraphNode[] = []
|
|
416
|
+
for (const nodeID of matchingIDs) {
|
|
417
|
+
if (results.length >= limit) break
|
|
418
|
+
const node = getNode(sessionID, nodeID)
|
|
419
|
+
if (node) results.push(node)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return results
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function deleteNode(sessionID: string, nodeID: string): boolean {
|
|
426
|
+
const node = getNode(sessionID, nodeID)
|
|
427
|
+
if (!node) return false
|
|
428
|
+
|
|
429
|
+
// Remove from caches
|
|
430
|
+
const cache = getNodeCache(sessionID)
|
|
431
|
+
cache.delete(nodeID)
|
|
432
|
+
|
|
433
|
+
// Remove from inverted index
|
|
434
|
+
const invIdx = getInvertedIndex(sessionID)
|
|
435
|
+
invIdx.remove(nodeID, node.content)
|
|
436
|
+
|
|
437
|
+
// Remove from type index
|
|
438
|
+
const typeIdx = getTypeIndex(sessionID)
|
|
439
|
+
if (typeIdx[node.type]) {
|
|
440
|
+
typeIdx[node.type] = typeIdx[node.type].filter((id) => id !== nodeID)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Remove from entity index
|
|
444
|
+
if (node.type === NodeType.ENTITY) {
|
|
445
|
+
const entIdx = getEntityIndex(sessionID)
|
|
446
|
+
if (entIdx[node.content]) {
|
|
447
|
+
entIdx[node.content] = entIdx[node.content].filter((id) => id !== nodeID)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Delete file
|
|
452
|
+
try {
|
|
453
|
+
const nodePath = path.join(getSessionDir(sessionID), "nodes", `${nodeID}.json`)
|
|
454
|
+
fs.unlinkSync(nodePath)
|
|
455
|
+
} catch {}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const edgesPath = path.join(getSessionDir(sessionID), "edges", `${nodeID}.edges.json`)
|
|
459
|
+
fs.unlinkSync(edgesPath)
|
|
460
|
+
} catch {}
|
|
461
|
+
|
|
462
|
+
return true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ========================================================================
|
|
466
|
+
// EDGE OPERATIONS
|
|
467
|
+
// ========================================================================
|
|
468
|
+
|
|
469
|
+
export function addEdgeWithSession(sessionID: string, edge: GraphEdge): void {
|
|
470
|
+
const eCache = getEdgeCache(sessionID)
|
|
471
|
+
|
|
472
|
+
// Update source's outgoing
|
|
473
|
+
let sourceAdj = eCache.get(edge.sourceID)
|
|
474
|
+
if (!sourceAdj) {
|
|
475
|
+
sourceAdj = loadEdgeAdjacency(sessionID, edge.sourceID)
|
|
476
|
+
}
|
|
477
|
+
sourceAdj.outgoing.push(edge)
|
|
478
|
+
eCache.set(edge.sourceID, sourceAdj)
|
|
479
|
+
|
|
480
|
+
// Update target's incoming
|
|
481
|
+
let targetAdj = eCache.get(edge.targetID)
|
|
482
|
+
if (!targetAdj) {
|
|
483
|
+
targetAdj = loadEdgeAdjacency(sessionID, edge.targetID)
|
|
484
|
+
}
|
|
485
|
+
targetAdj.incoming.push(edge)
|
|
486
|
+
eCache.set(edge.targetID, targetAdj)
|
|
487
|
+
|
|
488
|
+
// Queue writes
|
|
489
|
+
const sourcePath = path.join(getSessionDir(sessionID), "edges", `${edge.sourceID}.edges.json`)
|
|
490
|
+
queueWrite({ type: "edge", sessionID, path: sourcePath, data: sourceAdj })
|
|
491
|
+
|
|
492
|
+
const targetPath = path.join(getSessionDir(sessionID), "edges", `${edge.targetID}.edges.json`)
|
|
493
|
+
queueWrite({ type: "edge", sessionID, path: targetPath, data: targetAdj })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function loadEdgeAdjacency(sessionID: string, nodeID: string): EdgeAdjacencyList {
|
|
497
|
+
try {
|
|
498
|
+
const edgesPath = path.join(getSessionDir(sessionID), "edges", `${nodeID}.edges.json`)
|
|
499
|
+
return JSON.parse(fs.readFileSync(edgesPath, "utf-8"))
|
|
500
|
+
} catch {
|
|
501
|
+
return { outgoing: [], incoming: [] }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function getEdges(
|
|
506
|
+
sessionID: string,
|
|
507
|
+
nodeID: string,
|
|
508
|
+
direction: "outgoing" | "incoming" | "both" = "outgoing"
|
|
509
|
+
): GraphEdge[] {
|
|
510
|
+
const eCache = getEdgeCache(sessionID)
|
|
511
|
+
let adj = eCache.get(nodeID)
|
|
512
|
+
|
|
513
|
+
if (!adj) {
|
|
514
|
+
adj = loadEdgeAdjacency(sessionID, nodeID)
|
|
515
|
+
eCache.set(nodeID, adj)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (direction === "outgoing") return adj.outgoing
|
|
519
|
+
if (direction === "incoming") return adj.incoming
|
|
520
|
+
return [...adj.outgoing, ...adj.incoming]
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function getNeighbors(
|
|
524
|
+
sessionID: string,
|
|
525
|
+
nodeID: string,
|
|
526
|
+
direction: "outgoing" | "incoming" | "both" = "outgoing",
|
|
527
|
+
relationship?: RelationType
|
|
528
|
+
): GraphNode[] {
|
|
529
|
+
const edges = getEdges(sessionID, nodeID, direction)
|
|
530
|
+
const neighborIDs = new Set<string>()
|
|
531
|
+
|
|
532
|
+
for (const edge of edges) {
|
|
533
|
+
if (relationship && edge.relationship !== relationship) continue
|
|
534
|
+
const neighborID = edge.sourceID === nodeID ? edge.targetID : edge.sourceID
|
|
535
|
+
neighborIDs.add(neighborID)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const neighbors: GraphNode[] = []
|
|
539
|
+
for (const neighborID of neighborIDs) {
|
|
540
|
+
const node = getNode(sessionID, neighborID)
|
|
541
|
+
if (node) neighbors.push(node)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return neighbors
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ========================================================================
|
|
548
|
+
// SESSION OPERATIONS
|
|
549
|
+
// ========================================================================
|
|
550
|
+
|
|
551
|
+
export function clearSession(sessionID: string): void {
|
|
552
|
+
// Clear caches
|
|
553
|
+
nodeCache.delete(sessionID)
|
|
554
|
+
edgeCache.delete(sessionID)
|
|
555
|
+
invertedIndexes.delete(sessionID)
|
|
556
|
+
typeIndexes.delete(sessionID)
|
|
557
|
+
entityIndexes.delete(sessionID)
|
|
558
|
+
|
|
559
|
+
// Delete directory
|
|
560
|
+
try {
|
|
561
|
+
fs.rmSync(getSessionDir(sessionID), { recursive: true, force: true })
|
|
562
|
+
} catch {}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function getSessions(): string[] {
|
|
566
|
+
if (!baseDir) return []
|
|
567
|
+
try {
|
|
568
|
+
return fs.readdirSync(baseDir).filter((name) => {
|
|
569
|
+
const stat = fs.statSync(path.join(baseDir!, name))
|
|
570
|
+
return stat.isDirectory()
|
|
571
|
+
})
|
|
572
|
+
} catch {
|
|
573
|
+
return []
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function getStats(sessionID: string): {
|
|
578
|
+
nodeCount: number
|
|
579
|
+
edgeCount: number
|
|
580
|
+
nodesByType: Record<string, number>
|
|
581
|
+
cacheStats: { hits: number; misses: number; hitRate: string }
|
|
582
|
+
} {
|
|
583
|
+
const typeIdx = getTypeIndex(sessionID)
|
|
584
|
+
let nodeCount = 0
|
|
585
|
+
const nodesByType: Record<string, number> = {}
|
|
586
|
+
|
|
587
|
+
for (const [type, ids] of Object.entries(typeIdx)) {
|
|
588
|
+
nodesByType[type] = ids.length
|
|
589
|
+
nodeCount += ids.length
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let edgeCount = 0
|
|
593
|
+
try {
|
|
594
|
+
const edgesDir = path.join(getSessionDir(sessionID), "edges")
|
|
595
|
+
for (const file of fs.readdirSync(edgesDir)) {
|
|
596
|
+
const adj = JSON.parse(
|
|
597
|
+
fs.readFileSync(path.join(edgesDir, file), "utf-8")
|
|
598
|
+
) as EdgeAdjacencyList
|
|
599
|
+
edgeCount += adj.outgoing.length
|
|
600
|
+
}
|
|
601
|
+
} catch {}
|
|
602
|
+
|
|
603
|
+
const total = cacheHits + cacheMisses
|
|
604
|
+
const hitRate = total > 0 ? ((cacheHits / total) * 100).toFixed(1) + "%" : "N/A"
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
nodeCount,
|
|
608
|
+
edgeCount,
|
|
609
|
+
nodesByType,
|
|
610
|
+
cacheStats: { hits: cacheHits, misses: cacheMisses, hitRate },
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Get chunks that mention an entity.
|
|
616
|
+
*/
|
|
617
|
+
export function getChunksForEntity(sessionID: string, entityName: string): GraphNode[] {
|
|
618
|
+
const entIdx = getEntityIndex(sessionID)
|
|
619
|
+
const entityNodeIDs = entIdx[entityName] || []
|
|
620
|
+
const chunkIDs = new Set<string>()
|
|
621
|
+
|
|
622
|
+
for (const entityNodeID of entityNodeIDs) {
|
|
623
|
+
const edges = getEdges(sessionID, entityNodeID, "incoming")
|
|
624
|
+
for (const edge of edges) {
|
|
625
|
+
if (edge.relationship === RelationType.MENTIONS) {
|
|
626
|
+
chunkIDs.add(edge.sourceID)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const chunks: GraphNode[] = []
|
|
632
|
+
for (const chunkID of chunkIDs) {
|
|
633
|
+
const node = getNode(sessionID, chunkID)
|
|
634
|
+
if (node) chunks.push(node)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return chunks
|
|
638
|
+
}
|
|
639
|
+
}
|