@levalicious/server-memory 0.0.7 → 0.0.9

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
@@ -8,33 +8,37 @@ A basic implementation of persistent memory using a local knowledge graph. This
8
8
  Entities are the primary nodes in the knowledge graph. Each entity has:
9
9
  - A unique name (identifier)
10
10
  - An entity type (e.g., "person", "organization", "event")
11
- - A list of observations
11
+ - A list of observations (max 2, each max 140 characters)
12
+ - Modification timestamps (`mtime` for any change, `obsMtime` for observation changes)
12
13
 
13
14
  Example:
14
15
  ```json
15
16
  {
16
17
  "name": "John_Smith",
17
18
  "entityType": "person",
18
- "observations": ["Speaks fluent Spanish"]
19
+ "observations": ["Speaks fluent Spanish"],
20
+ "mtime": 1733423456789,
21
+ "obsMtime": 1733423456789
19
22
  }
20
23
  ```
21
24
 
22
25
  ### Relations
23
- Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other.
26
+ Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other. Each relation has a modification timestamp (`mtime`).
24
27
 
25
28
  Example:
26
29
  ```json
27
30
  {
28
31
  "from": "John_Smith",
29
32
  "to": "Anthropic",
30
- "relationType": "works_at"
33
+ "relationType": "works_at",
34
+ "mtime": 1733423456789
31
35
  }
32
36
  ```
33
37
  ### Observations
34
38
  Observations are discrete pieces of information about an entity. They are:
35
39
 
36
- - Stored as strings
37
- - Attached to specific entities
40
+ - Stored as strings (max 140 characters each)
41
+ - Attached to specific entities (max 2 per entity)
38
42
  - Can be added or removed independently
39
43
  - Should be atomic (one fact per observation)
40
44
 
@@ -44,8 +48,7 @@ Example:
44
48
  "entityName": "John_Smith",
45
49
  "observations": [
46
50
  "Speaks fluent Spanish",
47
- "Graduated in 2019",
48
- "Prefers morning meetings"
51
+ "Graduated in 2019"
49
52
  ]
50
53
  }
51
54
  ```
@@ -59,7 +62,7 @@ Example:
59
62
  - Each object contains:
60
63
  - `name` (string): Entity identifier
61
64
  - `entityType` (string): Type classification
62
- - `observations` (string[]): Associated observations
65
+ - `observations` (string[]): Associated observations (max 2, each max 140 chars)
63
66
  - Ignores entities with existing names
64
67
 
65
68
  - **create_relations**
@@ -76,9 +79,9 @@ Example:
76
79
  - Input: `observations` (array of objects)
77
80
  - Each object contains:
78
81
  - `entityName` (string): Target entity
79
- - `contents` (string[]): New observations to add
82
+ - `contents` (string[]): New observations to add (each max 140 chars)
80
83
  - Returns added observations per entity
81
- - Fails if entity doesn't exist
84
+ - Fails if entity doesn't exist or would exceed 2 observations
82
85
 
83
86
  - **delete_entities**
84
87
  - Remove entities and their relations
@@ -103,42 +106,45 @@ Example:
103
106
  - `relationType` (string): Relationship type
104
107
  - Silent operation if relation doesn't exist
105
108
 
106
- - **read_graph**
107
- - Read the entire knowledge graph
108
- - No input required
109
- - Returns complete graph structure with all entities and relations
110
-
111
109
  - **search_nodes**
112
- - Search for nodes based on query
113
- - Input: `query` (string)
110
+ - Search for nodes using a regex pattern
111
+ - Input: `query` (string), `entityCursor` (number, optional), `relationCursor` (number, optional)
114
112
  - Searches across:
115
113
  - Entity names
116
114
  - Entity types
117
115
  - Observation content
118
- - Returns matching entities and their relations
116
+ - Returns matching entities and their relations (paginated)
117
+
118
+ - **open_nodes_filtered**
119
+ - Retrieve specific nodes by name with filtered relations
120
+ - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
121
+ - Returns:
122
+ - Requested entities
123
+ - Only relations where both endpoints are in the requested set
124
+ - Silently skips non-existent nodes (paginated)
119
125
 
120
126
  - **open_nodes**
121
127
  - Retrieve specific nodes by name
122
- - Input: `names` (string[])
128
+ - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
123
129
  - Returns:
124
130
  - Requested entities
125
- - Relations between requested entities
126
- - Silently skips non-existent nodes
131
+ - Relations originating from requested entities
132
+ - Silently skips non-existent nodes (paginated)
127
133
 
128
134
  - **get_neighbors**
129
135
  - Get neighboring entities connected to a specific entity within a given depth
130
- - Input: `entityName` (string), `depth` (number, default: 1)
131
- - Returns entities connected within specified depth
136
+ - Input: `entityName` (string), `depth` (number, default: 0), `withEntities` (boolean, default: false), `entityCursor` (number, optional), `relationCursor` (number, optional)
137
+ - Returns relations (and optionally entities) connected within specified depth (paginated)
132
138
 
133
139
  - **find_path**
134
140
  - Find a path between two entities in the knowledge graph
135
- - Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5)
136
- - Returns path between entities if one exists
141
+ - Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5), `cursor` (number, optional)
142
+ - Returns path between entities if one exists (paginated)
137
143
 
138
144
  - **get_entities_by_type**
139
145
  - Get all entities of a specific type
140
- - Input: `entityType` (string)
141
- - Returns all entities matching the specified type
146
+ - Input: `entityType` (string), `cursor` (number, optional)
147
+ - Returns all entities matching the specified type (paginated)
142
148
 
143
149
  - **get_entity_types**
144
150
  - Get all unique entity types in the knowledge graph
@@ -156,14 +162,15 @@ Example:
156
162
  - Returns entity count, relation count, entity types count, relation types count
157
163
 
158
164
  - **get_orphaned_entities**
159
- - Get entities that have no relations
160
- - No input required
161
- - Returns entities with no connections
165
+ - Get entities that have no relations (orphaned entities)
166
+ - Input: `strict` (boolean, default: false), `cursor` (number, optional)
167
+ - In strict mode, returns entities not connected to 'Self' entity (directly or indirectly)
168
+ - Returns entities with no connections (paginated)
162
169
 
163
170
  - **validate_graph**
164
- - Validate the knowledge graph and return missing entities referenced in relations
171
+ - Validate the knowledge graph
165
172
  - No input required
166
- - Returns list of missing entities
173
+ - Returns missing entities referenced in relations and observation limit violations
167
174
 
168
175
  - **evaluate_bcl**
169
176
  - Evaluate a Binary Combinatory Logic (BCL) program
@@ -182,6 +189,12 @@ Example:
182
189
  - No input required
183
190
  - Resets BCL constructor
184
191
 
192
+ - **sequentialthinking**
193
+ - Record a thought in the knowledge graph
194
+ - Input: `observations` (string[], max 2, each max 140 chars), `previousCtxId` (string, optional)
195
+ - Creates a Thought entity and links it to the previous thought if provided
196
+ - Returns the new thought's context ID for chaining
197
+
185
198
  # Usage with Claude Desktop
186
199
 
187
200
  ### Setup
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { promises as fs } from 'fs';
5
+ import { randomBytes } from 'crypto';
5
6
  import path from 'path';
6
7
  import { fileURLToPath } from 'url';
7
8
  import lockfile from 'proper-lockfile';
@@ -13,6 +14,83 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
13
14
  ? process.env.MEMORY_FILE_PATH
14
15
  : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
15
16
  : defaultMemoryPath;
17
+ export const MAX_CHARS = 2048;
18
+ function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
19
+ const result = [];
20
+ let i = cursor;
21
+ // Calculate overhead for wrapper: {"items":[],"nextCursor":null,"totalCount":123}
22
+ const wrapperTemplate = { items: [], nextCursor: null, totalCount: items.length };
23
+ let overhead = JSON.stringify(wrapperTemplate).length;
24
+ let charCount = overhead;
25
+ while (i < items.length) {
26
+ const itemJson = JSON.stringify(items[i]);
27
+ const addedChars = itemJson.length + (result.length > 0 ? 1 : 0); // +1 for comma
28
+ if (charCount + addedChars > maxChars) {
29
+ break;
30
+ }
31
+ result.push(items[i]);
32
+ charCount += addedChars;
33
+ i++;
34
+ }
35
+ // Update nextCursor - recalculate if we stopped early (cursor digits may differ from null)
36
+ const nextCursor = i < items.length ? i : null;
37
+ return {
38
+ items: result,
39
+ nextCursor,
40
+ totalCount: items.length
41
+ };
42
+ }
43
+ function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
44
+ // Build incrementally, measuring actual serialized size
45
+ const entityCount = graph.entities.length;
46
+ const relationCount = graph.relations.length;
47
+ // Start with empty result to measure base overhead
48
+ const emptyResult = {
49
+ entities: { items: [], nextCursor: null, totalCount: entityCount },
50
+ relations: { items: [], nextCursor: null, totalCount: relationCount }
51
+ };
52
+ let currentSize = JSON.stringify(emptyResult).length;
53
+ const resultEntities = [];
54
+ const resultRelations = [];
55
+ let entityIdx = entityCursor;
56
+ let relationIdx = relationCursor;
57
+ // Add entities until we hit the limit
58
+ while (entityIdx < graph.entities.length) {
59
+ const entity = graph.entities[entityIdx];
60
+ const entityJson = JSON.stringify(entity);
61
+ const addedChars = entityJson.length + (resultEntities.length > 0 ? 1 : 0);
62
+ if (currentSize + addedChars > MAX_CHARS) {
63
+ break;
64
+ }
65
+ resultEntities.push(entity);
66
+ currentSize += addedChars;
67
+ entityIdx++;
68
+ }
69
+ // Add relations with remaining space
70
+ while (relationIdx < graph.relations.length) {
71
+ const relation = graph.relations[relationIdx];
72
+ const relationJson = JSON.stringify(relation);
73
+ const addedChars = relationJson.length + (resultRelations.length > 0 ? 1 : 0);
74
+ if (currentSize + addedChars > MAX_CHARS) {
75
+ break;
76
+ }
77
+ resultRelations.push(relation);
78
+ currentSize += addedChars;
79
+ relationIdx++;
80
+ }
81
+ return {
82
+ entities: {
83
+ items: resultEntities,
84
+ nextCursor: entityIdx < graph.entities.length ? entityIdx : null,
85
+ totalCount: entityCount
86
+ },
87
+ relations: {
88
+ items: resultRelations,
89
+ nextCursor: relationIdx < graph.relations.length ? relationIdx : null,
90
+ totalCount: relationCount
91
+ }
92
+ };
93
+ }
16
94
  // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
17
95
  export class KnowledgeGraphManager {
18
96
  bclCtr = 0;
@@ -79,7 +157,10 @@ export class KnowledgeGraphManager {
79
157
  }
80
158
  }
81
159
  }
82
- const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
160
+ const now = Date.now();
161
+ const newEntities = entities
162
+ .filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name))
163
+ .map(e => ({ ...e, mtime: now, obsMtime: e.observations.length > 0 ? now : undefined }));
83
164
  graph.entities.push(...newEntities);
84
165
  await this.saveGraph(graph);
85
166
  return newEntities;
@@ -88,9 +169,19 @@ export class KnowledgeGraphManager {
88
169
  async createRelations(relations) {
89
170
  return this.withLock(async () => {
90
171
  const graph = await this.loadGraph();
91
- const newRelations = relations.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
172
+ const now = Date.now();
173
+ // Update mtime on 'from' entities when relations are added
174
+ const fromEntityNames = new Set(relations.map(r => r.from));
175
+ graph.entities.forEach(e => {
176
+ if (fromEntityNames.has(e.name)) {
177
+ e.mtime = now;
178
+ }
179
+ });
180
+ const newRelations = relations
181
+ .filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
92
182
  existingRelation.to === r.to &&
93
- existingRelation.relationType === r.relationType));
183
+ existingRelation.relationType === r.relationType))
184
+ .map(r => ({ ...r, mtime: now }));
94
185
  graph.relations.push(...newRelations);
95
186
  await this.saveGraph(graph);
96
187
  return newRelations;
@@ -116,6 +207,11 @@ export class KnowledgeGraphManager {
116
207
  throw new Error(`Adding ${newObservations.length} observations to "${o.entityName}" would exceed limit of 2 (currently has ${entity.observations.length}).`);
117
208
  }
118
209
  entity.observations.push(...newObservations);
210
+ if (newObservations.length > 0) {
211
+ const now = Date.now();
212
+ entity.mtime = now;
213
+ entity.obsMtime = now;
214
+ }
119
215
  return { entityName: o.entityName, addedObservations: newObservations };
120
216
  });
121
217
  await this.saveGraph(graph);
@@ -133,10 +229,16 @@ export class KnowledgeGraphManager {
133
229
  async deleteObservations(deletions) {
134
230
  return this.withLock(async () => {
135
231
  const graph = await this.loadGraph();
232
+ const now = Date.now();
136
233
  deletions.forEach(d => {
137
234
  const entity = graph.entities.find(e => e.name === d.entityName);
138
235
  if (entity) {
236
+ const originalLen = entity.observations.length;
139
237
  entity.observations = entity.observations.filter(o => !d.observations.includes(o));
238
+ if (entity.observations.length !== originalLen) {
239
+ entity.mtime = now;
240
+ entity.obsMtime = now;
241
+ }
140
242
  }
141
243
  });
142
244
  await this.saveGraph(graph);
@@ -281,14 +383,44 @@ export class KnowledgeGraphManager {
281
383
  relationTypes: relationTypes.size
282
384
  };
283
385
  }
284
- async getOrphanedEntities() {
386
+ async getOrphanedEntities(strict = false) {
285
387
  const graph = await this.loadGraph();
286
- const connectedEntityNames = new Set();
388
+ if (!strict) {
389
+ // Simple mode: entities with no relations at all
390
+ const connectedEntityNames = new Set();
391
+ graph.relations.forEach(r => {
392
+ connectedEntityNames.add(r.from);
393
+ connectedEntityNames.add(r.to);
394
+ });
395
+ return graph.entities.filter(e => !connectedEntityNames.has(e.name));
396
+ }
397
+ // Strict mode: entities not connected to "Self" (directly or indirectly)
398
+ // Build adjacency list (bidirectional)
399
+ const neighbors = new Map();
400
+ graph.entities.forEach(e => neighbors.set(e.name, new Set()));
287
401
  graph.relations.forEach(r => {
288
- connectedEntityNames.add(r.from);
289
- connectedEntityNames.add(r.to);
402
+ neighbors.get(r.from)?.add(r.to);
403
+ neighbors.get(r.to)?.add(r.from);
290
404
  });
291
- return graph.entities.filter(e => !connectedEntityNames.has(e.name));
405
+ // BFS from Self to find all connected entities
406
+ const connectedToSelf = new Set();
407
+ const queue = ['Self'];
408
+ while (queue.length > 0) {
409
+ const current = queue.shift();
410
+ if (connectedToSelf.has(current))
411
+ continue;
412
+ connectedToSelf.add(current);
413
+ const currentNeighbors = neighbors.get(current);
414
+ if (currentNeighbors) {
415
+ for (const neighbor of currentNeighbors) {
416
+ if (!connectedToSelf.has(neighbor)) {
417
+ queue.push(neighbor);
418
+ }
419
+ }
420
+ }
421
+ }
422
+ // Return entities not connected to Self (excluding Self itself if it exists)
423
+ return graph.entities.filter(e => !connectedToSelf.has(e.name));
292
424
  }
293
425
  async validateGraph() {
294
426
  const graph = await this.loadGraph();
@@ -483,6 +615,44 @@ export class KnowledgeGraphManager {
483
615
  this.bclCtr = 0;
484
616
  this.bclTerm = "";
485
617
  }
618
+ async addThought(observations, previousCtxId) {
619
+ return this.withLock(async () => {
620
+ const graph = await this.loadGraph();
621
+ // Validate observations
622
+ if (observations.length > 2) {
623
+ throw new Error(`Thought has ${observations.length} observations. Maximum allowed is 2.`);
624
+ }
625
+ for (const obs of observations) {
626
+ if (obs.length > 140) {
627
+ throw new Error(`Observation exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
628
+ }
629
+ }
630
+ // Generate new context ID (24-char hex)
631
+ const now = Date.now();
632
+ const ctxId = randomBytes(12).toString('hex');
633
+ // Create thought entity
634
+ const thoughtEntity = {
635
+ name: ctxId,
636
+ entityType: "Thought",
637
+ observations,
638
+ mtime: now,
639
+ obsMtime: observations.length > 0 ? now : undefined,
640
+ };
641
+ graph.entities.push(thoughtEntity);
642
+ // Link to previous thought if it exists
643
+ if (previousCtxId) {
644
+ const prevEntity = graph.entities.find(e => e.name === previousCtxId);
645
+ if (prevEntity) {
646
+ // Update mtime on previous entity since we're adding a relation from it
647
+ prevEntity.mtime = now;
648
+ // Bidirectional chain: previous -> new (follows) and new -> previous (preceded_by)
649
+ graph.relations.push({ from: previousCtxId, to: ctxId, relationType: "follows", mtime: now }, { from: ctxId, to: previousCtxId, relationType: "preceded_by", mtime: now });
650
+ }
651
+ }
652
+ await this.saveGraph(graph);
653
+ return { ctxId };
654
+ });
655
+ }
486
656
  }
487
657
  /**
488
658
  * Creates a configured MCP server instance with all tools registered.
@@ -646,18 +816,20 @@ export function createServer(memoryFilePath) {
646
816
  },
647
817
  {
648
818
  name: "search_nodes",
649
- description: "Search for nodes in the knowledge graph using a regex pattern",
819
+ description: "Search for nodes in the knowledge graph using a regex pattern. Results are paginated (max 512 chars).",
650
820
  inputSchema: {
651
821
  type: "object",
652
822
  properties: {
653
- query: { type: "string", description: "Regex pattern to match against entity names, types, and observations. Use | for alternatives (e.g., 'Taranis|wheel'). Special regex characters must be escaped for literal matching." },
823
+ query: { type: "string", description: "Regex pattern to match against entity names, types, and observations." },
824
+ entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
825
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
654
826
  },
655
827
  required: ["query"],
656
828
  },
657
829
  },
658
830
  {
659
831
  name: "open_nodes_filtered",
660
- description: "Open specific nodes in the knowledge graph by their names, filtering relations to only those between the opened nodes",
832
+ description: "Open specific nodes in the knowledge graph by their names, filtering relations to only those between the opened nodes. Results are paginated (max 512 chars).",
661
833
  inputSchema: {
662
834
  type: "object",
663
835
  properties: {
@@ -666,13 +838,15 @@ export function createServer(memoryFilePath) {
666
838
  items: { type: "string" },
667
839
  description: "An array of entity names to retrieve",
668
840
  },
841
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
842
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
669
843
  },
670
844
  required: ["names"],
671
845
  },
672
846
  },
673
847
  {
674
848
  name: "open_nodes",
675
- description: "Open specific nodes in the knowledge graph by their names",
849
+ description: "Open specific nodes in the knowledge graph by their names. Results are paginated (max 512 chars).",
676
850
  inputSchema: {
677
851
  type: "object",
678
852
  properties: {
@@ -681,43 +855,49 @@ export function createServer(memoryFilePath) {
681
855
  items: { type: "string" },
682
856
  description: "An array of entity names to retrieve",
683
857
  },
858
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
859
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
684
860
  },
685
861
  required: ["names"],
686
862
  },
687
863
  },
688
864
  {
689
865
  name: "get_neighbors",
690
- description: "Get neighboring entities connected to a specific entity within a given depth",
866
+ description: "Get neighboring entities connected to a specific entity within a given depth. Results are paginated (max 512 chars).",
691
867
  inputSchema: {
692
868
  type: "object",
693
869
  properties: {
694
870
  entityName: { type: "string", description: "The name of the entity to find neighbors for" },
695
871
  depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
696
872
  withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
873
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
874
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
697
875
  },
698
876
  required: ["entityName"],
699
877
  },
700
878
  },
701
879
  {
702
880
  name: "find_path",
703
- description: "Find a path between two entities in the knowledge graph",
881
+ description: "Find a path between two entities in the knowledge graph. Results are paginated (max 512 chars).",
704
882
  inputSchema: {
705
883
  type: "object",
706
884
  properties: {
707
885
  fromEntity: { type: "string", description: "The name of the starting entity" },
708
886
  toEntity: { type: "string", description: "The name of the target entity" },
709
887
  maxDepth: { type: "number", description: "Maximum depth to search (default: 5)", default: 5 },
888
+ cursor: { type: "number", description: "Cursor for pagination" },
710
889
  },
711
890
  required: ["fromEntity", "toEntity"],
712
891
  },
713
892
  },
714
893
  {
715
894
  name: "get_entities_by_type",
716
- description: "Get all entities of a specific type",
895
+ description: "Get all entities of a specific type. Results are paginated (max 512 chars).",
717
896
  inputSchema: {
718
897
  type: "object",
719
898
  properties: {
720
899
  entityType: { type: "string", description: "The type of entities to retrieve" },
900
+ cursor: { type: "number", description: "Cursor for pagination" },
721
901
  },
722
902
  required: ["entityType"],
723
903
  },
@@ -748,10 +928,13 @@ export function createServer(memoryFilePath) {
748
928
  },
749
929
  {
750
930
  name: "get_orphaned_entities",
751
- description: "Get entities that have no relations (orphaned entities)",
931
+ description: "Get entities that have no relations (orphaned entities). In strict mode, returns entities not connected to 'Self' entity. Results are paginated (max 512 chars).",
752
932
  inputSchema: {
753
933
  type: "object",
754
- properties: {},
934
+ properties: {
935
+ strict: { type: "boolean", description: "If true, returns entities not connected to 'Self' (directly or indirectly). Default: false" },
936
+ cursor: { type: "number", description: "Cursor for pagination" },
937
+ },
755
938
  },
756
939
  },
757
940
  {
@@ -796,6 +979,28 @@ export function createServer(memoryFilePath) {
796
979
  properties: {},
797
980
  },
798
981
  },
982
+ {
983
+ name: "sequentialthinking",
984
+ description: `Record a thought in the knowledge graph. Creates a Thought entity with observations and links it to the previous thought if provided. Returns the new thought's context ID for chaining.
985
+
986
+ Use this to build chains of reasoning that persist in the graph. Each thought can have up to 2 observations (max 140 chars each).`,
987
+ inputSchema: {
988
+ type: "object",
989
+ properties: {
990
+ previousCtxId: {
991
+ type: "string",
992
+ description: "Context ID of the previous thought to chain from. Omit for first thought in a chain."
993
+ },
994
+ observations: {
995
+ type: "array",
996
+ items: { type: "string", maxLength: 140 },
997
+ maxItems: 2,
998
+ description: "Observations for this thought (max 2, each max 140 chars)"
999
+ },
1000
+ },
1001
+ required: ["observations"],
1002
+ },
1003
+ },
799
1004
  ],
800
1005
  };
801
1006
  });
@@ -820,26 +1025,40 @@ export function createServer(memoryFilePath) {
820
1025
  case "delete_relations":
821
1026
  await knowledgeGraphManager.deleteRelations(args.relations);
822
1027
  return { content: [{ type: "text", text: "Relations deleted successfully" }] };
823
- case "search_nodes":
824
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query), null, 2) }] };
825
- case "open_nodes_filtered":
826
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodesFiltered(args.names), null, 2) }] };
827
- case "open_nodes":
828
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names), null, 2) }] };
829
- case "get_neighbors":
830
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities), null, 2) }] };
831
- case "find_path":
832
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth), null, 2) }] };
833
- case "get_entities_by_type":
834
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntitiesByType(args.entityType), null, 2) }] };
1028
+ case "search_nodes": {
1029
+ const graph = await knowledgeGraphManager.searchNodes(args.query);
1030
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1031
+ }
1032
+ case "open_nodes_filtered": {
1033
+ const graph = await knowledgeGraphManager.openNodesFiltered(args.names);
1034
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1035
+ }
1036
+ case "open_nodes": {
1037
+ const graph = await knowledgeGraphManager.openNodes(args.names);
1038
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1039
+ }
1040
+ case "get_neighbors": {
1041
+ const graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
1042
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1043
+ }
1044
+ case "find_path": {
1045
+ const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
1046
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
1047
+ }
1048
+ case "get_entities_by_type": {
1049
+ const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
1050
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1051
+ }
835
1052
  case "get_entity_types":
836
1053
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntityTypes(), null, 2) }] };
837
1054
  case "get_relation_types":
838
1055
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getRelationTypes(), null, 2) }] };
839
1056
  case "get_stats":
840
1057
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
841
- case "get_orphaned_entities":
842
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getOrphanedEntities(), null, 2) }] };
1058
+ case "get_orphaned_entities": {
1059
+ const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false);
1060
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1061
+ }
843
1062
  case "validate_graph":
844
1063
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.validateGraph(), null, 2) }] };
845
1064
  case "evaluate_bcl":
@@ -849,6 +1068,10 @@ export function createServer(memoryFilePath) {
849
1068
  case "clear_bcl_term":
850
1069
  await knowledgeGraphManager.clearBCLTerm();
851
1070
  return { content: [{ type: "text", text: "BCL term constructor cleared successfully" }] };
1071
+ case "sequentialthinking": {
1072
+ const result = await knowledgeGraphManager.addThought(args.observations, args.previousCtxId);
1073
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1074
+ }
852
1075
  default:
853
1076
  throw new Error(`Unknown tool: ${name}`);
854
1077
  }
@@ -129,7 +129,7 @@ describe('MCP Memory Server E2E Tests', () => {
129
129
  deletions: [{ entityName: 'TestEntity', observations: ['Delete'] }]
130
130
  });
131
131
  const result = await callTool(client, 'open_nodes', { names: ['TestEntity'] });
132
- expect(result.entities[0].observations).toEqual(['Keep']);
132
+ expect(result.entities.items[0].observations).toEqual(['Keep']);
133
133
  });
134
134
  });
135
135
  describe('Relation Operations', () => {
@@ -192,25 +192,41 @@ describe('MCP Memory Server E2E Tests', () => {
192
192
  });
193
193
  });
194
194
  it('should search by regex pattern', async () => {
195
- const result = await callTool(client, 'search_nodes', {
196
- query: 'Script'
197
- });
198
- expect(result.entities).toHaveLength(2);
199
- expect(result.entities.map(e => e.name)).toContain('JavaScript');
200
- expect(result.entities.map(e => e.name)).toContain('TypeScript');
195
+ // Accumulate all entities across pagination
196
+ const allEntities = [];
197
+ let entityCursor = 0;
198
+ while (entityCursor !== null) {
199
+ const result = await callTool(client, 'search_nodes', {
200
+ query: 'Script',
201
+ entityCursor
202
+ });
203
+ allEntities.push(...result.entities.items);
204
+ entityCursor = result.entities.nextCursor;
205
+ }
206
+ expect(allEntities).toHaveLength(2);
207
+ expect(allEntities.map(e => e.name)).toContain('JavaScript');
208
+ expect(allEntities.map(e => e.name)).toContain('TypeScript');
201
209
  });
202
210
  it('should search with alternation', async () => {
203
- const result = await callTool(client, 'search_nodes', {
204
- query: 'JavaScript|Python'
205
- });
206
- expect(result.entities).toHaveLength(2);
211
+ // Accumulate all entities across pagination
212
+ const allEntities = [];
213
+ let entityCursor = 0;
214
+ while (entityCursor !== null) {
215
+ const result = await callTool(client, 'search_nodes', {
216
+ query: 'JavaScript|Python',
217
+ entityCursor
218
+ });
219
+ allEntities.push(...result.entities.items);
220
+ entityCursor = result.entities.nextCursor;
221
+ }
222
+ expect(allEntities).toHaveLength(2);
207
223
  });
208
224
  it('should search in observations', async () => {
209
225
  const result = await callTool(client, 'search_nodes', {
210
226
  query: 'Static'
211
227
  });
212
- expect(result.entities).toHaveLength(1);
213
- expect(result.entities[0].name).toBe('TypeScript');
228
+ expect(result.entities.items).toHaveLength(1);
229
+ expect(result.entities.items[0].name).toBe('TypeScript');
214
230
  });
215
231
  it('should reject invalid regex', async () => {
216
232
  await expect(callTool(client, 'search_nodes', { query: '[invalid' })).rejects.toThrow(/Invalid regex pattern/);
@@ -236,17 +252,17 @@ describe('MCP Memory Server E2E Tests', () => {
236
252
  const result = await callTool(client, 'open_nodes', {
237
253
  names: ['A', 'B']
238
254
  });
239
- expect(result.entities).toHaveLength(2);
255
+ expect(result.entities.items).toHaveLength(2);
240
256
  // open_nodes returns all relations where 'from' is in the requested set
241
257
  // A->B and A->C both have from='A' which is in the set
242
- expect(result.relations).toHaveLength(2);
258
+ expect(result.relations.items).toHaveLength(2);
243
259
  });
244
260
  it('should open nodes filtered (only internal relations)', async () => {
245
261
  const result = await callTool(client, 'open_nodes_filtered', {
246
262
  names: ['B', 'C']
247
263
  });
248
- expect(result.entities).toHaveLength(2);
249
- expect(result.relations).toHaveLength(0); // No relations between B and C
264
+ expect(result.entities.items).toHaveLength(2);
265
+ expect(result.relations.items).toHaveLength(0); // No relations between B and C
250
266
  });
251
267
  });
252
268
  describe('Graph Traversal', () => {
@@ -272,27 +288,57 @@ describe('MCP Memory Server E2E Tests', () => {
272
288
  entityName: 'Root',
273
289
  depth: 0
274
290
  });
275
- expect(result.entities).toHaveLength(0); // withEntities defaults to false
276
- expect(result.relations).toHaveLength(2); // Root's direct relations
291
+ expect(result.entities.items).toHaveLength(0); // withEntities defaults to false
292
+ expect(result.relations.items).toHaveLength(2); // Root's direct relations
277
293
  });
278
294
  it('should get neighbors with entities when requested', async () => {
279
- const result = await callTool(client, 'get_neighbors', {
280
- entityName: 'Root',
281
- depth: 1,
282
- withEntities: true
283
- });
284
- expect(result.entities.map(e => e.name)).toContain('Root');
285
- expect(result.entities.map(e => e.name)).toContain('Child1');
286
- expect(result.entities.map(e => e.name)).toContain('Child2');
295
+ // Accumulate all entities across pagination
296
+ const allEntities = [];
297
+ let entityCursor = 0;
298
+ while (entityCursor !== null) {
299
+ const result = await callTool(client, 'get_neighbors', {
300
+ entityName: 'Root',
301
+ depth: 1,
302
+ withEntities: true,
303
+ entityCursor
304
+ });
305
+ allEntities.push(...result.entities.items);
306
+ entityCursor = result.entities.nextCursor;
307
+ }
308
+ expect(allEntities).toHaveLength(3);
309
+ expect(allEntities.map(e => e.name)).toContain('Root');
310
+ expect(allEntities.map(e => e.name)).toContain('Child1');
311
+ expect(allEntities.map(e => e.name)).toContain('Child2');
287
312
  });
288
313
  it('should traverse to specified depth', async () => {
289
- const result = await callTool(client, 'get_neighbors', {
290
- entityName: 'Root',
291
- depth: 2,
292
- withEntities: true
293
- });
294
- expect(result.entities).toHaveLength(4); // All nodes
295
- expect(result.relations).toHaveLength(3); // All relations
314
+ // Collect all entities and relations using pagination
315
+ const allEntities = [];
316
+ const allRelations = [];
317
+ let entityCursor = 0;
318
+ let relationCursor = 0;
319
+ // Paginate through all results
320
+ while (entityCursor !== null || relationCursor !== null) {
321
+ const result = await callTool(client, 'get_neighbors', {
322
+ entityName: 'Root',
323
+ depth: 2,
324
+ withEntities: true,
325
+ // Pass large cursor to skip when done, 0 to fetch
326
+ entityCursor: entityCursor !== null ? entityCursor : 999999,
327
+ relationCursor: relationCursor !== null ? relationCursor : 999999
328
+ });
329
+ // Collect entities if we still need them
330
+ if (entityCursor !== null) {
331
+ allEntities.push(...result.entities.items);
332
+ entityCursor = result.entities.nextCursor;
333
+ }
334
+ // Collect relations if we still need them
335
+ if (relationCursor !== null) {
336
+ allRelations.push(...result.relations.items);
337
+ relationCursor = result.relations.nextCursor;
338
+ }
339
+ }
340
+ expect(allEntities).toHaveLength(4); // All nodes
341
+ expect(allRelations).toHaveLength(3); // All relations
296
342
  });
297
343
  it('should deduplicate relations in traversal', async () => {
298
344
  // Add a bidirectional relation
@@ -304,7 +350,7 @@ describe('MCP Memory Server E2E Tests', () => {
304
350
  depth: 1
305
351
  });
306
352
  // Each unique relation should appear only once
307
- const relationKeys = result.relations.map(r => `${r.from}|${r.relationType}|${r.to}`);
353
+ const relationKeys = result.relations.items.map(r => `${r.from}|${r.relationType}|${r.to}`);
308
354
  const uniqueKeys = [...new Set(relationKeys)];
309
355
  expect(relationKeys.length).toBe(uniqueKeys.length);
310
356
  });
@@ -313,9 +359,9 @@ describe('MCP Memory Server E2E Tests', () => {
313
359
  fromEntity: 'Root',
314
360
  toEntity: 'Grandchild'
315
361
  });
316
- expect(result).toHaveLength(2);
317
- expect(result[0].from).toBe('Root');
318
- expect(result[1].to).toBe('Grandchild');
362
+ expect(result.items).toHaveLength(2);
363
+ expect(result.items[0].from).toBe('Root');
364
+ expect(result.items[1].to).toBe('Grandchild');
319
365
  });
320
366
  it('should return empty path when no path exists', async () => {
321
367
  await callTool(client, 'create_entities', {
@@ -325,7 +371,7 @@ describe('MCP Memory Server E2E Tests', () => {
325
371
  fromEntity: 'Root',
326
372
  toEntity: 'Isolated'
327
373
  });
328
- expect(result).toHaveLength(0);
374
+ expect(result.items).toHaveLength(0);
329
375
  });
330
376
  });
331
377
  describe('Type Queries', () => {
@@ -348,8 +394,8 @@ describe('MCP Memory Server E2E Tests', () => {
348
394
  const result = await callTool(client, 'get_entities_by_type', {
349
395
  entityType: 'Person'
350
396
  });
351
- expect(result).toHaveLength(2);
352
- expect(result.every(e => e.entityType === 'Person')).toBe(true);
397
+ expect(result.items).toHaveLength(2);
398
+ expect(result.items.every(e => e.entityType === 'Person')).toBe(true);
353
399
  });
354
400
  it('should get all entity types', async () => {
355
401
  const result = await callTool(client, 'get_entity_types', {});
@@ -392,8 +438,34 @@ describe('MCP Memory Server E2E Tests', () => {
392
438
  relations: [{ from: 'Connected1', to: 'Connected2', relationType: 'links' }]
393
439
  });
394
440
  const result = await callTool(client, 'get_orphaned_entities', {});
395
- expect(result).toHaveLength(1);
396
- expect(result[0].name).toBe('Orphan');
441
+ expect(result.items).toHaveLength(1);
442
+ expect(result.items[0].name).toBe('Orphan');
443
+ });
444
+ it('should find entities not connected to Self in strict mode', async () => {
445
+ await callTool(client, 'create_entities', {
446
+ entities: [
447
+ { name: 'Self', entityType: 'Agent', observations: [] },
448
+ { name: 'ConnectedToSelf', entityType: 'Node', observations: [] },
449
+ { name: 'IndirectlyConnected', entityType: 'Node', observations: [] },
450
+ { name: 'Island1', entityType: 'Node', observations: [] },
451
+ { name: 'Island2', entityType: 'Node', observations: [] }
452
+ ]
453
+ });
454
+ await callTool(client, 'create_relations', {
455
+ relations: [
456
+ { from: 'Self', to: 'ConnectedToSelf', relationType: 'knows' },
457
+ { from: 'ConnectedToSelf', to: 'IndirectlyConnected', relationType: 'links' },
458
+ { from: 'Island1', to: 'Island2', relationType: 'links' } // Connected to each other but not to Self
459
+ ]
460
+ });
461
+ // Non-strict: Island1 and Island2 are connected, so not orphaned
462
+ const nonStrict = await callTool(client, 'get_orphaned_entities', {});
463
+ expect(nonStrict.items).toHaveLength(0);
464
+ // Strict: Island1 and Island2 are not connected to Self
465
+ const strict = await callTool(client, 'get_orphaned_entities', { strict: true });
466
+ expect(strict.items).toHaveLength(2);
467
+ const names = strict.items.map(e => e.name).sort();
468
+ expect(names).toEqual(['Island1', 'Island2']);
397
469
  });
398
470
  it('should validate graph and report violations', async () => {
399
471
  // Directly write invalid data to test validation
@@ -435,4 +507,59 @@ describe('MCP Memory Server E2E Tests', () => {
435
507
  await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
436
508
  });
437
509
  });
510
+ describe('Sequential Thinking', () => {
511
+ it('should create a thought and return ctxId', async () => {
512
+ const result = await callTool(client, 'sequentialthinking', {
513
+ observations: ['First thought observation']
514
+ });
515
+ expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
516
+ });
517
+ it('should chain thoughts with relations', async () => {
518
+ // Create first thought
519
+ const first = await callTool(client, 'sequentialthinking', {
520
+ observations: ['Starting point']
521
+ });
522
+ // Create second thought chained to first
523
+ const second = await callTool(client, 'sequentialthinking', {
524
+ previousCtxId: first.ctxId,
525
+ observations: ['Following up']
526
+ });
527
+ // Verify the chain via relations
528
+ const neighbors = await callTool(client, 'get_neighbors', {
529
+ entityName: first.ctxId,
530
+ depth: 1
531
+ });
532
+ // Should have 'follows' relation from first to second
533
+ expect(neighbors.relations.items.some(r => r.from === first.ctxId && r.to === second.ctxId && r.relationType === 'follows')).toBe(true);
534
+ });
535
+ it('should ignore invalid previousCtxId gracefully', async () => {
536
+ const result = await callTool(client, 'sequentialthinking', {
537
+ previousCtxId: 'nonexistent_thought',
538
+ observations: ['Orphaned thought']
539
+ });
540
+ expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
541
+ // Verify no relations were created
542
+ const neighbors = await callTool(client, 'get_neighbors', {
543
+ entityName: result.ctxId,
544
+ depth: 1
545
+ });
546
+ expect(neighbors.relations.totalCount).toBe(0);
547
+ });
548
+ it('should enforce observation limits on thoughts', async () => {
549
+ await expect(callTool(client, 'sequentialthinking', {
550
+ observations: ['One', 'Two', 'Three']
551
+ })).rejects.toThrow(/Maximum allowed is 2/);
552
+ });
553
+ it('should set mtime and obsMtime on thought entities', async () => {
554
+ const result = await callTool(client, 'sequentialthinking', {
555
+ observations: ['Timed thought']
556
+ });
557
+ const graph = await callTool(client, 'open_nodes', {
558
+ names: [result.ctxId]
559
+ });
560
+ const thought = graph.entities.items[0];
561
+ expect(thought.mtime).toBeDefined();
562
+ expect(thought.obsMtime).toBeDefined();
563
+ });
564
+ });
438
565
  });
@@ -1,5 +1,7 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ import { MAX_CHARS } from "../server.js";
4
+ export { MAX_CHARS };
3
5
  export async function createTestClient(server) {
4
6
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5
7
  const client = new Client({
@@ -18,6 +20,16 @@ export async function createTestClient(server) {
18
20
  }
19
21
  };
20
22
  }
23
+ // Read operations that should be paginated
24
+ const PAGINATED_TOOLS = new Set([
25
+ 'search_nodes',
26
+ 'open_nodes',
27
+ 'open_nodes_filtered',
28
+ 'get_neighbors',
29
+ 'find_path',
30
+ 'get_entities_by_type',
31
+ 'get_orphaned_entities',
32
+ ]);
21
33
  export async function callTool(client, name, args) {
22
34
  const result = await client.callTool({ name, arguments: args });
23
35
  const content = result.content;
@@ -26,6 +38,10 @@ export async function callTool(client, name, args) {
26
38
  }
27
39
  const first = content[0];
28
40
  if (first.type === 'text' && first.text) {
41
+ // Only enforce char limit on paginated read operations
42
+ if (PAGINATED_TOOLS.has(name) && first.text.length > MAX_CHARS) {
43
+ throw new Error(`Response exceeds ${MAX_CHARS} char limit: got ${first.text.length} chars`);
44
+ }
29
45
  try {
30
46
  return JSON.parse(first.text);
31
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -20,7 +20,7 @@
20
20
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
21
21
  },
22
22
  "dependencies": {
23
- "@modelcontextprotocol/sdk": "1.22.0",
23
+ "@modelcontextprotocol/sdk": "1.24.0",
24
24
  "proper-lockfile": "^4.1.2"
25
25
  },
26
26
  "devDependencies": {