server-memory-enhanced 2.2.1 → 2.3.1

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
@@ -15,8 +15,10 @@ An enhanced version of the Memory MCP server that provides persistent knowledge
15
15
  ### Entities
16
16
  Each entity now includes:
17
17
  - `name`: Entity identifier
18
- - `entityType`: Type of entity
19
- - `observations`: Array of observation strings
18
+ - `entityType`: Type of entity (free-form, any domain-specific type allowed)
19
+ - `observations`: Array of **versioned Observation objects** (not strings) - **BREAKING CHANGE**
20
+ - Each observation has: `id`, `content`, `timestamp`, `version`, `supersedes`, `superseded_by`
21
+ - Supports full version history tracking
20
22
  - `agentThreadId`: Unique identifier for the agent thread
21
23
  - `timestamp`: ISO 8601 timestamp of creation
22
24
  - `confidence`: Confidence score (0.0 to 1.0)
@@ -52,39 +54,64 @@ The server stores data in separate JSONL files per agent thread:
52
54
 
53
55
  ## Available Tools
54
56
 
57
+ ### ⭐ Recommended Tool (New)
58
+ 1. **save_memory**: **[RECOMMENDED]** Unified tool for creating entities and relations atomically with server-side validation
59
+ - Enforces observation limits (max 300 chars, 3 sentences per observation, ignoring periods in version numbers)
60
+ - Requires at least 1 relation per entity (prevents orphaned nodes)
61
+ - Free-form entity types with soft normalization
62
+ - Atomic transactions (all-or-nothing)
63
+ - Bidirectional relation tracking
64
+ - Quality score calculation
65
+ - Clear, actionable error messages
66
+
55
67
  ### Core Operations
56
- 1. **create_entities**: Create new entities with metadata (including importance)
57
- 2. **create_relations**: Create relationships between entities with metadata (including importance)
68
+ > ⚠️ **Note**: `create_entities` and `create_relations` are **deprecated**. New code should use `save_memory` for better reliability and validation.
69
+
70
+ 1. **create_entities**: Create new entities with metadata (including importance) - **[DEPRECATED - Use save_memory]**
71
+ 2. **create_relations**: Create relationships between entities with metadata (including importance) - **[DEPRECATED - Use save_memory]**
58
72
  3. **add_observations**: Add observations to existing entities with metadata (including importance)
59
- 4. **delete_entities**: Remove entities and cascading relations
60
- 5. **delete_observations**: Remove specific observations
61
- 6. **delete_relations**: Delete relationships
62
- 7. **read_graph**: Read the entire knowledge graph
63
- 8. **search_nodes**: Search entities by name, type, or observation content
64
- 9. **open_nodes**: Retrieve specific entities by name
65
- 10. **query_nodes**: Advanced querying with range-based filtering by timestamp, confidence, and importance
73
+ 4. **update_observation**: **[NEW]** Update an existing observation by creating a new version with updated content, maintaining version history
74
+ 5. **delete_entities**: Remove entities and cascading relations
75
+ 6. **delete_observations**: Remove specific observations
76
+ 7. **delete_relations**: Delete relationships
77
+ 8. **read_graph**: Read the entire knowledge graph
78
+ 9. **search_nodes**: Search entities by name, type, or observation content
79
+ 10. **open_nodes**: Retrieve specific entities by name
80
+ 11. **query_nodes**: Advanced querying with range-based filtering by timestamp, confidence, and importance
81
+ 12. **list_entities**: List entities with optional filtering by type and name pattern for quick discovery
82
+ 13. **validate_memory**: Validate entities without saving (dry-run) - check for errors before attempting save_memory
66
83
 
67
84
  ### Memory Management & Insights
68
- 11. **get_memory_stats**: Get comprehensive statistics (entity counts, thread activity, avg confidence/importance, recent activity)
69
- 12. **get_recent_changes**: Retrieve entities and relations created/modified since a specific timestamp
70
- 13. **prune_memory**: Remove old or low-importance entities to manage memory size
71
- 14. **bulk_update**: Efficiently update multiple entities at once (confidence, importance, observations)
85
+ 14. **get_analytics**: **[NEW]** Get simple, LLM-friendly analytics about your knowledge graph
86
+ - Recent changes (last 10 entities)
87
+ - Top important entities (by importance score)
88
+ - Most connected entities (by relation count)
89
+ - Orphaned entities (quality check)
90
+ 15. **get_observation_history**: **[NEW]** Retrieve version history for observations
91
+ - Track how observations evolve over time
92
+ - View complete version chains
93
+ - Supports rollback by viewing previous versions
94
+ 16. **get_memory_stats**: Get comprehensive statistics (entity counts, thread activity, avg confidence/importance, recent activity)
95
+ 17. **get_recent_changes**: Retrieve entities and relations created/modified since a specific timestamp
96
+ 18. **prune_memory**: Remove old or low-importance entities to manage memory size
97
+ 19. **bulk_update**: Efficiently update multiple entities at once (confidence, importance, observations)
98
+ 20. **list_conversations**: List all available agent threads (conversations) with metadata including entity counts, relation counts, and activity timestamps
72
99
 
73
100
  ### Relationship Intelligence
74
- 15. **find_relation_path**: Find the shortest path of relationships between two entities (useful for "how are they connected?")
75
- 16. **get_context**: Retrieve entities and relations related to specified entities up to a certain depth
101
+ 21. **find_relation_path**: Find the shortest path of relationships between two entities (useful for "how are they connected?")
102
+ 22. **get_context**: Retrieve entities and relations related to specified entities up to a certain depth
76
103
 
77
104
  ### Quality & Review
78
- 17. **detect_conflicts**: Detect potentially conflicting observations using pattern matching and negation detection
79
- 18. **flag_for_review**: Mark entities for human review with a specific reason (Human-in-the-Loop)
80
- 19. **get_flagged_entities**: Retrieve all entities flagged for review
105
+ 23. **detect_conflicts**: Detect potentially conflicting observations using pattern matching and negation detection
106
+ 24. **flag_for_review**: Mark entities for human review with a specific reason (Human-in-the-Loop)
107
+ 25. **get_flagged_entities**: Retrieve all entities flagged for review
81
108
 
82
109
  ## Usage
83
110
 
84
111
  ### Installation
85
112
 
86
113
  ```bash
87
- npm install @modelcontextprotocol/server-memory-enhanced
114
+ npm install server-memory-enhanced
88
115
  ```
89
116
 
90
117
  ### Running the Server
@@ -95,44 +122,150 @@ npx mcp-server-memory-enhanced
95
122
 
96
123
  ### Configuration
97
124
 
125
+ #### File Storage (Default)
126
+
98
127
  Set the `MEMORY_DIR_PATH` environment variable to customize the storage location:
99
128
 
100
129
  ```bash
101
130
  MEMORY_DIR_PATH=/path/to/memory/directory npx mcp-server-memory-enhanced
102
131
  ```
103
132
 
104
- ## Example
133
+ #### Neo4j Storage (Optional)
134
+
135
+ The server supports Neo4j as an alternative storage backend. If Neo4j environment variables are set, the server will attempt to connect to Neo4j. If the connection fails or variables are not set, it will automatically fall back to file-based JSONL storage.
136
+
137
+ **Environment Variables:**
138
+
139
+ ```bash
140
+ # Neo4j connection settings
141
+ export NEO4J_URI=neo4j://localhost:7687
142
+ export NEO4J_USERNAME=neo4j
143
+ export NEO4J_PASSWORD=your_password
144
+ export NEO4J_DATABASE=neo4j # Optional, defaults to 'neo4j'
145
+
146
+ # Run the server
147
+ npx mcp-server-memory-enhanced
148
+ ```
149
+
150
+ **Using Docker Compose:**
151
+
152
+ A `docker-compose.yml` file is provided for local development with Neo4j:
153
+
154
+ ```bash
155
+ # Start Neo4j and the MCP server
156
+ docker-compose up
157
+
158
+ # The Neo4j browser will be available at http://localhost:7474
159
+ # Username: neo4j, Password: testpassword
160
+ ```
161
+
162
+ **Using with Claude Desktop:**
163
+
164
+ Configure the server in your Claude Desktop configuration with Neo4j:
165
+
166
+ ```json
167
+ {
168
+ "mcpServers": {
169
+ "memory-enhanced": {
170
+ "command": "npx",
171
+ "args": ["-y", "mcp-server-memory-enhanced"],
172
+ "env": {
173
+ "NEO4J_URI": "neo4j://localhost:7687",
174
+ "NEO4J_USERNAME": "neo4j",
175
+ "NEO4J_PASSWORD": "your_password"
176
+ }
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ **Benefits of Neo4j Storage:**
183
+
184
+ - **Graph-native queries**: Faster relationship traversals and path finding
185
+ - **Scalability**: Better performance with large knowledge graphs
186
+ - **Advanced queries**: Native support for graph algorithms
187
+ - **Visualization**: Use Neo4j Browser to visualize your knowledge graph
188
+ - **Automatic fallback**: If Neo4j is not available, automatically uses file storage
189
+
190
+ ## User Guide
191
+
192
+ ### ✨ Using save_memory (Recommended)
193
+
194
+ The `save_memory` tool is the recommended way to create entities and relations. It provides atomic transactions and server-side validation to ensure high-quality knowledge graphs.
195
+
196
+ #### Key Principles
197
+
198
+ 1. **Atomic Observations**: Each observation should be a single, atomic fact
199
+ - ✅ Good: `"Works at Google"`, `"Lives in San Francisco"`
200
+ - ❌ Bad: `"Works at Google and lives in San Francisco and has a PhD in Computer Science"`
201
+ - **Max length**: 300 characters per observation
202
+ - **Max sentences**: 3 sentences per observation (technical content with version numbers supported)
203
+
204
+ 2. **Mandatory Relations**: Every entity must connect to at least one other entity
205
+ - ✅ Good: `{ targetEntity: "Google", relationType: "works at" }`
206
+ - ❌ Bad: Empty relations array `[]`
207
+ - This prevents orphaned nodes and ensures a well-connected knowledge graph
208
+
209
+ 3. **Free Entity Types**: Use any entity type that makes sense for your domain
210
+ - ✅ Good: `"Person"`, `"Company"`, `"Document"`, `"Recipe"`, `"Patient"`, `"API"`
211
+ - Soft normalization: `"person"` → `"Person"` (warning, not error)
212
+ - Space warning: `"API Key"` → suggests `"APIKey"`
213
+
214
+ 4. **Error Messages**: The tool provides clear, actionable error messages
215
+ - Too long: `"Observation too long (350 chars). Max 300. Suggestion: Split into multiple observations."`
216
+ - No relations: `"Entity 'X' must have at least 1 relation. Suggestion: Add relations to show connections."`
217
+ - Too many sentences: `"Too many sentences (4). Max 3. Suggestion: Split this into 4 separate observations."`
218
+
219
+ ### Example Usage
105
220
 
106
221
  ```typescript
107
- // Create entities with metadata including importance
108
- await createEntities({
222
+ // RECOMMENDED: Use save_memory for atomic entity and relation creation
223
+ await save_memory({
109
224
  entities: [
110
225
  {
111
226
  name: "Alice",
112
- entityType: "person",
113
- observations: ["works at Acme Corp"],
114
- agentThreadId: "thread-001",
115
- timestamp: "2024-01-20T10:00:00Z",
116
- confidence: 0.95,
117
- importance: 0.9 // Critical entity
227
+ entityType: "Person",
228
+ observations: ["Works at Google", "Lives in SF"], // Atomic facts, under 300 chars
229
+ relations: [{ targetEntity: "Bob", relationType: "knows" }] // At least 1 relation required
230
+ },
231
+ {
232
+ name: "Bob",
233
+ entityType: "Person",
234
+ observations: ["Works at Microsoft"],
235
+ relations: [{ targetEntity: "Alice", relationType: "knows" }]
118
236
  }
119
- ]
237
+ ],
238
+ threadId: "conversation-001"
120
239
  });
121
240
 
122
- // Create relations with metadata including importance
123
- await createRelations({
124
- relations: [
125
- {
126
- from: "Alice",
127
- to: "Bob",
128
- relationType: "knows",
129
- agentThreadId: "thread-001",
130
- timestamp: "2024-01-20T10:01:00Z",
131
- confidence: 0.9,
132
- importance: 0.75 // Important relationship
133
- }
134
- ]
241
+ // Get analytics about your knowledge graph
242
+ await get_analytics({
243
+ threadId: "conversation-001"
244
+ });
245
+ // Returns: {
246
+ // recent_changes: [...], // Last 10 entities
247
+ // top_important: [...], // Top 10 by importance
248
+ // most_connected: [...], // Top 10 by relation count
249
+ // orphaned_entities: [...] // Quality check
250
+ // }
251
+
252
+ // Get observation version history
253
+ await get_observation_history({
254
+ entityName: "Python Scripts",
255
+ observationId: "obs_abc123"
135
256
  });
257
+ // Returns: { history: [{ id, content, version, timestamp, supersedes, superseded_by }, ...] }
258
+
259
+ // Update an existing observation (creates a new version)
260
+ await update_observation({
261
+ entityName: "Alice",
262
+ observationId: "obs_abc123",
263
+ newContent: "Works at Google (Senior Engineer)",
264
+ agentThreadId: "conversation-001",
265
+ timestamp: "2024-01-20T12:00:00Z",
266
+ confidence: 0.95 // Optional: update confidence
267
+ });
268
+ // Returns: { success: true, updatedObservation: { id, content, version: 2, supersedes, ... }, message: "..." }
136
269
 
137
270
  // Query nodes with range-based filtering
138
271
  await queryNodes({
@@ -146,6 +279,10 @@ await queryNodes({
146
279
  await getMemoryStats();
147
280
  // Returns: { entityCount, relationCount, threadCount, entityTypes, avgConfidence, avgImportance, recentActivity }
148
281
 
282
+ // List all conversations (agent threads)
283
+ await listConversations();
284
+ // Returns: { conversations: [{ agentThreadId, entityCount, relationCount, firstCreated, lastUpdated }, ...] }
285
+
149
286
  // Get recent changes since last interaction
150
287
  await getRecentChanges({ since: "2024-01-20T10:00:00Z" });
151
288
 
@@ -173,6 +310,49 @@ await bulkUpdate({
173
310
  await pruneMemory({ olderThan: "2024-01-01T00:00:00Z", importanceLessThan: 0.3, keepMinEntities: 100 });
174
311
  ```
175
312
 
313
+ ### 🔄 Migration Guide
314
+
315
+ For users of the old `create_entities` and `create_relations` tools:
316
+
317
+ #### What Changed
318
+ - **Old approach**: Two separate tools that could be used independently
319
+ - `create_entities` → creates entities
320
+ - `create_relations` → creates relations (optional, often skipped by LLMs)
321
+ - **New approach**: Single `save_memory` tool with atomic transactions
322
+ - Creates entities and relations together
323
+ - Enforces mandatory relations (at least 1 per entity)
324
+ - Validates observation length and atomicity
325
+
326
+ #### Migrating Your Code
327
+ ```typescript
328
+ // ❌ OLD WAY (deprecated but still works)
329
+ await create_entities({
330
+ entities: [{ name: "Alice", entityType: "person", observations: ["works at Google and lives in SF"] }]
331
+ });
332
+ await create_relations({ // Often forgotten!
333
+ relations: [{ from: "Alice", to: "Bob", relationType: "knows" }]
334
+ });
335
+
336
+ // ✅ NEW WAY (recommended)
337
+ await save_memory({
338
+ entities: [
339
+ {
340
+ name: "Alice",
341
+ entityType: "Person",
342
+ observations: ["Works at Google", "Lives in SF"], // Split into atomic facts
343
+ relations: [{ targetEntity: "Bob", relationType: "knows" }] // Required!
344
+ }
345
+ ],
346
+ threadId: "conversation-001"
347
+ });
348
+ ```
349
+
350
+ #### Migration Strategy
351
+ 1. **Old tools remain available**: `create_entities` and `create_relations` are deprecated but not removed
352
+ 2. **No forced migration**: Update your code gradually at your own pace
353
+ 3. **New code should use `save_memory`**: Benefits from validation and atomic transactions
354
+ 4. **Observation versioning**: New installations use versioned observations (breaking change for data model)
355
+
176
356
  ## Development
177
357
 
178
358
  ### Build
@@ -196,3 +376,24 @@ npm run watch
196
376
  ## License
197
377
 
198
378
  MIT
379
+
380
+ ## 🤝 Contributing
381
+
382
+ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
383
+
384
+ ## 🔒 Security
385
+
386
+ See [SECURITY.MD](SECURITY.md) for reporting security vulnerabilities.
387
+
388
+ ## 📜 License
389
+
390
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
391
+
392
+ ## 💬 Community
393
+
394
+ - [GitHub Discussions](https://github.com/modelcontextprotocol/servers/discussions)
395
+ - [Model Context Protocol Documentation](https://modelcontextprotocol.io)
396
+
397
+ ---
398
+
399
+ Part of the [Model Context Protocol](https://modelcontextprotocol.io) project.
package/dist/index.js CHANGED
@@ -6,8 +6,9 @@ import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { KnowledgeGraphManager } from './lib/knowledge-graph-manager.js';
9
- import { EntitySchema, RelationSchema, SaveMemoryInputSchema, SaveMemoryOutputSchema, GetAnalyticsInputSchema, GetAnalyticsOutputSchema, GetObservationHistoryInputSchema, GetObservationHistoryOutputSchema } from './lib/schemas.js';
9
+ import { EntitySchema, RelationSchema, SaveMemoryInputSchema, SaveMemoryOutputSchema, GetAnalyticsInputSchema, GetAnalyticsOutputSchema, GetObservationHistoryInputSchema, GetObservationHistoryOutputSchema, ListEntitiesInputSchema, ListEntitiesOutputSchema, ValidateMemoryInputSchema, ValidateMemoryOutputSchema, UpdateObservationInputSchema, UpdateObservationOutputSchema } from './lib/schemas.js';
10
10
  import { handleSaveMemory } from './lib/save-memory-handler.js';
11
+ import { validateSaveMemoryRequest } from './lib/validation.js';
11
12
  import { JsonlStorageAdapter } from './lib/jsonl-storage-adapter.js';
12
13
  import { Neo4jStorageAdapter } from './lib/neo4j-storage-adapter.js';
13
14
  import { NEO4J_ENV_VARS, STORAGE_LOG_MESSAGES, NEO4J_ERROR_MESSAGES } from './lib/storage-config.js';
@@ -110,27 +111,57 @@ const server = new McpServer({
110
111
  // Register NEW save_memory tool (Section 1 of spec - Unified Tool)
111
112
  server.registerTool("save_memory", {
112
113
  title: "Save Memory",
113
- description: "Save entities and their relations to memory graph atomically. RULES: 1) Each observation max 150 chars (atomic facts only). 2) Each entity MUST have at least 1 relation. This is the recommended way to create entities and relations.",
114
+ description: "Save entities and their relations to memory graph atomically. RULES: 1) Each observation max 300 chars (atomic facts, technical content supported). 2) Each entity MUST have at least 1 relation. This is the recommended way to create entities and relations.",
114
115
  inputSchema: SaveMemoryInputSchema,
115
116
  outputSchema: SaveMemoryOutputSchema
116
117
  }, async (input) => {
117
- const result = await handleSaveMemory(input, (entities) => knowledgeGraphManager.createEntities(entities), (relations) => knowledgeGraphManager.createRelations(relations));
118
+ const result = await handleSaveMemory(input, (entities) => knowledgeGraphManager.createEntities(entities), (relations) => knowledgeGraphManager.createRelations(relations), (threadId) => knowledgeGraphManager.getEntityNamesInThread(threadId));
118
119
  if (result.success) {
120
+ // Build success message with entity names
121
+ let successText = `✓ Successfully saved ${result.created.entities} entities and ${result.created.relations} relations.\n` +
122
+ `Quality score: ${(result.quality_score * 100).toFixed(1)}%\n`;
123
+ if (result.created.entity_names && result.created.entity_names.length > 0) {
124
+ successText += `\nCreated entities: ${result.created.entity_names.join(', ')}\n`;
125
+ }
126
+ if (result.warnings.length > 0) {
127
+ successText += `\nWarnings:\n${result.warnings.join('\n')}`;
128
+ }
119
129
  return {
120
130
  content: [{
121
131
  type: "text",
122
- text: `✓ Successfully saved ${result.created.entities} entities and ${result.created.relations} relations.\n` +
123
- `Quality score: ${(result.quality_score * 100).toFixed(1)}%\n` +
124
- (result.warnings.length > 0 ? `\nWarnings:\n${result.warnings.join('\n')}` : '')
132
+ text: successText
125
133
  }],
126
134
  structuredContent: result
127
135
  };
128
136
  }
129
137
  else {
138
+ // Format validation errors for display
139
+ let errorText = '✗ Validation failed:\n\n';
140
+ if (result.validation_errors) {
141
+ if (Array.isArray(result.validation_errors) && result.validation_errors.length > 0) {
142
+ // Check if structured errors
143
+ if (typeof result.validation_errors[0] === 'object') {
144
+ const structuredErrors = result.validation_errors;
145
+ errorText += structuredErrors.map(err => {
146
+ let msg = `Entity #${err.entity_index} "${err.entity_name}" (${err.entity_type}):\n`;
147
+ err.errors.forEach((e) => msg += ` - ${e}\n`);
148
+ if (err.observations && err.observations.length > 0) {
149
+ msg += ` Observations: ${err.observations.join(', ')}\n`;
150
+ }
151
+ return msg;
152
+ }).join('\n');
153
+ }
154
+ else {
155
+ // Fallback to string errors
156
+ errorText += result.validation_errors.join('\n');
157
+ }
158
+ }
159
+ }
160
+ errorText += '\nFix all validation errors and retry. All entities must be valid to maintain memory integrity.';
130
161
  return {
131
162
  content: [{
132
163
  type: "text",
133
- text: `✗ Validation failed:\n${result.validation_errors?.join('\n')}`
164
+ text: errorText
134
165
  }],
135
166
  structuredContent: result,
136
167
  isError: true
@@ -236,6 +267,32 @@ server.registerTool("delete_observations", {
236
267
  structuredContent: { success: true, message: "Observations deleted successfully" }
237
268
  };
238
269
  });
270
+ // Register update_observation tool
271
+ server.registerTool("update_observation", {
272
+ title: "Update Observation",
273
+ description: "Update an existing observation by creating a new version with updated content. This maintains version history through the supersedes/superseded_by chain.",
274
+ inputSchema: UpdateObservationInputSchema.shape,
275
+ outputSchema: UpdateObservationOutputSchema.shape
276
+ }, async ({ entityName, observationId, newContent, agentThreadId, timestamp, confidence, importance }) => {
277
+ const updatedObservation = await knowledgeGraphManager.updateObservation({
278
+ entityName,
279
+ observationId,
280
+ newContent,
281
+ agentThreadId,
282
+ timestamp,
283
+ confidence,
284
+ importance
285
+ });
286
+ const message = `Observation updated successfully. New version: ${updatedObservation.id} (v${updatedObservation.version})`;
287
+ return {
288
+ content: [{ type: "text", text: message }],
289
+ structuredContent: {
290
+ success: true,
291
+ updatedObservation,
292
+ message
293
+ }
294
+ };
295
+ });
239
296
  // Register delete_relations tool
240
297
  server.registerTool("delete_relations", {
241
298
  title: "Delete Relations",
@@ -329,6 +386,139 @@ server.registerTool("query_nodes", {
329
386
  structuredContent: { ...graph }
330
387
  };
331
388
  });
389
+ // Register list_entities tool for simple entity lookup
390
+ server.registerTool("list_entities", {
391
+ title: "List Entities",
392
+ description: "List entities with optional filtering by entity type and name pattern. Returns a simple list of entity names and types for quick discovery.",
393
+ inputSchema: ListEntitiesInputSchema,
394
+ outputSchema: ListEntitiesOutputSchema
395
+ }, async (input) => {
396
+ const { threadId, entityType, namePattern } = input;
397
+ const entities = await knowledgeGraphManager.listEntities(threadId, entityType, namePattern);
398
+ return {
399
+ content: [{
400
+ type: "text",
401
+ text: `Found ${entities.length} entities:\n` +
402
+ entities.map(e => ` - ${e.name} (${e.entityType})`).join('\n')
403
+ }],
404
+ structuredContent: { entities }
405
+ };
406
+ });
407
+ // Register validate_memory tool for pre-validation (dry-run)
408
+ server.registerTool("validate_memory", {
409
+ title: "Validate Memory",
410
+ description: "Validate entities without saving (dry-run). Check for errors before attempting save_memory. Returns detailed validation results per entity.",
411
+ inputSchema: ValidateMemoryInputSchema,
412
+ outputSchema: ValidateMemoryOutputSchema
413
+ }, async (input) => {
414
+ const { entities, threadId } = input;
415
+ // Get existing entity names for cross-thread reference validation
416
+ let existingEntityNames;
417
+ try {
418
+ existingEntityNames = await knowledgeGraphManager.getEntityNamesInThread(threadId);
419
+ }
420
+ catch (error) {
421
+ // If we can't get existing entities, proceed without cross-thread validation
422
+ }
423
+ // Preserve original entityType values before validation normalizes them
424
+ // This is needed to match warnings to the correct entities
425
+ const originalEntityTypes = entities.map((e) => e.entityType);
426
+ // Run validation (same logic as save_memory but without saving)
427
+ const validationResult = validateSaveMemoryRequest(entities, existingEntityNames);
428
+ // Transform validation result into per-entity format
429
+ const results = new Map();
430
+ // Initialize all entities as valid
431
+ entities.forEach((entity, index) => {
432
+ results.set(index, {
433
+ index: index,
434
+ name: entity.name,
435
+ type: originalEntityTypes[index], // Use original type, not normalized
436
+ valid: true,
437
+ errors: [],
438
+ warnings: []
439
+ });
440
+ });
441
+ // Add errors to corresponding entities
442
+ validationResult.errors.forEach((err) => {
443
+ const result = results.get(err.entityIndex);
444
+ if (result) {
445
+ result.valid = false;
446
+ const errorMsg = err.suggestion
447
+ ? `${err.error} Suggestion: ${err.suggestion}`
448
+ : err.error;
449
+ result.errors.push(errorMsg);
450
+ }
451
+ });
452
+ // Add warnings to corresponding entities
453
+ validationResult.warnings.forEach((warning) => {
454
+ // Parse entity type from warning if possible, otherwise add to first entity
455
+ const entityMatch = warning.match(/EntityType '([^']+)'/);
456
+ if (entityMatch) {
457
+ const warningEntityType = entityMatch[1];
458
+ let attached = false;
459
+ // Find entity whose ORIGINAL entityType matches the type in the warning
460
+ // (warnings contain the original type before normalization)
461
+ originalEntityTypes.forEach((origType, index) => {
462
+ if (origType === warningEntityType) {
463
+ const result = results.get(index);
464
+ if (result) {
465
+ result.warnings.push(warning);
466
+ attached = true;
467
+ }
468
+ }
469
+ });
470
+ // If no matching entity type found, attach to first entity as fallback
471
+ if (!attached) {
472
+ const firstResult = results.get(0);
473
+ if (firstResult) {
474
+ firstResult.warnings.push(warning);
475
+ }
476
+ }
477
+ }
478
+ else {
479
+ // No entity type information in warning; attach to first entity
480
+ const firstResult = results.get(0);
481
+ if (firstResult) {
482
+ firstResult.warnings.push(warning);
483
+ }
484
+ }
485
+ });
486
+ const resultArray = Array.from(results.values());
487
+ const allValid = resultArray.every(r => r.valid);
488
+ // Format response text
489
+ let responseText = allValid
490
+ ? `✓ All ${entities.length} entities are valid and ready to save.\n`
491
+ : `✗ Validation failed for ${resultArray.filter(r => !r.valid).length} of ${entities.length} entities:\n\n`;
492
+ if (!allValid) {
493
+ resultArray.filter(r => !r.valid).forEach(r => {
494
+ responseText += `Entity #${r.index} "${r.name}" (${r.type}):\n`;
495
+ r.errors.forEach(e => responseText += ` - ${e}\n`);
496
+ if (r.warnings.length > 0) {
497
+ r.warnings.forEach(w => responseText += ` ⚠ ${w}\n`);
498
+ }
499
+ responseText += '\n';
500
+ });
501
+ }
502
+ // Add warnings for valid entities if any
503
+ const validWithWarnings = resultArray.filter(r => r.valid && r.warnings.length > 0);
504
+ if (validWithWarnings.length > 0) {
505
+ responseText += 'Warnings:\n';
506
+ validWithWarnings.forEach(r => {
507
+ responseText += `Entity #${r.index} "${r.name}" (${r.type}):\n`;
508
+ r.warnings.forEach(w => responseText += ` ⚠ ${w}\n`);
509
+ });
510
+ }
511
+ return {
512
+ content: [{
513
+ type: "text",
514
+ text: responseText
515
+ }],
516
+ structuredContent: {
517
+ all_valid: allValid,
518
+ results: resultArray
519
+ }
520
+ };
521
+ });
332
522
  // Register get_memory_stats tool
333
523
  server.registerTool("get_memory_stats", {
334
524
  title: "Get Memory Statistics",
@@ -5,8 +5,9 @@
5
5
  /**
6
6
  * Maximum length for observations in characters
7
7
  * Per spec Section 2: Hard Limits on Observation Length
8
+ * Increased to 300 to accommodate technical content (URLs, connection strings, etc.)
8
9
  */
9
- export const MAX_OBSERVATION_LENGTH = 150;
10
+ export const MAX_OBSERVATION_LENGTH = 300;
10
11
  /**
11
12
  * Maximum number of sentences allowed per observation
12
13
  * Per spec Section 2: Hard Limits on Observation Length
@@ -240,10 +240,14 @@ export class JsonlStorageAdapter {
240
240
  * Serialize thread data to JSONL lines
241
241
  */
242
242
  serializeThreadData(threadData) {
243
- return [
244
- ...threadData.entities.map(e => this.serializeEntity(e)),
245
- ...threadData.relations.map(r => this.serializeRelation(r))
246
- ];
243
+ const lines = [];
244
+ for (const e of threadData.entities) {
245
+ lines.push(this.serializeEntity(e));
246
+ }
247
+ for (const r of threadData.relations) {
248
+ lines.push(this.serializeRelation(r));
249
+ }
250
+ return lines;
247
251
  }
248
252
  /**
249
253
  * Save data for a specific thread
@@ -11,6 +11,38 @@ export class KnowledgeGraphManager {
11
11
  this.storage = storageAdapter || new JsonlStorageAdapter(memoryDirPath);
12
12
  // Lazy initialization - will be called on first operation
13
13
  }
14
+ /**
15
+ * Check if content contains any negation words (using word boundary matching)
16
+ * Handles punctuation and contractions, avoids creating intermediate Set for performance
17
+ */
18
+ hasNegation(content) {
19
+ // Extract words using word boundary regex, preserving contractions (include apostrophes)
20
+ const lowerContent = content.toLowerCase();
21
+ const wordMatches = lowerContent.match(/\b[\w']+\b/g);
22
+ if (!wordMatches) {
23
+ return false;
24
+ }
25
+ // Check each word against negation words without creating intermediate Set
26
+ for (const word of wordMatches) {
27
+ if (KnowledgeGraphManager.NEGATION_WORDS.has(word)) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Create a composite key for relation deduplication.
35
+ *
36
+ * We explicitly normalize the components to primitive strings to ensure
37
+ * stable serialization and to document the assumption that `from`, `to`,
38
+ * and `relationType` are simple string identifiers.
39
+ */
40
+ createRelationKey(relation) {
41
+ const from = String(relation.from);
42
+ const to = String(relation.to);
43
+ const relationType = String(relation.relationType);
44
+ return JSON.stringify([from, to, relationType]);
45
+ }
14
46
  /**
15
47
  * Ensure storage is initialized before any operation
16
48
  * This is called automatically by all public methods
@@ -21,12 +53,80 @@ export class KnowledgeGraphManager {
21
53
  }
22
54
  await this.initializePromise;
23
55
  }
56
+ /**
57
+ * Find an entity by name in the knowledge graph.
58
+ * @param graph - The knowledge graph to search
59
+ * @param entityName - Name of the entity to find
60
+ * @returns The found entity
61
+ * @throws Error if entity not found
62
+ */
63
+ findEntity(graph, entityName) {
64
+ const entity = graph.entities.find(e => e.name === entityName);
65
+ if (!entity) {
66
+ throw new Error(`Entity '${entityName}' not found`);
67
+ }
68
+ return entity;
69
+ }
70
+ /**
71
+ * Find an observation by ID within an entity.
72
+ * @param entity - The entity containing the observation
73
+ * @param observationId - ID of the observation to find
74
+ * @returns The found observation
75
+ * @throws Error if observation not found
76
+ */
77
+ findObservation(entity, observationId) {
78
+ const observation = entity.observations.find(o => o.id === observationId);
79
+ if (!observation) {
80
+ throw new Error(`Observation '${observationId}' not found in entity '${entity.name}'`);
81
+ }
82
+ return observation;
83
+ }
84
+ /**
85
+ * Validate that an observation can be updated (not already superseded).
86
+ * @param observation - The observation to validate
87
+ * @throws Error if observation has already been superseded
88
+ */
89
+ validateObservationNotSuperseded(observation) {
90
+ if (observation.superseded_by) {
91
+ throw new Error(`Observation '${observation.id}' has already been superseded by '${observation.superseded_by}'. Update the latest version instead.`);
92
+ }
93
+ }
94
+ /**
95
+ * Resolve confidence value using inheritance chain: params > observation > entity.
96
+ * @param providedValue - Value provided in parameters (optional)
97
+ * @param observationValue - Value from observation (optional)
98
+ * @param entityValue - Value from entity (fallback)
99
+ * @returns Resolved confidence value
100
+ */
101
+ resolveInheritedValue(providedValue, observationValue, entityValue) {
102
+ return providedValue ?? observationValue ?? entityValue;
103
+ }
104
+ /**
105
+ * Create a new observation version from an existing observation.
106
+ * @param oldObs - The observation being updated
107
+ * @param entity - The entity containing the observation
108
+ * @param params - Update parameters
109
+ * @returns New observation with incremented version
110
+ */
111
+ createObservationVersion(oldObs, entity, params) {
112
+ return {
113
+ id: `obs_${randomUUID()}`,
114
+ content: params.newContent,
115
+ timestamp: params.timestamp,
116
+ version: oldObs.version + 1,
117
+ supersedes: oldObs.id,
118
+ agentThreadId: params.agentThreadId,
119
+ confidence: this.resolveInheritedValue(params.confidence, oldObs.confidence, entity.confidence),
120
+ importance: this.resolveInheritedValue(params.importance, oldObs.importance, entity.importance)
121
+ };
122
+ }
24
123
  async createEntities(entities) {
25
124
  await this.ensureInitialized();
26
125
  const graph = await this.storage.loadGraph();
27
126
  // Entity names are globally unique across all threads in the collaborative knowledge graph
28
127
  // This prevents duplicate entities while allowing multiple threads to contribute to the same entity
29
- const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
128
+ const existingNames = new Set(graph.entities.map(e => e.name));
129
+ const newEntities = entities.filter(e => !existingNames.has(e.name));
30
130
  graph.entities.push(...newEntities);
31
131
  await this.storage.saveGraph(graph);
32
132
  return newEntities;
@@ -44,9 +144,15 @@ export class KnowledgeGraphManager {
44
144
  });
45
145
  // Relations are globally unique by (from, to, relationType) across all threads
46
146
  // This enables multiple threads to collaboratively build the knowledge graph
47
- const newRelations = validRelations.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
48
- existingRelation.to === r.to &&
49
- existingRelation.relationType === r.relationType));
147
+ const existingRelationKeys = new Set(graph.relations.map(r => this.createRelationKey(r)));
148
+ // Create composite keys once per valid relation to avoid duplicate serialization
149
+ const validRelationsWithKeys = validRelations.map(r => ({
150
+ relation: r,
151
+ key: this.createRelationKey(r)
152
+ }));
153
+ const newRelations = validRelationsWithKeys
154
+ .filter(item => !existingRelationKeys.has(item.key))
155
+ .map(item => item.relation);
50
156
  graph.relations.push(...newRelations);
51
157
  await this.storage.saveGraph(graph);
52
158
  return newRelations;
@@ -60,10 +166,16 @@ export class KnowledgeGraphManager {
60
166
  }
61
167
  // Check for existing observations with same content to create version chain
62
168
  const newObservations = [];
169
+ // Build a Set of existing observation contents for efficient lookup (single-pass)
170
+ const existingContents = entity.observations.reduce((set, obs) => {
171
+ if (!obs.superseded_by) {
172
+ set.add(obs.content);
173
+ }
174
+ return set;
175
+ }, new Set());
63
176
  for (const content of o.contents) {
64
- // Find if there's an existing observation with this content (latest version)
65
- const existingObs = entity.observations.find(obs => obs.content === content && !obs.superseded_by);
66
- if (existingObs) {
177
+ // Check if observation with this content already exists (latest version)
178
+ if (existingContents.has(content)) {
67
179
  // Don't add duplicate - observation with this content already exists
68
180
  // Versioning is for UPDATES to content, not for re-asserting the same content
69
181
  continue;
@@ -92,8 +204,9 @@ export class KnowledgeGraphManager {
92
204
  }
93
205
  async deleteEntities(entityNames) {
94
206
  const graph = await this.storage.loadGraph();
95
- graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
96
- graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
207
+ const namesToDelete = new Set(entityNames);
208
+ graph.entities = graph.entities.filter(e => !namesToDelete.has(e.name));
209
+ graph.relations = graph.relations.filter(r => !namesToDelete.has(r.from) && !namesToDelete.has(r.to));
97
210
  await this.storage.saveGraph(graph);
98
211
  }
99
212
  async deleteObservations(deletions) {
@@ -107,6 +220,41 @@ export class KnowledgeGraphManager {
107
220
  });
108
221
  await this.storage.saveGraph(graph);
109
222
  }
223
+ /**
224
+ * Update an existing observation by creating a new version with updated content.
225
+ * This maintains the version history through the supersedes/superseded_by chain.
226
+ *
227
+ * @param params - Update parameters
228
+ * @param params.entityName - Name of the entity containing the observation
229
+ * @param params.observationId - ID of the observation to update
230
+ * @param params.newContent - New content for the observation
231
+ * @param params.agentThreadId - Agent thread ID making this update
232
+ * @param params.timestamp - ISO 8601 timestamp of the update
233
+ * @param params.confidence - Optional confidence score (0-1), inherits from old observation if not provided
234
+ * @param params.importance - Optional importance score (0-1), inherits from old observation if not provided
235
+ * @returns The newly created observation with incremented version number
236
+ * @throws Error if entity not found
237
+ * @throws Error if observation not found
238
+ * @throws Error if observation has already been superseded (must update latest version)
239
+ */
240
+ async updateObservation(params) {
241
+ await this.ensureInitialized();
242
+ const graph = await this.storage.loadGraph();
243
+ // Find and validate the entity and observation
244
+ const entity = this.findEntity(graph, params.entityName);
245
+ const oldObs = this.findObservation(entity, params.observationId);
246
+ this.validateObservationNotSuperseded(oldObs);
247
+ // Create new version with inheritance chain
248
+ const newObs = this.createObservationVersion(oldObs, entity, params);
249
+ // Link old observation to new one
250
+ oldObs.superseded_by = newObs.id;
251
+ // Add new observation to entity
252
+ entity.observations.push(newObs);
253
+ // Update entity timestamp
254
+ entity.timestamp = params.timestamp;
255
+ await this.storage.saveGraph(graph);
256
+ return newObs;
257
+ }
110
258
  async deleteRelations(relations) {
111
259
  const graph = await this.storage.loadGraph();
112
260
  // Delete relations globally across all threads by matching (from, to, relationType)
@@ -203,6 +351,69 @@ export class KnowledgeGraphManager {
203
351
  relations: filteredRelations,
204
352
  };
205
353
  }
354
+ /**
355
+ * Get names of all entities that can be referenced in relations.
356
+ * @returns Set of entity names that exist in the graph.
357
+ *
358
+ * Note: Returns ALL entities globally because entity names are globally unique across
359
+ * all threads in the collaborative knowledge graph (by design - see createEntities).
360
+ * This enables any thread to reference any existing entity, supporting incremental
361
+ * building and cross-thread collaboration. Thread-specific filtering is not needed
362
+ * since entity names cannot conflict across threads.
363
+ */
364
+ async getAllEntityNames() {
365
+ const graph = await this.storage.loadGraph();
366
+ const entityNames = new Set();
367
+ // Return all entities in the graph that can be referenced
368
+ // This allows incremental building: entities from previous save_memory calls
369
+ // can be referenced in new calls, enabling cross-save entity relations
370
+ for (const entity of graph.entities) {
371
+ entityNames.add(entity.name);
372
+ }
373
+ return entityNames;
374
+ }
375
+ /**
376
+ * @deprecated Use {@link getAllEntityNames} instead.
377
+ *
378
+ * This method is kept for backward compatibility. It accepts a threadId parameter
379
+ * for API consistency but does not use it for filtering; it returns the same
380
+ * global set of entity names as {@link getAllEntityNames}.
381
+ *
382
+ * @param threadId The thread ID (accepted but not used)
383
+ * @returns Set of entity names that exist in the graph
384
+ */
385
+ async getEntityNamesInThread(threadId) {
386
+ return this.getAllEntityNames();
387
+ }
388
+ /**
389
+ * List entities with optional filtering by type and name pattern
390
+ * @param threadId Optional thread ID to filter by. If not provided, returns entities from all threads.
391
+ * @param entityType Optional entity type filter (exact match)
392
+ * @param namePattern Optional name pattern filter (case-insensitive substring match)
393
+ * @returns Array of entities with name and entityType
394
+ */
395
+ async listEntities(threadId, entityType, namePattern) {
396
+ const graph = await this.storage.loadGraph();
397
+ let filteredEntities = graph.entities;
398
+ // Filter by thread ID if specified (otherwise returns all threads)
399
+ if (threadId) {
400
+ filteredEntities = filteredEntities.filter(e => e.agentThreadId === threadId);
401
+ }
402
+ // Filter by entity type if specified
403
+ if (entityType) {
404
+ filteredEntities = filteredEntities.filter(e => e.entityType === entityType);
405
+ }
406
+ // Filter by name pattern if specified (case-insensitive)
407
+ if (namePattern) {
408
+ const pattern = namePattern.toLowerCase();
409
+ filteredEntities = filteredEntities.filter(e => e.name.toLowerCase().includes(pattern));
410
+ }
411
+ // Return simplified list with just name and entityType
412
+ return filteredEntities.map(e => ({
413
+ name: e.name,
414
+ entityType: e.entityType
415
+ }));
416
+ }
206
417
  // Enhancement 1: Memory Statistics & Insights
207
418
  async getMemoryStats() {
208
419
  const graph = await this.storage.loadGraph();
@@ -265,6 +476,19 @@ export class KnowledgeGraphManager {
265
476
  if (from === to) {
266
477
  return { found: true, path: [from], relations: [] };
267
478
  }
479
+ // Build indexes for efficient relation lookup
480
+ const relationsFrom = new Map();
481
+ const relationsTo = new Map();
482
+ for (const rel of graph.relations) {
483
+ if (!relationsFrom.has(rel.from)) {
484
+ relationsFrom.set(rel.from, []);
485
+ }
486
+ relationsFrom.get(rel.from).push(rel);
487
+ if (!relationsTo.has(rel.to)) {
488
+ relationsTo.set(rel.to, []);
489
+ }
490
+ relationsTo.get(rel.to).push(rel);
491
+ }
268
492
  // BFS to find shortest path
269
493
  const queue = [
270
494
  { entity: from, path: [from], relations: [] }
@@ -276,8 +500,8 @@ export class KnowledgeGraphManager {
276
500
  continue;
277
501
  }
278
502
  // Find all relations connected to current entity (both outgoing and incoming for bidirectional search)
279
- const outgoing = graph.relations.filter(r => r.from === current.entity);
280
- const incoming = graph.relations.filter(r => r.to === current.entity);
503
+ const outgoing = relationsFrom.get(current.entity) || [];
504
+ const incoming = relationsTo.get(current.entity) || [];
281
505
  // Check outgoing relations
282
506
  for (const rel of outgoing) {
283
507
  if (rel.to === to) {
@@ -335,8 +559,8 @@ export class KnowledgeGraphManager {
335
559
  continue;
336
560
  }
337
561
  // Check for negation patterns
338
- const obs1HasNegation = Array.from(KnowledgeGraphManager.NEGATION_WORDS).some(word => obs1Content.includes(word));
339
- const obs2HasNegation = Array.from(KnowledgeGraphManager.NEGATION_WORDS).some(word => obs2Content.includes(word));
562
+ const obs1HasNegation = this.hasNegation(obs1Content);
563
+ const obs2HasNegation = this.hasNegation(obs2Content);
340
564
  // If one has negation and they share key words, might be a conflict
341
565
  if (obs1HasNegation !== obs2HasNegation) {
342
566
  const words1 = obs1Content.split(/\s+/).filter(w => w.length > 3);
@@ -9,18 +9,57 @@ import { randomUUID } from 'crypto';
9
9
  * Saves entities and their relations to the knowledge graph atomically
10
10
  * Either all entities + relations succeed, or none are saved (rollback)
11
11
  */
12
- export async function handleSaveMemory(input, createEntitiesFn, createRelationsFn) {
12
+ export async function handleSaveMemory(input, createEntitiesFn, createRelationsFn, getExistingEntityNamesFn) {
13
13
  const timestamp = new Date().toISOString();
14
- // Validate the entire request
15
- const validationResult = validateSaveMemoryRequest(input.entities);
14
+ // Get existing entity names for cross-thread reference validation
15
+ let existingEntityNames;
16
+ if (getExistingEntityNamesFn) {
17
+ try {
18
+ existingEntityNames = await getExistingEntityNamesFn(input.threadId);
19
+ }
20
+ catch (error) {
21
+ // If we can't get existing entities, proceed without cross-thread validation
22
+ console.warn(`Failed to get existing entities for thread ${input.threadId}:`, error);
23
+ }
24
+ }
25
+ // Validate the entire request (with cross-thread entity reference support)
26
+ const validationResult = validateSaveMemoryRequest(input.entities, existingEntityNames);
16
27
  if (!validationResult.valid) {
17
- // Return validation errors
28
+ // Group errors by entity for better structure
29
+ const errorsByEntity = new Map();
30
+ for (const err of validationResult.errors) {
31
+ if (!errorsByEntity.has(err.entityIndex)) {
32
+ errorsByEntity.set(err.entityIndex, {
33
+ entity_name: err.entity,
34
+ entity_type: err.entityType,
35
+ errors: [],
36
+ observations: []
37
+ });
38
+ }
39
+ const entityErrors = errorsByEntity.get(err.entityIndex);
40
+ const errorMsg = err.suggestion
41
+ ? `${err.error} Suggestion: ${err.suggestion}`
42
+ : err.error;
43
+ entityErrors.errors.push(errorMsg);
44
+ if (err.observationPreview) {
45
+ entityErrors.observations.push(err.observationPreview);
46
+ }
47
+ }
48
+ // Convert to structured format
49
+ const structuredErrors = Array.from(errorsByEntity.entries()).map(([index, data]) => ({
50
+ entity_index: index,
51
+ entity_name: data.entity_name,
52
+ entity_type: data.entity_type,
53
+ errors: data.errors,
54
+ observations: data.observations.length > 0 ? data.observations : undefined
55
+ }));
56
+ // Return validation errors with detailed structure
18
57
  return {
19
58
  success: false,
20
59
  created: { entities: 0, relations: 0 },
21
60
  warnings: [],
22
61
  quality_score: 0,
23
- validation_errors: validationResult.errors.map(err => `${err.entity}: ${err.error}${err.suggestion ? ` Suggestion: ${err.suggestion}` : ''}`)
62
+ validation_errors: structuredErrors
24
63
  };
25
64
  }
26
65
  try {
@@ -77,11 +116,14 @@ export async function handleSaveMemory(input, createEntitiesFn, createRelationsF
77
116
  const createdRelations = await createRelationsFn(relations);
78
117
  // Calculate quality score
79
118
  const qualityScore = calculateQualityScore(input.entities);
119
+ // Extract entity names for reference in subsequent calls
120
+ const entityNames = createdEntities.map(e => e.name);
80
121
  return {
81
122
  success: true,
82
123
  created: {
83
124
  entities: createdEntities.length,
84
- relations: createdRelations.length
125
+ relations: createdRelations.length,
126
+ entity_names: entityNames
85
127
  },
86
128
  warnings: validationResult.warnings,
87
129
  quality_score: qualityScore
@@ -42,7 +42,7 @@ export const SaveMemoryRelationSchema = z.object({
42
42
  export const SaveMemoryEntitySchema = z.object({
43
43
  name: z.string().min(1).max(100).describe("Unique identifier for the entity"),
44
44
  entityType: z.string().min(1).max(50).describe("Type of entity (e.g., Person, Document, File, or custom types like Patient, API). Convention: start with capital letter."),
45
- observations: z.array(z.string().min(5).max(150).describe("Atomic fact, max 150 chars")).min(1).describe("Array of atomic facts. Each must be ONE fact, max 150 chars."),
45
+ observations: z.array(z.string().min(5).max(300).describe("Atomic fact, max 300 chars (increased to accommodate technical content)")).min(1).describe("Array of atomic facts. Each must be ONE fact, max 300 chars."),
46
46
  relations: z.array(SaveMemoryRelationSchema)
47
47
  .min(1)
48
48
  .describe("REQUIRED: Every entity must have at least 1 relation"),
@@ -57,7 +57,8 @@ export const SaveMemoryOutputSchema = z.object({
57
57
  success: z.boolean(),
58
58
  created: z.object({
59
59
  entities: z.number(),
60
- relations: z.number()
60
+ relations: z.number(),
61
+ entity_names: z.array(z.string()).optional().describe("Names of created entities (for reference in subsequent calls)")
61
62
  }),
62
63
  warnings: z.array(z.string()),
63
64
  quality_score: z.number().min(0).max(1),
@@ -100,3 +101,46 @@ export const GetObservationHistoryInputSchema = z.object({
100
101
  export const GetObservationHistoryOutputSchema = z.object({
101
102
  history: z.array(ObservationSchema).describe("Full version chain of the observation, chronologically ordered")
102
103
  });
104
+ // Schema for list_entities tool (Simple Entity Lookup)
105
+ export const ListEntitiesInputSchema = z.object({
106
+ threadId: z.string().optional().describe("Filter by thread ID (optional - returns entities from all threads if not specified)"),
107
+ entityType: z.string().optional().describe("Filter by entity type (e.g., 'Person', 'Service', 'Document')"),
108
+ namePattern: z.string().optional().describe("Filter by name pattern (case-insensitive substring match)")
109
+ });
110
+ export const ListEntitiesOutputSchema = z.object({
111
+ entities: z.array(z.object({
112
+ name: z.string(),
113
+ entityType: z.string()
114
+ })).describe("List of entities matching the filters")
115
+ });
116
+ // Schema for validate_memory tool (Pre-Validation)
117
+ export const ValidateMemoryInputSchema = z.object({
118
+ entities: z.array(SaveMemoryEntitySchema).min(1).describe("Array of entities to validate"),
119
+ threadId: z.string().min(1).describe("Thread ID for this conversation/project")
120
+ });
121
+ export const ValidateMemoryOutputSchema = z.object({
122
+ all_valid: z.boolean().describe("True if all entities pass validation"),
123
+ results: z.array(z.object({
124
+ index: z.number().describe("Entity index in the input array"),
125
+ name: z.string().describe("Entity name"),
126
+ type: z.string().describe("Entity type"),
127
+ valid: z.boolean().describe("True if this entity passes validation"),
128
+ errors: z.array(z.string()).describe("List of validation errors"),
129
+ warnings: z.array(z.string()).describe("List of validation warnings")
130
+ })).describe("Validation results for each entity")
131
+ });
132
+ // Schema for update_observation tool
133
+ export const UpdateObservationInputSchema = z.object({
134
+ entityName: z.string().min(1).describe("Name of the entity containing the observation"),
135
+ observationId: z.string().min(1).describe("ID of the observation to update"),
136
+ newContent: z.string().min(1).max(300).describe("New content for the observation (max 300 chars). Minimum 1 character to allow short but valid observations like abbreviations or single words."),
137
+ agentThreadId: z.string().min(1).describe("Agent thread ID making this update"),
138
+ timestamp: z.string().describe("ISO 8601 timestamp of the update"),
139
+ confidence: z.number().min(0).max(1).optional().describe("Optional confidence score (0-1), inherits from old observation if not provided"),
140
+ importance: z.number().min(0).max(1).optional().describe("Optional importance score (0-1), inherits from old observation if not provided")
141
+ });
142
+ export const UpdateObservationOutputSchema = z.object({
143
+ success: z.boolean(),
144
+ updatedObservation: ObservationSchema.describe("The new version of the observation"),
145
+ message: z.string()
146
+ });
@@ -3,24 +3,39 @@
3
3
  */
4
4
  import { MAX_OBSERVATION_LENGTH, MIN_OBSERVATION_LENGTH, MAX_SENTENCES, SENTENCE_TERMINATORS, TARGET_AVG_RELATIONS, RELATION_SCORE_WEIGHT, OBSERVATION_SCORE_WEIGHT } from './constants.js';
5
5
  /**
6
- * Counts actual sentences in text, ignoring periods in version numbers and decimals
6
+ * Counts actual sentences in text, ignoring periods in technical content
7
7
  * @param text The text to analyze
8
8
  * @returns Number of actual sentences
9
9
  */
10
10
  function countSentences(text) {
11
- // Remove version numbers (e.g., 1.2.0, v5.4.3, V2.1.0) and decimal numbers before counting
12
- // This prevents false positives where technical data is incorrectly counted as sentences
13
- // Using explicit case handling [vV] for version prefix
14
- const cleaned = text
15
- .replace(/\b[vV]?\d+\.\d+(\.\d+)*\b/g, 'VERSION'); // handles version numbers and decimals
16
- // Split on actual sentence terminators
11
+ // Patterns to ignore - technical content that contains periods but aren't sentence boundaries
12
+ // NOTE: Order matters! More specific patterns (multi-letter abbreviations) must come before more general patterns
13
+ const patternsToIgnore = [
14
+ /https?:\/\/[^\s]+/g, // URLs (http:// or https://) - allows periods in paths
15
+ /\b\d+\.\d+\.\d+\.\d+\b/g, // IP addresses (e.g., 192.168.1.1)
16
+ /\b[A-Za-z]:[\\\/](?:[^\s<>:"|?*]+(?:\s+[^\s<>:"|?*]+)*)/g, // Windows/Unix paths (handles spaces, e.g., C:\Program Files\...)
17
+ /\b[vV]?\d+\.\d+(\.\d+)*\b/g, // Version numbers (e.g., v1.2.0, 5.4.3)
18
+ /\b(?:[A-Z]\.){2,}/g, // Multi-letter abbreviations (e.g., U.S., U.K., U.S.A., P.D.F., I.B.M., etc.) - must come before single-letter pattern
19
+ /\b[A-Z][a-z]{0,3}\./g, // Common single-letter abbreviations (e.g., Dr., Mr., Mrs., Ms., Jr., Sr., etc.)
20
+ /\b[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?){2,}\b/g, // Hostnames/domains with at least 2 dots (e.g., sub.domain.com) - must come after all abbreviation patterns
21
+ ];
22
+ // Replace technical patterns with placeholders to prevent false sentence detection
23
+ let cleaned = text;
24
+ for (const pattern of patternsToIgnore) {
25
+ cleaned = cleaned.replace(pattern, 'PLACEHOLDER');
26
+ }
27
+ // Split on actual sentence terminators and count non-empty sentences
17
28
  const sentences = cleaned.split(SENTENCE_TERMINATORS).filter(s => s.trim().length > 0);
18
29
  return sentences.length;
19
30
  }
31
+ /**
32
+ * Maximum length for observation preview in error messages
33
+ */
34
+ const OBSERVATION_PREVIEW_LENGTH = 50;
20
35
  /**
21
36
  * Validates a single observation according to spec requirements:
22
37
  * - Min 5 characters
23
- * - Max 150 characters
38
+ * - Max 300 characters (increased to accommodate technical content)
24
39
  * - Max 3 sentences (ignoring periods in version numbers and decimals)
25
40
  */
26
41
  export function validateObservation(obs) {
@@ -35,7 +50,7 @@ export function validateObservation(obs) {
35
50
  return {
36
51
  valid: false,
37
52
  error: `Observation too long (${obs.length} chars). Max ${MAX_OBSERVATION_LENGTH}.`,
38
- suggestion: `Split into multiple observations.`
53
+ suggestion: `Split into atomic facts.`
39
54
  };
40
55
  }
41
56
  const sentenceCount = countSentences(obs);
@@ -62,15 +77,20 @@ export function validateEntityRelations(entity) {
62
77
  return { valid: true };
63
78
  }
64
79
  /**
65
- * Validates that relation targets exist in the same request
80
+ * Validates that relation targets exist in the same request or in existing entities
81
+ * @param entity The entity whose relations to validate
82
+ * @param allEntityNames Set of entity names in the current request
83
+ * @param existingEntityNames Optional set of entity names that already exist in storage (for cross-thread references)
66
84
  */
67
- export function validateRelationTargets(entity, allEntityNames) {
85
+ export function validateRelationTargets(entity, allEntityNames, existingEntityNames) {
68
86
  for (const relation of entity.relations) {
69
- if (!allEntityNames.has(relation.targetEntity)) {
87
+ const targetInCurrentBatch = allEntityNames.has(relation.targetEntity);
88
+ const targetInExisting = existingEntityNames?.has(relation.targetEntity) ?? false;
89
+ if (!targetInCurrentBatch && !targetInExisting) {
70
90
  return {
71
91
  valid: false,
72
- error: `Target entity '${relation.targetEntity}' not found in request`,
73
- suggestion: `targetEntity must reference another entity in the same save_memory call`
92
+ error: `Target entity '${relation.targetEntity}' not found in request or existing entities`,
93
+ suggestion: `targetEntity must reference another entity in the same save_memory call or an existing entity`
74
94
  };
75
95
  }
76
96
  }
@@ -99,15 +119,23 @@ export function normalizeEntityType(entityType) {
99
119
  }
100
120
  return { normalized, warnings };
101
121
  }
102
- export function validateSaveMemoryRequest(entities) {
122
+ /**
123
+ * Validates all aspects of a save_memory request
124
+ * @param entities The entities to validate
125
+ * @param existingEntityNames Optional set of entity names that already exist in storage (for cross-thread references)
126
+ */
127
+ export function validateSaveMemoryRequest(entities, existingEntityNames) {
103
128
  const errors = [];
104
129
  const warnings = [];
105
130
  // Collect all entity names for relation validation
106
131
  const entityNames = new Set(entities.map(e => e.name));
107
- for (const entity of entities) {
132
+ for (let entityIndex = 0; entityIndex < entities.length; entityIndex++) {
133
+ const entity = entities[entityIndex];
108
134
  // Validate entity type and collect warnings
135
+ // Note: entityType is normalized in-place for consistency with existing behavior
136
+ // The normalized value is used throughout the rest of validation and saving
109
137
  const { normalized, warnings: typeWarnings } = normalizeEntityType(entity.entityType);
110
- entity.entityType = normalized; // Apply normalization
138
+ entity.entityType = normalized; // Apply normalization (intentional mutation for consistency)
111
139
  warnings.push(...typeWarnings);
112
140
  // Validate observations (note: observations are still strings in SaveMemoryEntity input)
113
141
  for (let i = 0; i < entity.observations.length; i++) {
@@ -115,8 +143,12 @@ export function validateSaveMemoryRequest(entities) {
115
143
  if (!obsResult.valid) {
116
144
  errors.push({
117
145
  entity: entity.name,
146
+ entityIndex: entityIndex,
147
+ entityType: entity.entityType,
118
148
  error: `Observation ${i + 1}: ${obsResult.error}`,
119
- suggestion: obsResult.suggestion
149
+ suggestion: obsResult.suggestion,
150
+ observationPreview: entity.observations[i].substring(0, OBSERVATION_PREVIEW_LENGTH) +
151
+ (entity.observations[i].length > OBSERVATION_PREVIEW_LENGTH ? '...' : '')
120
152
  });
121
153
  }
122
154
  }
@@ -125,15 +157,19 @@ export function validateSaveMemoryRequest(entities) {
125
157
  if (!relResult.valid) {
126
158
  errors.push({
127
159
  entity: entity.name,
160
+ entityIndex: entityIndex,
161
+ entityType: entity.entityType,
128
162
  error: relResult.error || 'Invalid relations',
129
163
  suggestion: relResult.suggestion
130
164
  });
131
165
  }
132
- // Validate relation targets
133
- const targetResult = validateRelationTargets(entity, entityNames);
166
+ // Validate relation targets (now supports cross-thread references)
167
+ const targetResult = validateRelationTargets(entity, entityNames, existingEntityNames);
134
168
  if (!targetResult.valid) {
135
169
  errors.push({
136
170
  entity: entity.name,
171
+ entityIndex: entityIndex,
172
+ entityType: entity.entityType,
137
173
  error: targetResult.error || 'Invalid relation target',
138
174
  suggestion: targetResult.suggestion
139
175
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "server-memory-enhanced",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Enhanced MCP server for memory with agent threading, timestamps, and confidence scoring",
5
5
  "license": "MIT",
6
6
  "mcpName": "io.github.modelcontextprotocol/server-memory-enhanced",