server-memory-enhanced 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +171 -7
- package/dist/lib/constants.js +2 -1
- package/dist/lib/knowledge-graph-manager.js +63 -0
- package/dist/lib/save-memory-handler.js +48 -6
- package/dist/lib/schemas.js +31 -2
- package/dist/lib/validation.js +56 -20
- package/package.json +1 -1
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 } 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
|
|
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:
|
|
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:
|
|
164
|
+
text: errorText
|
|
134
165
|
}],
|
|
135
166
|
structuredContent: result,
|
|
136
167
|
isError: true
|
|
@@ -329,6 +360,139 @@ server.registerTool("query_nodes", {
|
|
|
329
360
|
structuredContent: { ...graph }
|
|
330
361
|
};
|
|
331
362
|
});
|
|
363
|
+
// Register list_entities tool for simple entity lookup
|
|
364
|
+
server.registerTool("list_entities", {
|
|
365
|
+
title: "List Entities",
|
|
366
|
+
description: "List entities with optional filtering by entity type and name pattern. Returns a simple list of entity names and types for quick discovery.",
|
|
367
|
+
inputSchema: ListEntitiesInputSchema,
|
|
368
|
+
outputSchema: ListEntitiesOutputSchema
|
|
369
|
+
}, async (input) => {
|
|
370
|
+
const { threadId, entityType, namePattern } = input;
|
|
371
|
+
const entities = await knowledgeGraphManager.listEntities(threadId, entityType, namePattern);
|
|
372
|
+
return {
|
|
373
|
+
content: [{
|
|
374
|
+
type: "text",
|
|
375
|
+
text: `Found ${entities.length} entities:\n` +
|
|
376
|
+
entities.map(e => ` - ${e.name} (${e.entityType})`).join('\n')
|
|
377
|
+
}],
|
|
378
|
+
structuredContent: { entities }
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
// Register validate_memory tool for pre-validation (dry-run)
|
|
382
|
+
server.registerTool("validate_memory", {
|
|
383
|
+
title: "Validate Memory",
|
|
384
|
+
description: "Validate entities without saving (dry-run). Check for errors before attempting save_memory. Returns detailed validation results per entity.",
|
|
385
|
+
inputSchema: ValidateMemoryInputSchema,
|
|
386
|
+
outputSchema: ValidateMemoryOutputSchema
|
|
387
|
+
}, async (input) => {
|
|
388
|
+
const { entities, threadId } = input;
|
|
389
|
+
// Get existing entity names for cross-thread reference validation
|
|
390
|
+
let existingEntityNames;
|
|
391
|
+
try {
|
|
392
|
+
existingEntityNames = await knowledgeGraphManager.getEntityNamesInThread(threadId);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
// If we can't get existing entities, proceed without cross-thread validation
|
|
396
|
+
}
|
|
397
|
+
// Preserve original entityType values before validation normalizes them
|
|
398
|
+
// This is needed to match warnings to the correct entities
|
|
399
|
+
const originalEntityTypes = entities.map((e) => e.entityType);
|
|
400
|
+
// Run validation (same logic as save_memory but without saving)
|
|
401
|
+
const validationResult = validateSaveMemoryRequest(entities, existingEntityNames);
|
|
402
|
+
// Transform validation result into per-entity format
|
|
403
|
+
const results = new Map();
|
|
404
|
+
// Initialize all entities as valid
|
|
405
|
+
entities.forEach((entity, index) => {
|
|
406
|
+
results.set(index, {
|
|
407
|
+
index: index,
|
|
408
|
+
name: entity.name,
|
|
409
|
+
type: originalEntityTypes[index], // Use original type, not normalized
|
|
410
|
+
valid: true,
|
|
411
|
+
errors: [],
|
|
412
|
+
warnings: []
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
// Add errors to corresponding entities
|
|
416
|
+
validationResult.errors.forEach((err) => {
|
|
417
|
+
const result = results.get(err.entityIndex);
|
|
418
|
+
if (result) {
|
|
419
|
+
result.valid = false;
|
|
420
|
+
const errorMsg = err.suggestion
|
|
421
|
+
? `${err.error} Suggestion: ${err.suggestion}`
|
|
422
|
+
: err.error;
|
|
423
|
+
result.errors.push(errorMsg);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
// Add warnings to corresponding entities
|
|
427
|
+
validationResult.warnings.forEach((warning) => {
|
|
428
|
+
// Parse entity type from warning if possible, otherwise add to first entity
|
|
429
|
+
const entityMatch = warning.match(/EntityType '([^']+)'/);
|
|
430
|
+
if (entityMatch) {
|
|
431
|
+
const warningEntityType = entityMatch[1];
|
|
432
|
+
let attached = false;
|
|
433
|
+
// Find entity whose ORIGINAL entityType matches the type in the warning
|
|
434
|
+
// (warnings contain the original type before normalization)
|
|
435
|
+
originalEntityTypes.forEach((origType, index) => {
|
|
436
|
+
if (origType === warningEntityType) {
|
|
437
|
+
const result = results.get(index);
|
|
438
|
+
if (result) {
|
|
439
|
+
result.warnings.push(warning);
|
|
440
|
+
attached = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// If no matching entity type found, attach to first entity as fallback
|
|
445
|
+
if (!attached) {
|
|
446
|
+
const firstResult = results.get(0);
|
|
447
|
+
if (firstResult) {
|
|
448
|
+
firstResult.warnings.push(warning);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
// No entity type information in warning; attach to first entity
|
|
454
|
+
const firstResult = results.get(0);
|
|
455
|
+
if (firstResult) {
|
|
456
|
+
firstResult.warnings.push(warning);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
const resultArray = Array.from(results.values());
|
|
461
|
+
const allValid = resultArray.every(r => r.valid);
|
|
462
|
+
// Format response text
|
|
463
|
+
let responseText = allValid
|
|
464
|
+
? `✓ All ${entities.length} entities are valid and ready to save.\n`
|
|
465
|
+
: `✗ Validation failed for ${resultArray.filter(r => !r.valid).length} of ${entities.length} entities:\n\n`;
|
|
466
|
+
if (!allValid) {
|
|
467
|
+
resultArray.filter(r => !r.valid).forEach(r => {
|
|
468
|
+
responseText += `Entity #${r.index} "${r.name}" (${r.type}):\n`;
|
|
469
|
+
r.errors.forEach(e => responseText += ` - ${e}\n`);
|
|
470
|
+
if (r.warnings.length > 0) {
|
|
471
|
+
r.warnings.forEach(w => responseText += ` ⚠ ${w}\n`);
|
|
472
|
+
}
|
|
473
|
+
responseText += '\n';
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
// Add warnings for valid entities if any
|
|
477
|
+
const validWithWarnings = resultArray.filter(r => r.valid && r.warnings.length > 0);
|
|
478
|
+
if (validWithWarnings.length > 0) {
|
|
479
|
+
responseText += 'Warnings:\n';
|
|
480
|
+
validWithWarnings.forEach(r => {
|
|
481
|
+
responseText += `Entity #${r.index} "${r.name}" (${r.type}):\n`;
|
|
482
|
+
r.warnings.forEach(w => responseText += ` ⚠ ${w}\n`);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
content: [{
|
|
487
|
+
type: "text",
|
|
488
|
+
text: responseText
|
|
489
|
+
}],
|
|
490
|
+
structuredContent: {
|
|
491
|
+
all_valid: allValid,
|
|
492
|
+
results: resultArray
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
});
|
|
332
496
|
// Register get_memory_stats tool
|
|
333
497
|
server.registerTool("get_memory_stats", {
|
|
334
498
|
title: "Get Memory Statistics",
|
package/dist/lib/constants.js
CHANGED
|
@@ -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 =
|
|
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
|
|
@@ -203,6 +203,69 @@ export class KnowledgeGraphManager {
|
|
|
203
203
|
relations: filteredRelations,
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Get names of all entities that can be referenced in relations.
|
|
208
|
+
* @returns Set of entity names that exist in the graph.
|
|
209
|
+
*
|
|
210
|
+
* Note: Returns ALL entities globally because entity names are globally unique across
|
|
211
|
+
* all threads in the collaborative knowledge graph (by design - see createEntities).
|
|
212
|
+
* This enables any thread to reference any existing entity, supporting incremental
|
|
213
|
+
* building and cross-thread collaboration. Thread-specific filtering is not needed
|
|
214
|
+
* since entity names cannot conflict across threads.
|
|
215
|
+
*/
|
|
216
|
+
async getAllEntityNames() {
|
|
217
|
+
const graph = await this.storage.loadGraph();
|
|
218
|
+
const entityNames = new Set();
|
|
219
|
+
// Return all entities in the graph that can be referenced
|
|
220
|
+
// This allows incremental building: entities from previous save_memory calls
|
|
221
|
+
// can be referenced in new calls, enabling cross-save entity relations
|
|
222
|
+
for (const entity of graph.entities) {
|
|
223
|
+
entityNames.add(entity.name);
|
|
224
|
+
}
|
|
225
|
+
return entityNames;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* @deprecated Use {@link getAllEntityNames} instead.
|
|
229
|
+
*
|
|
230
|
+
* This method is kept for backward compatibility. It accepts a threadId parameter
|
|
231
|
+
* for API consistency but does not use it for filtering; it returns the same
|
|
232
|
+
* global set of entity names as {@link getAllEntityNames}.
|
|
233
|
+
*
|
|
234
|
+
* @param threadId The thread ID (accepted but not used)
|
|
235
|
+
* @returns Set of entity names that exist in the graph
|
|
236
|
+
*/
|
|
237
|
+
async getEntityNamesInThread(threadId) {
|
|
238
|
+
return this.getAllEntityNames();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* List entities with optional filtering by type and name pattern
|
|
242
|
+
* @param threadId Optional thread ID to filter by. If not provided, returns entities from all threads.
|
|
243
|
+
* @param entityType Optional entity type filter (exact match)
|
|
244
|
+
* @param namePattern Optional name pattern filter (case-insensitive substring match)
|
|
245
|
+
* @returns Array of entities with name and entityType
|
|
246
|
+
*/
|
|
247
|
+
async listEntities(threadId, entityType, namePattern) {
|
|
248
|
+
const graph = await this.storage.loadGraph();
|
|
249
|
+
let filteredEntities = graph.entities;
|
|
250
|
+
// Filter by thread ID if specified (otherwise returns all threads)
|
|
251
|
+
if (threadId) {
|
|
252
|
+
filteredEntities = filteredEntities.filter(e => e.agentThreadId === threadId);
|
|
253
|
+
}
|
|
254
|
+
// Filter by entity type if specified
|
|
255
|
+
if (entityType) {
|
|
256
|
+
filteredEntities = filteredEntities.filter(e => e.entityType === entityType);
|
|
257
|
+
}
|
|
258
|
+
// Filter by name pattern if specified (case-insensitive)
|
|
259
|
+
if (namePattern) {
|
|
260
|
+
const pattern = namePattern.toLowerCase();
|
|
261
|
+
filteredEntities = filteredEntities.filter(e => e.name.toLowerCase().includes(pattern));
|
|
262
|
+
}
|
|
263
|
+
// Return simplified list with just name and entityType
|
|
264
|
+
return filteredEntities.map(e => ({
|
|
265
|
+
name: e.name,
|
|
266
|
+
entityType: e.entityType
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
206
269
|
// Enhancement 1: Memory Statistics & Insights
|
|
207
270
|
async getMemoryStats() {
|
|
208
271
|
const graph = await this.storage.loadGraph();
|
|
@@ -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
|
-
//
|
|
15
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
package/dist/lib/schemas.js
CHANGED
|
@@ -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(
|
|
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,31 @@ 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
|
+
});
|
package/dist/lib/validation.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "2.3.0",
|
|
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",
|