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 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 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
@@ -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",
@@ -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
@@ -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
- // 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,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
+ });
@@ -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.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",