vesper-memory 0.5.4 → 0.6.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -6
  3. package/config/sqlite-schema.sql +31 -0
  4. package/dist/cli.js +6 -49
  5. package/dist/cli.js.map +1 -1
  6. package/dist/consolidation/pipeline.d.ts +25 -6
  7. package/dist/consolidation/pipeline.d.ts.map +1 -1
  8. package/dist/consolidation/pipeline.js +310 -67
  9. package/dist/consolidation/pipeline.js.map +1 -1
  10. package/dist/memory-layers/semantic-memory.d.ts +8 -3
  11. package/dist/memory-layers/semantic-memory.d.ts.map +1 -1
  12. package/dist/memory-layers/semantic-memory.js +110 -50
  13. package/dist/memory-layers/semantic-memory.js.map +1 -1
  14. package/dist/memory-layers/skill-library.d.ts +9 -9
  15. package/dist/memory-layers/skill-library.d.ts.map +1 -1
  16. package/dist/memory-layers/skill-library.js +85 -56
  17. package/dist/memory-layers/skill-library.js.map +1 -1
  18. package/dist/memory-layers/working-memory.d.ts +2 -2
  19. package/dist/memory-layers/working-memory.d.ts.map +1 -1
  20. package/dist/memory-layers/working-memory.js +57 -47
  21. package/dist/memory-layers/working-memory.js.map +1 -1
  22. package/dist/retrieval/hybrid-search.d.ts.map +1 -1
  23. package/dist/retrieval/hybrid-search.js +10 -5
  24. package/dist/retrieval/hybrid-search.js.map +1 -1
  25. package/dist/retrieval/index-outbox.d.ts +50 -0
  26. package/dist/retrieval/index-outbox.d.ts.map +1 -0
  27. package/dist/retrieval/index-outbox.js +225 -0
  28. package/dist/retrieval/index-outbox.js.map +1 -0
  29. package/dist/retrieval/memory-retriever.d.ts +123 -0
  30. package/dist/retrieval/memory-retriever.d.ts.map +1 -0
  31. package/dist/retrieval/memory-retriever.js +723 -0
  32. package/dist/retrieval/memory-retriever.js.map +1 -0
  33. package/dist/router/smart-router.js +18 -18
  34. package/dist/server-wrapper.js +0 -0
  35. package/dist/server.d.ts +9 -1
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +859 -355
  38. package/dist/server.js.map +1 -1
  39. package/dist/utils/claude-mcp-config.d.ts +3 -0
  40. package/dist/utils/claude-mcp-config.d.ts.map +1 -0
  41. package/dist/utils/claude-mcp-config.js +42 -0
  42. package/dist/utils/claude-mcp-config.js.map +1 -0
  43. package/dist/utils/monitoring.d.ts +1 -0
  44. package/dist/utils/monitoring.d.ts.map +1 -1
  45. package/dist/utils/monitoring.js +1 -0
  46. package/dist/utils/monitoring.js.map +1 -1
  47. package/dist/utils/paths.d.ts +13 -1
  48. package/dist/utils/paths.d.ts.map +1 -1
  49. package/dist/utils/paths.js +24 -0
  50. package/dist/utils/paths.js.map +1 -1
  51. package/dist/utils/validation.d.ts +14 -2
  52. package/dist/utils/validation.d.ts.map +1 -1
  53. package/dist/utils/validation.js +64 -48
  54. package/dist/utils/validation.js.map +1 -1
  55. package/docker-compose.yml +3 -3
  56. package/package.json +7 -4
  57. package/scripts/README.md +1 -1
  58. package/scripts/health-check.sh +8 -4
  59. package/scripts/init-qdrant.ts +42 -60
  60. package/scripts/test-mcp-server.sh +2 -2
  61. package/dist/config/rate-limits.d.ts +0 -76
  62. package/dist/config/rate-limits.d.ts.map +0 -1
  63. package/dist/config/rate-limits.js +0 -161
  64. package/dist/config/rate-limits.js.map +0 -1
  65. package/dist/middleware/auth.d.ts +0 -287
  66. package/dist/middleware/auth.d.ts.map +0 -1
  67. package/dist/middleware/auth.js +0 -610
  68. package/dist/middleware/auth.js.map +0 -1
  69. package/dist/monitoring/health.d.ts +0 -241
  70. package/dist/monitoring/health.d.ts.map +0 -1
  71. package/dist/monitoring/health.js +0 -649
  72. package/dist/monitoring/health.js.map +0 -1
  73. package/dist/monitoring/metrics.d.ts +0 -153
  74. package/dist/monitoring/metrics.d.ts.map +0 -1
  75. package/dist/monitoring/metrics.js +0 -349
  76. package/dist/monitoring/metrics.js.map +0 -1
  77. package/dist/security/rate-limit-middleware.d.ts +0 -119
  78. package/dist/security/rate-limit-middleware.d.ts.map +0 -1
  79. package/dist/security/rate-limit-middleware.js +0 -193
  80. package/dist/security/rate-limit-middleware.js.map +0 -1
package/dist/server.js CHANGED
@@ -20,10 +20,14 @@ import { validateInput, StoreMemoryInputSchema, RetrieveMemoryInputSchema, ListR
20
20
  import { getSqlitePath, ensureDirectories } from "./utils/paths.js";
21
21
  import { createEmbeddingClient } from "./embeddings/client.js";
22
22
  import { HybridSearchEngine } from "./retrieval/hybrid-search.js";
23
+ import { MemoryRetriever } from "./retrieval/memory-retriever.js";
24
+ import { IndexOutboxProcessor, initializeIndexOutboxSchema } from "./retrieval/index-outbox.js";
23
25
  import { WorkingMemoryLayer } from "./memory-layers/working-memory.js";
24
26
  import { SemanticMemoryLayer } from "./memory-layers/semantic-memory.js";
25
27
  import { SkillLibrary } from "./memory-layers/skill-library.js";
26
28
  import { ConsolidationPipeline } from "./consolidation/pipeline.js";
29
+ import { ConsolidationScheduler } from "./scheduler/consolidation-scheduler.js";
30
+ import { PerformanceMonitor } from "./utils/monitoring.js";
27
31
  import * as SmartRouter from "./router/smart-router.js";
28
32
  // Import better-sqlite3 constructor using createRequire
29
33
  const require = createRequire(import.meta.url);
@@ -34,6 +38,7 @@ const connections = {};
34
38
  * Used for A/B benchmarking (enabled vs disabled comparison)
35
39
  */
36
40
  let vesperEnabled = true;
41
+ const retrievalMonitor = new PerformanceMonitor();
37
42
  /**
38
43
  * Tool definitions for the MCP server
39
44
  */
@@ -64,8 +69,29 @@ const TOOLS = [
64
69
  },
65
70
  namespace: NAMESPACE_PROP,
66
71
  agent_id: { type: "string", description: "ID of the agent storing this memory" },
67
- agent_role: { type: "string", description: "Role of the agent (e.g., 'orchestrator', 'code-reviewer')" },
72
+ agent_role: {
73
+ type: "string",
74
+ description: "Role of the agent (e.g., 'orchestrator', 'code-reviewer')",
75
+ },
68
76
  task_id: { type: "string", description: "Task ID this memory is associated with" },
77
+ importance: {
78
+ type: "number",
79
+ minimum: 0,
80
+ maximum: 1,
81
+ description: "Write-time salience from 0 to 1 (default: 0.5)",
82
+ },
83
+ valid_from: {
84
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
85
+ description: "When this memory became valid (Unix milliseconds or ISO 8601)",
86
+ },
87
+ valid_until: {
88
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
89
+ description: "When this memory stopped being valid (Unix milliseconds or ISO 8601)",
90
+ },
91
+ expired_at: {
92
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
93
+ description: "Optional retention expiry; expired memories are excluded from retrieval",
94
+ },
69
95
  },
70
96
  required: ["content", "memory_type"],
71
97
  },
@@ -91,13 +117,29 @@ const TOOLS = [
91
117
  },
92
118
  routing_strategy: {
93
119
  type: "string",
94
- enum: ["fast_path", "semantic", "full_text", "graph"],
120
+ enum: ["fast_path", "semantic", "full_text", "hybrid", "graph"],
95
121
  description: "Retrieval strategy to use (default: auto-select)",
96
122
  },
97
123
  namespace: NAMESPACE_PROP,
98
124
  agent_id: { type: "string", description: "Filter by agent ID" },
99
125
  task_id: { type: "string", description: "Filter by task ID" },
100
126
  exclude_agent: { type: "string", description: "Exclude memories from this agent" },
127
+ include_superseded: {
128
+ type: "boolean",
129
+ description: "Include decisions replaced by newer decisions (default: false)",
130
+ },
131
+ include_archived: {
132
+ type: "boolean",
133
+ description: "Include archived memories (default: false)",
134
+ },
135
+ as_of: {
136
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
137
+ description: "Return memories valid at this time (Unix milliseconds or ISO 8601)",
138
+ },
139
+ explain: {
140
+ type: "boolean",
141
+ description: "Include content-free retrieval stage status and candidate counts",
142
+ },
101
143
  },
102
144
  required: ["query"],
103
145
  },
@@ -223,15 +265,21 @@ const TOOLS = [
223
265
  task_id: { type: "string", description: "Filter source memories by task ID" },
224
266
  query: { type: "string", description: "Semantic query to select relevant memories" },
225
267
  max_items: { type: "number", description: "Max items to share (default: 10)" },
226
- include_skills: { type: "boolean", description: "Include matching skills (default: false)" },
227
- include_entities: { type: "boolean", description: "Include related entities (default: true)" },
268
+ include_skills: {
269
+ type: "boolean",
270
+ description: "Include matching skills (default: false)",
271
+ },
272
+ include_entities: {
273
+ type: "boolean",
274
+ description: "Include related entities (default: true)",
275
+ },
228
276
  },
229
277
  required: ["source_namespace", "target_namespace"],
230
278
  },
231
279
  },
232
280
  {
233
281
  name: "store_decision",
234
- description: "Store an architectural or design decision with reduced decay and automatic conflict detection against existing decisions.",
282
+ description: "Store a versioned architectural or design decision with reduced decay; optionally close a superseded decision without deleting its history.",
235
283
  inputSchema: {
236
284
  type: "object",
237
285
  properties: {
@@ -241,7 +289,19 @@ const TOOLS = [
241
289
  agent_role: { type: "string", description: "Role of the agent" },
242
290
  task_id: { type: "string", description: "Related task ID" },
243
291
  supersedes: { type: "string", description: "ID of a previous decision this replaces" },
244
- metadata: { type: "object", description: "Additional metadata", additionalProperties: true },
292
+ valid_from: {
293
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
294
+ description: "When this decision became valid (Unix milliseconds or ISO 8601)",
295
+ },
296
+ valid_until: {
297
+ oneOf: [{ type: "number" }, { type: "string", format: "date-time" }],
298
+ description: "When this decision stopped being valid (Unix milliseconds or ISO 8601)",
299
+ },
300
+ metadata: {
301
+ type: "object",
302
+ description: "Additional metadata",
303
+ additionalProperties: true,
304
+ },
245
305
  },
246
306
  required: ["content"],
247
307
  },
@@ -269,34 +329,49 @@ const TOOLS = [
269
329
  /**
270
330
  * Initialize connections to Redis, Qdrant, and SQLite
271
331
  */
272
- async function initializeConnections() {
332
+ export async function initializeConnections() {
273
333
  try {
274
334
  console.error("[INFO] Initializing connections to external services...");
275
335
  // Initialize Redis (optional - used for caching)
336
+ let redisClient;
276
337
  try {
277
- connections.redis = new Redis({
338
+ if (process.env.VESPER_DISABLE_OPTIONAL_SERVICES === "1") {
339
+ throw new Error("disabled by VESPER_DISABLE_OPTIONAL_SERVICES");
340
+ }
341
+ redisClient = new Redis({
278
342
  host: process.env.REDIS_HOST || "localhost",
279
343
  port: parseInt(process.env.REDIS_PORT || "6379"),
280
344
  password: process.env.REDIS_PASSWORD,
281
- retryStrategy: (times) => Math.min(times * 50, 2000),
345
+ retryStrategy: (times) => (times <= 3 ? Math.min(times * 50, 2000) : null),
282
346
  maxRetriesPerRequest: 3,
283
347
  connectTimeout: 5000,
284
348
  });
285
- connections.redis.on("connect", () => {
349
+ redisClient.on("connect", () => {
286
350
  console.error("[INFO] ✅ Redis connected");
287
351
  });
288
- connections.redis.on("error", (err) => {
352
+ redisClient.on("error", (err) => {
289
353
  console.error("[WARN] Redis connection error:", err.message);
290
354
  });
291
355
  // Test connection with timeout
292
- const pingTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Connection timeout")), 5000));
293
- await Promise.race([connections.redis.ping(), pingTimeout]);
356
+ let pingTimer;
357
+ const pingTimeout = new Promise((_, reject) => {
358
+ pingTimer = setTimeout(() => reject(new Error("Connection timeout")), 5000);
359
+ });
360
+ try {
361
+ await Promise.race([redisClient.ping(), pingTimeout]);
362
+ }
363
+ finally {
364
+ if (pingTimer)
365
+ clearTimeout(pingTimer);
366
+ }
367
+ connections.redis = redisClient;
294
368
  console.error("[INFO] ✅ Redis ping successful");
295
369
  // Initialize Working Memory Layer
296
- connections.workingMemory = new WorkingMemoryLayer(connections.redis, 5);
370
+ connections.workingMemory = new WorkingMemoryLayer(redisClient, 5);
297
371
  console.error("[INFO] ✅ Working memory layer initialized");
298
372
  }
299
373
  catch (err) {
374
+ redisClient?.disconnect();
300
375
  const errorMsg = err instanceof Error ? err.message : String(err);
301
376
  console.error("\x1b[33m⚠️ Redis not available:\x1b[0m", errorMsg);
302
377
  console.error("\x1b[34mℹ️ Impact:\x1b[0m Working memory disabled");
@@ -312,6 +387,8 @@ async function initializeConnections() {
312
387
  const dbPath = process.env.SQLITE_DB || getSqlitePath();
313
388
  connections.sqlite = new DatabaseConstructor(dbPath);
314
389
  connections.sqlite.pragma("journal_mode = WAL");
390
+ connections.sqlite.pragma("foreign_keys = ON");
391
+ connections.sqlite.pragma("busy_timeout = 5000");
315
392
  // Initialize schema
316
393
  initializeSqliteSchema();
317
394
  // Clear logging about storage location
@@ -328,6 +405,9 @@ async function initializeConnections() {
328
405
  }
329
406
  // Initialize Embedding Service (optional - graceful degradation)
330
407
  try {
408
+ if (process.env.VESPER_DISABLE_OPTIONAL_SERVICES === "1") {
409
+ throw new Error("disabled by VESPER_DISABLE_OPTIONAL_SERVICES");
410
+ }
331
411
  connections.embeddingClient = createEmbeddingClient({
332
412
  serviceUrl: process.env.EMBEDDING_SERVICE_URL || "http://localhost:8000",
333
413
  timeout: 10000,
@@ -335,10 +415,7 @@ async function initializeConnections() {
335
415
  });
336
416
  // Test connection with timeout
337
417
  const healthTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Health check timeout")), 10000));
338
- const health = await Promise.race([
339
- connections.embeddingClient.health(),
340
- healthTimeout,
341
- ]);
418
+ const health = await Promise.race([connections.embeddingClient.health(), healthTimeout]);
342
419
  console.error(`[INFO] ✅ Embedding service connected: ${health.model} (${health.dimensions}-dim)`);
343
420
  }
344
421
  catch (err) {
@@ -350,15 +427,15 @@ async function initializeConnections() {
350
427
  }
351
428
  // Initialize Hybrid Search Engine (optional - requires Qdrant)
352
429
  try {
430
+ if (process.env.VESPER_DISABLE_OPTIONAL_SERVICES === "1") {
431
+ throw new Error("disabled by VESPER_DISABLE_OPTIONAL_SERVICES");
432
+ }
353
433
  const qdrantUrl = process.env.QDRANT_URL || "http://localhost:6333";
354
434
  const qdrantApiKey = process.env.QDRANT_API_KEY;
355
435
  connections.hybridSearch = new HybridSearchEngine(qdrantUrl, "memory-vectors", 1024, qdrantApiKey);
356
436
  // Initialize collection with timeout
357
437
  const initTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Initialization timeout")), 10000));
358
- await Promise.race([
359
- connections.hybridSearch.initializeCollection(),
360
- initTimeout,
361
- ]);
438
+ await Promise.race([connections.hybridSearch.initializeCollection(), initTimeout]);
362
439
  console.error("[INFO] ✅ Qdrant hybrid search initialized");
363
440
  }
364
441
  catch (err) {
@@ -392,32 +469,72 @@ async function initializeConnections() {
392
469
  console.error("[WARN] Skill library initialization failed:", err instanceof Error ? err.message : String(err));
393
470
  }
394
471
  }
395
- // Initialize Consolidation Pipeline (requires all memory layers)
396
- if (connections.workingMemory && connections.semanticMemory && connections.skillLibrary && connections.sqlite) {
472
+ // SQLite is canonical. FTS retrieval is always available; Redis and Qdrant
473
+ // are optional projections repaired through the durable index outbox.
474
+ if (connections.sqlite) {
475
+ connections.memoryRetriever = new MemoryRetriever({
476
+ db: connections.sqlite,
477
+ embeddingClient: connections.embeddingClient,
478
+ hybridSearch: connections.hybridSearch,
479
+ });
480
+ connections.memoryRetriever.initialize();
481
+ connections.indexOutbox = new IndexOutboxProcessor(connections.sqlite, {
482
+ embeddingClient: connections.embeddingClient,
483
+ hybridSearch: connections.hybridSearch,
484
+ workingMemory: connections.workingMemory,
485
+ projectionConfig: {
486
+ qdrant: process.env.VESPER_DISABLE_OPTIONAL_SERVICES !== "1",
487
+ redis: process.env.VESPER_DISABLE_OPTIONAL_SERVICES !== "1",
488
+ },
489
+ });
490
+ setImmediate(async () => {
491
+ try {
492
+ const stats = await connections.indexOutbox.drain();
493
+ console.error(`[INFO] Index outbox: ${stats.completed} completed, ${stats.pending} pending`);
494
+ }
495
+ catch (error) {
496
+ console.error("[WARN] Index outbox drain failed:", error instanceof Error ? error.message : String(error));
497
+ }
498
+ });
499
+ connections.indexOutboxTimer = setInterval(async () => {
500
+ try {
501
+ await connections.indexOutbox?.drain();
502
+ }
503
+ catch (error) {
504
+ console.error("[WARN] Periodic index repair failed:", error instanceof Error ? error.message : String(error));
505
+ }
506
+ }, 30_000);
507
+ connections.indexOutboxTimer.unref();
508
+ }
509
+ // Consolidation reads canonical SQLite rows. Redis is an optional cache,
510
+ // not an ingestion dependency.
511
+ if (connections.semanticMemory && connections.skillLibrary && connections.sqlite) {
397
512
  try {
398
513
  connections.consolidationPipeline = new ConsolidationPipeline(connections.workingMemory, connections.semanticMemory, connections.skillLibrary, connections.sqlite);
399
514
  console.error("[INFO] ✅ Consolidation pipeline initialized");
400
- // Run startup consolidation asynchronously (non-blocking)
401
- // This runs every time the MCP server starts (when Claude Code restarts)
402
- setImmediate(async () => {
403
- try {
404
- console.error("[INFO] 🔄 Running startup consolidation...");
405
- const stats = await connections.consolidationPipeline.consolidate();
406
- console.error(`[INFO] ✅ Startup consolidation complete: ${stats.memoriesProcessed} memories, ` +
407
- `${stats.entitiesExtracted} entities, ${stats.conflictsDetected} conflicts, ` +
408
- `${stats.skillsExtracted} skills in ${stats.duration}ms`);
409
- }
410
- catch (err) {
411
- console.error("[WARN] Startup consolidation failed:", err instanceof Error ? err.message : String(err));
412
- }
413
- });
515
+ connections.consolidationScheduler = new ConsolidationScheduler(connections.consolidationPipeline);
516
+ connections.consolidationScheduler.start();
517
+ // Process the bounded, durable backlog without delaying MCP startup.
518
+ if (process.env.VESPER_DISABLE_STARTUP_CONSOLIDATION !== "1")
519
+ setImmediate(async () => {
520
+ try {
521
+ console.error("[INFO] 🔄 Running startup consolidation...");
522
+ const stats = await connections.consolidationScheduler.runNow();
523
+ console.error(`[INFO] ✅ Startup consolidation complete: ${stats.memoriesProcessed} memories, ` +
524
+ `${stats.entitiesExtracted} entities, ${stats.conflictsDetected} conflicts, ` +
525
+ `${stats.skillsExtracted} skills in ${stats.duration}ms`);
526
+ }
527
+ catch (err) {
528
+ console.error("[WARN] Startup consolidation failed:", err instanceof Error ? err.message : String(err));
529
+ }
530
+ });
414
531
  }
415
532
  catch (err) {
416
533
  console.error("[WARN] Consolidation pipeline initialization failed:", err instanceof Error ? err.message : String(err));
417
534
  }
418
535
  }
419
536
  else {
420
- console.error("[WARN] Consolidation pipeline not initialized (missing dependencies)");
537
+ console.error("[WARN] Consolidation pipeline not initialized (missing SQLite-backed layers)");
421
538
  }
422
539
  // Initialize Smart Router with all available dependencies
423
540
  SmartRouter.init({
@@ -442,10 +559,10 @@ async function initializeConnections() {
442
559
  console.error("\n" + "=".repeat(60));
443
560
  console.error("📊 Vesper Service Status:");
444
561
  console.error("=".repeat(60));
445
- console.error(`SQLite: ${connections.sqlite ? '\x1b[32m✓ Ready\x1b[0m' : '\x1b[31m✗ Failed\x1b[0m'} (required)`);
446
- console.error(`Redis: ${connections.redis ? '\x1b[32m✓ Ready\x1b[0m' : '\x1b[33m⚠ Disabled\x1b[0m'} (working memory)`);
447
- console.error(`Embedding: ${connections.embeddingClient ? '\x1b[32m✓ Ready\x1b[0m' : '\x1b[33m⚠ Disabled\x1b[0m'} (semantic search)`);
448
- console.error(`Qdrant: ${connections.hybridSearch ? '\x1b[32m✓ Ready\x1b[0m' : '\x1b[33m⚠ Disabled\x1b[0m'} (vector storage)`);
562
+ console.error(`SQLite: ${connections.sqlite ? "\x1b[32m✓ Ready\x1b[0m" : "\x1b[31m✗ Failed\x1b[0m"} (required)`);
563
+ console.error(`Redis: ${connections.redis ? "\x1b[32m✓ Ready\x1b[0m" : "\x1b[33m⚠ Disabled\x1b[0m"} (working memory)`);
564
+ console.error(`Embedding: ${connections.embeddingClient ? "\x1b[32m✓ Ready\x1b[0m" : "\x1b[33m⚠ Disabled\x1b[0m"} (semantic search)`);
565
+ console.error(`Qdrant: ${connections.hybridSearch ? "\x1b[32m✓ Ready\x1b[0m" : "\x1b[33m⚠ Disabled\x1b[0m"} (vector storage)`);
449
566
  console.error("=".repeat(60));
450
567
  // Show degraded mode warning if any optional services are down
451
568
  const optionalServicesDown = !connections.redis || !connections.embeddingClient || !connections.hybridSearch;
@@ -488,23 +605,66 @@ function initializeSqliteSchema() {
488
605
  updated_at INTEGER
489
606
  );
490
607
  `);
491
- // v0.5.0 namespace migration for memories table
492
- const memoryNamespaceCols = [
493
- "ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'default'",
494
- "ALTER TABLE memories ADD COLUMN agent_id TEXT",
495
- "ALTER TABLE memories ADD COLUMN agent_role TEXT",
496
- "ALTER TABLE memories ADD COLUMN task_id TEXT",
497
- ];
498
- for (const sql of memoryNamespaceCols) {
499
- try {
500
- connections.sqlite.exec(sql);
501
- }
502
- catch (_) { /* column already exists */ }
608
+ ensureColumn("memories", "consolidated_at", "INTEGER");
609
+ ensureColumn("memories", "namespace", "TEXT DEFAULT 'default'");
610
+ ensureColumn("memories", "agent_id", "TEXT");
611
+ ensureColumn("memories", "agent_role", "TEXT");
612
+ ensureColumn("memories", "task_id", "TEXT");
613
+ ensureColumn("memories", "importance", "REAL DEFAULT 0.5");
614
+ ensureColumn("memories", "access_count", "INTEGER DEFAULT 0");
615
+ ensureColumn("memories", "last_accessed", "INTEGER");
616
+ const addedValidFrom = ensureColumn("memories", "valid_from", "INTEGER");
617
+ ensureColumn("memories", "valid_until", "INTEGER");
618
+ ensureColumn("memories", "expired_at", "INTEGER");
619
+ ensureColumn("memories", "archived_at", "INTEGER");
620
+ // Leave legacy rows unconsolidated. The old write path did not reliably
621
+ // populate the Redis source consumed by the old pipeline, so bounded runs of
622
+ // the canonical SQLite pipeline must be allowed to backfill them.
623
+ if (addedValidFrom) {
624
+ connections.sqlite.exec("UPDATE memories SET valid_from = created_at WHERE valid_from IS NULL");
503
625
  }
504
626
  connections.sqlite.exec(`
505
627
  CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);
506
628
  CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(namespace, agent_id);
629
+ CREATE INDEX IF NOT EXISTS idx_memories_task ON memories(namespace, task_id);
630
+ CREATE INDEX IF NOT EXISTS idx_memories_validity
631
+ ON memories(namespace, valid_from, valid_until, expired_at, archived_at);
507
632
  `);
633
+ initializeIndexOutboxSchema(connections.sqlite);
634
+ connections.sqlite.pragma("user_version = 1");
635
+ }
636
+ function ensureColumn(table, column, definition) {
637
+ if (!connections.sqlite)
638
+ throw new Error("SQLite not initialized");
639
+ const columns = connections.sqlite.pragma(`table_info(${table})`);
640
+ if (columns.some((existing) => existing.name === column))
641
+ return false;
642
+ connections.sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
643
+ return true;
644
+ }
645
+ function toEpochMillis(value) {
646
+ if (value === undefined)
647
+ return undefined;
648
+ if (typeof value === "number")
649
+ return value;
650
+ const parsed = Date.parse(value);
651
+ if (!Number.isFinite(parsed)) {
652
+ throw new McpError(ErrorCode.InvalidParams, `Invalid timestamp: ${value}`);
653
+ }
654
+ return parsed;
655
+ }
656
+ function sqliteEpochMillisExpression(expression) {
657
+ return `(CASE
658
+ WHEN typeof(${expression}) IN ('integer', 'real')
659
+ THEN CASE WHEN ABS(${expression}) < 100000000000 THEN ${expression} * 1000 ELSE ${expression} END
660
+ WHEN trim(${expression}) != '' AND trim(${expression}) NOT GLOB '*[^0-9.]*'
661
+ THEN CASE
662
+ WHEN ABS(CAST(${expression} AS REAL)) < 100000000000
663
+ THEN CAST(${expression} AS REAL) * 1000
664
+ ELSE CAST(${expression} AS REAL)
665
+ END
666
+ ELSE CAST(strftime('%s', ${expression}) AS INTEGER) * 1000
667
+ END)`;
508
668
  }
509
669
  /**
510
670
  * Initialize SQLite schema for semantic memory (HippoRAG knowledge graph)
@@ -560,6 +720,8 @@ function initializeSemanticMemorySchema() {
560
720
  fact_id_1 TEXT,
561
721
  fact_id_2 TEXT,
562
722
  conflict_type TEXT,
723
+ entity_id TEXT,
724
+ property TEXT,
563
725
  description TEXT,
564
726
  severity TEXT,
565
727
  resolution_status TEXT
@@ -567,10 +729,18 @@ function initializeSemanticMemorySchema() {
567
729
 
568
730
  CREATE TABLE IF NOT EXISTS backup_metadata (
569
731
  id TEXT PRIMARY KEY,
732
+ backup_timestamp TEXT,
570
733
  backup_type TEXT NOT NULL,
734
+ status TEXT DEFAULT 'completed',
571
735
  backup_path TEXT NOT NULL,
736
+ file_size_bytes INTEGER,
737
+ entities_count INTEGER,
738
+ relationships_count INTEGER,
739
+ facts_count INTEGER,
572
740
  created_at TEXT NOT NULL,
573
- expires_at TEXT NOT NULL
741
+ expires_at TEXT NOT NULL,
742
+ notes TEXT,
743
+ metadata TEXT
574
744
  );
575
745
  `);
576
746
  // v0.5.0 namespace migration for semantic memory tables
@@ -584,8 +754,26 @@ function initializeSemanticMemorySchema() {
584
754
  try {
585
755
  connections.sqlite.exec(sql);
586
756
  }
587
- catch (_) { /* column already exists */ }
757
+ catch (_) {
758
+ /* column already exists */
759
+ }
588
760
  }
761
+ ensureColumn("conflicts", "entity_id", "TEXT");
762
+ ensureColumn("conflicts", "property", "TEXT");
763
+ ensureColumn("backup_metadata", "backup_timestamp", "TEXT");
764
+ ensureColumn("backup_metadata", "status", "TEXT DEFAULT 'completed'");
765
+ ensureColumn("backup_metadata", "file_size_bytes", "INTEGER");
766
+ ensureColumn("backup_metadata", "entities_count", "INTEGER");
767
+ ensureColumn("backup_metadata", "relationships_count", "INTEGER");
768
+ ensureColumn("backup_metadata", "facts_count", "INTEGER");
769
+ ensureColumn("backup_metadata", "notes", "TEXT");
770
+ ensureColumn("backup_metadata", "metadata", "TEXT");
771
+ connections.sqlite.exec(`
772
+ UPDATE backup_metadata
773
+ SET backup_timestamp = COALESCE(backup_timestamp, created_at),
774
+ status = COALESCE(status, 'completed')
775
+ `);
776
+ ensureColumn("relationships", "last_decayed_at", "TEXT");
589
777
  connections.sqlite.exec(`
590
778
  CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace);
591
779
  CREATE INDEX IF NOT EXISTS idx_relationships_namespace ON relationships(namespace);
@@ -618,7 +806,9 @@ function initializeSkillLibrarySchema() {
618
806
  try {
619
807
  connections.sqlite.exec("ALTER TABLE skills ADD COLUMN namespace TEXT DEFAULT 'default'");
620
808
  }
621
- catch (_) { /* column already exists */ }
809
+ catch (_) {
810
+ /* column already exists */
811
+ }
622
812
  connections.sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_skills_namespace ON skills(namespace);`);
623
813
  // Apply lazy loading migration (idempotent - safe to run multiple times)
624
814
  try {
@@ -771,56 +961,58 @@ async function handleStoreMemory(input) {
771
961
  throw new McpError(ErrorCode.InternalError, "SQLite database not available");
772
962
  }
773
963
  try {
774
- // Validate input
775
964
  const validatedInput = validateInput(StoreMemoryInputSchema, input);
776
- const namespace = validatedInput.namespace || 'default';
777
- // Use pure UUID for Qdrant compatibility
965
+ const namespace = validatedInput.namespace || "default";
778
966
  const id = randomUUID();
779
967
  const now = Date.now();
780
- // Generate embedding if service is available
781
- let embedding;
782
- if (connections.embeddingClient) {
783
- try {
784
- embedding = await connections.embeddingClient.embed(validatedInput.content);
785
- console.error(`[INFO] Generated embedding for memory ${id}`);
786
- }
787
- catch (err) {
788
- console.error("[WARN] Failed to generate embedding (continuing without):", err instanceof Error ? err.message : String(err));
789
- }
968
+ const validFrom = toEpochMillis(validatedInput.valid_from) ?? now;
969
+ const validUntil = toEpochMillis(validatedInput.valid_until);
970
+ const expiredAt = toEpochMillis(validatedInput.expired_at);
971
+ if (validUntil !== undefined && validUntil <= validFrom) {
972
+ throw new McpError(ErrorCode.InvalidParams, "valid_until must be after valid_from");
790
973
  }
791
- // Store in SQLite with namespace and agent attribution
792
- const stmt = connections.sqlite.prepare(`
793
- INSERT INTO memories (id, content, memory_type, created_at, updated_at, metadata, namespace, agent_id, agent_role, task_id)
794
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
795
- `);
796
- stmt.run(id, validatedInput.content, validatedInput.memory_type, now, now, JSON.stringify(validatedInput.metadata || {}), namespace, validatedInput.agent_id || null, validatedInput.agent_role || null, validatedInput.task_id || null);
797
- // Store embedding in Qdrant if available (include namespace in payload)
798
- if (embedding && connections.hybridSearch) {
974
+ const metadata = validatedInput.metadata || {};
975
+ const commit = connections.sqlite.transaction(() => {
976
+ connections.sqlite
977
+ .prepare(`
978
+ INSERT INTO memories (
979
+ id, content, memory_type, created_at, updated_at, metadata, namespace,
980
+ agent_id, agent_role, task_id, importance, access_count, last_accessed,
981
+ valid_from, valid_until, expired_at, archived_at, consolidated_at
982
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, NULL, NULL)
983
+ `)
984
+ .run(id, validatedInput.content, validatedInput.memory_type, now, now, JSON.stringify(metadata), namespace, validatedInput.agent_id || null, validatedInput.agent_role || null, validatedInput.task_id || null, validatedInput.importance ?? 0.5, validFrom, validUntil ?? null, expiredAt ?? null);
985
+ connections.indexOutbox?.enqueue({
986
+ memoryId: id,
987
+ operation: "upsert",
988
+ namespace,
989
+ content: validatedInput.content,
990
+ memoryType: validatedInput.memory_type,
991
+ metadata,
992
+ agentId: validatedInput.agent_id,
993
+ taskId: validatedInput.task_id,
994
+ createdAt: now,
995
+ });
996
+ });
997
+ commit();
998
+ if (connections.indexOutbox) {
799
999
  try {
800
- await connections.hybridSearch.upsertMemory(id, embedding, {
801
- content: validatedInput.content,
802
- memory_type: validatedInput.memory_type,
803
- created_at: now,
804
- namespace,
805
- agent_id: validatedInput.agent_id,
806
- task_id: validatedInput.task_id,
807
- ...validatedInput.metadata,
808
- });
809
- console.error(`[INFO] Stored embedding in Qdrant for ${id}`);
1000
+ await connections.indexOutbox.drain(20);
810
1001
  }
811
- catch (err) {
812
- console.error("[ERROR] Failed to store embedding in Qdrant:", err instanceof Error ? err.message : String(err));
813
- throw new McpError(ErrorCode.InternalError, `Qdrant storage failed: ${err instanceof Error ? err.message : String(err)}`);
1002
+ catch (error) {
1003
+ console.error("[WARN] Inline index drain failed; mutation remains durable:", error instanceof Error ? error.message : String(error));
814
1004
  }
815
1005
  }
816
- console.error(`[INFO] Memory stored: ${id} (type: ${validatedInput.memory_type}, ns: ${namespace}, length: ${validatedInput.content.length}, embedding: ${embedding ? "yes" : "no"})`);
1006
+ const indexState = connections.indexOutbox?.getState(id) ?? "pending";
1007
+ console.error(`[INFO] Memory stored: ${id} (type: ${validatedInput.memory_type}, ns: ${namespace}, index: ${indexState})`);
817
1008
  return {
818
1009
  success: true,
819
1010
  memory_id: id,
820
1011
  namespace,
821
1012
  timestamp: now,
822
1013
  message: `Memory stored successfully (${validatedInput.memory_type})`,
823
- has_embedding: !!embedding,
1014
+ has_embedding: indexState === "indexed" && !!connections.hybridSearch,
1015
+ index_state: indexState,
824
1016
  };
825
1017
  }
826
1018
  catch (err) {
@@ -834,139 +1026,122 @@ async function handleRetrieveMemory(input) {
834
1026
  if (!connections.sqlite) {
835
1027
  throw new McpError(ErrorCode.InternalError, "SQLite database not available");
836
1028
  }
1029
+ const startedAt = performance.now();
837
1030
  try {
838
- // Validate input
839
1031
  const validatedInput = validateInput(RetrieveMemoryInputSchema, input);
840
- const namespace = validatedInput.namespace || 'default';
1032
+ const namespace = validatedInput.namespace || "default";
841
1033
  const maxResults = validatedInput.max_results || 5;
842
- // Try SmartRouter first (unless a specific routing_strategy is requested)
843
- if (!validatedInput.routing_strategy) {
844
- try {
845
- const routerContext = {
846
- userId: 'default',
847
- conversationId: 'mcp',
848
- timestamp: new Date(),
849
- namespace,
850
- };
851
- const routerResults = await SmartRouter.retrieve(validatedInput.query, routerContext);
852
- if (routerResults.length > 0) {
853
- const classification = SmartRouter.classifyQuery(validatedInput.query);
854
- console.error(`[INFO] SmartRouter hit: query="${validatedInput.query}", type=${classification.type}, ns=${namespace}, results=${routerResults.length}`);
855
- return {
856
- success: true,
857
- query: validatedInput.query,
858
- namespace,
859
- routing_strategy: classification.type,
860
- results: routerResults.map((r) => ({
861
- id: r.id,
862
- content: r.content,
863
- memory_type: r.source,
864
- created_at: r.timestamp instanceof Date ? r.timestamp.getTime() : r.timestamp,
865
- similarity_score: r.similarity,
866
- confidence: r.confidence,
867
- })),
868
- count: routerResults.length,
869
- };
870
- }
871
- // Router returned empty - fall through to direct search
872
- console.error(`[INFO] SmartRouter returned empty, falling back to direct search for: "${validatedInput.query}"`);
873
- }
874
- catch (err) {
875
- console.error("[WARN] SmartRouter failed, falling back to direct search:", err instanceof Error ? err.message : String(err));
876
- }
877
- }
878
- // Build Qdrant filter for namespace
879
- const qdrantFilter = {
880
- must: [
881
- { key: "namespace", match: { value: namespace } },
882
- ...(validatedInput.agent_id ? [{ key: "agent_id", match: { value: validatedInput.agent_id } }] : []),
883
- ...(validatedInput.task_id ? [{ key: "task_id", match: { value: validatedInput.task_id } }] : []),
884
- ],
885
- ...(validatedInput.exclude_agent ? {
886
- must_not: [{ key: "agent_id", match: { value: validatedInput.exclude_agent } }]
887
- } : {}),
1034
+ const commonFilters = {
1035
+ namespace,
1036
+ memoryTypes: validatedInput.memory_types,
1037
+ maxResults,
1038
+ agentId: validatedInput.agent_id,
1039
+ taskId: validatedInput.task_id,
1040
+ excludeAgent: validatedInput.exclude_agent,
1041
+ includeSuperseded: validatedInput.include_superseded,
1042
+ includeArchived: validatedInput.include_archived,
1043
+ asOf: toEpochMillis(validatedInput.as_of),
888
1044
  };
889
- // Try semantic search if embedding service is available
890
- if (connections.embeddingClient && connections.hybridSearch) {
1045
+ if (!connections.memoryRetriever) {
1046
+ throw new McpError(ErrorCode.InternalError, "Memory retriever not available");
1047
+ }
1048
+ // Redis can nominate recent candidates, but SQLite still authorizes and
1049
+ // hydrates every returned field. A cache miss falls through to FTS.
1050
+ if (validatedInput.routing_strategy === "fast_path" && connections.workingMemory) {
891
1051
  try {
892
- // Generate query embedding
893
- const queryEmbedding = await connections.embeddingClient.embed(validatedInput.query);
894
- // Perform hybrid search with namespace filter
895
- let searchResults = await connections.hybridSearch.hybridSearch(queryEmbedding, maxResults, qdrantFilter);
896
- // Fallback: if namespace is "default" and filtered search returns empty,
897
- // retry without filter (backward compat for pre-namespace vectors)
898
- if (searchResults.length === 0 && namespace === 'default') {
899
- searchResults = await connections.hybridSearch.hybridSearch(queryEmbedding, maxResults);
1052
+ const cached = await connections.workingMemory.search(validatedInput.query, Math.min(maxResults * 4, 100), namespace);
1053
+ const scores = new Map(cached.map(({ memory, similarity }) => [memory.conversationId, similarity]));
1054
+ const results = connections.memoryRetriever.retrieveByIds(cached.map(({ memory }) => memory.conversationId), { ...commonFilters, similarityScores: scores, source: "fast_path" });
1055
+ if (results.length > 0) {
1056
+ retrievalMonitor.recordCacheHit();
1057
+ return formatRetrievalResponse(validatedInput.query, namespace, "fast_path", results, validatedInput.explain
1058
+ ? {
1059
+ requested_strategy: "fast_path",
1060
+ effective_strategy: "fast_path",
1061
+ stages: [{ stage: "cache", candidate_count: cached.length, status: "ok" }],
1062
+ returned_count: results.length,
1063
+ }
1064
+ : undefined);
900
1065
  }
901
- console.error(`[INFO] Semantic search: query="${validatedInput.query}", ns=${namespace}, results=${searchResults.length}`);
902
- return {
903
- success: true,
904
- query: validatedInput.query,
905
- namespace,
906
- routing_strategy: "semantic",
907
- results: searchResults.map((r) => ({
908
- id: r.id,
909
- content: r.payload?.content,
910
- memory_type: r.payload?.memory_type,
911
- created_at: r.payload?.created_at,
912
- similarity_score: r.fusedScore,
913
- rank: r.rank,
914
- metadata: r.payload,
915
- })),
916
- count: searchResults.length,
917
- };
1066
+ retrievalMonitor.recordCacheMiss();
918
1067
  }
919
- catch (err) {
920
- console.error("[WARN] Semantic search failed, falling back to text search:", err instanceof Error ? err.message : String(err));
1068
+ catch (error) {
1069
+ retrievalMonitor.recordCacheMiss();
1070
+ console.error("[WARN] Fast-path lookup failed; falling back to canonical retrieval:", error instanceof Error ? error.message : String(error));
921
1071
  }
922
1072
  }
923
- // Fallback: SQL-based text search with namespace filter
924
- let query = "SELECT id, content, memory_type, created_at, metadata, agent_id, task_id FROM memories WHERE namespace = ?";
925
- const params = [namespace];
926
- // Add type filter if specified
927
- if (validatedInput.memory_types && validatedInput.memory_types.length > 0) {
928
- const placeholders = validatedInput.memory_types.map(() => "?").join(",");
929
- query += ` AND memory_type IN (${placeholders})`;
930
- params.push(...validatedInput.memory_types);
931
- }
932
- // Add agent/task filters
933
- if (validatedInput.agent_id) {
934
- query += " AND agent_id = ?";
935
- params.push(validatedInput.agent_id);
936
- }
937
- if (validatedInput.task_id) {
938
- query += " AND task_id = ?";
939
- params.push(validatedInput.task_id);
940
- }
941
- if (validatedInput.exclude_agent) {
942
- query += " AND (agent_id IS NULL OR agent_id != ?)";
943
- params.push(validatedInput.exclude_agent);
944
- }
945
- query += " ORDER BY created_at DESC LIMIT ?";
946
- params.push(maxResults);
947
- const stmt = connections.sqlite.prepare(query);
948
- const results = stmt.all(...params);
949
- console.error(`[INFO] Text search (fallback): query="${validatedInput.query}", ns=${namespace}, results=${results.length}`);
950
- return {
951
- success: true,
1073
+ // The legacy graph route did not implement the advertised PPR algorithm
1074
+ // and returned non-memory entities. Preserve the public option as a safe
1075
+ // compatibility alias while keeping the memory contract truthful.
1076
+ const requestedStrategy = validatedInput.routing_strategy;
1077
+ const strategy = requestedStrategy === "semantic" ||
1078
+ requestedStrategy === "full_text" ||
1079
+ requestedStrategy === "hybrid"
1080
+ ? requestedStrategy
1081
+ : requestedStrategy === "fast_path"
1082
+ ? "full_text"
1083
+ : "auto";
1084
+ const response = await connections.memoryRetriever.retrieve({
952
1085
  query: validatedInput.query,
953
- namespace,
954
- routing_strategy: "text_fallback",
955
- results: results.map((r) => ({
956
- id: r.id,
957
- content: r.content,
958
- memory_type: r.memory_type,
959
- created_at: r.created_at,
960
- agent_id: r.agent_id,
961
- task_id: r.task_id,
962
- metadata: JSON.parse(r.metadata || "{}"),
963
- })),
964
- count: results.length,
965
- };
1086
+ ...commonFilters,
1087
+ strategy,
1088
+ explain: validatedInput.explain,
1089
+ });
1090
+ const trace = response.trace
1091
+ ? {
1092
+ requested_strategy: requestedStrategy ?? "auto",
1093
+ effective_strategy: response.strategy,
1094
+ compatibility_fallback: requestedStrategy === "graph" ? "hybrid_or_full_text" : undefined,
1095
+ candidate_limit: response.trace.candidateLimit,
1096
+ stages: response.trace.stages.map((stage) => ({
1097
+ stage: stage.stage,
1098
+ candidate_count: stage.candidateCount,
1099
+ status: stage.status,
1100
+ fallback: stage.fallback,
1101
+ })),
1102
+ returned_count: response.trace.returnedCount,
1103
+ }
1104
+ : undefined;
1105
+ console.error(`[INFO] Canonical retrieval: query="${validatedInput.query}", ns=${namespace}, ` +
1106
+ `strategy=${response.strategy}, results=${response.results.length}`);
1107
+ return formatRetrievalResponse(validatedInput.query, namespace, response.strategy, response.results, trace);
966
1108
  }
967
1109
  catch (err) {
1110
+ if (err instanceof McpError)
1111
+ throw err;
968
1112
  throw new McpError(ErrorCode.InternalError, `Failed to retrieve memory: ${err instanceof Error ? err.message : String(err)}`);
969
1113
  }
1114
+ finally {
1115
+ retrievalMonitor.recordLatency(performance.now() - startedAt);
1116
+ }
1117
+ }
1118
+ function formatRetrievalResponse(query, namespace, strategy, results, trace) {
1119
+ return {
1120
+ success: true,
1121
+ query,
1122
+ namespace,
1123
+ routing_strategy: strategy,
1124
+ results: results.map((result) => ({
1125
+ id: result.id,
1126
+ content: result.content,
1127
+ memory_type: result.memoryType,
1128
+ created_at: result.createdAt,
1129
+ updated_at: result.updatedAt,
1130
+ namespace: result.namespace,
1131
+ agent_id: result.agentId,
1132
+ agent_role: result.agentRole,
1133
+ task_id: result.taskId,
1134
+ similarity_score: result.similarityScore,
1135
+ dense_score: result.denseScore,
1136
+ lexical_score: result.lexicalScore,
1137
+ fused_score: result.fusedScore,
1138
+ rank: result.rank,
1139
+ sources: result.sources,
1140
+ metadata: result.metadata,
1141
+ })),
1142
+ count: results.length,
1143
+ ...(trace ? { trace } : {}),
1144
+ };
970
1145
  }
971
1146
  /**
972
1147
  * Handle list_recent tool call
@@ -978,10 +1153,58 @@ async function handleListRecent(input) {
978
1153
  try {
979
1154
  // Validate input
980
1155
  const validatedInput = validateInput(ListRecentInputSchema, input);
981
- const namespace = validatedInput.namespace || 'default';
1156
+ const namespace = validatedInput.namespace || "default";
982
1157
  const limit = validatedInput.limit || 5;
983
- let query = "SELECT id, content, memory_type, created_at, agent_id, task_id FROM memories WHERE namespace = ?";
984
- const params = [namespace];
1158
+ const now = Date.now();
1159
+ let query = `SELECT id, content, memory_type, created_at, agent_id, task_id
1160
+ FROM memories
1161
+ WHERE namespace = ?
1162
+ AND (valid_from IS NULL OR ${sqliteEpochMillisExpression("valid_from")} IS NULL OR ${sqliteEpochMillisExpression("valid_from")} <= ?)
1163
+ AND (valid_until IS NULL OR ${sqliteEpochMillisExpression("valid_until")} IS NULL OR ${sqliteEpochMillisExpression("valid_until")} > ?)
1164
+ AND (expired_at IS NULL OR ${sqliteEpochMillisExpression("expired_at")} IS NULL OR ${sqliteEpochMillisExpression("expired_at")} > ?)
1165
+ AND (archived_at IS NULL OR ${sqliteEpochMillisExpression("archived_at")} IS NULL OR ${sqliteEpochMillisExpression("archived_at")} > ?)
1166
+ AND (metadata IS NULL OR json_valid(metadata) = 0 OR (
1167
+ COALESCE(json_extract(metadata, '$.is_valid'), 1) != 0
1168
+ AND COALESCE(json_extract(metadata, '$.invalid'), 0) = 0
1169
+ AND COALESCE(json_extract(metadata, '$.is_archived'), 0) = 0
1170
+ AND COALESCE(json_extract(metadata, '$.archived'), 0) = 0
1171
+ AND (
1172
+ valid_from IS NOT NULL
1173
+ OR
1174
+ json_extract(metadata, '$.valid_from') IS NULL
1175
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.valid_from')")} IS NULL
1176
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.valid_from')")} <= ?
1177
+ )
1178
+ AND (
1179
+ valid_until IS NOT NULL
1180
+ OR
1181
+ json_extract(metadata, '$.valid_until') IS NULL
1182
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.valid_until')")} IS NULL
1183
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.valid_until')")} > ?
1184
+ )
1185
+ AND (
1186
+ expired_at IS NOT NULL
1187
+ OR
1188
+ json_extract(metadata, '$.expired_at') IS NULL
1189
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.expired_at')")} IS NULL
1190
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.expired_at')")} > ?
1191
+ )
1192
+ AND (
1193
+ archived_at IS NOT NULL
1194
+ OR
1195
+ json_extract(metadata, '$.archived_at') IS NULL
1196
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.archived_at')")} IS NULL
1197
+ OR ${sqliteEpochMillisExpression("json_extract(metadata, '$.archived_at')")} > ?
1198
+ )
1199
+ AND (
1200
+ json_extract(metadata, '$.superseded_by') IS NULL
1201
+ OR (
1202
+ json_extract(metadata, '$.superseded_at') IS NOT NULL
1203
+ AND ${sqliteEpochMillisExpression("json_extract(metadata, '$.superseded_at')")} > ?
1204
+ )
1205
+ )
1206
+ ))`;
1207
+ const params = [namespace, now, now, now, now, now, now, now, now, now];
985
1208
  if (validatedInput.memory_type) {
986
1209
  query += " AND memory_type = ?";
987
1210
  params.push(validatedInput.memory_type);
@@ -1021,7 +1244,7 @@ async function handleGetStats(input) {
1021
1244
  try {
1022
1245
  // Validate input
1023
1246
  const validatedInput = validateInput(GetStatsInputSchema, input);
1024
- const namespace = validatedInput.namespace || 'default';
1247
+ const namespace = validatedInput.namespace || "default";
1025
1248
  const countStmt = connections.sqlite.prepare("SELECT COUNT(*) as count FROM memories WHERE namespace = ?");
1026
1249
  const countResult = countStmt.get(namespace);
1027
1250
  const totalMemories = countResult.count;
@@ -1046,14 +1269,53 @@ async function handleGetStats(input) {
1046
1269
  }
1047
1270
  stats.memory_type_breakdown = memoryTypeBreakdown;
1048
1271
  if (validatedInput.detailed) {
1049
- // Add detailed statistics
1050
- stats.estimated_storage_bytes = totalMemories * 500; // Rough estimate
1051
- stats.retrieval_latency_ms = {
1052
- p50: 15,
1053
- p95: 45,
1054
- p99: 120,
1272
+ const pageCount = connections.sqlite.pragma("page_count", { simple: true });
1273
+ const pageSize = connections.sqlite.pragma("page_size", { simple: true });
1274
+ const retrievalMetrics = retrievalMonitor.getMetrics();
1275
+ const lifecycle = connections.sqlite
1276
+ .prepare(`
1277
+ SELECT
1278
+ SUM(CASE WHEN valid_until IS NOT NULL THEN 1 ELSE 0 END) AS closed,
1279
+ SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END) AS archived,
1280
+ SUM(access_count) AS total_accesses
1281
+ FROM memories WHERE namespace = ?
1282
+ `)
1283
+ .get(namespace);
1284
+ stats.sqlite_storage_bytes = pageCount * pageSize;
1285
+ stats.retrieval_latency_ms = retrievalMetrics.latency;
1286
+ stats.retrieval_requests_observed = retrievalMetrics.totalRequests;
1287
+ stats.cache_hit_rate =
1288
+ retrievalMetrics.totalCacheRequests > 0 ? retrievalMetrics.cacheHitRate : null;
1289
+ stats.cache_requests_observed = retrievalMetrics.totalCacheRequests;
1290
+ stats.index_outbox_pending = connections.indexOutbox?.getPendingCount() ?? null;
1291
+ stats.lifecycle = {
1292
+ closed: lifecycle.closed ?? 0,
1293
+ archived: lifecycle.archived ?? 0,
1294
+ total_accesses: lifecycle.total_accesses ?? 0,
1055
1295
  };
1056
- stats.cache_hit_rate = connections.redis ? 0.72 : null;
1296
+ stats.consolidation_scheduler = connections.consolidationScheduler?.getStatus() ?? null;
1297
+ if (connections.workingMemory) {
1298
+ try {
1299
+ stats.working_memory = await connections.workingMemory.getStats(namespace);
1300
+ }
1301
+ catch (error) {
1302
+ stats.working_memory = {
1303
+ available: false,
1304
+ error: error instanceof Error ? error.message : String(error),
1305
+ };
1306
+ }
1307
+ }
1308
+ if (connections.hybridSearch) {
1309
+ try {
1310
+ stats.vector_index = await connections.hybridSearch.getCollectionStats();
1311
+ }
1312
+ catch (error) {
1313
+ stats.vector_index = {
1314
+ available: false,
1315
+ error: error instanceof Error ? error.message : String(error),
1316
+ };
1317
+ }
1318
+ }
1057
1319
  }
1058
1320
  console.error(`[INFO] Stats retrieved: ${totalMemories} total memories`);
1059
1321
  return stats;
@@ -1071,45 +1333,49 @@ async function handleDeleteMemory(input) {
1071
1333
  }
1072
1334
  try {
1073
1335
  const validatedInput = validateInput(DeleteMemoryInputSchema, input);
1074
- const namespace = validatedInput.namespace || 'default';
1336
+ const namespace = validatedInput.namespace || "default";
1075
1337
  // Verify memory exists in SQLite
1076
- const existing = connections.sqlite.prepare("SELECT id, content, memory_type, created_at FROM memories WHERE id = ? AND namespace = ?").get(validatedInput.memory_id, namespace);
1338
+ const existing = connections.sqlite
1339
+ .prepare(`SELECT id, content, memory_type, created_at, metadata, agent_id, task_id
1340
+ FROM memories WHERE id = ? AND namespace = ?`)
1341
+ .get(validatedInput.memory_id, namespace);
1077
1342
  if (!existing) {
1078
1343
  return {
1079
1344
  success: false,
1080
1345
  message: `Memory not found: ${validatedInput.memory_id} in namespace '${namespace}'`,
1081
1346
  };
1082
1347
  }
1083
- // Delete from SQLite memories table
1084
- connections.sqlite.prepare("DELETE FROM memories WHERE id = ? AND namespace = ?").run(validatedInput.memory_id, namespace);
1085
- // Delete from Qdrant (best-effort)
1086
- if (connections.hybridSearch) {
1087
- try {
1088
- await connections.hybridSearch.deleteMemory(validatedInput.memory_id);
1089
- }
1090
- catch (err) {
1091
- console.error("[WARN] Failed to delete from Qdrant (continuing):", err instanceof Error ? err.message : String(err));
1092
- }
1093
- }
1094
- // Delete from Redis (best-effort)
1095
- if (connections.redis) {
1348
+ // Canonical deletion and its projection tombstone commit together. Derived
1349
+ // stores can be offline; the outbox is retried on later starts/writes.
1350
+ const commit = connections.sqlite.transaction(() => {
1351
+ connections.sqlite
1352
+ .prepare("DELETE FROM facts WHERE source_conversation = ? AND namespace = ?")
1353
+ .run(validatedInput.memory_id, namespace);
1354
+ connections.sqlite
1355
+ .prepare("DELETE FROM memories WHERE id = ? AND namespace = ?")
1356
+ .run(validatedInput.memory_id, namespace);
1357
+ connections.indexOutbox?.enqueue({
1358
+ memoryId: existing.id,
1359
+ operation: "delete",
1360
+ namespace,
1361
+ content: existing.content,
1362
+ memoryType: existing.memory_type,
1363
+ metadata: safeJsonObject(existing.metadata),
1364
+ agentId: existing.agent_id ?? undefined,
1365
+ taskId: existing.task_id ?? undefined,
1366
+ createdAt: existing.created_at,
1367
+ });
1368
+ });
1369
+ commit();
1370
+ if (connections.indexOutbox) {
1096
1371
  try {
1097
- // Delete the individual working memory key
1098
- await connections.redis.del(`${namespace}:working:${validatedInput.memory_id}`);
1099
- // Remove from the recent list
1100
- await connections.redis.lrem(`${namespace}:working:recent`, 0, validatedInput.memory_id);
1372
+ await connections.indexOutbox.drain(20);
1101
1373
  }
1102
- catch (err) {
1103
- console.error("[WARN] Failed to delete from Redis (continuing):", err instanceof Error ? err.message : String(err));
1374
+ catch (error) {
1375
+ console.error("[WARN] Inline index deletion failed; tombstone remains durable:", error instanceof Error ? error.message : String(error));
1104
1376
  }
1105
1377
  }
1106
- // Clean up orphaned facts where source_conversation = memory_id
1107
- try {
1108
- connections.sqlite.prepare("DELETE FROM facts WHERE source_conversation = ? AND namespace = ?").run(validatedInput.memory_id, namespace);
1109
- }
1110
- catch (err) {
1111
- console.error("[WARN] Failed to clean up orphaned facts (continuing):", err instanceof Error ? err.message : String(err));
1112
- }
1378
+ const indexState = connections.indexOutbox?.getState(existing.id) ?? "pending";
1113
1379
  console.error(`[INFO] Memory deleted: ${validatedInput.memory_id} (type: ${existing.memory_type}, ns: ${namespace})`);
1114
1380
  return {
1115
1381
  success: true,
@@ -1120,6 +1386,7 @@ async function handleDeleteMemory(input) {
1120
1386
  created_at: existing.created_at,
1121
1387
  },
1122
1388
  namespace,
1389
+ index_state: indexState,
1123
1390
  message: `Memory deleted successfully`,
1124
1391
  };
1125
1392
  }
@@ -1130,6 +1397,19 @@ async function handleDeleteMemory(input) {
1130
1397
  throw new McpError(ErrorCode.InternalError, `Failed to delete memory: ${err instanceof Error ? err.message : String(err)}`);
1131
1398
  }
1132
1399
  }
1400
+ function safeJsonObject(value) {
1401
+ if (!value)
1402
+ return {};
1403
+ try {
1404
+ const parsed = JSON.parse(value);
1405
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1406
+ ? parsed
1407
+ : {};
1408
+ }
1409
+ catch {
1410
+ return {};
1411
+ }
1412
+ }
1133
1413
  /**
1134
1414
  * Handle vesper_enable tool call
1135
1415
  */
@@ -1185,11 +1465,11 @@ async function handleRecordSkillOutcome(input) {
1185
1465
  if (validatedInput.satisfaction === undefined) {
1186
1466
  throw new McpError(ErrorCode.InvalidParams, "satisfaction is required for success outcome");
1187
1467
  }
1188
- connections.skillLibrary.recordSuccess(validatedInput.skill_id, validatedInput.satisfaction);
1468
+ connections.skillLibrary.recordSuccess(validatedInput.skill_id, validatedInput.satisfaction, validatedInput.namespace);
1189
1469
  console.error(`[INFO] Recorded skill success: ${validatedInput.skill_id} (satisfaction: ${validatedInput.satisfaction})`);
1190
1470
  }
1191
1471
  else {
1192
- connections.skillLibrary.recordFailure(validatedInput.skill_id);
1472
+ connections.skillLibrary.recordFailure(validatedInput.skill_id, validatedInput.namespace);
1193
1473
  console.error(`[INFO] Recorded skill failure: ${validatedInput.skill_id}`);
1194
1474
  }
1195
1475
  return {
@@ -1235,7 +1515,7 @@ async function handleLoadSkill(input) {
1235
1515
  let fullSkill = null;
1236
1516
  if (connections.workingMemory) {
1237
1517
  try {
1238
- const cached = await connections.workingMemory.getCachedSkill(validatedInput.skill_id);
1518
+ const cached = await connections.workingMemory.getCachedSkill(validatedInput.skill_id, validatedInput.namespace);
1239
1519
  if (cached) {
1240
1520
  fullSkill = cached.skill;
1241
1521
  fromCache = true;
@@ -1249,14 +1529,14 @@ async function handleLoadSkill(input) {
1249
1529
  }
1250
1530
  // Step 2: Load from database if not cached
1251
1531
  if (!fullSkill) {
1252
- fullSkill = connections.skillLibrary.loadFull(validatedInput.skill_id);
1532
+ fullSkill = connections.skillLibrary.loadFull(validatedInput.skill_id, validatedInput.namespace);
1253
1533
  if (!fullSkill) {
1254
1534
  throw new McpError(ErrorCode.InvalidParams, `Skill not found: ${validatedInput.skill_id}`);
1255
1535
  }
1256
1536
  // Cache the loaded skill (if working memory available)
1257
1537
  if (connections.workingMemory) {
1258
1538
  try {
1259
- await connections.workingMemory.cacheSkill(fullSkill, 3600); // 1 hour TTL
1539
+ await connections.workingMemory.cacheSkill(fullSkill, 3600, validatedInput.namespace); // 1 hour TTL
1260
1540
  }
1261
1541
  catch (err) {
1262
1542
  console.warn(`[WARN] Failed to cache skill: ${err instanceof Error ? err.message : String(err)}`);
@@ -1265,7 +1545,7 @@ async function handleLoadSkill(input) {
1265
1545
  }
1266
1546
  const loadTime = Date.now() - startTime;
1267
1547
  console.error(`[INFO] Loaded full skill: ${fullSkill.name} (${validatedInput.skill_id}) ` +
1268
- `in ${loadTime}ms (${fromCache ? 'cache' : 'database'})`);
1548
+ `in ${loadTime}ms (${fromCache ? "cache" : "database"})`);
1269
1549
  return {
1270
1550
  success: true,
1271
1551
  skill: {
@@ -1293,7 +1573,7 @@ async function handleLoadSkill(input) {
1293
1573
  },
1294
1574
  load_time_ms: loadTime,
1295
1575
  from_cache: fromCache,
1296
- message: `Loaded full skill: ${fullSkill.name}${fromCache ? ' (cached)' : ''}`,
1576
+ message: `Loaded full skill: ${fullSkill.name}${fromCache ? " (cached)" : ""}`,
1297
1577
  };
1298
1578
  }
1299
1579
  catch (err) {
@@ -1315,63 +1595,195 @@ async function handleShareContext(input) {
1315
1595
  try {
1316
1596
  const validatedInput = validateInput(ShareContextInputSchema, input);
1317
1597
  const { source_namespace, target_namespace } = validatedInput;
1598
+ if (source_namespace === target_namespace) {
1599
+ throw new McpError(ErrorCode.InvalidParams, "source_namespace and target_namespace must be different");
1600
+ }
1318
1601
  const maxItems = validatedInput.max_items || 10;
1319
1602
  const now = Date.now();
1320
1603
  const sharedItems = [];
1321
- // Query memories from source namespace
1322
- let memQuery = "SELECT id, content, memory_type, created_at, metadata, agent_id, task_id FROM memories WHERE namespace = ?";
1323
- const memParams = [source_namespace];
1324
- if (validatedInput.task_id) {
1325
- memQuery += " AND task_id = ?";
1326
- memParams.push(validatedInput.task_id);
1604
+ let memoryIds;
1605
+ if (validatedInput.query && connections.memoryRetriever) {
1606
+ const selected = await connections.memoryRetriever.retrieve({
1607
+ query: validatedInput.query,
1608
+ namespace: source_namespace,
1609
+ taskId: validatedInput.task_id,
1610
+ maxResults: maxItems,
1611
+ strategy: "auto",
1612
+ });
1613
+ memoryIds = selected.results.map((memory) => memory.id);
1327
1614
  }
1328
- memQuery += " ORDER BY created_at DESC LIMIT ?";
1329
- memParams.push(maxItems);
1330
- const memories = connections.sqlite.prepare(memQuery).all(...memParams);
1331
- // Copy memories to target namespace
1332
- for (const mem of memories) {
1333
- const newId = randomUUID();
1334
- connections.sqlite.prepare(`
1335
- INSERT INTO memories (id, content, memory_type, created_at, updated_at, metadata, namespace, agent_id, agent_role, task_id)
1336
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1337
- `).run(newId, mem.content, mem.memory_type, mem.created_at, now, mem.metadata, target_namespace, mem.agent_id, null, mem.task_id);
1338
- sharedItems.push({ type: 'memory', id: newId, original_id: mem.id });
1615
+ else {
1616
+ let selectionSql = `
1617
+ SELECT id FROM memories
1618
+ WHERE namespace = ? AND archived_at IS NULL
1619
+ AND (valid_from IS NULL OR valid_from <= ?)
1620
+ AND (valid_until IS NULL OR valid_until > ?)
1621
+ AND (expired_at IS NULL OR expired_at > ?)
1622
+ `;
1623
+ const selectionParams = [source_namespace, now, now, now];
1624
+ if (validatedInput.task_id) {
1625
+ selectionSql += " AND task_id = ?";
1626
+ selectionParams.push(validatedInput.task_id);
1627
+ }
1628
+ selectionSql += " ORDER BY created_at DESC LIMIT ?";
1629
+ selectionParams.push(maxItems);
1630
+ memoryIds = connections.sqlite.prepare(selectionSql).all(...selectionParams).map((row) => row.id);
1339
1631
  }
1340
- // Include entities if requested
1632
+ const memories = memoryIds.length > 0
1633
+ ? connections.sqlite
1634
+ .prepare(`
1635
+ SELECT id, content, memory_type, created_at, metadata, agent_id,
1636
+ agent_role, task_id, importance, valid_from, valid_until, expired_at
1637
+ FROM memories WHERE namespace = ? AND id IN (${memoryIds.map(() => "?").join(",")})
1638
+ `)
1639
+ .all(source_namespace, ...memoryIds)
1640
+ : [];
1641
+ const memoryOrder = new Map(memoryIds.map((id, index) => [id, index]));
1642
+ memories.sort((a, b) => (memoryOrder.get(a.id) ?? 0) - (memoryOrder.get(b.id) ?? 0));
1643
+ const selectedSkills = validatedInput.include_skills
1644
+ ? connections.sqlite
1645
+ .prepare(`
1646
+ SELECT * FROM skills
1647
+ WHERE namespace = ? AND is_archived = 0
1648
+ AND (? IS NULL OR name LIKE ? OR description LIKE ? OR triggers LIKE ?)
1649
+ ORDER BY avg_user_satisfaction DESC, success_count DESC
1650
+ LIMIT ?
1651
+ `)
1652
+ .all(source_namespace, validatedInput.query ?? null, `%${validatedInput.query ?? ""}%`, `%${validatedInput.query ?? ""}%`, `%${validatedInput.query ?? ""}%`, maxItems)
1653
+ : [];
1654
+ const selectedEntities = validatedInput.include_entities
1655
+ ? connections.sqlite
1656
+ .prepare("SELECT * FROM entities WHERE namespace = ? LIMIT ?")
1657
+ .all(source_namespace, maxItems)
1658
+ : [];
1659
+ let skillsShared = 0;
1341
1660
  let entityCount = 0;
1342
- if (validatedInput.include_entities) {
1343
- const entities = connections.sqlite.prepare("SELECT * FROM entities WHERE namespace = ? LIMIT ?").all(source_namespace, maxItems);
1344
- for (const entity of entities) {
1345
- if (connections.semanticMemory) {
1346
- connections.semanticMemory.upsertEntity({ name: entity.name, type: entity.type, description: entity.description, confidence: entity.confidence }, target_namespace);
1347
- entityCount++;
1661
+ const handoffId = randomUUID();
1662
+ const handoffContent = `Context handoff from namespace '${source_namespace}': ${memories.length} memories selected`;
1663
+ const handoffMetadata = {
1664
+ handoff: true,
1665
+ source_namespace,
1666
+ query: validatedInput.query ?? null,
1667
+ };
1668
+ const commit = connections.sqlite.transaction(() => {
1669
+ for (const memory of memories) {
1670
+ const newId = randomUUID();
1671
+ const copiedMetadata = {
1672
+ ...safeJsonObject(memory.metadata),
1673
+ shared_from_namespace: source_namespace,
1674
+ shared_from_memory_id: memory.id,
1675
+ shared_at: now,
1676
+ };
1677
+ connections.sqlite
1678
+ .prepare(`
1679
+ INSERT INTO memories (
1680
+ id, content, memory_type, created_at, updated_at, metadata, namespace,
1681
+ agent_id, agent_role, task_id, importance, access_count, last_accessed,
1682
+ valid_from, valid_until, expired_at, archived_at, consolidated_at
1683
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, NULL, NULL)
1684
+ `)
1685
+ .run(newId, memory.content, memory.memory_type, memory.created_at, now, JSON.stringify(copiedMetadata), target_namespace, memory.agent_id, memory.agent_role, memory.task_id, memory.importance ?? 0.5, memory.valid_from ?? memory.created_at, memory.valid_until, memory.expired_at);
1686
+ connections.indexOutbox?.enqueue({
1687
+ memoryId: newId,
1688
+ operation: "upsert",
1689
+ namespace: target_namespace,
1690
+ content: memory.content,
1691
+ memoryType: memory.memory_type,
1692
+ metadata: copiedMetadata,
1693
+ agentId: memory.agent_id ?? undefined,
1694
+ taskId: memory.task_id ?? undefined,
1695
+ createdAt: memory.created_at,
1696
+ });
1697
+ sharedItems.push({ type: "memory", id: newId, original_id: memory.id });
1698
+ }
1699
+ for (const skill of selectedSkills) {
1700
+ const newId = randomUUID();
1701
+ const targetName = `${skill.name} [${target_namespace}]`;
1702
+ const result = connections.sqlite
1703
+ .prepare(`
1704
+ INSERT OR IGNORE INTO skills (
1705
+ id, name, description, summary, category, code, code_type, triggers,
1706
+ prerequisites, success_count, failure_count, avg_user_satisfaction,
1707
+ uses_skills, used_by_skills, namespace, is_archived, created_from,
1708
+ created_at, last_used, last_modified, version, notes, metadata
1709
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)
1710
+ `)
1711
+ .run(newId, targetName, skill.description, skill.summary, skill.category, skill.code, skill.code_type, skill.triggers, skill.prerequisites, skill.success_count, skill.failure_count, skill.avg_user_satisfaction, skill.uses_skills, skill.used_by_skills, target_namespace, skill.created_from, skill.created_at, skill.last_used, now, skill.version, skill.notes, skill.metadata);
1712
+ if (result.changes > 0) {
1713
+ skillsShared++;
1714
+ sharedItems.push({ type: "skill", id: newId, original_id: skill.id });
1715
+ }
1716
+ }
1717
+ for (const entity of selectedEntities) {
1718
+ const existing = connections.sqlite
1719
+ .prepare("SELECT id FROM entities WHERE namespace = ? AND name = ? AND type = ? LIMIT 1")
1720
+ .get(target_namespace, entity.name, entity.type);
1721
+ if (existing) {
1722
+ connections.sqlite
1723
+ .prepare(`UPDATE entities
1724
+ SET description = COALESCE(?, description),
1725
+ confidence = MAX(confidence, ?), last_accessed = ?
1726
+ WHERE id = ?`)
1727
+ .run(entity.description, entity.confidence ?? 1, new Date(now).toISOString(), existing.id);
1728
+ }
1729
+ else {
1730
+ connections.sqlite
1731
+ .prepare(`INSERT INTO entities (
1732
+ id, name, type, description, confidence, created_at,
1733
+ last_accessed, access_count, namespace
1734
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1735
+ .run(randomUUID(), entity.name, entity.type, entity.description, entity.confidence ?? 1, entity.created_at ?? new Date(now).toISOString(), new Date(now).toISOString(), entity.access_count ?? 1, target_namespace);
1348
1736
  }
1737
+ entityCount++;
1738
+ }
1739
+ connections.sqlite
1740
+ .prepare(`
1741
+ INSERT INTO memories (
1742
+ id, content, memory_type, created_at, updated_at, metadata, namespace,
1743
+ importance, access_count, valid_from, consolidated_at
1744
+ ) VALUES (?, ?, 'episodic', ?, ?, ?, ?, 0.5, 0, ?, NULL)
1745
+ `)
1746
+ .run(handoffId, handoffContent, now, now, JSON.stringify(handoffMetadata), target_namespace, now);
1747
+ connections.indexOutbox?.enqueue({
1748
+ memoryId: handoffId,
1749
+ operation: "upsert",
1750
+ namespace: target_namespace,
1751
+ content: handoffContent,
1752
+ memoryType: "episodic",
1753
+ metadata: handoffMetadata,
1754
+ createdAt: now,
1755
+ });
1756
+ });
1757
+ commit();
1758
+ if (connections.indexOutbox) {
1759
+ try {
1760
+ await connections.indexOutbox.drain(maxItems + 1);
1761
+ }
1762
+ catch (error) {
1763
+ console.error("[WARN] Shared-context index drain failed; mutations remain durable:", error instanceof Error ? error.message : String(error));
1349
1764
  }
1350
1765
  }
1351
- // Store handoff event in target namespace
1352
- const handoffId = randomUUID();
1353
- connections.sqlite.prepare(`
1354
- INSERT INTO memories (id, content, memory_type, created_at, updated_at, metadata, namespace)
1355
- VALUES (?, ?, ?, ?, ?, ?, ?)
1356
- `).run(handoffId, `Context handoff from namespace '${source_namespace}': ${memories.length} memories, ${entityCount} entities shared`, 'episodic', now, now, JSON.stringify({ handoff: true, source_namespace, items_shared: sharedItems.length, entities_shared: entityCount }), target_namespace);
1357
- console.error(`[INFO] Shared ${sharedItems.length} memories + ${entityCount} entities from ${source_namespace} to ${target_namespace}`);
1766
+ console.error(`[INFO] Shared ${memories.length} memories + ${skillsShared} skills + ${entityCount} entities from ${source_namespace} to ${target_namespace}`);
1358
1767
  return {
1359
1768
  success: true,
1360
1769
  source_namespace,
1361
1770
  target_namespace,
1362
- memories_shared: sharedItems.length,
1771
+ memories_shared: memories.length,
1772
+ skills_shared: skillsShared,
1363
1773
  entities_shared: entityCount,
1364
1774
  handoff_id: handoffId,
1365
1775
  };
1366
1776
  }
1367
1777
  catch (err) {
1778
+ if (err instanceof McpError)
1779
+ throw err;
1368
1780
  throw new McpError(ErrorCode.InternalError, `Failed to share context: ${err instanceof Error ? err.message : String(err)}`);
1369
1781
  }
1370
1782
  }
1371
1783
  /**
1372
1784
  * Handle store_decision tool call
1373
1785
  *
1374
- * Stores a decision with reduced decay and conflict detection.
1786
+ * Stores a versioned decision with reduced decay and explicit supersession.
1375
1787
  */
1376
1788
  async function handleStoreDecision(input) {
1377
1789
  if (!connections.sqlite) {
@@ -1379,64 +1791,130 @@ async function handleStoreDecision(input) {
1379
1791
  }
1380
1792
  try {
1381
1793
  const validatedInput = validateInput(StoreDecisionInputSchema, input);
1382
- const namespace = validatedInput.namespace || 'default';
1794
+ const namespace = validatedInput.namespace || "default";
1383
1795
  const id = randomUUID();
1384
1796
  const now = Date.now();
1385
- // Build metadata with decay_factor
1797
+ const validFrom = toEpochMillis(validatedInput.valid_from) ?? now;
1798
+ const validUntil = toEpochMillis(validatedInput.valid_until);
1799
+ if (validUntil !== undefined && validUntil <= validFrom) {
1800
+ throw new McpError(ErrorCode.InvalidParams, "valid_until must be after valid_from");
1801
+ }
1386
1802
  const metadata = {
1387
1803
  ...(validatedInput.metadata || {}),
1388
1804
  decay_factor: 0.25,
1389
1805
  supersedes: validatedInput.supersedes || null,
1390
1806
  };
1391
- // Store as decision memory type
1392
- connections.sqlite.prepare(`
1393
- INSERT INTO memories (id, content, memory_type, created_at, updated_at, metadata, namespace, agent_id, agent_role, task_id)
1394
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1395
- `).run(id, validatedInput.content, 'decision', now, now, JSON.stringify(metadata), namespace, validatedInput.agent_id || null, validatedInput.agent_role || null, validatedInput.task_id || null);
1396
- // Generate embedding and store in Qdrant
1397
- let hasEmbedding = false;
1398
- if (connections.embeddingClient && connections.hybridSearch) {
1399
- try {
1400
- const embedding = await connections.embeddingClient.embed(validatedInput.content);
1401
- await connections.hybridSearch.upsertMemory(id, embedding, {
1402
- content: validatedInput.content,
1403
- memory_type: 'decision',
1404
- created_at: now,
1807
+ const superseded = validatedInput.supersedes
1808
+ ? connections.sqlite
1809
+ .prepare(`
1810
+ SELECT id, content, memory_type, created_at, metadata, agent_id, task_id,
1811
+ valid_from, valid_until
1812
+ FROM memories
1813
+ WHERE id = ? AND namespace = ? AND memory_type = 'decision'
1814
+ `)
1815
+ .get(validatedInput.supersedes, namespace)
1816
+ : undefined;
1817
+ if (validatedInput.supersedes && !superseded) {
1818
+ throw new McpError(ErrorCode.InvalidParams, `Superseded decision not found in namespace '${namespace}'`);
1819
+ }
1820
+ if (superseded) {
1821
+ const supersededMetadata = safeJsonObject(superseded.metadata);
1822
+ if (typeof supersededMetadata.superseded_by === "string") {
1823
+ throw new McpError(ErrorCode.InvalidParams, "Decision has already been superseded");
1824
+ }
1825
+ if (superseded.valid_from !== null && validFrom < superseded.valid_from) {
1826
+ throw new McpError(ErrorCode.InvalidParams, "A replacement decision cannot become effective before the decision it supersedes");
1827
+ }
1828
+ if (superseded.valid_until !== null && validFrom > superseded.valid_until) {
1829
+ throw new McpError(ErrorCode.InvalidParams, "Replacement valid_from cannot be after the superseded decision's valid_until");
1830
+ }
1831
+ }
1832
+ const commit = connections.sqlite.transaction(() => {
1833
+ connections.sqlite
1834
+ .prepare(`
1835
+ INSERT INTO memories (
1836
+ id, content, memory_type, created_at, updated_at, metadata, namespace,
1837
+ agent_id, agent_role, task_id, importance, access_count, last_accessed,
1838
+ valid_from, valid_until, expired_at, archived_at, consolidated_at
1839
+ ) VALUES (?, ?, 'decision', ?, ?, ?, ?, ?, ?, ?, 0.9, 0, NULL, ?, ?, NULL, NULL, NULL)
1840
+ `)
1841
+ .run(id, validatedInput.content, now, now, JSON.stringify(metadata), namespace, validatedInput.agent_id || null, validatedInput.agent_role || null, validatedInput.task_id || null, validFrom, validUntil ?? null);
1842
+ connections.indexOutbox?.enqueue({
1843
+ memoryId: id,
1844
+ operation: "upsert",
1845
+ namespace,
1846
+ content: validatedInput.content,
1847
+ memoryType: "decision",
1848
+ metadata,
1849
+ agentId: validatedInput.agent_id,
1850
+ taskId: validatedInput.task_id,
1851
+ createdAt: now,
1852
+ });
1853
+ if (superseded) {
1854
+ const oldMetadata = {
1855
+ ...safeJsonObject(superseded.metadata),
1856
+ superseded_by: id,
1857
+ superseded_at: validFrom,
1858
+ superseded_recorded_at: now,
1859
+ };
1860
+ const supersessionUpdate = connections.sqlite
1861
+ .prepare(`
1862
+ UPDATE memories
1863
+ SET metadata = ?, updated_at = ?, valid_until = ?
1864
+ WHERE id = ? AND namespace = ?
1865
+ AND metadata IS ? AND valid_until IS ?
1866
+ `)
1867
+ .run(JSON.stringify(oldMetadata), now, validFrom, superseded.id, namespace, superseded.metadata, superseded.valid_until);
1868
+ if (supersessionUpdate.changes !== 1) {
1869
+ throw new McpError(ErrorCode.InvalidParams, "Superseded decision changed concurrently; retry with the current decision");
1870
+ }
1871
+ connections.indexOutbox?.enqueue({
1872
+ memoryId: superseded.id,
1873
+ operation: "upsert",
1405
1874
  namespace,
1406
- agent_id: validatedInput.agent_id,
1407
- task_id: validatedInput.task_id,
1408
- decay_factor: 0.25,
1875
+ content: superseded.content,
1876
+ memoryType: superseded.memory_type,
1877
+ metadata: oldMetadata,
1878
+ agentId: superseded.agent_id ?? undefined,
1879
+ taskId: superseded.task_id ?? undefined,
1880
+ createdAt: superseded.created_at,
1409
1881
  });
1410
- hasEmbedding = true;
1411
1882
  }
1412
- catch (err) {
1413
- console.error("[WARN] Failed to generate embedding for decision:", err instanceof Error ? err.message : String(err));
1883
+ });
1884
+ commit();
1885
+ if (connections.indexOutbox) {
1886
+ try {
1887
+ await connections.indexOutbox.drain(20);
1414
1888
  }
1415
- }
1416
- // If supersedes, mark the old decision's metadata
1417
- if (validatedInput.supersedes) {
1418
- const oldMem = connections.sqlite.prepare("SELECT metadata FROM memories WHERE id = ? AND namespace = ?").get(validatedInput.supersedes, namespace);
1419
- if (oldMem) {
1420
- const oldMeta = JSON.parse(oldMem.metadata || '{}');
1421
- oldMeta.superseded_by = id;
1422
- connections.sqlite.prepare("UPDATE memories SET metadata = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(oldMeta), now, validatedInput.supersedes);
1889
+ catch (error) {
1890
+ console.error("[WARN] Decision index drain failed; mutation remains durable:", error instanceof Error ? error.message : String(error));
1423
1891
  }
1424
1892
  }
1425
- // Check for conflicting decisions in same namespace
1426
- const existingDecisions = connections.sqlite.prepare("SELECT id, content FROM memories WHERE namespace = ? AND memory_type = 'decision' AND id != ? ORDER BY created_at DESC LIMIT 10").all(namespace, id);
1427
- console.error(`[INFO] Decision stored: ${id} (ns: ${namespace}, supersedes: ${validatedInput.supersedes || 'none'})`);
1893
+ const indexState = connections.indexOutbox?.getState(id) ?? "pending";
1894
+ // This is intentionally a count of candidates, not a claim that semantic
1895
+ // conflicts were detected. Conflict classification requires a verifier.
1896
+ const existingDecisions = connections.sqlite
1897
+ .prepare(`SELECT id FROM memories
1898
+ WHERE namespace = ? AND memory_type = 'decision' AND id != ?
1899
+ AND valid_until IS NULL
1900
+ ORDER BY created_at DESC LIMIT 10`)
1901
+ .all(namespace, id);
1902
+ console.error(`[INFO] Decision stored: ${id} (ns: ${namespace}, supersedes: ${validatedInput.supersedes || "none"})`);
1428
1903
  return {
1429
1904
  success: true,
1430
1905
  decision_id: id,
1431
1906
  namespace,
1432
1907
  timestamp: now,
1433
- has_embedding: hasEmbedding,
1908
+ has_embedding: indexState === "indexed" && !!connections.hybridSearch,
1909
+ index_state: indexState,
1434
1910
  supersedes: validatedInput.supersedes || null,
1435
- existing_decisions_count: existingDecisions.length,
1911
+ potential_conflict_candidates: existingDecisions.length,
1436
1912
  message: `Decision stored successfully`,
1437
1913
  };
1438
1914
  }
1439
1915
  catch (err) {
1916
+ if (err instanceof McpError)
1917
+ throw err;
1440
1918
  throw new McpError(ErrorCode.InternalError, `Failed to store decision: ${err instanceof Error ? err.message : String(err)}`);
1441
1919
  }
1442
1920
  }
@@ -1448,7 +1926,8 @@ async function handleListNamespaces() {
1448
1926
  throw new McpError(ErrorCode.InternalError, "SQLite database not available");
1449
1927
  }
1450
1928
  try {
1451
- const namespaces = connections.sqlite.prepare(`
1929
+ const namespaces = connections.sqlite
1930
+ .prepare(`
1452
1931
  SELECT namespace, COUNT(*) as memory_count,
1453
1932
  COUNT(DISTINCT agent_id) as agent_count,
1454
1933
  MAX(created_at) as last_activity
@@ -1456,10 +1935,11 @@ async function handleListNamespaces() {
1456
1935
  WHERE namespace IS NOT NULL
1457
1936
  GROUP BY namespace
1458
1937
  ORDER BY last_activity DESC
1459
- `).all();
1938
+ `)
1939
+ .all();
1460
1940
  return {
1461
1941
  success: true,
1462
- namespaces: namespaces.map(ns => ({
1942
+ namespaces: namespaces.map((ns) => ({
1463
1943
  namespace: ns.namespace,
1464
1944
  memory_count: ns.memory_count,
1465
1945
  agent_count: ns.agent_count,
@@ -1482,21 +1962,33 @@ async function handleNamespaceStats(input) {
1482
1962
  try {
1483
1963
  const validatedInput = validateInput(NamespaceStatsInputSchema, input);
1484
1964
  const namespace = validatedInput.namespace;
1485
- const memoryCount = connections.sqlite.prepare("SELECT COUNT(*) as count FROM memories WHERE namespace = ?").get(namespace).count;
1486
- const typeBreakdown = connections.sqlite.prepare("SELECT memory_type, COUNT(*) as count FROM memories WHERE namespace = ? GROUP BY memory_type").all(namespace);
1487
- const entityCount = connections.sqlite.prepare("SELECT COUNT(*) as count FROM entities WHERE namespace = ?").get(namespace).count;
1488
- const skillCount = connections.sqlite.prepare("SELECT COUNT(*) as count FROM skills WHERE namespace = ?").get(namespace).count;
1489
- const agents = connections.sqlite.prepare("SELECT DISTINCT agent_id, agent_role FROM memories WHERE namespace = ? AND agent_id IS NOT NULL").all(namespace);
1490
- const decisionCount = connections.sqlite.prepare("SELECT COUNT(*) as count FROM memories WHERE namespace = ? AND memory_type = 'decision'").get(namespace).count;
1965
+ const memoryCount = connections.sqlite
1966
+ .prepare("SELECT COUNT(*) as count FROM memories WHERE namespace = ?")
1967
+ .get(namespace).count;
1968
+ const typeBreakdown = connections.sqlite
1969
+ .prepare("SELECT memory_type, COUNT(*) as count FROM memories WHERE namespace = ? GROUP BY memory_type")
1970
+ .all(namespace);
1971
+ const entityCount = connections.sqlite
1972
+ .prepare("SELECT COUNT(*) as count FROM entities WHERE namespace = ?")
1973
+ .get(namespace).count;
1974
+ const skillCount = connections.sqlite
1975
+ .prepare("SELECT COUNT(*) as count FROM skills WHERE namespace = ?")
1976
+ .get(namespace).count;
1977
+ const agents = connections.sqlite
1978
+ .prepare("SELECT DISTINCT agent_id, agent_role FROM memories WHERE namespace = ? AND agent_id IS NOT NULL")
1979
+ .all(namespace);
1980
+ const decisionCount = connections.sqlite
1981
+ .prepare("SELECT COUNT(*) as count FROM memories WHERE namespace = ? AND memory_type = 'decision'")
1982
+ .get(namespace).count;
1491
1983
  return {
1492
1984
  success: true,
1493
1985
  namespace,
1494
1986
  memories: memoryCount,
1495
- memory_type_breakdown: Object.fromEntries(typeBreakdown.map(t => [t.memory_type, t.count])),
1987
+ memory_type_breakdown: Object.fromEntries(typeBreakdown.map((t) => [t.memory_type, t.count])),
1496
1988
  entities: entityCount,
1497
1989
  skills: skillCount,
1498
1990
  decisions: decisionCount,
1499
- agents: agents.map(a => ({ agent_id: a.agent_id, agent_role: a.agent_role })),
1991
+ agents: agents.map((a) => ({ agent_id: a.agent_id, agent_role: a.agent_role })),
1500
1992
  agent_count: agents.length,
1501
1993
  };
1502
1994
  }
@@ -1507,7 +1999,7 @@ async function handleNamespaceStats(input) {
1507
1999
  /**
1508
2000
  * Process tool calls
1509
2001
  */
1510
- async function processTool(name, input) {
2002
+ export async function processTool(name, input) {
1511
2003
  console.error(`[INFO] Processing tool: ${name}`);
1512
2004
  // Handle toggle tools first (these always work regardless of enabled state)
1513
2005
  switch (name) {
@@ -1520,7 +2012,7 @@ async function processTool(name, input) {
1520
2012
  }
1521
2013
  // Check if Vesper is disabled (pass-through mode)
1522
2014
  if (!vesperEnabled) {
1523
- console.error('[INFO] Vesper is disabled (pass-through mode)');
2015
+ console.error("[INFO] Vesper is disabled (pass-through mode)");
1524
2016
  return {
1525
2017
  success: true,
1526
2018
  disabled: true,
@@ -1565,7 +2057,7 @@ async function main() {
1565
2057
  // Create MCP server
1566
2058
  const server = new Server({
1567
2059
  name: "vesper",
1568
- version: "0.5.0",
2060
+ version: "0.5.4",
1569
2061
  }, {
1570
2062
  capabilities: {
1571
2063
  tools: {},
@@ -1593,11 +2085,7 @@ async function main() {
1593
2085
  };
1594
2086
  }
1595
2087
  catch (err) {
1596
- const errorMessage = err instanceof McpError
1597
- ? err.message
1598
- : err instanceof Error
1599
- ? err.message
1600
- : String(err);
2088
+ const errorMessage = err instanceof McpError ? err.message : err instanceof Error ? err.message : String(err);
1601
2089
  console.error(`[ERROR] Tool error for ${name}: ${errorMessage}`);
1602
2090
  return {
1603
2091
  content: [
@@ -1623,21 +2111,37 @@ async function main() {
1623
2111
  }
1624
2112
  // Cleanup handler for graceful shutdown
1625
2113
  function cleanup() {
1626
- console.error('[INFO] Shutting down Vesper...');
2114
+ console.error("[INFO] Shutting down Vesper...");
2115
+ connections.consolidationScheduler?.stop();
2116
+ if (connections.indexOutboxTimer)
2117
+ clearInterval(connections.indexOutboxTimer);
1627
2118
  if (connections.redis) {
1628
2119
  connections.redis.disconnect();
1629
2120
  }
1630
2121
  if (connections.sqlite) {
1631
2122
  connections.sqlite.close();
1632
2123
  }
1633
- console.error('[INFO] Vesper shutdown complete');
2124
+ console.error("[INFO] Vesper shutdown complete");
1634
2125
  process.exit(0);
1635
2126
  }
1636
- process.on('SIGINT', cleanup);
1637
- process.on('SIGTERM', cleanup);
1638
- // Run the server
1639
- main().catch((err) => {
1640
- console.error("[FATAL] Server error:", err instanceof Error ? err.message : String(err));
1641
- process.exit(1);
1642
- });
2127
+ export function closeConnectionsForTesting() {
2128
+ connections.consolidationScheduler?.stop();
2129
+ if (connections.indexOutboxTimer)
2130
+ clearInterval(connections.indexOutboxTimer);
2131
+ connections.redis?.disconnect();
2132
+ if (connections.sqlite?.open)
2133
+ connections.sqlite.close();
2134
+ for (const key of Object.keys(connections)) {
2135
+ delete connections[key];
2136
+ }
2137
+ }
2138
+ process.on("SIGINT", cleanup);
2139
+ process.on("SIGTERM", cleanup);
2140
+ // Run the server unless a test imports the production handlers directly.
2141
+ if (process.env.VESPER_SKIP_AUTO_START !== "1") {
2142
+ main().catch((err) => {
2143
+ console.error("[FATAL] Server error:", err instanceof Error ? err.message : String(err));
2144
+ process.exit(1);
2145
+ });
2146
+ }
1643
2147
  //# sourceMappingURL=server.js.map