@levalicious/server-memory 0.0.14 → 0.0.16

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
@@ -1,6 +1,25 @@
1
1
  # Knowledge Graph Memory Server
2
2
 
3
- A basic implementation of persistent memory using a local knowledge graph. This lets Claude remember information about the user across chats.
3
+ A persistent knowledge graph with binary storage, PageRank-based ranking, and Maximum Entropy Random Walk (MERW) exploration. Designed as an MCP server for use with LLM agents.
4
+
5
+ ## Storage Format
6
+
7
+ The knowledge graph is stored in two binary files using a custom mmap-backed arena allocator:
8
+
9
+ - **`<base>.graph`** — Entity records (72 bytes each), adjacency blocks, and node log
10
+ - **`<base>.strings`** — Interned, refcounted string table
11
+
12
+ This replaces the original JSONL format. The binary format supports O(1) entity lookup, POSIX file locking for concurrent access, and in-place mutation without rewriting the entire file.
13
+
14
+ > [!NOTE]
15
+ > **Migrating from JSONL:** If you have an existing `.json` knowledge graph, use the migration script:
16
+ > ```sh
17
+ > npx tsx scripts/migrate-jsonl.ts [path/to/memory.json]
18
+ > ```
19
+ > The original `.json` file is preserved. See also `scripts/verify-migration.ts` to validate the result. The `MEMORY_FILE_PATH` does not need to change.
20
+
21
+ > [!NOTE]
22
+ > **Automatic v1→v2 migration:** Graph files using the v1 format (64-byte entity records) are automatically migrated to v2 (72-byte records with MERW ψ field) on first open. The old file is preserved as `<name>.graph.v1`.
4
23
 
5
24
  ## Core Concepts
6
25
 
@@ -53,6 +72,27 @@ Example:
53
72
  }
54
73
  ```
55
74
 
75
+ ### Ranking
76
+
77
+ Two ranking systems are maintained and updated after every graph mutation:
78
+
79
+ - **PageRank (`pagerank`)** — Structural importance via Monte Carlo random walks on graph topology (Avrachenkov et al. Algorithm 4). Each mutation triggers a full sampling pass.
80
+ - **LLM Rank (`llmrank`)** — Walker visit counts that track which nodes the LLM actually opens/searches. Primary sort for `llmrank` is walker visits, with PageRank as tiebreaker.
81
+
82
+ ### Maximum Entropy Random Walk (MERW)
83
+
84
+ The `random_walk` tool uses MERW rather than a standard uniform random walk. MERW maximizes the global entropy rate by sampling uniformly among all paths in the graph, rather than locally maximizing entropy at each vertex.
85
+
86
+ Transition probabilities are computed from the dominant eigenvector ψ of the (damped) adjacency matrix:
87
+
88
+ ```
89
+ S_ij = (A_ij / λ) · (ψ_j / ψ_i)
90
+ ```
91
+
92
+ The eigenvector is computed via sparse power iteration with teleportation damping (α=0.85), warm-started from the previously stored ψ values. After a small graph mutation, convergence typically requires only 2–5 iterations rather than a full cold start.
93
+
94
+ **Practical effect:** Walks gravitate toward structurally rich regions of the graph rather than wandering down linear chains, making serendipitous exploration more productive.
95
+
56
96
  ## API
57
97
 
58
98
  ### Tools
@@ -110,51 +150,51 @@ Example:
110
150
  - Search for nodes using a regex pattern
111
151
  - Input:
112
152
  - `query` (string): Regex pattern to search
113
- - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
114
- - `sortDir` (string, optional): Sort direction ("asc" or "desc")
115
- - `entityCursor` (number, optional), `relationCursor` (number, optional)
153
+ - `sortBy` (string, optional): Sort field (`mtime`, `obsMtime`, `name`, `pagerank`, `llmrank`). Default: `llmrank`
154
+ - `sortDir` (string, optional): Sort direction (`asc` or `desc`)
155
+ - `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
156
+ - `entityCursor`, `relationCursor` (number, optional): Pagination cursors
116
157
  - Searches across entity names, types, and observation content
117
158
  - Returns matching entities and their relations (paginated)
118
159
 
119
- - **open_nodes_filtered**
120
- - Retrieve specific nodes by name with filtered relations
121
- - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
122
- - Returns:
123
- - Requested entities
124
- - Only relations where both endpoints are in the requested set
125
- - Silently skips non-existent nodes (paginated)
126
-
127
160
  - **open_nodes**
128
161
  - Retrieve specific nodes by name
129
- - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
130
- - Returns:
131
- - Requested entities
132
- - Relations originating from requested entities
133
- - Silently skips non-existent nodes (paginated)
162
+ - Input:
163
+ - `names` (string[]): Entity names to retrieve
164
+ - `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
165
+ - `entityCursor`, `relationCursor` (number, optional): Pagination cursors
166
+ - Returns requested entities and relations originating from them (paginated)
167
+ - Silently skips non-existent nodes
134
168
 
135
169
  - **get_neighbors**
136
170
  - Get names of neighboring entities connected to a specific entity within a given depth
137
171
  - Input:
138
172
  - `entityName` (string): The entity to find neighbors for
139
173
  - `depth` (number, default: 1): Maximum traversal depth
140
- - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
141
- - `sortDir` (string, optional): Sort direction ("asc" or "desc")
174
+ - `sortBy` (string, optional): Sort field (`mtime`, `obsMtime`, `name`, `pagerank`, `llmrank`). Default: `llmrank`
175
+ - `sortDir` (string, optional): Sort direction (`asc` or `desc`)
176
+ - `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
142
177
  - `cursor` (number, optional): Pagination cursor
143
178
  - Returns neighbor names with timestamps (paginated)
144
179
  - Use `open_nodes` to get full entity data for neighbors
145
180
 
146
181
  - **find_path**
147
182
  - Find a path between two entities in the knowledge graph
148
- - Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5), `cursor` (number, optional)
183
+ - Input:
184
+ - `fromEntity` (string): Starting entity
185
+ - `toEntity` (string): Target entity
186
+ - `maxDepth` (number, default: 5): Maximum search depth
187
+ - `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
188
+ - `cursor` (number, optional): Pagination cursor
149
189
  - Returns path between entities if one exists (paginated)
150
190
 
151
191
  - **get_entities_by_type**
152
192
  - Get all entities of a specific type
153
193
  - Input:
154
194
  - `entityType` (string): Type to filter by
155
- - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
156
- - `sortDir` (string, optional): Sort direction ("asc" or "desc")
157
- - `cursor` (number, optional)
195
+ - `sortBy` (string, optional): Sort field (`mtime`, `obsMtime`, `name`, `pagerank`, `llmrank`). Default: `llmrank`
196
+ - `sortDir` (string, optional): Sort direction (`asc` or `desc`)
197
+ - `cursor` (number, optional): Pagination cursor
158
198
  - Returns all entities matching the specified type (paginated)
159
199
 
160
200
  - **get_entity_types**
@@ -176,9 +216,9 @@ Example:
176
216
  - Get entities that have no relations (orphaned entities)
177
217
  - Input:
178
218
  - `strict` (boolean, default: false): If true, returns entities not connected to 'Self' entity
179
- - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
180
- - `sortDir` (string, optional): Sort direction ("asc" or "desc")
181
- - `cursor` (number, optional)
219
+ - `sortBy` (string, optional): Sort field (`mtime`, `obsMtime`, `name`, `pagerank`, `llmrank`). Default: `llmrank`
220
+ - `sortDir` (string, optional): Sort direction (`asc` or `desc`)
221
+ - `cursor` (number, optional): Pagination cursor
182
222
  - Returns entities with no connections (paginated)
183
223
 
184
224
  - **validate_graph**
@@ -195,13 +235,15 @@ Example:
195
235
  - Useful for interpreting `mtime`/`obsMtime` values from entities
196
236
 
197
237
  - **random_walk**
198
- - Perform a random walk from a starting entity, following random relations
238
+ - Perform a MERW-weighted random walk from a starting entity
199
239
  - Input:
200
240
  - `start` (string): Name of the entity to start the walk from
201
241
  - `depth` (number, default: 3): Number of hops to take
202
242
  - `seed` (string, optional): Seed for reproducible walks
243
+ - `direction` (string, optional): Edge direction filter (`forward`, `backward`, `any`). Default: `forward`
244
+ - Neighbors are selected proportional to their MERW eigenvector component ψ
245
+ - Falls back to uniform sampling if ψ has not been computed
203
246
  - Returns the terminal entity name and the path taken
204
- - Useful for serendipitous exploration of the knowledge graph
205
247
 
206
248
  - **sequentialthinking**
207
249
  - Record a thought in the knowledge graph
@@ -209,6 +251,15 @@ Example:
209
251
  - Creates a Thought entity and links it to the previous thought if provided
210
252
  - Returns the new thought's context ID for chaining
211
253
 
254
+ - **kb_load**
255
+ - Load a plaintext document into the knowledge graph
256
+ - Input:
257
+ - `filePath` (string): Absolute path to a plaintext file (`.txt`, `.md`, `.tex`, source code, etc.)
258
+ - `title` (string, optional): Document title. Defaults to filename without extension
259
+ - `topK` (number, optional): Number of top TextRank sentences to highlight in the index. Default: 15
260
+ - Creates a doubly-linked chain of TextChunk entities, a Document entity, and a DocumentIndex with TextRank-selected entry points
261
+ - For PDFs, convert to text first (e.g., `pdftotext`)
262
+
212
263
  # Usage with Claude Desktop
213
264
 
214
265
  ### Setup
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * delete-document.ts — Remove a kb_load-style document and its TextChunk chain
4
+ * from the binary knowledge graph.
5
+ *
6
+ * Usage:
7
+ * MEMORY_FILE_PATH=~/.local/share/memory/vscode.json npx tsx scripts/delete-document.ts <document-entity-name> [--live]
8
+ *
9
+ * Without --live, runs in dry-run mode: walks the chain, counts chunks, prints
10
+ * what would be deleted, but does not mutate anything.
11
+ *
12
+ * With --live, actually deletes the document entity, the index entity (if any),
13
+ * and every TextChunk in the chain.
14
+ */
15
+ import { KnowledgeGraphManager } from '../server.js';
16
+ const DOC_NAME = process.argv[2];
17
+ const LIVE = process.argv.includes('--live');
18
+ const BATCH_SIZE = 200;
19
+ if (!DOC_NAME) {
20
+ console.error('Usage: npx tsx scripts/delete-document.ts <document-entity-name> [--live]');
21
+ process.exit(1);
22
+ }
23
+ const memoryFilePath = process.env.MEMORY_FILE_PATH ?? `${process.env.HOME}/.local/share/memory/vscode.json`;
24
+ console.log(`Opening graph at: ${memoryFilePath}`);
25
+ console.log(`Mode: ${LIVE ? '🔴 LIVE — will delete' : '🟢 DRY RUN — read only'}`);
26
+ console.log();
27
+ const mgr = new KnowledgeGraphManager(memoryFilePath);
28
+ // ── Step 1: Open the document node, find starts_with target ──────────
29
+ const docGraph = await mgr.openNodes([DOC_NAME], 'forward');
30
+ const docEntity = docGraph.entities.find(e => e.name === DOC_NAME);
31
+ if (!docEntity) {
32
+ console.error(`Entity "${DOC_NAME}" not found.`);
33
+ process.exit(1);
34
+ }
35
+ console.log(`Found document: "${DOC_NAME}" (type: ${docEntity.entityType})`);
36
+ const startsWithRel = docGraph.relations.find(r => r.relationType === 'starts_with');
37
+ if (!startsWithRel) {
38
+ console.error(`No "starts_with" relation found on "${DOC_NAME}". Is this a kb_load document?`);
39
+ process.exit(1);
40
+ }
41
+ const headChunkName = startsWithRel.to;
42
+ console.log(`Head chunk: ${headChunkName}`);
43
+ // ── Step 2: Walk the chain via "follows" relations ───────────────────
44
+ const toDelete = [];
45
+ let currentName = headChunkName;
46
+ let visited = 0;
47
+ while (currentName) {
48
+ toDelete.push(currentName);
49
+ visited++;
50
+ if (visited % 500 === 0) {
51
+ process.stdout.write(` … walked ${visited} chunks\r`);
52
+ }
53
+ // Find the "follows" relation from this chunk
54
+ const chunkGraph = await mgr.openNodes([currentName], 'forward');
55
+ const followsRel = chunkGraph.relations.find(r => r.relationType === 'follows');
56
+ currentName = followsRel ? followsRel.to : '';
57
+ }
58
+ console.log(`\nChain walk complete: ${toDelete.length} TextChunks found.`);
59
+ // ── Step 3: Check for an index entity ────────────────────────────────
60
+ const indexName = `${DOC_NAME}__index`;
61
+ const indexGraph = await mgr.openNodes([indexName], 'forward');
62
+ const indexEntity = indexGraph.entities.find(e => e.name === indexName);
63
+ const extraDeletes = [DOC_NAME];
64
+ if (indexEntity) {
65
+ extraDeletes.push(indexName);
66
+ console.log(`Index entity found: "${indexName}"`);
67
+ }
68
+ else {
69
+ console.log(`No index entity "${indexName}" found (old-style import).`);
70
+ }
71
+ const totalDeletes = extraDeletes.length + toDelete.length;
72
+ console.log(`\nTotal entities to delete: ${totalDeletes} (${extraDeletes.length} header + ${toDelete.length} chunks)`);
73
+ if (!LIVE) {
74
+ console.log('\n✅ Dry run complete. Re-run with --live to actually delete.');
75
+ process.exit(0);
76
+ }
77
+ // ── Step 4: Delete in batches ────────────────────────────────────────
78
+ console.log(`\nDeleting ${totalDeletes} entities in batches of ${BATCH_SIZE}...`);
79
+ // Delete chunks first (the bulk), then the header entities
80
+ let deleted = 0;
81
+ for (let i = 0; i < toDelete.length; i += BATCH_SIZE) {
82
+ const batch = toDelete.slice(i, i + BATCH_SIZE);
83
+ await mgr.deleteEntities(batch);
84
+ deleted += batch.length;
85
+ process.stdout.write(` Deleted ${deleted}/${toDelete.length} chunks\r`);
86
+ }
87
+ console.log(`\n Chunks done.`);
88
+ // Delete document + index
89
+ await mgr.deleteEntities(extraDeletes);
90
+ console.log(` Deleted document header${indexEntity ? ' + index' : ''}.`);
91
+ console.log(`\n🔴 Done. Removed ${totalDeletes} entities from the graph.`);
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url';
8
8
  import { GraphFile, DIR_FORWARD, DIR_BACKWARD } from './src/graphfile.js';
9
9
  import { StringTable } from './src/stringtable.js';
10
10
  import { structuralSample } from './src/pagerank.js';
11
+ import { computeMerwPsi } from './src/merw.js';
11
12
  import { validateExtension, loadDocument } from './src/kb_load.js';
12
13
  // Define memory file path using environment variable with fallback
13
14
  const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
@@ -100,7 +101,7 @@ function sortNeighbors(neighbors, sortBy = "llmrank", sortDir, rankMaps) {
100
101
  return mult * (aVal - bVal);
101
102
  });
102
103
  }
103
- export const MAX_CHARS = 2048;
104
+ export const MAX_CHARS = 16384;
104
105
  function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
105
106
  const result = [];
106
107
  let i = cursor;
@@ -127,54 +128,13 @@ function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
127
128
  };
128
129
  }
129
130
  function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
130
- // Build incrementally, measuring actual serialized size
131
- const entityCount = graph.entities.length;
132
- const relationCount = graph.relations.length;
133
- // Start with empty result to measure base overhead
134
- const emptyResult = {
135
- entities: { items: [], nextCursor: null, totalCount: entityCount },
136
- relations: { items: [], nextCursor: null, totalCount: relationCount }
137
- };
138
- let currentSize = JSON.stringify(emptyResult).length;
139
- const resultEntities = [];
140
- const resultRelations = [];
141
- let entityIdx = entityCursor;
142
- let relationIdx = relationCursor;
143
- // Add entities until we hit the limit
144
- while (entityIdx < graph.entities.length) {
145
- const entity = graph.entities[entityIdx];
146
- const entityJson = JSON.stringify(entity);
147
- const addedChars = entityJson.length + (resultEntities.length > 0 ? 1 : 0);
148
- if (currentSize + addedChars > MAX_CHARS) {
149
- break;
150
- }
151
- resultEntities.push(entity);
152
- currentSize += addedChars;
153
- entityIdx++;
154
- }
155
- // Add relations with remaining space
156
- while (relationIdx < graph.relations.length) {
157
- const relation = graph.relations[relationIdx];
158
- const relationJson = JSON.stringify(relation);
159
- const addedChars = relationJson.length + (resultRelations.length > 0 ? 1 : 0);
160
- if (currentSize + addedChars > MAX_CHARS) {
161
- break;
162
- }
163
- resultRelations.push(relation);
164
- currentSize += addedChars;
165
- relationIdx++;
166
- }
131
+ // Entities and relations have independent cursors, so paginate them
132
+ // independently each gets the full budget. The caller already has
133
+ // previously-returned pages and only needs the next page of whichever
134
+ // section it is advancing.
167
135
  return {
168
- entities: {
169
- items: resultEntities,
170
- nextCursor: entityIdx < graph.entities.length ? entityIdx : null,
171
- totalCount: entityCount
172
- },
173
- relations: {
174
- items: resultRelations,
175
- nextCursor: relationIdx < graph.relations.length ? relationIdx : null,
176
- totalCount: relationCount
177
- }
136
+ entities: paginateItems(graph.entities, entityCursor),
137
+ relations: paginateItems(graph.relations, relationCursor),
178
138
  };
179
139
  }
180
140
  // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
@@ -195,9 +155,10 @@ export class KnowledgeGraphManager {
195
155
  this.gf = new GraphFile(graphPath, this.st);
196
156
  this.nameIndex = new Map();
197
157
  this.rebuildNameIndex();
198
- // Run initial structural sampling if graph is non-empty
158
+ // Run initial structural sampling and MERW if graph is non-empty
199
159
  if (this.nameIndex.size > 0) {
200
160
  structuralSample(this.gf, 1, 0.85);
161
+ computeMerwPsi(this.gf);
201
162
  this.gf.sync();
202
163
  }
203
164
  }
@@ -297,11 +258,12 @@ export class KnowledgeGraphManager {
297
258
  }
298
259
  });
299
260
  }
300
- /** Re-run structural sampling (call after graph mutations) */
261
+ /** Re-run structural sampling and MERW eigenvector computation (call after graph mutations) */
301
262
  resample() {
302
263
  this.withWriteLock(() => {
303
264
  if (this.nameIndex.size > 0) {
304
265
  structuralSample(this.gf, 1, 0.85);
266
+ computeMerwPsi(this.gf);
305
267
  }
306
268
  });
307
269
  }
@@ -827,7 +789,7 @@ export class KnowledgeGraphManager {
827
789
  if (!offset)
828
790
  break;
829
791
  const edges = this.gf.getEdges(offset);
830
- const validNeighbors = new Set();
792
+ const candidates = [];
831
793
  for (const edge of edges) {
832
794
  if (direction === 'forward' && edge.direction !== DIR_FORWARD)
833
795
  continue;
@@ -835,14 +797,46 @@ export class KnowledgeGraphManager {
835
797
  continue;
836
798
  const targetRec = this.gf.readEntity(edge.targetOffset);
837
799
  const neighborName = this.st.get(BigInt(targetRec.nameId));
838
- if (neighborName !== current)
839
- validNeighbors.add(neighborName);
800
+ if (neighborName !== current && this.nameIndex.has(neighborName)) {
801
+ candidates.push({ name: neighborName, psi: targetRec.psi });
802
+ }
803
+ }
804
+ // Deduplicate: keep max psi per name (multiple edge types to same target)
805
+ const byName = new Map();
806
+ for (const c of candidates) {
807
+ const existing = byName.get(c.name);
808
+ if (existing === undefined || c.psi > existing) {
809
+ byName.set(c.name, c.psi);
810
+ }
840
811
  }
841
- const neighborArr = Array.from(validNeighbors).filter(n => this.nameIndex.has(n));
842
- if (neighborArr.length === 0)
812
+ if (byName.size === 0)
843
813
  break;
844
- const idx = Math.floor(random() * neighborArr.length);
845
- current = neighborArr[idx];
814
+ const neighborArr = Array.from(byName.entries());
815
+ // MERW-weighted sampling: probability proportional to ψ_j
816
+ // (The ψ_i denominator is constant for all neighbors and cancels in normalization)
817
+ let totalPsi = 0;
818
+ for (const [, psi] of neighborArr)
819
+ totalPsi += psi;
820
+ let chosen;
821
+ if (totalPsi > 0) {
822
+ // Weighted sampling by psi
823
+ const r = random() * totalPsi;
824
+ let cumulative = 0;
825
+ chosen = neighborArr[neighborArr.length - 1][0]; // fallback
826
+ for (const [name, psi] of neighborArr) {
827
+ cumulative += psi;
828
+ if (r <= cumulative) {
829
+ chosen = name;
830
+ break;
831
+ }
832
+ }
833
+ }
834
+ else {
835
+ // psi not yet computed (all zero) — fall back to uniform
836
+ const idx = Math.floor(random() * neighborArr.length);
837
+ chosen = neighborArr[idx][0];
838
+ }
839
+ current = chosen;
846
840
  pathNames.push(current);
847
841
  }
848
842
  return { entity: current, path: pathNames };
@@ -959,7 +953,7 @@ export function createServer(memoryFilePath) {
959
953
  sizes: ["any"]
960
954
  }
961
955
  ],
962
- version: "0.0.14",
956
+ version: "0.0.16",
963
957
  }, {
964
958
  capabilities: {
965
959
  tools: {},
@@ -4,6 +4,11 @@
4
4
  * All records live in a MemoryFile (graph.mem). Variable-length strings are
5
5
  * stored in a separate StringTable (strings.mem) and referenced by u32 ID.
6
6
  *
7
+ * Versioning:
8
+ * MEMFILE_VERSION 1 = original 64-byte entity records (no psi field)
9
+ * MEMFILE_VERSION 2 = 72-byte entity records with f64 psi for MERW
10
+ * On open, version 1 files are migrated to version 2 automatically.
11
+ *
7
12
  * Graph file layout:
8
13
  * [memfile header: 32 bytes]
9
14
  * [graph header block: first allocation]
@@ -12,7 +17,7 @@
12
17
  * u64 walker_total total walker visits (global counter)
13
18
  * [entity records, adj blocks, node log ...]
14
19
  *
15
- * EntityRecord: 64 bytes fixed
20
+ * EntityRecord: 72 bytes fixed (v2)
16
21
  * u32 name_id string table ID
17
22
  * u32 type_id string table ID
18
23
  * u64 adj_offset offset to AdjBlock (0 = no edges)
@@ -24,6 +29,7 @@
24
29
  * u32 obs1_id string table ID (0 = empty)
25
30
  * u64 structural_visits structural PageRank visit count
26
31
  * u64 walker_visits walker PageRank visit count
32
+ * f64 psi MERW dominant eigenvector component
27
33
  *
28
34
  * AdjBlock:
29
35
  * u32 count
@@ -44,15 +50,21 @@
44
50
  * u32 capacity
45
51
  * u64 offsets[capacity]
46
52
  */
53
+ import * as fs from 'fs';
47
54
  import { MemoryFile } from './memoryfile.js';
48
55
  // --- Constants ---
49
- export const ENTITY_RECORD_SIZE = 64;
56
+ export const ENTITY_RECORD_SIZE = 72;
57
+ const OLD_ENTITY_RECORD_SIZE = 64; // v1 layout without psi
50
58
  export const ADJ_ENTRY_SIZE = 24; // 8 + 4 + 4 + 8, naturally aligned
51
59
  const ADJ_HEADER_SIZE = 8; // count:u32 + capacity:u32
52
60
  const NODE_LOG_HEADER_SIZE = 8; // count:u32 + capacity:u32
53
61
  const GRAPH_HEADER_SIZE = 24; // node_log_offset:u64 + structural_total:u64 + walker_total:u64
54
62
  const INITIAL_ADJ_CAPACITY = 4;
55
63
  const INITIAL_LOG_CAPACITY = 256;
64
+ // Graph-layer version stored in the memfile header version field
65
+ const GRAPH_VERSION_V1 = 1; // original 64-byte entity records
66
+ const GRAPH_VERSION_V2 = 2; // 72-byte entity records with f64 psi
67
+ export const CURRENT_GRAPH_VERSION = GRAPH_VERSION_V2;
56
68
  // Direction flags
57
69
  export const DIR_FORWARD = 0n;
58
70
  export const DIR_BACKWARD = 1n;
@@ -72,7 +84,8 @@ const E_OBS1_ID = 40;
72
84
  // 4 bytes pad at 44
73
85
  const E_STRUCTURAL_VISITS = 48; // u64: 48..55, 8-aligned
74
86
  const E_WALKER_VISITS = 56; // u64: 56..63, 8-aligned
75
- // total = 64
87
+ const E_PSI = 64; // f64: 64..71, 8-aligned (MERW eigenvector component)
88
+ // total = 72
76
89
  // AdjEntry field offsets (within each entry)
77
90
  const AE_TARGET_DIR = 0;
78
91
  const AE_RELTYPE_ID = 8;
@@ -107,6 +120,7 @@ export function readEntityRecord(mf, offset) {
107
120
  obs1Id: buf.readUInt32LE(E_OBS1_ID),
108
121
  structuralVisits: buf.readBigUInt64LE(E_STRUCTURAL_VISITS),
109
122
  walkerVisits: buf.readBigUInt64LE(E_WALKER_VISITS),
123
+ psi: buf.readDoubleLE(E_PSI),
110
124
  };
111
125
  }
112
126
  export function writeEntityRecord(mf, rec) {
@@ -121,6 +135,7 @@ export function writeEntityRecord(mf, rec) {
121
135
  buf.writeUInt32LE(rec.obs1Id, E_OBS1_ID);
122
136
  buf.writeBigUInt64LE(rec.structuralVisits, E_STRUCTURAL_VISITS);
123
137
  buf.writeBigUInt64LE(rec.walkerVisits, E_WALKER_VISITS);
138
+ buf.writeDoubleLE(rec.psi, E_PSI);
124
139
  mf.write(rec.offset, buf);
125
140
  }
126
141
  export function readAdjBlock(mf, adjOffset) {
@@ -174,12 +189,98 @@ export class GraphFile {
174
189
  this.st = stringTable;
175
190
  const stats = this.mf.stats();
176
191
  if (stats.allocated <= 32n) {
192
+ // Fresh DB — initialize with current version
177
193
  this.graphHeaderOffset = this.initGraphHeader();
194
+ this.mf.setVersion(CURRENT_GRAPH_VERSION);
178
195
  }
179
196
  else {
180
- // First allocation is graph header, at offset 40 (32 memfile header + 8 alloc_t header)
197
+ // Existing DB check version and migrate if needed
181
198
  this.graphHeaderOffset = 40n;
199
+ const version = this.mf.getVersion();
200
+ if (version === GRAPH_VERSION_V1) {
201
+ this.migrateV1toV2(graphPath);
202
+ }
203
+ else if (version !== CURRENT_GRAPH_VERSION) {
204
+ throw new Error(`GraphFile: unknown version ${version} (expected ${GRAPH_VERSION_V1} or ${CURRENT_GRAPH_VERSION})`);
205
+ }
206
+ }
207
+ }
208
+ /**
209
+ * Migrate a v1 graph file (64-byte entity records) to v2 (72-byte with psi).
210
+ *
211
+ * Strategy: read all entities and edges from the current (v1) file into memory,
212
+ * close it, rename it to .v1, create a fresh file, write everything back with
213
+ * the new layout.
214
+ */
215
+ migrateV1toV2(graphPath) {
216
+ // 1. Read all entities and adjacency data from v1 layout
217
+ const offsets = this.getAllEntityOffsets();
218
+ const oldEntities = [];
219
+ for (const offset of offsets) {
220
+ // Read v1 entity (64 bytes) — the first 64 bytes match our struct minus psi
221
+ const buf = this.mf.read(offset, BigInt(OLD_ENTITY_RECORD_SIZE));
222
+ const rec = {
223
+ offset,
224
+ nameId: buf.readUInt32LE(E_NAME_ID),
225
+ typeId: buf.readUInt32LE(E_TYPE_ID),
226
+ adjOffset: buf.readBigUInt64LE(E_ADJ_OFFSET),
227
+ mtime: buf.readBigUInt64LE(E_MTIME),
228
+ obsMtime: buf.readBigUInt64LE(E_OBS_MTIME),
229
+ obsCount: buf.readUInt8(E_OBS_COUNT),
230
+ obs0Id: buf.readUInt32LE(E_OBS0_ID),
231
+ obs1Id: buf.readUInt32LE(E_OBS1_ID),
232
+ structuralVisits: buf.readBigUInt64LE(E_STRUCTURAL_VISITS),
233
+ walkerVisits: buf.readBigUInt64LE(E_WALKER_VISITS),
234
+ psi: 0,
235
+ };
236
+ const edges = rec.adjOffset !== 0n ? readAdjBlock(this.mf, rec.adjOffset).entries : [];
237
+ oldEntities.push({ rec, edges });
238
+ }
239
+ // 2. Read global counters
240
+ const structuralTotal = this.getStructuralTotal();
241
+ const walkerTotal = this.getWalkerTotal();
242
+ // 3. Close old file, rename to .v1 backup
243
+ this.mf.sync();
244
+ this.mf.close();
245
+ const backupPath = graphPath + '.v1';
246
+ fs.renameSync(graphPath, backupPath);
247
+ // 4. Create fresh v2 file
248
+ this.mf = new MemoryFile(graphPath, 65536);
249
+ this.mf.setVersion(CURRENT_GRAPH_VERSION);
250
+ this.graphHeaderOffset = this.initGraphHeader();
251
+ // 5. Write global counters
252
+ const ghBuf = Buffer.alloc(8);
253
+ ghBuf.writeBigUInt64LE(structuralTotal, 0);
254
+ this.mf.write(this.graphHeaderOffset + BigInt(GH_STRUCTURAL_TOTAL), ghBuf);
255
+ ghBuf.writeBigUInt64LE(walkerTotal, 0);
256
+ this.mf.write(this.graphHeaderOffset + BigInt(GH_WALKER_TOTAL), ghBuf);
257
+ // 6. Write all entities with new 72-byte layout, building offset remap
258
+ const offsetMap = new Map(); // old offset → new offset
259
+ for (const { rec } of oldEntities) {
260
+ const newOffset = this.mf.alloc(BigInt(ENTITY_RECORD_SIZE));
261
+ if (newOffset === 0n)
262
+ throw new Error('GraphFile migration: entity alloc failed');
263
+ offsetMap.set(rec.offset, newOffset);
264
+ const newRec = { ...rec, offset: newOffset, adjOffset: 0n, psi: 0 };
265
+ writeEntityRecord(this.mf, newRec);
266
+ this.nodeLogAppend(newOffset);
267
+ }
268
+ // 7. Rebuild adjacency blocks with remapped target offsets
269
+ for (const { rec, edges } of oldEntities) {
270
+ const newOffset = offsetMap.get(rec.offset);
271
+ for (const edge of edges) {
272
+ const newTarget = offsetMap.get(edge.targetOffset);
273
+ if (newTarget === undefined)
274
+ continue; // skip dangling refs
275
+ this.addEdge(newOffset, {
276
+ targetOffset: newTarget,
277
+ direction: edge.direction,
278
+ relTypeId: edge.relTypeId,
279
+ mtime: edge.mtime,
280
+ });
281
+ }
182
282
  }
283
+ this.mf.sync();
183
284
  }
184
285
  initGraphHeader() {
185
286
  // Allocate graph header block
@@ -233,6 +334,7 @@ export class GraphFile {
233
334
  obs1Id: 0,
234
335
  structuralVisits: 0n,
235
336
  walkerVisits: 0n,
337
+ psi: 0,
236
338
  };
237
339
  writeEntityRecord(this.mf, rec);
238
340
  this.nodeLogAppend(offset);
@@ -534,6 +636,18 @@ export class GraphFile {
534
636
  const rec = this.readEntity(entityOffset);
535
637
  return Number(rec.walkerVisits) / Number(total);
536
638
  }
639
+ // --- MERW eigenvector ---
640
+ /** Write the psi (MERW eigenvector component) for an entity. */
641
+ setPsi(entityOffset, psi) {
642
+ const buf = Buffer.alloc(8);
643
+ buf.writeDoubleLE(psi, 0);
644
+ this.mf.write(entityOffset + BigInt(E_PSI), buf);
645
+ }
646
+ /** Read the psi (MERW eigenvector component) for an entity. */
647
+ getPsi(entityOffset) {
648
+ const buf = this.mf.read(entityOffset + BigInt(E_PSI), 8n);
649
+ return buf.readDoubleLE(0);
650
+ }
537
651
  // --- Lifecycle & Concurrency ---
538
652
  /** Acquire a shared (read) lock on the graph file. */
539
653
  lockShared() {
@@ -115,6 +115,23 @@ export class MemoryFile {
115
115
  this.assertOpen();
116
116
  return native.stats(this.handle);
117
117
  }
118
+ /**
119
+ * Read the memfile version field (u32 at offset 4).
120
+ */
121
+ getVersion() {
122
+ this.assertOpen();
123
+ const buf = native.read(this.handle, 4n, 4n);
124
+ return buf.readUInt32LE(0);
125
+ }
126
+ /**
127
+ * Write the memfile version field (u32 at offset 4).
128
+ */
129
+ setVersion(version) {
130
+ this.assertOpen();
131
+ const buf = Buffer.alloc(4);
132
+ buf.writeUInt32LE(version, 0);
133
+ native.write(this.handle, 4n, buf);
134
+ }
118
135
  /**
119
136
  * Close the memory file. Syncs and unmaps.
120
137
  * The instance is unusable after this.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Maximum Entropy Random Walk (MERW) — dominant eigenvector computation
3
+ * via power iteration on the graph's adjacency matrix.
4
+ *
5
+ * MERW transition probabilities: S_ij = (A_ij / λ) * (ψ_j / ψ_i)
6
+ * Stationary distribution: ρ_i = ψ_i² / ‖ψ‖₂²
7
+ *
8
+ * We compute ψ (the dominant right eigenvector of A) using sparse power
9
+ * iteration directly on the GraphFile adjacency lists. No dense matrix
10
+ * is ever constructed.
11
+ *
12
+ * For directed graphs that may not be strongly connected, we add
13
+ * teleportation damping (like PageRank): at each step, follow an edge
14
+ * with probability `alpha`, or jump to a uniform random node with
15
+ * probability `(1 - alpha)`. This guarantees convergence to a unique
16
+ * positive eigenvector.
17
+ */
18
+ import { DIR_FORWARD } from './graphfile.js';
19
+ const DEFAULT_ALPHA = 0.85;
20
+ const DEFAULT_MAX_ITER = 200;
21
+ const DEFAULT_TOL = 1e-8;
22
+ /**
23
+ * Compute the dominant eigenvector of the (damped) adjacency matrix
24
+ * via power iteration and write ψ_i into each entity record.
25
+ *
26
+ * Warm-starts from the ψ values already stored in the entity records.
27
+ * New nodes (psi === 0) are seeded with the mean of existing values.
28
+ * On a fresh graph (all zeros), falls back to uniform initialization.
29
+ *
30
+ * @param gf GraphFile to operate on
31
+ * @param alpha Damping factor (probability of following an edge). Default 0.85.
32
+ * @param maxIter Maximum iterations. Default 200.
33
+ * @param tol Convergence tolerance (L2 norm of change). Default 1e-8.
34
+ * @returns Number of iterations performed.
35
+ */
36
+ export function computeMerwPsi(gf, alpha = DEFAULT_ALPHA, maxIter = DEFAULT_MAX_ITER, tol = DEFAULT_TOL) {
37
+ const offsets = gf.getAllEntityOffsets();
38
+ const n = offsets.length;
39
+ if (n === 0)
40
+ return 0;
41
+ // Build offset → index map for O(1) lookup
42
+ const indexMap = new Map();
43
+ for (let i = 0; i < n; i++) {
44
+ indexMap.set(offsets[i], i);
45
+ }
46
+ // Build sparse adjacency: for each node, list of forward neighbor indices
47
+ const adj = new Array(n);
48
+ for (let i = 0; i < n; i++) {
49
+ const edges = gf.getEdges(offsets[i]);
50
+ const neighbors = [];
51
+ for (const e of edges) {
52
+ if (e.direction !== DIR_FORWARD)
53
+ continue;
54
+ const j = indexMap.get(e.targetOffset);
55
+ if (j !== undefined)
56
+ neighbors.push(j);
57
+ }
58
+ adj[i] = neighbors;
59
+ }
60
+ // Warm-start: read existing ψ from entity records
61
+ let psi = new Float64Array(n);
62
+ let hasWarm = false;
63
+ let warmSum = 0;
64
+ let warmCount = 0;
65
+ for (let i = 0; i < n; i++) {
66
+ const val = gf.getPsi(offsets[i]);
67
+ psi[i] = val;
68
+ if (val > 0) {
69
+ hasWarm = true;
70
+ warmSum += val;
71
+ warmCount++;
72
+ }
73
+ }
74
+ if (hasWarm) {
75
+ // Seed new/zero nodes with the mean of existing nonzero values
76
+ const mean = warmSum / warmCount;
77
+ for (let i = 0; i < n; i++) {
78
+ if (psi[i] <= 0)
79
+ psi[i] = mean;
80
+ }
81
+ }
82
+ else {
83
+ // Cold start: uniform
84
+ const uniform = 1.0 / Math.sqrt(n);
85
+ psi.fill(uniform);
86
+ }
87
+ // Normalize initial vector to unit L2
88
+ let initNorm = 0;
89
+ for (let i = 0; i < n; i++)
90
+ initNorm += psi[i] * psi[i];
91
+ initNorm = Math.sqrt(initNorm);
92
+ if (initNorm > 0) {
93
+ for (let i = 0; i < n; i++)
94
+ psi[i] /= initNorm;
95
+ }
96
+ let psiNext = new Float64Array(n);
97
+ const teleport = (1.0 - alpha) / n;
98
+ let iter = 0;
99
+ for (iter = 0; iter < maxIter; iter++) {
100
+ // Matrix-vector multiply: psiNext = alpha * A * psi + (1-alpha)/n * sum(psi)
101
+ // Since ψ is normalized, sum(psi) components contribute uniformly.
102
+ // For the adjacency multiply, A_ij = 1 if edge i→j exists.
103
+ // Power iteration: psiNext_j = alpha * Σ_{i: i→j} psi_i + teleport * Σ_k psi_k
104
+ //
105
+ // We iterate over source nodes and scatter to targets.
106
+ psiNext.fill(0);
107
+ // Compute sum of psi for teleportation
108
+ let psiSum = 0;
109
+ for (let i = 0; i < n; i++)
110
+ psiSum += psi[i];
111
+ const teleportContrib = teleport * psiSum;
112
+ // Sparse multiply: scatter from sources to targets
113
+ for (let i = 0; i < n; i++) {
114
+ const neighbors = adj[i];
115
+ const val = alpha * psi[i];
116
+ for (const j of neighbors) {
117
+ psiNext[j] += val;
118
+ }
119
+ }
120
+ // Add teleportation
121
+ for (let i = 0; i < n; i++) {
122
+ psiNext[i] += teleportContrib;
123
+ }
124
+ // Normalize to unit L2
125
+ let norm = 0;
126
+ for (let i = 0; i < n; i++)
127
+ norm += psiNext[i] * psiNext[i];
128
+ norm = Math.sqrt(norm);
129
+ if (norm > 0) {
130
+ for (let i = 0; i < n; i++)
131
+ psiNext[i] /= norm;
132
+ }
133
+ // Check convergence: L2 norm of difference
134
+ let diff = 0;
135
+ for (let i = 0; i < n; i++) {
136
+ const d = psiNext[i] - psi[i];
137
+ diff += d * d;
138
+ }
139
+ diff = Math.sqrt(diff);
140
+ // Swap buffers
141
+ const tmp = psi;
142
+ psi = psiNext;
143
+ psiNext = tmp;
144
+ if (diff < tol) {
145
+ iter++;
146
+ break;
147
+ }
148
+ }
149
+ // Ensure all components are positive (Perron-Frobenius: dominant eigenvector is non-negative,
150
+ // but numerical noise can produce tiny negatives). Clamp to 0.
151
+ for (let i = 0; i < n; i++) {
152
+ if (psi[i] < 0)
153
+ psi[i] = 0;
154
+ }
155
+ // Write ψ_i into each entity record
156
+ for (let i = 0; i < n; i++) {
157
+ gf.setPsi(offsets[i], psi[i]);
158
+ }
159
+ return iter;
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -26,13 +26,11 @@
26
26
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
27
27
  },
28
28
  "dependencies": {
29
- "@modelcontextprotocol/sdk": "1.26.0",
30
- "proper-lockfile": "^4.1.2"
29
+ "@modelcontextprotocol/sdk": "1.26.0"
31
30
  },
32
31
  "devDependencies": {
33
32
  "@types/jest": "^30.0.0",
34
33
  "@types/node": "^25",
35
- "@types/proper-lockfile": "^4.1.4",
36
34
  "eslint": "^10.0.0",
37
35
  "husky": "^9.1.7",
38
36
  "jest": "^30.2.0",