@yamo/memory-mesh 2.3.1 → 3.0.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 (102) hide show
  1. package/bin/memory_mesh.js +1 -1
  2. package/lib/llm/client.d.ts +111 -0
  3. package/lib/llm/client.js +299 -357
  4. package/lib/llm/client.ts +413 -0
  5. package/lib/llm/index.d.ts +17 -0
  6. package/lib/llm/index.js +15 -8
  7. package/lib/llm/index.ts +19 -0
  8. package/lib/memory/adapters/client.d.ts +183 -0
  9. package/lib/memory/adapters/client.js +518 -0
  10. package/lib/memory/adapters/client.ts +678 -0
  11. package/lib/memory/adapters/config.d.ts +137 -0
  12. package/lib/memory/adapters/config.js +189 -0
  13. package/lib/memory/adapters/config.ts +259 -0
  14. package/lib/memory/adapters/errors.d.ts +76 -0
  15. package/lib/memory/adapters/errors.js +128 -0
  16. package/lib/memory/adapters/errors.ts +166 -0
  17. package/lib/memory/context-manager.d.ts +44 -0
  18. package/lib/memory/context-manager.js +344 -0
  19. package/lib/memory/context-manager.ts +432 -0
  20. package/lib/memory/embeddings/factory.d.ts +59 -0
  21. package/lib/memory/embeddings/factory.js +148 -0
  22. package/lib/{embeddings/factory.js → memory/embeddings/factory.ts} +69 -28
  23. package/lib/memory/embeddings/index.d.ts +2 -0
  24. package/lib/memory/embeddings/index.js +2 -0
  25. package/lib/memory/embeddings/index.ts +2 -0
  26. package/lib/memory/embeddings/service.d.ts +164 -0
  27. package/lib/memory/embeddings/service.js +515 -0
  28. package/lib/{embeddings/service.js → memory/embeddings/service.ts} +223 -156
  29. package/lib/memory/index.d.ts +9 -0
  30. package/lib/memory/index.js +9 -1
  31. package/lib/memory/index.ts +20 -0
  32. package/lib/memory/memory-mesh.d.ts +274 -0
  33. package/lib/memory/memory-mesh.js +1445 -1189
  34. package/lib/memory/memory-mesh.ts +1803 -0
  35. package/lib/memory/memory-translator.d.ts +19 -0
  36. package/lib/memory/memory-translator.js +125 -0
  37. package/lib/memory/memory-translator.ts +158 -0
  38. package/lib/memory/schema.d.ts +111 -0
  39. package/lib/memory/schema.js +183 -0
  40. package/lib/memory/schema.ts +267 -0
  41. package/lib/memory/scorer.d.ts +26 -0
  42. package/lib/memory/scorer.js +77 -0
  43. package/lib/memory/scorer.ts +95 -0
  44. package/lib/memory/search/index.d.ts +1 -0
  45. package/lib/memory/search/index.js +1 -0
  46. package/lib/memory/search/index.ts +1 -0
  47. package/lib/memory/search/keyword-search.d.ts +62 -0
  48. package/lib/memory/search/keyword-search.js +135 -0
  49. package/lib/{search/keyword-search.js → memory/search/keyword-search.ts} +66 -36
  50. package/lib/scrubber/config/defaults.d.ts +53 -0
  51. package/lib/scrubber/config/defaults.js +49 -57
  52. package/lib/scrubber/config/defaults.ts +117 -0
  53. package/lib/scrubber/index.d.ts +6 -0
  54. package/lib/scrubber/index.js +3 -23
  55. package/lib/scrubber/index.ts +7 -0
  56. package/lib/scrubber/scrubber.d.ts +61 -0
  57. package/lib/scrubber/scrubber.js +99 -121
  58. package/lib/scrubber/scrubber.ts +168 -0
  59. package/lib/scrubber/stages/chunker.d.ts +13 -0
  60. package/lib/scrubber/stages/metadata-annotator.d.ts +18 -0
  61. package/lib/scrubber/stages/normalizer.d.ts +13 -0
  62. package/lib/scrubber/stages/semantic-filter.d.ts +13 -0
  63. package/lib/scrubber/stages/structural-cleaner.d.ts +13 -0
  64. package/lib/scrubber/stages/validator.d.ts +18 -0
  65. package/lib/scrubber/telemetry.d.ts +36 -0
  66. package/lib/scrubber/telemetry.js +53 -58
  67. package/lib/scrubber/telemetry.ts +99 -0
  68. package/lib/utils/logger.d.ts +29 -0
  69. package/lib/utils/logger.js +64 -0
  70. package/lib/utils/logger.ts +85 -0
  71. package/lib/utils/skill-metadata.d.ts +32 -0
  72. package/lib/utils/skill-metadata.js +132 -0
  73. package/lib/utils/skill-metadata.ts +147 -0
  74. package/lib/yamo/emitter.d.ts +73 -0
  75. package/lib/yamo/emitter.js +78 -143
  76. package/lib/yamo/emitter.ts +249 -0
  77. package/lib/yamo/schema.d.ts +58 -0
  78. package/lib/yamo/schema.js +81 -108
  79. package/lib/yamo/schema.ts +165 -0
  80. package/package.json +11 -8
  81. package/index.d.ts +0 -111
  82. package/lib/embeddings/index.js +0 -2
  83. package/lib/index.js +0 -6
  84. package/lib/lancedb/client.js +0 -633
  85. package/lib/lancedb/config.js +0 -215
  86. package/lib/lancedb/errors.js +0 -144
  87. package/lib/lancedb/index.js +0 -4
  88. package/lib/lancedb/schema.js +0 -197
  89. package/lib/scrubber/errors/scrubber-error.js +0 -43
  90. package/lib/scrubber/stages/chunker.js +0 -103
  91. package/lib/scrubber/stages/metadata-annotator.js +0 -74
  92. package/lib/scrubber/stages/normalizer.js +0 -59
  93. package/lib/scrubber/stages/semantic-filter.js +0 -61
  94. package/lib/scrubber/stages/structural-cleaner.js +0 -82
  95. package/lib/scrubber/stages/validator.js +0 -66
  96. package/lib/scrubber/utils/hash.js +0 -39
  97. package/lib/scrubber/utils/html-parser.js +0 -45
  98. package/lib/scrubber/utils/pattern-matcher.js +0 -63
  99. package/lib/scrubber/utils/token-counter.js +0 -31
  100. package/lib/search/index.js +0 -1
  101. package/lib/utils/index.js +0 -1
  102. package/lib/yamo/index.js +0 -15
@@ -12,1249 +12,1505 @@
12
12
  * Also supports STDIN input for YAMO skill compatibility:
13
13
  * echo '{"action": "ingest", "content": "..."}' | node tools/memory_mesh.js
14
14
  */
15
-
16
- import { fileURLToPath } from 'url';
15
+ import { fileURLToPath } from "url";
17
16
  import fs from "fs";
17
+ import path from "path";
18
18
  import crypto from "crypto";
19
- import { LanceDBClient } from "../lancedb/client.js";
20
- import { getConfig } from "../lancedb/config.js";
21
- import { getEmbeddingDimension } from "../lancedb/schema.js";
22
- import { handleError, StorageError, QueryError } from "../lancedb/errors.js";
23
- import EmbeddingFactory from "../embeddings/factory.js";
19
+ import { LanceDBClient } from "./adapters/client.js";
20
+ import { getConfig } from "./adapters/config.js";
21
+ import { getEmbeddingDimension, createSynthesizedSkillSchema, } from "./schema.js";
22
+ import { handleError } from "./adapters/errors.js";
23
+ import EmbeddingFactory from "./embeddings/factory.js";
24
24
  import { Scrubber } from "../scrubber/scrubber.js";
25
- import { KeywordSearch } from "../search/keyword-search.js";
25
+ import { extractSkillIdentity, extractSkillTags, } from "../utils/skill-metadata.js";
26
+ import { KeywordSearch } from "./search/keyword-search.js";
26
27
  import { YamoEmitter } from "../yamo/emitter.js";
27
28
  import { LLMClient } from "../llm/client.js";
28
-
29
+ import * as lancedb from "@lancedb/lancedb";
30
+ import { createLogger } from "../utils/logger.js";
31
+ const logger = createLogger("brain");
29
32
  /**
30
33
  * MemoryMesh class for managing vector memory storage
31
34
  */
32
- class MemoryMesh {
33
- /**
34
- * Create a new MemoryMesh instance
35
- * @param {Object} [options={}] - Configuration options
36
- * @param {boolean} [options.enableYamo=true] - Enable YAMO block emission
37
- * @param {boolean} [options.enableLLM=true] - Enable LLM for reflections
38
- * @param {string} [options.agentId='default'] - Agent identifier for YAMO blocks
39
- * @param {string} [options.llmProvider] - LLM provider (openai, anthropic, ollama)
40
- * @param {string} [options.llmApiKey] - LLM API key
41
- * @param {string} [options.llmModel] - LLM model name
42
- */
43
- constructor(options = {}) {
44
- this.client = null;
45
- this.config = null;
46
- this.embeddingFactory = new EmbeddingFactory();
47
- this.keywordSearch = new KeywordSearch();
48
- this.isInitialized = false;
49
- this.vectorDimension = 384; // Will be set during init()
50
-
51
- // YAMO and LLM support
52
- this.enableYamo = options.enableYamo !== false; // Default: true
53
- this.enableLLM = options.enableLLM !== false; // Default: true
54
- this.agentId = options.agentId || 'default';
55
- this.yamoTable = null; // Will be initialized in init()
56
- this.llmClient = null;
57
-
58
- // Initialize LLM client if enabled
59
- if (this.enableLLM) {
60
- this.llmClient = new LLMClient({
61
- provider: options.llmProvider,
62
- apiKey: options.llmApiKey,
63
- model: options.llmModel
64
- });
35
+ export class MemoryMesh {
36
+ client;
37
+ config;
38
+ embeddingFactory;
39
+ keywordSearch;
40
+ isInitialized;
41
+ vectorDimension;
42
+ enableYamo;
43
+ enableLLM;
44
+ enableMemory;
45
+ agentId;
46
+ yamoTable;
47
+ skillTable;
48
+ llmClient;
49
+ scrubber;
50
+ queryCache;
51
+ cacheConfig;
52
+ skillDirectories; // Store skill directories for synthesis
53
+ dbDir; // Store custom dbDir for in-memory databases
54
+ /**
55
+ * Create a new MemoryMesh instance
56
+ * @param {Object} [options={}]
57
+ */
58
+ constructor(options = {}) {
59
+ this.client = null;
60
+ this.config = null;
61
+ this.embeddingFactory = new EmbeddingFactory();
62
+ this.keywordSearch = new KeywordSearch();
63
+ this.isInitialized = false;
64
+ this.vectorDimension = 384; // Will be set during init()
65
+ // YAMO and LLM support
66
+ this.enableYamo = options.enableYamo !== false;
67
+ this.enableLLM = options.enableLLM !== false;
68
+ this.enableMemory = options.enableMemory !== false;
69
+ this.agentId = options.agentId || "YAMO_AGENT";
70
+ this.yamoTable = null;
71
+ this.skillTable = null;
72
+ this.llmClient = this.enableLLM ? new LLMClient() : null;
73
+ // Store skill directories for synthesis
74
+ if (Array.isArray(options.skill_directories)) {
75
+ this.skillDirectories = options.skill_directories;
76
+ }
77
+ else if (options.skill_directories) {
78
+ this.skillDirectories = [options.skill_directories];
79
+ }
80
+ else {
81
+ this.skillDirectories = ["skills"];
82
+ }
83
+ // Initialize LLM client if enabled
84
+ if (this.enableLLM) {
85
+ this.llmClient = new LLMClient({
86
+ provider: options.llmProvider,
87
+ apiKey: options.llmApiKey,
88
+ model: options.llmModel,
89
+ maxTokens: options.llmMaxTokens,
90
+ });
91
+ }
92
+ // Scrubber for Layer 0 sanitization
93
+ this.scrubber = new Scrubber({
94
+ enabled: true,
95
+ chunking: {
96
+ minTokens: 1, // Allow short memories
97
+ }, // Type cast for partial config
98
+ validation: {
99
+ enforceMinLength: false, // Disable strict length validation
100
+ },
101
+ });
102
+ // Simple LRU cache for search queries (5 minute TTL)
103
+ this.queryCache = new Map();
104
+ this.cacheConfig = {
105
+ maxSize: 500,
106
+ ttlMs: 5 * 60 * 1000, // 5 minutes
107
+ };
108
+ // Store custom dbDir for test isolation
109
+ this.dbDir = options.dbDir;
65
110
  }
66
-
67
- // Scrubber for Layer 0 sanitization
68
- this.scrubber = new Scrubber({
69
- enabled: true,
70
- chunking: {
71
- minTokens: 1 // Allow short memories
72
- },
73
- validation: {
74
- enforceMinLength: false // Disable strict length validation
75
- }
76
- });
77
-
78
- // Simple LRU cache for search queries (5 minute TTL)
79
- this.queryCache = new Map();
80
- this.cacheConfig = {
81
- maxSize: 500,
82
- ttlMs: 5 * 60 * 1000, // 5 minutes
83
- };
84
- }
85
-
86
- /**
87
- * Generate a cache key from query and options
88
- * @private
89
- * @param {string} query - Search query
90
- * @param {Object} options - Search options
91
- * @returns {string} Cache key
92
- */
93
- _generateCacheKey(query, options = {}) {
94
- const normalizedOptions = {
95
- limit: options.limit || 10,
96
- filter: options.filter || null,
97
- // Normalize options that affect results
98
- };
99
- return `search:${query}:${JSON.stringify(normalizedOptions)}`;
100
- }
101
-
102
- /**
103
- * Get cached result if valid
104
- * @private
105
- * @param {string} key - Cache key
106
- * @returns {Object|null} Cached result or null if expired/missing
107
- */
108
- _getCachedResult(key) {
109
- const entry = this.queryCache.get(key);
110
- if (!entry) return null;
111
-
112
- // Check TTL
113
- if (Date.now() - entry.timestamp > this.cacheConfig.ttlMs) {
114
- this.queryCache.delete(key);
115
- return null;
111
+ /**
112
+ * Generate a cache key from query and options
113
+ * @private
114
+ */
115
+ _generateCacheKey(query, options = {}) {
116
+ const normalizedOptions = {
117
+ limit: options.limit || 10,
118
+ filter: options.filter || null,
119
+ // Normalize options that affect results
120
+ };
121
+ return `search:${query}:${JSON.stringify(normalizedOptions)}`;
116
122
  }
117
-
118
- // Move to end (most recently used)
119
- this.queryCache.delete(key);
120
- this.queryCache.set(key, entry);
121
-
122
- return entry.result;
123
- }
124
-
125
- /**
126
- * Cache a search result
127
- * @private
128
- * @param {string} key - Cache key
129
- * @param {Object} result - Search result to cache
130
- */
131
- _cacheResult(key, result) {
132
- // Evict oldest if at max size
133
- if (this.queryCache.size >= this.cacheConfig.maxSize) {
134
- const firstKey = this.queryCache.keys().next().value;
135
- this.queryCache.delete(firstKey);
123
+ /**
124
+ * Get cached result if valid
125
+ * @private
126
+ *
127
+ * Race condition fix: The delete-then-set pattern for LRU tracking creates a window
128
+ * where another operation could observe the key as missing. We use a try-finally
129
+ * pattern to ensure atomicity at the application level.
130
+ */
131
+ _getCachedResult(key) {
132
+ const entry = this.queryCache.get(key);
133
+ if (!entry) {
134
+ return null;
135
+ }
136
+ // Check TTL - must be done before any mutation
137
+ const now = Date.now();
138
+ if (now - entry.timestamp > this.cacheConfig.ttlMs) {
139
+ this.queryCache.delete(key);
140
+ return null;
141
+ }
142
+ // Move to end (most recently used) - delete and re-add with updated timestamp
143
+ // While not truly atomic, the key remains accessible during the operation
144
+ // since we already have the entry reference
145
+ this.queryCache.delete(key);
146
+ this.queryCache.set(key, {
147
+ ...entry,
148
+ timestamp: now, // Update timestamp for LRU tracking
149
+ });
150
+ return entry.result;
136
151
  }
137
-
138
- this.queryCache.set(key, {
139
- result,
140
- timestamp: Date.now()
141
- });
142
- }
143
-
144
- /**
145
- * Clear all cached results
146
- */
147
- clearCache() {
148
- this.queryCache.clear();
149
- }
150
-
151
- /**
152
- * Get cache statistics
153
- * @returns {Object} Cache stats
154
- */
155
- getCacheStats() {
156
- return {
157
- size: this.queryCache.size,
158
- maxSize: this.cacheConfig.maxSize,
159
- ttlMs: this.cacheConfig.ttlMs
160
- };
161
- }
162
-
163
- /**
164
- * Validate and sanitize metadata to prevent prototype pollution
165
- * @param {Object} metadata - Metadata to validate
166
- * @returns {Object} Sanitized metadata
167
- * @private
168
- */
169
- _validateMetadata(metadata) {
170
- if (typeof metadata !== 'object' || metadata === null) {
171
- throw new Error('Metadata must be a non-null object');
152
+ /**
153
+ * Cache a search result
154
+ * @private
155
+ */
156
+ _cacheResult(key, result) {
157
+ // Evict oldest if at max size
158
+ if (this.queryCache.size >= this.cacheConfig.maxSize) {
159
+ const firstKey = this.queryCache.keys().next().value;
160
+ if (firstKey !== undefined) {
161
+ this.queryCache.delete(firstKey);
162
+ }
163
+ }
164
+ this.queryCache.set(key, {
165
+ result,
166
+ timestamp: Date.now(),
167
+ });
172
168
  }
173
-
174
- // Sanitize keys to prevent prototype pollution
175
- const sanitized = {};
176
- for (const [key, value] of Object.entries(metadata)) {
177
- // Skip dangerous keys that could pollute prototype
178
- // Note: 'constructor' and 'prototype' are handled by hasOwnProperty check
179
- // '.__proto__' needs explicit check because Object.entries() doesn't include it
180
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
181
- continue;
182
- }
183
- // Skip inherited properties
184
- if (!Object.prototype.hasOwnProperty.call(metadata, key)) {
185
- continue;
186
- }
187
- sanitized[key] = value;
169
+ /**
170
+ * Clear all cached results
171
+ */
172
+ clearCache() {
173
+ this.queryCache.clear();
188
174
  }
189
- return sanitized;
190
- }
191
-
192
- /**
193
- * Sanitize and validate content before storage
194
- * @param {string} content - Content to sanitize
195
- * @returns {string} Sanitized content
196
- * @private
197
- */
198
- _sanitizeContent(content) {
199
- if (typeof content !== 'string') {
200
- throw new Error('Content must be a string');
175
+ /**
176
+ * Get cache statistics
177
+ */
178
+ getCacheStats() {
179
+ return {
180
+ size: this.queryCache.size,
181
+ maxSize: this.cacheConfig.maxSize,
182
+ ttlMs: this.cacheConfig.ttlMs,
183
+ };
201
184
  }
202
-
203
- // Limit content length
204
- const MAX_CONTENT_LENGTH = 100000; // 100KB limit
205
- if (content.length > MAX_CONTENT_LENGTH) {
206
- throw new Error(`Content exceeds maximum length of ${MAX_CONTENT_LENGTH} characters`);
185
+ /**
186
+ * Validate and sanitize metadata to prevent prototype pollution
187
+ * @private
188
+ */
189
+ _validateMetadata(metadata) {
190
+ if (typeof metadata !== "object" || metadata === null) {
191
+ throw new Error("Metadata must be a non-null object");
192
+ }
193
+ // Sanitize keys to prevent prototype pollution
194
+ const sanitized = {};
195
+ for (const [key, value] of Object.entries(metadata)) {
196
+ // Skip dangerous keys that could pollute prototype
197
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
198
+ continue;
199
+ }
200
+ // Skip inherited properties
201
+ if (!Object.prototype.hasOwnProperty.call(metadata, key)) {
202
+ continue;
203
+ }
204
+ sanitized[key] = value;
205
+ }
206
+ return sanitized;
207
207
  }
208
-
209
- return content.trim();
210
- }
211
-
212
- /**
213
- * Initialize the LanceDB client
214
- * @returns {Promise<void>}
215
- */
216
- async init() {
217
- if (this.isInitialized) {
218
- return;
208
+ /**
209
+ * Sanitize and validate content before storage
210
+ * @private
211
+ */
212
+ _sanitizeContent(content) {
213
+ if (typeof content !== "string") {
214
+ throw new Error("Content must be a string");
215
+ }
216
+ // Limit content length
217
+ const MAX_CONTENT_LENGTH = 100000; // 100KB limit
218
+ if (content.length > MAX_CONTENT_LENGTH) {
219
+ throw new Error(`Content exceeds maximum length of ${MAX_CONTENT_LENGTH} characters`);
220
+ }
221
+ return content.trim();
219
222
  }
220
-
221
- try {
222
- // Load configuration
223
- this.config = getConfig();
224
-
225
- // Detect vector dimension from embedding model configuration
226
- const modelName = process.env.EMBEDDING_MODEL_NAME || 'Xenova/all-MiniLM-L6-v2';
227
- const envDimension = parseInt(process.env.EMBEDDING_DIMENSION || '0') || null;
228
- this.vectorDimension = envDimension || getEmbeddingDimension(modelName);
229
-
230
- // Only log in debug mode to avoid corrupting spinner/REPL display
231
- if (process.env.YAMO_DEBUG === 'true') {
232
- console.error(`[MemoryMesh] Using vector dimension: ${this.vectorDimension} (model: ${modelName})`);
233
- }
234
-
235
- // Create LanceDBClient with detected dimension
236
- this.client = new LanceDBClient({
237
- uri: this.config.LANCEDB_URI,
238
- tableName: this.config.LANCEDB_MEMORY_TABLE,
239
- vectorDimension: this.vectorDimension,
240
- maxRetries: 3,
241
- retryDelay: 1000
242
- });
243
-
244
- // Connect to database
245
- await this.client.connect();
246
-
247
- // Configure embedding factory from environment
248
- const embeddingConfigs = this._parseEmbeddingConfig();
249
- this.embeddingFactory.configure(embeddingConfigs);
250
- await this.embeddingFactory.init();
251
-
252
- // Hydrate Keyword Search (In-Memory)
253
- // Note: This is efficient for small datasets (< 10k).
254
- // For larger, we should persist the inverted index or use LanceDB FTS.
255
- if (this.client) {
223
+ /**
224
+ * Initialize the LanceDB client
225
+ */
226
+ async init() {
227
+ if (this.isInitialized) {
228
+ return;
229
+ }
230
+ if (!this.enableMemory) {
231
+ this.isInitialized = true;
232
+ if (process.env.YAMO_DEBUG === "true") {
233
+ logger.debug("MemoryMesh initialization skipped (enableMemory=false)");
234
+ }
235
+ return;
236
+ }
256
237
  try {
257
- const allRecords = await this.client.getAll({ limit: 10000 });
258
- this.keywordSearch.load(allRecords);
259
- } catch (e) {
260
- // Ignore if table doesn't exist yet
238
+ // Load configuration
239
+ this.config = getConfig();
240
+ // Detect vector dimension from embedding model configuration
241
+ const modelName = process.env.EMBEDDING_MODEL_NAME || "Xenova/all-MiniLM-L6-v2";
242
+ const envDimension = parseInt(process.env.EMBEDDING_DIMENSION || "0") || null;
243
+ this.vectorDimension = envDimension || getEmbeddingDimension(modelName);
244
+ // Only log in debug mode to avoid corrupting spinner/REPL display
245
+ if (process.env.YAMO_DEBUG === "true") {
246
+ logger.debug({ dimension: this.vectorDimension, model: modelName }, "Using vector dimension");
247
+ }
248
+ // Use custom dbDir if provided (for test isolation), otherwise use config
249
+ const dbUri = this.dbDir || this.config.LANCEDB_URI;
250
+ // Create LanceDBClient with detected dimension
251
+ this.client = new LanceDBClient({
252
+ uri: dbUri,
253
+ tableName: this.config.LANCEDB_MEMORY_TABLE,
254
+ vectorDimension: this.vectorDimension,
255
+ maxRetries: 3,
256
+ retryDelay: 1000,
257
+ });
258
+ // Connect to database
259
+ await this.client.connect();
260
+ // Configure embedding factory from environment
261
+ const embeddingConfigs = this._parseEmbeddingConfig();
262
+ this.embeddingFactory.configure(embeddingConfigs);
263
+ await this.embeddingFactory.init();
264
+ // Hydrate Keyword Search (In-Memory)
265
+ if (this.client) {
266
+ try {
267
+ const allRecords = await this.client.getAll({ limit: 10000 });
268
+ this.keywordSearch.load(allRecords);
269
+ }
270
+ catch (_e) {
271
+ // Ignore if table doesn't exist yet
272
+ }
273
+ }
274
+ // Initialize extension tables if enabled
275
+ if (this.enableYamo && this.client && this.client.db) {
276
+ try {
277
+ const { createYamoTable } = await import("../yamo/schema.js");
278
+ this.yamoTable = await createYamoTable(this.client.db, "yamo_blocks");
279
+ // Initialize synthesized skills table (Recursive Skill Synthesis)
280
+ // const { createSynthesizedSkillSchema } = await import('./schema'); // Imported statically now
281
+ const existingTables = await this.client.db.tableNames();
282
+ if (existingTables.includes("synthesized_skills")) {
283
+ this.skillTable =
284
+ await this.client.db.openTable("synthesized_skills");
285
+ }
286
+ else {
287
+ const skillSchema = createSynthesizedSkillSchema(this.vectorDimension);
288
+ this.skillTable = await this.client.db.createTable("synthesized_skills", [], {
289
+ schema: skillSchema,
290
+ });
291
+ }
292
+ if (process.env.YAMO_DEBUG === "true") {
293
+ logger.debug("YAMO blocks and synthesized skills tables initialized");
294
+ }
295
+ }
296
+ catch (e) {
297
+ logger.warn({ err: e }, "Failed to initialize extension tables");
298
+ }
299
+ }
300
+ this.isInitialized = true;
261
301
  }
262
- }
263
-
264
- // Initialize YAMO blocks table if enabled
265
- if (this.enableYamo && this.client && this.client.db) {
302
+ catch (error) {
303
+ const e = error instanceof Error ? error : new Error(String(error));
304
+ throw e;
305
+ }
306
+ }
307
+ /**
308
+ * Add content to memory with auto-generated embedding and scrubbing.
309
+ *
310
+ * This is the primary method for storing information in the memory mesh.
311
+ * The content goes through several processing steps:
312
+ *
313
+ * 1. **Scrubbing**: PII and sensitive data are sanitized (if enabled)
314
+ * 2. **Validation**: Content length and metadata are validated
315
+ * 3. **Embedding**: Content is converted to a vector representation
316
+ * 4. **Storage**: Record is stored in LanceDB with metadata
317
+ * 5. **Emission**: Optional YAMO block emitted for provenance tracking
318
+ *
319
+ * @param content - The text content to store in memory
320
+ * @param metadata - Optional metadata (type, source, tags, etc.)
321
+ * @returns Promise with memory record containing id, content, metadata, created_at
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * const memory = await mesh.add("User likes TypeScript", {
326
+ * type: "preference",
327
+ * source: "chat",
328
+ * tags: ["programming", "languages"]
329
+ * });
330
+ * ```
331
+ *
332
+ * @throws {Error} If content exceeds max length (100KB)
333
+ * @throws {Error} If embedding generation fails
334
+ * @throws {Error} If database client is not initialized
335
+ */
336
+ async add(content, metadata = {}) {
337
+ await this.init();
338
+ const type = metadata.type || "event";
339
+ const enrichedMetadata = { ...metadata, type };
266
340
  try {
267
- const { createYamoTable } = await import('../yamo/schema.js');
268
- this.yamoTable = await createYamoTable(this.client.db, 'yamo_blocks');
269
- if (process.env.YAMO_DEBUG === 'true') {
270
- console.error('[MemoryMesh] YAMO blocks table initialized');
271
- }
272
- } catch (e) {
273
- // Log warning but don't fail initialization
274
- console.warn('[MemoryMesh] Failed to initialize YAMO table:', e instanceof Error ? e.message : String(e));
275
- }
276
- }
277
-
278
- this.isInitialized = true;
279
-
280
- } catch (error) {
281
- const e = error instanceof Error ? error : new Error(String(error));
282
- throw e;
341
+ let processedContent = content;
342
+ let scrubbedMetadata = {};
343
+ try {
344
+ const scrubbedResult = await this.scrubber.process({
345
+ content: content,
346
+ source: "memory-api",
347
+ type: "txt",
348
+ });
349
+ if (scrubbedResult.success && scrubbedResult.chunks.length > 0) {
350
+ processedContent = scrubbedResult.chunks
351
+ .map((c) => c.text)
352
+ .join("\n\n");
353
+ if (scrubbedResult.metadata) {
354
+ scrubbedMetadata = {
355
+ ...scrubbedResult.metadata,
356
+ scrubber_telemetry: JSON.stringify(scrubbedResult.telemetry),
357
+ };
358
+ }
359
+ }
360
+ }
361
+ catch (scrubError) {
362
+ if (process.env.YAMO_DEBUG === "true") {
363
+ logger.error({ err: scrubError }, "Scrubber failed");
364
+ }
365
+ }
366
+ const sanitizedContent = this._sanitizeContent(processedContent);
367
+ const sanitizedMetadata = this._validateMetadata({
368
+ ...scrubbedMetadata,
369
+ ...enrichedMetadata,
370
+ });
371
+ if (process.env.YAMO_DEBUG === "true") {
372
+ console.error("[DEBUG] brain.add() scrubbedMetadata.type:", scrubbedMetadata.type);
373
+ console.error("[DEBUG] brain.add() enrichedMetadata.type:", enrichedMetadata.type);
374
+ console.error("[DEBUG] brain.add() sanitizedMetadata.type:", sanitizedMetadata.type);
375
+ }
376
+ const vector = await this.embeddingFactory.embed(sanitizedContent);
377
+ // Dedup: search by the already-computed vector before inserting.
378
+ // Catches exact duplicates regardless of which write path is used,
379
+ // protecting callers that bypass captureInteraction()'s dedup guard.
380
+ if (this.client) {
381
+ const nearest = await this.client.search(vector, { limit: 1 });
382
+ if (nearest.length > 0 && nearest[0].content === sanitizedContent) {
383
+ return {
384
+ id: nearest[0].id,
385
+ content: sanitizedContent,
386
+ metadata: sanitizedMetadata,
387
+ created_at: new Date().toISOString(),
388
+ };
389
+ }
390
+ }
391
+ const id = `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
392
+ const record = {
393
+ id,
394
+ vector,
395
+ content: sanitizedContent,
396
+ metadata: JSON.stringify(sanitizedMetadata),
397
+ };
398
+ if (process.env.YAMO_DEBUG === "true") {
399
+ console.error("[DEBUG] record.metadata.type:", JSON.parse(record.metadata).type);
400
+ }
401
+ if (!this.client) {
402
+ throw new Error("Database client not initialized");
403
+ }
404
+ const result = await this.client.add(record);
405
+ if (process.env.YAMO_DEBUG === "true") {
406
+ try {
407
+ console.error("[DEBUG] result.metadata.type:", JSON.parse(result.metadata).type);
408
+ }
409
+ catch {
410
+ console.error("[DEBUG] result.metadata:", result.metadata);
411
+ }
412
+ }
413
+ this.keywordSearch.add(record.id, record.content, sanitizedMetadata);
414
+ if (this.enableYamo) {
415
+ this._emitYamoBlock("retain", result.id, YamoEmitter.buildRetainBlock({
416
+ content: sanitizedContent,
417
+ metadata: sanitizedMetadata,
418
+ id: result.id,
419
+ agentId: this.agentId,
420
+ memoryType: sanitizedMetadata.type || "event",
421
+ })).catch((error) => {
422
+ // Log emission failures in debug mode but don't throw
423
+ if (process.env.YAMO_DEBUG === "true") {
424
+ logger.warn({ err: error }, "Failed to emit YAMO block (retain)");
425
+ }
426
+ });
427
+ }
428
+ return {
429
+ id: result.id,
430
+ content: sanitizedContent,
431
+ metadata: sanitizedMetadata,
432
+ created_at: new Date().toISOString(),
433
+ };
434
+ }
435
+ catch (error) {
436
+ throw error instanceof Error ? error : new Error(String(error));
437
+ }
283
438
  }
284
- }
285
-
286
- /**
287
- * Add content to memory with auto-generated embedding
288
- * @param {string} content - Text content to store
289
- * @param {Object} metadata - Optional metadata tags
290
- * @returns {Promise<Object>} Created record with ID
291
- */
292
- async add(content, metadata = {}) {
293
- await this.init();
294
-
295
- // Default to 'event' if no type provided
296
- const type = metadata.type || 'event';
297
- const enrichedMetadata = { ...metadata, type };
298
-
299
- try {
300
- // Layer 0: Scrubber Sanitization
301
- let processedContent = content;
302
- let scrubbedMetadata = {};
303
-
304
- try {
305
- const scrubbedResult = await this.scrubber.process({
306
- content: content,
307
- source: 'memory-api',
308
- type: 'txt' // Default to text
309
- });
310
-
311
- if (scrubbedResult.success && scrubbedResult.chunks.length > 0) {
312
- // Reconstruct cleaned content
313
- processedContent = scrubbedResult.chunks.map(c => c.text).join('\n\n');
314
-
315
- // Merge scrubber telemetry/metadata if useful
316
- if (scrubbedResult.metadata) {
317
- scrubbedMetadata = {
318
- ...scrubbedResult.metadata,
319
- scrubber_telemetry: JSON.stringify(scrubbedResult.telemetry)
320
- };
321
- }
322
- }
323
- } catch (scrubError) {
324
- // Fallback to raw content if scrubber fails, but log it
325
- if (process.env.YAMO_DEBUG === 'true') {
326
- const message = scrubError instanceof Error ? scrubError.message : String(scrubError);
327
- console.error(`[MemoryMesh] Scrubber failed: ${message}`);
328
- }
329
- }
330
-
331
- // Validate and sanitize inputs (legacy check)
332
- const sanitizedContent = this._sanitizeContent(processedContent);
333
- const sanitizedMetadata = this._validateMetadata({ ...enrichedMetadata, ...scrubbedMetadata });
334
-
335
- // Generate ID
336
- const id = `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
337
-
338
- // Generate embedding using EmbeddingFactory
339
- const vector = await this.embeddingFactory.embed(sanitizedContent);
340
-
341
- // Prepare record data with sanitized metadata
342
- const record = {
343
- id,
344
- vector,
345
- content: sanitizedContent,
346
- metadata: JSON.stringify(sanitizedMetadata)
347
- };
348
-
349
-
350
- // Add to LanceDB
351
- if (!this.client) throw new Error('Database client not initialized');
352
- const result = await this.client.add(record);
353
-
354
- // Add to Keyword Search
355
- this.keywordSearch.add(record.id, record.content, sanitizedMetadata);
356
-
357
- // Emit YAMO block for retain operation (async, non-blocking)
358
- if (this.enableYamo) {
359
- // Fire and forget - don't await
360
- this._emitYamoBlock('retain', result.id, YamoEmitter.buildRetainBlock({
361
- content: sanitizedContent,
362
- metadata: sanitizedMetadata,
363
- id: result.id,
364
- agentId: this.agentId,
365
- memoryType: sanitizedMetadata.type || 'event'
366
- })).catch(err => {
367
- if (process.env.YAMO_DEBUG === 'true') {
368
- console.error('[MemoryMesh] YAMO emission failed in add():', err);
369
- }
439
+ /**
440
+ * Reflect on recent memories
441
+ */
442
+ async reflect(options = {}) {
443
+ await this.init();
444
+ const lookback = options.lookback || 10;
445
+ const topic = options.topic;
446
+ const generate = options.generate !== false;
447
+ let memories = [];
448
+ if (topic) {
449
+ memories = await this.search(topic, { limit: lookback });
450
+ }
451
+ else {
452
+ const all = await this.getAll();
453
+ memories = all
454
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
455
+ .slice(0, lookback);
456
+ }
457
+ const prompt = `Review these memories. Synthesize a high-level "belief" or "observation".`;
458
+ if (!generate || !this.enableLLM || !this.llmClient) {
459
+ return {
460
+ topic,
461
+ count: memories.length,
462
+ context: memories.map((m) => ({
463
+ content: m.content,
464
+ type: m.metadata?.type || "event",
465
+ id: m.id,
466
+ })),
467
+ prompt,
468
+ };
469
+ }
470
+ let reflection = "";
471
+ let confidence = 0;
472
+ try {
473
+ const result = await this.llmClient.reflect(prompt, memories);
474
+ reflection = result.reflection;
475
+ confidence = result.confidence;
476
+ }
477
+ catch (_error) {
478
+ reflection = `Aggregated from ${memories.length} memories on topic: ${topic || "general"}`;
479
+ confidence = 0.5;
480
+ }
481
+ const reflectionId = `reflect_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
482
+ await this.add(reflection, {
483
+ type: "reflection",
484
+ topic: topic || "general",
485
+ source_memory_count: memories.length,
486
+ confidence,
487
+ generated_at: new Date().toISOString(),
370
488
  });
371
- }
372
-
373
- return {
374
- id: result.id,
375
- content: sanitizedContent,
376
- metadata: sanitizedMetadata,
377
- created_at: new Date().toISOString()
378
- };
379
-
380
-
381
- } catch (error) {
382
- const e = error instanceof Error ? error : new Error(String(error));
383
- throw e;
384
- }
385
- }
386
-
387
- /**
388
- * Reflect on recent memories to generate insights (enhanced with LLM + YAMO)
389
- * @param {Object} options
390
- * @param {string} [options.topic] - Topic to search for
391
- * @param {number} [options.lookback=10] - Number of memories to consider
392
- * @param {boolean} [options.generate=true] - Whether to generate reflection via LLM
393
- * @returns {Promise<Object>} Reflection result with YAMO block
394
- */
395
- async reflect(options = {}) {
396
- await this.init();
397
-
398
- const lookback = options.lookback || 10;
399
- const topic = options.topic;
400
- const generate = options.generate !== false;
401
-
402
- // Gather memories
403
- let memories = [];
404
- if (topic) {
405
- memories = await this.search(topic, { limit: lookback });
406
- } else {
407
- const all = await this.getAll();
408
- memories = all
409
- .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
410
- .slice(0, lookback);
411
- }
412
-
413
- const prompt = `Review these memories. Synthesize a high-level "belief" or "observation".`;
414
-
415
- // Check if LLM generation is requested and available
416
- if (!generate || !this.enableLLM || !this.llmClient) {
417
- // Return prompt-only mode (backward compatible)
418
- return {
419
- topic,
420
- count: memories.length,
421
- context: memories.map(m => ({
422
- content: m.content,
423
- type: m.metadata?.type || 'event',
424
- id: m.id
425
- })),
426
- prompt
427
- };
428
- }
429
-
430
- // Generate reflection via LLM
431
- let reflection = null;
432
- let confidence = 0;
433
-
434
- try {
435
- const result = await this.llmClient.reflect(prompt, memories);
436
- reflection = result.reflection;
437
- confidence = result.confidence;
438
- } catch (error) {
439
- const errorMessage = error instanceof Error ? error.message : String(error);
440
- console.warn(`[MemoryMesh] LLM reflection failed: ${errorMessage}`);
441
- // Fall back to simple aggregation
442
- reflection = `Aggregated from ${memories.length} memories on topic: ${topic || 'general'}`;
443
- confidence = 0.5;
444
- }
445
-
446
- // Store reflection to memory
447
- const reflectionId = `reflect_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
448
- await this.add(reflection, {
449
- type: 'reflection',
450
- topic: topic || 'general',
451
- source_memory_count: memories.length,
452
- confidence,
453
- generated_at: new Date().toISOString()
454
- });
455
-
456
- // Emit YAMO block if enabled
457
- let yamoBlock = null;
458
- if (this.enableYamo) {
459
- yamoBlock = YamoEmitter.buildReflectBlock({
460
- topic: topic || 'general',
461
- memoryCount: memories.length,
462
- agentId: this.agentId,
463
- reflection,
464
- confidence
465
- });
466
-
467
- await this._emitYamoBlock('reflect', reflectionId, yamoBlock);
468
- }
469
-
470
- return {
471
- id: reflectionId,
472
- topic: topic || 'general',
473
- reflection,
474
- confidence,
475
- sourceMemoryCount: memories.length,
476
- yamoBlock,
477
- createdAt: new Date().toISOString()
478
- };
479
- }
480
-
481
- /**
482
- * Emit a YAMO block to the YAMO blocks table
483
- * @private
484
- * @param {string} operationType - 'retain', 'recall', 'reflect'
485
- * @param {string|undefined} memoryId - Associated memory ID (undefined for recall)
486
- * @param {string} yamoText - The YAMO block text
487
- */
488
- async _emitYamoBlock(operationType, memoryId, yamoText) {
489
- if (!this.yamoTable) {
490
- if (process.env.YAMO_DEBUG === 'true') {
491
- console.warn('[MemoryMesh] YAMO table not initialized, skipping emission');
492
- }
493
- return;
494
- }
495
-
496
- const yamoId = `yamo_${operationType}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
497
-
498
- try {
499
- await this.yamoTable.add([{
500
- id: yamoId,
501
- agent_id: this.agentId,
502
- operation_type: operationType,
503
- yamo_text: yamoText,
504
- timestamp: new Date(),
505
- block_hash: null, // Future: blockchain anchoring
506
- prev_hash: null,
507
- metadata: JSON.stringify({
508
- memory_id: memoryId || null,
509
- timestamp: new Date().toISOString()
510
- })
511
- }]);
512
-
513
- if (process.env.YAMO_DEBUG === 'true') {
514
- console.log(`[MemoryMesh] YAMO block emitted: ${yamoId}`);
515
- }
516
- } catch (error) {
517
- const errorMessage = error instanceof Error ? error.message : String(error);
518
- console.error(`[MemoryMesh] Failed to emit YAMO block: ${errorMessage}`);
519
- }
520
- }
521
-
522
- /**
523
- * Add multiple memory entries in batch for efficiency
524
- * @param {Array<{content: string, metadata?: Object}>} entries - Array of entries to add
525
- * @returns {Promise<Object>} Result with count and IDs
526
- */
527
- async addBatch(entries) {
528
- if (!Array.isArray(entries) || entries.length === 0) {
529
- throw new Error('Entries must be a non-empty array');
489
+ let yamoBlock = null;
490
+ if (this.enableYamo) {
491
+ yamoBlock = YamoEmitter.buildReflectBlock({
492
+ topic: topic || "general",
493
+ memoryCount: memories.length,
494
+ agentId: this.agentId,
495
+ reflection,
496
+ confidence,
497
+ });
498
+ await this._emitYamoBlock("reflect", reflectionId, yamoBlock);
499
+ }
500
+ return {
501
+ id: reflectionId,
502
+ topic: topic || "general",
503
+ reflection,
504
+ confidence,
505
+ sourceMemoryCount: memories.length,
506
+ yamoBlock,
507
+ createdAt: new Date().toISOString(),
508
+ };
530
509
  }
531
-
532
- await this.init();
533
-
534
- try {
535
- const now = Date.now();
536
- const records = [];
537
-
538
- // Process entries in parallel for embeddings
539
- const embeddingPromises = entries.map(async (entry, index) => {
540
- // Layer 0: Scrubber Sanitization
541
- let processedContent = entry.content;
542
- let scrubbedMetadata = {};
543
-
510
+ /**
511
+ * Ingest synthesized skill
512
+ * @param sourceFilePath - If provided, skip file write (file already exists)
513
+ */
514
+ async ingestSkill(yamoText, metadata = {}, sourceFilePath) {
515
+ await this.init();
516
+ if (!this.skillTable) {
517
+ throw new Error("Skill table not initialized");
518
+ }
519
+ // DEBUG: Trace sourceFilePath parameter
520
+ if (process.env.YAMO_DEBUG_PATHS === "true") {
521
+ console.error(`[BRAIN.ingestSkill] sourceFilePath parameter: ${sourceFilePath || "undefined"}`);
522
+ }
544
523
  try {
545
- const scrubbedResult = await this.scrubber.process({
546
- content: entry.content,
547
- source: 'memory-batch',
548
- type: 'txt'
549
- });
550
-
551
- if (scrubbedResult.success && scrubbedResult.chunks.length > 0) {
552
- processedContent = scrubbedResult.chunks.map(c => c.text).join('\n\n');
553
- if (scrubbedResult.metadata) {
554
- scrubbedMetadata = {
555
- ...scrubbedResult.metadata,
556
- scrubber_telemetry: JSON.stringify(scrubbedResult.telemetry)
557
- };
524
+ const identity = extractSkillIdentity(yamoText);
525
+ const name = metadata.name || identity.name;
526
+ const intent = identity.intent;
527
+ const description = identity.description;
528
+ // RECURSION DETECTION: Check for recursive naming patterns
529
+ // Patterns like "SkillSkill", "SkillSkillSkill" indicate filename-derived names
530
+ const recursivePattern = /^(Skill|skill){2,}/;
531
+ if (recursivePattern.test(name)) {
532
+ logger.warn({ originalName: name }, "Detected recursive naming pattern, rejecting ingestion to prevent loop");
533
+ throw new Error(`Recursive naming pattern detected: ${name}. Skills must have proper name: field.`);
534
+ }
535
+ // Extract tags for tag-aware embeddings (improves semantic search)
536
+ const tags = extractSkillTags(yamoText);
537
+ const tagText = tags.length > 0 ? `\nTags: ${tags.join(", ")}` : "";
538
+ const embeddingText = `Skill: ${name}\nIntent: ${intent}${tagText}\nDescription: ${description}`;
539
+ const vector = await this.embeddingFactory.embed(embeddingText);
540
+ const id = `skill_${Date.now()}_${crypto.randomBytes(2).toString("hex")}`;
541
+ const skillMetadata = {
542
+ reliability: 0.5,
543
+ use_count: 0,
544
+ source: "manual",
545
+ ...metadata,
546
+ // Store source file path for policy loading and parent discovery
547
+ ...(sourceFilePath && { source_file: sourceFilePath }),
548
+ };
549
+ const record = {
550
+ id,
551
+ name,
552
+ intent,
553
+ yamo_text: yamoText,
554
+ vector,
555
+ metadata: JSON.stringify(skillMetadata),
556
+ created_at: new Date(),
557
+ };
558
+ await this.skillTable.add([record]);
559
+ // NEW: Persist to filesystem for longevity and visibility
560
+ // Skip if sourceFilePath provided (file already exists from SkillCreator)
561
+ // Skip if using in-memory database (:memory:)
562
+ if (!sourceFilePath && this.dbDir !== ":memory:") {
563
+ try {
564
+ const skillsDir = path.resolve(process.cwd(), this.skillDirectories[0] || "skills");
565
+ if (!fs.existsSync(skillsDir)) {
566
+ fs.mkdirSync(skillsDir, { recursive: true });
567
+ }
568
+ // Robust filename with length limit to prevent ENAMETOOLONG
569
+ const safeName = name
570
+ .toLowerCase()
571
+ .replace(/[^a-z0-9]/g, "-")
572
+ .replace(/-+/g, "-")
573
+ .substring(0, 50);
574
+ const fileName = `skill-${safeName}.yamo`;
575
+ const filePath = path.join(skillsDir, fileName);
576
+ // Only write if file doesn't already exist to prevent duplicates
577
+ if (!fs.existsSync(filePath)) {
578
+ fs.writeFileSync(filePath, yamoText, "utf8");
579
+ if (process.env.YAMO_DEBUG === "true") {
580
+ logger.debug({ filePath }, "Skill persisted to file");
581
+ }
582
+ }
583
+ }
584
+ catch (fileError) {
585
+ logger.warn({ err: fileError }, "Failed to persist skill to file");
586
+ }
558
587
  }
559
- }
560
- } catch (e) {
561
- // Fallback silently
588
+ return { id, name, intent };
562
589
  }
563
-
564
- const sanitizedContent = this._sanitizeContent(processedContent);
565
- const sanitizedMetadata = this._validateMetadata({ ...(entry.metadata || {}), ...scrubbedMetadata });
566
-
567
- const id = `mem_${now}_${Math.random().toString(36).substr(2, 9)}_${index}`;
568
- const vector = await this.embeddingFactory.embed(sanitizedContent);
569
-
590
+ catch (error) {
591
+ throw new Error(`Skill ingestion failed: ${error.message}`);
592
+ }
593
+ }
594
+ /**
595
+ * Recursive Skill Synthesis
596
+ */
597
+ async synthesize(options = {}) {
598
+ await this.init();
599
+ const topic = options.topic || "general_improvement";
600
+ const enrichedPrompt = options.enrichedPrompt || topic; // PHASE 4: Use enriched prompt
601
+ // const lookback = options.lookback || 20;
602
+ logger.info({ topic, enrichedPrompt }, "Synthesizing logic");
603
+ // OPTIMIZATION: If we have an execution engine (kernel), use SkillCreator!
604
+ if (this._kernel_execute) {
605
+ logger.info("Dispatching to SkillCreator agent...");
606
+ try {
607
+ // Use stored skill directories
608
+ const skillDirs = this.skillDirectories;
609
+ // Track existing .yamo files before SkillCreator runs
610
+ const filesBefore = new Set();
611
+ for (const dir of skillDirs) {
612
+ if (fs.existsSync(dir)) {
613
+ const walk = (currentDir) => {
614
+ try {
615
+ const entries = fs.readdirSync(currentDir, {
616
+ withFileTypes: true,
617
+ });
618
+ for (const entry of entries) {
619
+ const fullPath = path.join(currentDir, entry.name);
620
+ if (entry.isDirectory()) {
621
+ walk(fullPath);
622
+ }
623
+ else if (entry.isFile() && entry.name.endsWith(".yamo")) {
624
+ filesBefore.add(fullPath);
625
+ }
626
+ }
627
+ }
628
+ catch (e) {
629
+ // Skip directories we can't read
630
+ logger.debug({ dir, error: e }, "Could not read directory");
631
+ }
632
+ };
633
+ walk(dir);
634
+ }
635
+ }
636
+ // PHASE 4: Use enriched prompt for SkillCreator
637
+ await this._kernel_execute(`SkillCreator: design a new skill to handle ${enrichedPrompt}`, {
638
+ v1_1_enabled: true,
639
+ });
640
+ // Find newly created .yamo file
641
+ let newSkillFile;
642
+ for (const dir of skillDirs) {
643
+ if (fs.existsSync(dir)) {
644
+ const walk = (currentDir) => {
645
+ try {
646
+ const entries = fs.readdirSync(currentDir, {
647
+ withFileTypes: true,
648
+ });
649
+ for (const entry of entries) {
650
+ const fullPath = path.join(currentDir, entry.name);
651
+ if (entry.isDirectory()) {
652
+ walk(fullPath);
653
+ }
654
+ else if (entry.isFile() && entry.name.endsWith(".yamo")) {
655
+ if (!filesBefore.has(fullPath)) {
656
+ newSkillFile = fullPath;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ catch (e) {
662
+ logger.debug({ dir, error: e }, "Could not read directory");
663
+ }
664
+ };
665
+ walk(dir);
666
+ }
667
+ }
668
+ // Ingest the newly created skill file
669
+ if (newSkillFile) {
670
+ logger.info({ skillFile: newSkillFile }, "Ingesting newly synthesized skill");
671
+ let skillContent = fs.readFileSync(newSkillFile, "utf8");
672
+ // PHASE 4: Expand compressed → canonical for disk storage
673
+ // Skills created by evolution are typically compressed; expand to canonical for readability
674
+ // Skip expansion in test environment or when disabled
675
+ const expansionEnabled = process.env.YAMO_EXPANSION_ENABLED !== "false";
676
+ const isCompressed = !skillContent.includes("---") ||
677
+ (skillContent.includes("---") &&
678
+ skillContent.split("---").length <= 1);
679
+ if (expansionEnabled && isCompressed) {
680
+ logger.info({ skillFile: newSkillFile }, "Expanding compressed skill to canonical format");
681
+ try {
682
+ const expanded = await this._kernel_execute("skill-expansion-system-prompt.yamo", {
683
+ input_yamo: skillContent,
684
+ });
685
+ if (expanded && expanded.canonical_yamo) {
686
+ skillContent = expanded.canonical_yamo;
687
+ // Write expanded canonical format back to disk
688
+ fs.writeFileSync(newSkillFile, skillContent, "utf8");
689
+ logger.info({ skillFile: newSkillFile }, "Skill expanded to canonical format on disk");
690
+ }
691
+ }
692
+ catch (e) {
693
+ logger.warn({ err: e }, "Failed to expand skill to canonical, using compressed format");
694
+ }
695
+ }
696
+ // ENSURE: Synthesized skills always have proper metadata with meaningful name
697
+ // This prevents duplicate skill-agent-{timestamp}.yamo files
698
+ const synIdentity = extractSkillIdentity(skillContent);
699
+ const hasName = !synIdentity.name.startsWith("Unnamed_");
700
+ if (!skillContent.includes("---") || !hasName) {
701
+ logger.info({ skillFile: newSkillFile }, "Adding metadata block to synthesized skill");
702
+ const intent = synIdentity.intent !== "general_procedure"
703
+ ? synIdentity.intent.replace(/[^a-zA-Z0-9]/g, "")
704
+ : "Synthesized";
705
+ const PascalCase = intent.charAt(0).toUpperCase() + intent.slice(1);
706
+ const skillName = `${PascalCase}_${Date.now().toString(36)}`;
707
+ const metadata = `---
708
+ name: ${skillName}
709
+ version: 1.0.0
710
+ author: YAMO Evolution
711
+ license: MIT
712
+ tags: synthesized, evolution, auto-generated
713
+ description: Auto-generated skill to handle: ${enrichedPrompt || topic}
714
+ ---
715
+ `;
716
+ // Prepend metadata if skill doesn't have it
717
+ if (!skillContent.startsWith("---")) {
718
+ skillContent = metadata + skillContent;
719
+ // Write back to disk with proper metadata
720
+ fs.writeFileSync(newSkillFile, skillContent, "utf8");
721
+ logger.info({ skillFile: newSkillFile, skillName }, "Added metadata block to synthesized skill");
722
+ }
723
+ }
724
+ const skill = await this.ingestSkill(skillContent, {
725
+ source: "synthesized",
726
+ trigger_topic: topic,
727
+ }, newSkillFile);
728
+ return {
729
+ status: "success",
730
+ analysis: "SkillCreator orchestrated evolution",
731
+ skill_id: skill.id,
732
+ skill_name: skill.name,
733
+ yamo_text: skillContent,
734
+ };
735
+ }
736
+ // Fallback if no new file found
737
+ return {
738
+ status: "success",
739
+ analysis: "SkillCreator orchestrated evolution (no file detected)",
740
+ skill_name: topic.split(" ")[0],
741
+ };
742
+ }
743
+ catch (e) {
744
+ logger.error({ err: e }, "SkillCreator agent failed");
745
+ return {
746
+ status: "error",
747
+ error: e.message,
748
+ analysis: "SkillCreator agent failed",
749
+ };
750
+ }
751
+ }
752
+ // SkillCreator is required for synthesis
753
+ if (!this._kernel_execute) {
754
+ throw new Error("Kernel execution (_kernel_execute) is required for synthesis. Use YamoKernel instead of MemoryMesh directly.");
755
+ }
756
+ // Should never reach here
570
757
  return {
571
- id,
572
- vector,
573
- content: sanitizedContent,
574
- metadata: JSON.stringify(sanitizedMetadata)
758
+ status: "error",
759
+ analysis: "Unexpected state in synthesis",
575
760
  };
576
- });
577
-
578
- const recordsWithEmbeddings = await Promise.all(embeddingPromises);
579
-
580
- // Add all records to database
581
- if (!this.client) throw new Error('Database client not initialized');
582
- const result = await this.client.addBatch(recordsWithEmbeddings);
583
-
584
- return {
585
- count: result.count,
586
- success: result.success,
587
- ids: recordsWithEmbeddings.map(r => r.id)
588
- };
589
-
590
- } catch (error) {
591
- const e = error instanceof Error ? error : new Error(String(error));
592
- throw e;
593
- }
594
- }
595
-
596
- /**
597
- * Search memory by semantic similarity
598
- * @param {string} query - Search query text
599
- * @param {Object} options - Search options
600
- * @param {number} [options.limit=10] - Maximum number of results
601
- * @param {string} [options.filter] - Optional filter expression
602
- * @param {boolean} [options.useCache=true] - Whether to use query cache
603
- * @returns {Promise<Array>} Search results with scores
604
- */
605
- async search(query, options = {}) {
606
- await this.init();
607
-
608
- try {
609
- const limit = options.limit || 10;
610
- const filter = options.filter || null;
611
- // @ts-ignore
612
- const useCache = options.useCache !== undefined ? options.useCache : true;
613
-
614
- // Check cache first (unless disabled)
615
- if (useCache) {
616
- const cacheKey = this._generateCacheKey(query, { limit, filter });
617
- const cached = this._getCachedResult(cacheKey);
618
- if (cached) {
619
- return cached;
620
- }
621
- }
622
-
623
- // Generate embedding using EmbeddingFactory
624
- const vector = await this.embeddingFactory.embed(query);
625
-
626
- // 1. Vector Search
627
- if (!this.client) throw new Error('Database client not initialized');
628
- const vectorResults = await this.client.search(vector, {
629
- limit: limit * 2, // Fetch more for re-ranking
630
- metric: 'cosine',
631
- filter
632
- });
633
-
634
- // 2. Keyword Search
635
- const keywordResults = this.keywordSearch.search(query, { limit: limit * 2 });
636
-
637
- // 3. Reciprocal Rank Fusion (RRF)
638
- const k = 60; // RRF constant
639
- const scores = new Map(); // id -> score
640
- const docMap = new Map(); // id -> doc
641
-
642
- // Process Vector Results
643
- vectorResults.forEach((doc, rank) => {
644
- const rrf = 1 / (k + rank + 1);
645
- scores.set(doc.id, (scores.get(doc.id) || 0) + rrf);
646
- docMap.set(doc.id, doc);
647
- });
648
-
649
- // Process Keyword Results
650
- keywordResults.forEach((doc, rank) => {
651
- const rrf = 1 / (k + rank + 1);
652
- scores.set(doc.id, (scores.get(doc.id) || 0) + rrf);
653
-
654
- if (!docMap.has(doc.id)) {
655
- // Add keyword-only match
656
- docMap.set(doc.id, {
657
- id: doc.id,
658
- content: doc.content,
659
- metadata: doc.metadata,
660
- score: 0, // Base score, will be overwritten
661
- created_at: new Date().toISOString() // Approximate or missing
662
- });
663
- }
664
- });
665
-
666
- // Sort by RRF score
667
- const mergedResults = Array.from(scores.entries())
668
- .sort((a, b) => b[1] - a[1])
669
- .slice(0, limit)
670
- .map(([id, score]) => {
671
- const doc = docMap.get(id);
672
- if (doc) return { ...doc, score };
673
- return null;
674
- })
675
- .filter(d => d !== null);
676
-
677
- // Cache the result (unless disabled)
678
- if (useCache) {
679
- const cacheKey = this._generateCacheKey(query, { limit, filter });
680
- this._cacheResult(cacheKey, mergedResults);
681
- }
682
-
683
- // Emit YAMO block for recall operation (async, non-blocking)
684
- if (this.enableYamo) {
685
- this._emitYamoBlock('recall', undefined, YamoEmitter.buildRecallBlock({
686
- query,
687
- resultCount: mergedResults.length,
688
- limit,
689
- agentId: this.agentId,
690
- searchType: 'hybrid'
691
- })).catch(err => {
692
- if (process.env.YAMO_DEBUG === 'true') {
693
- console.error('[MemoryMesh] YAMO emission failed in search():', err);
694
- }
695
- });
696
- }
697
-
698
- return mergedResults;
699
-
700
- } catch (error) {
701
- const e = error instanceof Error ? error : new Error(String(error));
702
- throw e;
703
761
  }
704
- }
705
-
706
- /**
707
- * Get a record by ID
708
- * @param {string} id - Record ID
709
- * @returns {Promise<Object|null>} Record object or null if not found
710
- */
711
- async get(id) {
712
- await this.init();
713
-
714
- try {
715
- if (!this.client) throw new Error('Database client not initialized');
716
- const record = await this.client.getById(id);
717
-
718
- if (!record) {
719
- return null;
720
- }
721
-
722
- return {
723
- id: record.id,
724
- content: record.content,
725
- metadata: record.metadata,
726
- created_at: record.created_at,
727
- updated_at: record.updated_at
728
- };
729
-
730
-
731
- } catch (error) {
732
- const e = error instanceof Error ? error : new Error(String(error));
733
- throw e;
762
+ /**
763
+ * Update reliability
764
+ */
765
+ async updateSkillReliability(id, success) {
766
+ await this.init();
767
+ if (!this.skillTable) {
768
+ throw new Error("Skill table not initialized");
769
+ }
770
+ try {
771
+ const results = await this.skillTable
772
+ .query()
773
+ .filter(`id == '${id}'`)
774
+ .toArray();
775
+ if (results.length === 0) {
776
+ throw new Error(`Skill ${id} not found`);
777
+ }
778
+ const record = results[0];
779
+ const metadata = JSON.parse(record.metadata);
780
+ const adjustment = success ? 0.1 : -0.2;
781
+ metadata.reliability = Math.max(0, Math.min(1.0, (metadata.reliability || 0.5) + adjustment));
782
+ metadata.use_count = (metadata.use_count || 0) + 1;
783
+ metadata.last_used = new Date().toISOString();
784
+ await this.skillTable.update({
785
+ where: `id == '${id}'`,
786
+ values: { metadata: JSON.stringify(metadata) },
787
+ });
788
+ return {
789
+ id,
790
+ reliability: metadata.reliability,
791
+ use_count: metadata.use_count,
792
+ };
793
+ }
794
+ catch (error) {
795
+ throw new Error(`Failed to update skill reliability: ${error.message}`);
796
+ }
734
797
  }
735
- }
736
-
737
- /**
738
- * Get all memory records
739
- * @param {Object} options - Options
740
- * @param {number} [options.limit] - Limit results
741
- * @returns {Promise<Array>} Array of records
742
- */
743
- async getAll(options = {}) {
744
- await this.init();
745
- try {
746
- if (!this.client) throw new Error('Database client not initialized');
747
- return await this.client.getAll(options);
748
- } catch (error) {
749
- const e = error instanceof Error ? error : new Error(String(error));
750
- throw e;
798
+ /**
799
+ * Prune skills
800
+ */
801
+ async pruneSkills(threshold = 0.3) {
802
+ await this.init();
803
+ if (!this.skillTable) {
804
+ throw new Error("Skill table not initialized");
805
+ }
806
+ try {
807
+ const allSkills = await this.skillTable.query().toArray();
808
+ let prunedCount = 0;
809
+ for (const skill of allSkills) {
810
+ const metadata = JSON.parse(skill.metadata);
811
+ if (metadata.reliability < threshold) {
812
+ await this.skillTable.delete(`id == '${skill.id}'`);
813
+ prunedCount++;
814
+ }
815
+ }
816
+ return {
817
+ pruned_count: prunedCount,
818
+ total_remaining: allSkills.length - prunedCount,
819
+ };
820
+ }
821
+ catch (error) {
822
+ throw new Error(`Pruning failed: ${error.message}`);
823
+ }
751
824
  }
752
- }
753
-
754
- /**
755
- * Get YAMO blocks for this agent (audit trail)
756
- * @param {Object} options - Query options
757
- * @param {string} [options.operationType] - Filter by operation type ('retain', 'recall', 'reflect')
758
- * @param {number} [options.limit=10] - Max results to return
759
- * @returns {Promise<Array>} List of YAMO blocks
760
- */
761
- async getYamoLog(options = {}) {
762
- if (!this.yamoTable) {
763
- return [];
825
+ /**
826
+ * List all synthesized skills
827
+ * @param {Object} [options={}] - Search options
828
+ * @returns {Promise<Array>} Normalized skill results
829
+ */
830
+ async listSkills(options = {}) {
831
+ await this.init();
832
+ if (!this.skillTable) {
833
+ return [];
834
+ }
835
+ try {
836
+ const limit = options.limit || 10;
837
+ const results = await this.skillTable.query().limit(limit).toArray();
838
+ return results.map((r) => ({
839
+ ...r,
840
+ score: 1.0, // Full score for direct listing
841
+ // Parse metadata JSON string to object
842
+ metadata: typeof r.metadata === "string" ? JSON.parse(r.metadata) : r.metadata,
843
+ }));
844
+ }
845
+ catch (error) {
846
+ if (process.env.YAMO_DEBUG === "true") {
847
+ logger.error({ err: error }, "Skill list failed");
848
+ }
849
+ return [];
850
+ }
764
851
  }
765
-
766
- const limit = options.limit || 10;
767
- const operationType = options.operationType;
768
-
769
- try {
770
- // Use search with empty vector to get all records, then filter
771
- // This avoids using the protected execute() method
772
- const allResults = [];
773
-
774
- // Build query manually using the LanceDB table
775
- // @ts-ignore - LanceDB types may not match exactly
776
- const table = this.yamoTable;
777
-
778
- // Get all records and filter
779
- // @ts-ignore
780
- const records = await table.query().limit(limit * 2).toArrow();
781
-
782
- // Process Arrow table
783
- for (const row of records) {
784
- const opType = row.operationType;
785
- if (!operationType || opType === operationType) {
786
- allResults.push({
787
- id: row.id,
788
- agentId: row.agentId,
789
- operationType: row.operationType,
790
- yamoText: row.yamoText,
791
- timestamp: row.timestamp,
792
- blockHash: row.blockHash,
793
- metadata: row.metadata ? JSON.parse(row.metadata) : null
794
- });
795
-
796
- if (allResults.length >= limit) {
797
- break;
798
- }
852
+ /**
853
+ * Search for synthesized skills by semantic intent
854
+ * @param {string} query - Search query (intent description)
855
+ * @param {Object} [options={}] - Search options
856
+ * @returns {Promise<Array>} Normalized skill results
857
+ */
858
+ async searchSkills(query, options = {}) {
859
+ await this.init();
860
+ if (!this.skillTable) {
861
+ return [];
862
+ }
863
+ try {
864
+ // 1. Check for explicit skill targeting (e.g., "Architect: ...")
865
+ const explicitMatch = query.match(/^([a-zA-Z0-9_-]+):/);
866
+ if (explicitMatch) {
867
+ const targetName = explicitMatch[1];
868
+ const directResults = await this.skillTable
869
+ .query()
870
+ .where(`name == '${targetName}'`)
871
+ .limit(1)
872
+ .toArray();
873
+ if (directResults.length > 0) {
874
+ return directResults.map((r) => ({
875
+ ...r,
876
+ score: 1.0, // Maximum score for explicit target
877
+ }));
878
+ }
879
+ }
880
+ // 2. Hybrid search: vector + keyword matching
881
+ const limit = options.limit || 5;
882
+ // 2a. Vector search (get more candidates for fusion)
883
+ const vector = await this.embeddingFactory.embed(query);
884
+ const vectorResults = await this.skillTable
885
+ .search(vector)
886
+ .limit(limit * 3)
887
+ .toArray();
888
+ // 2b. Keyword matching against skill fields (including tags)
889
+ const queryTokens = this._tokenizeQuery(query);
890
+ const keywordScores = new Map();
891
+ let maxKeywordScore = 0;
892
+ for (const result of vectorResults) {
893
+ let score = 0;
894
+ const nameTokens = this._tokenizeQuery(result.name);
895
+ const intentTokens = this._tokenizeQuery(result.intent || "");
896
+ const tags = extractSkillTags(result.yamo_text);
897
+ const tagTokens = tags.flatMap((t) => this._tokenizeQuery(t));
898
+ const descTokens = this._tokenizeQuery(result.yamo_text.substring(0, 500)); // First 500 chars
899
+ // Token matching with field-based weights
900
+ // Support both exact and partial matches (for compound words)
901
+ for (const qToken of queryTokens) {
902
+ // Exact or partial match in name
903
+ if (nameTokens.some((nt) => nt === qToken || qToken.includes(nt) || nt.includes(qToken))) {
904
+ score += 10.0; // Highest: name match
905
+ }
906
+ // Exact or partial match in tags
907
+ if (tagTokens.some((tt) => tt === qToken || qToken.includes(tt) || tt.includes(qToken))) {
908
+ score += 7.0; // High: tag match
909
+ }
910
+ // Exact match in intent
911
+ if (intentTokens.some((it) => it === qToken)) {
912
+ score += 5.0; // Medium: intent match
913
+ }
914
+ // Exact match in description
915
+ if (descTokens.some((dt) => dt === qToken)) {
916
+ score += 1.0; // Low: description match
917
+ }
918
+ }
919
+ if (score > 0) {
920
+ keywordScores.set(result.id, score);
921
+ maxKeywordScore = Math.max(maxKeywordScore, score);
922
+ }
923
+ }
924
+ // 2c. Combine scores using weighted fusion
925
+ const fusedResults = vectorResults.map((r) => {
926
+ // Normalize vector distance to [0, 1] similarity score
927
+ // LanceDB cosine distance ranges from 0 (identical) to 2 (opposite)
928
+ const rawDistance = r._distance !== undefined ? r._distance : 1.0;
929
+ const vectorScore = Math.max(0, Math.min(1.0, 1 - rawDistance / 2));
930
+ const keywordScore = keywordScores.get(r.id) || 0;
931
+ // Normalize keyword score by max observed (or use fixed max to avoid division by zero)
932
+ const normalizedKeyword = maxKeywordScore > 0 ? keywordScore / maxKeywordScore : 0;
933
+ // Weighted combination: 70% keyword, 30% vector
934
+ // Keywords get higher weight to prioritize exact matches
935
+ const combinedScore = 0.7 * normalizedKeyword + 0.3 * vectorScore;
936
+ return {
937
+ ...r,
938
+ score: combinedScore,
939
+ _vectorScore: vectorScore,
940
+ _keywordScore: keywordScore,
941
+ };
942
+ });
943
+ // Sort by combined score and return top results
944
+ // Don't normalize - we already calculated hybrid scores
945
+ return fusedResults
946
+ .sort((a, b) => b.score - a.score)
947
+ .slice(0, limit)
948
+ .map((r) => ({
949
+ ...r,
950
+ // Parse metadata JSON string to object for policy loading
951
+ metadata: typeof r.metadata === "string"
952
+ ? JSON.parse(r.metadata)
953
+ : r.metadata,
954
+ }))
955
+ .map((r) => ({
956
+ ...r,
957
+ score: parseFloat(r.score.toFixed(2)), // Round for consistency
958
+ }));
959
+ }
960
+ catch (error) {
961
+ if (process.env.YAMO_DEBUG === "true") {
962
+ logger.error({ err: error }, "Skill search failed");
963
+ }
964
+ return [];
799
965
  }
800
- }
801
-
802
- return allResults;
803
- } catch (error) {
804
- const errorMessage = error instanceof Error ? error.message : String(error);
805
- console.error('[MemoryMesh] Failed to get YAMO log:', errorMessage);
806
- return [];
807
966
  }
808
- }
809
-
810
- /**
811
- * Update a memory record
812
- * @param {string} id - Record ID
813
- * @param {string} content - New content
814
- * @param {Object} metadata - New metadata
815
- * @returns {Promise<Object>} Result
816
- */
817
- async update(id, content, metadata = {}) {
818
- await this.init();
819
-
820
- try {
821
- // Layer 0: Scrubber Sanitization
822
- let processedContent = content;
823
- let scrubbedMetadata = {};
824
-
825
- try {
826
- const scrubbedResult = await this.scrubber.process({
827
- content: content,
828
- source: 'memory-update',
829
- type: 'txt'
830
- });
831
-
832
- if (scrubbedResult.success && scrubbedResult.chunks.length > 0) {
833
- processedContent = scrubbedResult.chunks.map(c => c.text).join('\n\n');
834
- if (scrubbedResult.metadata) {
835
- scrubbedMetadata = {
836
- ...scrubbedResult.metadata,
837
- scrubber_telemetry: JSON.stringify(scrubbedResult.telemetry)
838
- };
839
- }
840
- }
841
- } catch (e) {
842
- // Fallback
843
- }
844
-
845
- const sanitizedContent = this._sanitizeContent(processedContent);
846
- const sanitizedMetadata = this._validateMetadata({ ...metadata, ...scrubbedMetadata });
847
-
848
- // Re-generate embedding
849
- const vector = await this.embeddingFactory.embed(sanitizedContent);
850
-
851
- const updateData = {
852
- vector,
853
- content: sanitizedContent,
854
- metadata: JSON.stringify(sanitizedMetadata)
855
- };
856
-
857
- if (!this.client) throw new Error('Database client not initialized');
858
- const result = await this.client.update(id, updateData);
859
-
860
- return {
861
- id: result.id,
862
- content: sanitizedContent,
863
- success: result.success
864
- };
865
-
866
- } catch (error) {
867
- const e = error instanceof Error ? error : new Error(String(error));
868
- throw e;
967
+ /**
968
+ * Get recent YAMO logs for the heartbeat
969
+ * @param {Object} options
970
+ */
971
+ async getYamoLog(options = {}) {
972
+ if (!this.yamoTable) {
973
+ return [];
974
+ }
975
+ const limit = options.limit || 10;
976
+ const maxRetries = 5;
977
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
978
+ try {
979
+ // orderBy might not be in LanceDB types but is supported in runtime
980
+ const query = this.yamoTable.query();
981
+ let results;
982
+ try {
983
+ results = await query
984
+ .orderBy("timestamp", "desc")
985
+ .limit(limit)
986
+ .toArray();
987
+ }
988
+ catch (_e) {
989
+ // Fallback if orderBy not supported
990
+ results = await query.limit(1000).toArray(); // Get more and sort manually
991
+ }
992
+ // Sort newest first in memory
993
+ return results
994
+ .sort((a, b) => {
995
+ const tA = a.timestamp instanceof Date
996
+ ? a.timestamp.getTime()
997
+ : Number(a.timestamp);
998
+ const tB = b.timestamp instanceof Date
999
+ ? b.timestamp.getTime()
1000
+ : Number(b.timestamp);
1001
+ return tB - tA;
1002
+ })
1003
+ .slice(0, limit)
1004
+ .map((r) => ({
1005
+ id: r.id,
1006
+ yamoText: r.yamo_text,
1007
+ timestamp: r.timestamp,
1008
+ }));
1009
+ }
1010
+ catch (error) {
1011
+ const msg = error.message || "";
1012
+ const isRetryable = msg.includes("LanceError(IO)") ||
1013
+ msg.includes("next batch") ||
1014
+ msg.includes("No such file") ||
1015
+ msg.includes("busy");
1016
+ if (isRetryable && attempt < maxRetries) {
1017
+ // If we suspect stale table handle, try to refresh it
1018
+ try {
1019
+ // Re-open table to get fresh file handles
1020
+ const { createYamoTable } = await import("../yamo/schema.js");
1021
+ if (this.dbDir) {
1022
+ const db = await lancedb.connect(this.dbDir);
1023
+ this.yamoTable = await createYamoTable(db, "yamo_blocks");
1024
+ if (process.env.YAMO_DEBUG === "true") {
1025
+ logger.debug({ attempt, msg: msg.substring(0, 100) }, "Refreshed yamoTable handle during retry");
1026
+ }
1027
+ }
1028
+ }
1029
+ catch (e) {
1030
+ logger.warn({ err: e }, "Failed to refresh table handle during retry");
1031
+ }
1032
+ const delay = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms, 2000ms, 4000ms
1033
+ await new Promise((resolve) => setTimeout(resolve, delay));
1034
+ continue;
1035
+ }
1036
+ // Only log warning on final failure
1037
+ if (attempt === maxRetries) {
1038
+ logger.warn({ err: error }, "Failed to get log after retries");
1039
+ }
1040
+ else if (!isRetryable) {
1041
+ // Non-retryable error
1042
+ logger.warn({ err: error }, "Failed to get log (non-retryable)");
1043
+ break;
1044
+ }
1045
+ }
1046
+ }
1047
+ return [];
869
1048
  }
870
- }
871
-
872
- /**
873
- * Delete a record by ID
874
- * @param {string} id - Record ID to delete
875
- * @returns {Promise<Object>} Result with success status
876
- */
877
- async delete(id) {
878
- await this.init();
879
-
880
- try {
881
- if (!this.client) throw new Error('Database client not initialized');
882
- const result = await this.client.delete(id);
883
-
884
- // Remove from Keyword Search
885
- this.keywordSearch.remove(id);
886
-
887
- return {
888
- deleted: result.id,
889
- success: result.success
890
- };
891
-
892
-
893
- } catch (error) {
894
- const e = error instanceof Error ? error : new Error(String(error));
895
- throw e;
1049
+ /**
1050
+ * Emit a YAMO block to the YAMO blocks table
1051
+ * @private
1052
+ *
1053
+ * Note: YAMO emission is non-critical - failures are logged but don't throw
1054
+ * to prevent disrupting the main operation.
1055
+ */
1056
+ async _emitYamoBlock(operationType, memoryId, yamoText) {
1057
+ if (!this.yamoTable) {
1058
+ return;
1059
+ }
1060
+ const yamoId = `yamo_${operationType}_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
1061
+ try {
1062
+ await this.yamoTable.add([
1063
+ {
1064
+ id: yamoId,
1065
+ agent_id: this.agentId,
1066
+ operation_type: operationType,
1067
+ yamo_text: yamoText,
1068
+ timestamp: new Date(),
1069
+ block_hash: null,
1070
+ prev_hash: null,
1071
+ metadata: JSON.stringify({
1072
+ memory_id: memoryId || null,
1073
+ timestamp: new Date().toISOString(),
1074
+ }),
1075
+ },
1076
+ ]);
1077
+ }
1078
+ catch (error) {
1079
+ // Log emission failures in debug mode
1080
+ // Emission is non-critical, so we don't throw
1081
+ if (process.env.YAMO_DEBUG === "true") {
1082
+ logger.warn({ err: error, operationType }, "YAMO emission failed");
1083
+ }
1084
+ }
896
1085
  }
897
- }
898
-
899
- /**
900
- * Get database statistics
901
- * @returns {Promise<Object>} Statistics including count, size, etc.
902
- */
903
- async stats() {
904
- await this.init();
905
-
906
- try {
907
- if (!this.client) throw new Error('Database client not initialized');
908
- const dbStats = await this.client.getStats();
909
- const embeddingStats = this.embeddingFactory.getStats();
910
-
911
- return {
912
- count: dbStats.count,
913
- tableName: dbStats.tableName,
914
- uri: dbStats.uri,
915
- isConnected: dbStats.isConnected,
916
- embedding: embeddingStats
917
- };
918
-
919
-
920
- } catch (error) {
921
- const e = error instanceof Error ? error : new Error(String(error));
922
- throw e;
1086
+ /**
1087
+ * Search memory using hybrid vector + keyword search with Reciprocal Rank Fusion (RRF).
1088
+ *
1089
+ * This method performs semantic search by combining:
1090
+ * 1. **Vector Search**: Uses embeddings to find semantically similar content
1091
+ * 2. **Keyword Search**: Uses BM25-style keyword matching
1092
+ * 3. **RRF Fusion**: Combines both result sets using Reciprocal Rank Fusion
1093
+ *
1094
+ * The RRF algorithm scores each document as: `sum(1 / (k + rank))` where k=60.
1095
+ * This gives higher scores to documents that rank well in BOTH searches.
1096
+ *
1097
+ * **Performance**: Uses adaptive sorting strategy
1098
+ * - Small datasets (≤ 2× limit): Full sort O(n log n)
1099
+ * - Large datasets: Partial selection sort O(n×k) where k=limit
1100
+ *
1101
+ * **Caching**: Results are cached for 5 minutes by default (configurable via options)
1102
+ *
1103
+ * @param query - The search query text
1104
+ * @param options - Search options
1105
+ * @param options.limit - Maximum results to return (default: 10)
1106
+ * @param options.filter - LanceDB filter expression (e.g., "type == 'preference'")
1107
+ * @param options.useCache - Enable/disable result caching (default: true)
1108
+ * @returns Promise with array of search results, sorted by relevance score
1109
+ *
1110
+ * @example
1111
+ * ```typescript
1112
+ * // Simple search
1113
+ * const results = await mesh.search("TypeScript preferences");
1114
+ *
1115
+ * // Search with filter
1116
+ * const code = await mesh.search("bug fix", { filter: "type == 'error'" });
1117
+ *
1118
+ * // Search with limit
1119
+ * const top3 = await mesh.search("security issues", { limit: 3 });
1120
+ * ```
1121
+ *
1122
+ * @throws {Error} If embedding generation fails
1123
+ * @throws {Error} If database client is not initialized
1124
+ */
1125
+ async search(query, options = {}) {
1126
+ await this.init();
1127
+ try {
1128
+ const limit = options.limit || 10;
1129
+ const filter = options.filter || null;
1130
+ const useCache = options.useCache !== undefined ? options.useCache : true;
1131
+ if (useCache) {
1132
+ const cacheKey = this._generateCacheKey(query, { limit, filter });
1133
+ const cached = this._getCachedResult(cacheKey);
1134
+ if (cached) {
1135
+ return cached;
1136
+ }
1137
+ }
1138
+ const vector = await this.embeddingFactory.embed(query);
1139
+ if (!this.client) {
1140
+ throw new Error("Database client not initialized");
1141
+ }
1142
+ const vectorResults = await this.client.search(vector, {
1143
+ limit: limit * 2,
1144
+ metric: "cosine",
1145
+ filter,
1146
+ });
1147
+ const keywordResults = this.keywordSearch.search(query, {
1148
+ limit: limit * 2,
1149
+ });
1150
+ // Optimized Reciprocal Rank Fusion (RRF) with min-heap for O(n log k) performance
1151
+ // Instead of sorting all results (O(n log n)), we maintain a heap of size k (O(n log k))
1152
+ const k = 60; // RRF constant
1153
+ const scores = new Map();
1154
+ const docMap = new Map();
1155
+ // Process vector results - O(m) where m = vectorResults.length
1156
+ for (let rank = 0; rank < vectorResults.length; rank++) {
1157
+ const doc = vectorResults[rank];
1158
+ const rrf = 1 / (k + rank + 1);
1159
+ scores.set(doc.id, (scores.get(doc.id) || 0) + rrf);
1160
+ docMap.set(doc.id, doc);
1161
+ }
1162
+ // Process keyword results - O(n) where n = keywordResults.length
1163
+ for (let rank = 0; rank < keywordResults.length; rank++) {
1164
+ const doc = keywordResults[rank];
1165
+ const rrf = 1 / (k + rank + 1);
1166
+ scores.set(doc.id, (scores.get(doc.id) || 0) + rrf);
1167
+ if (!docMap.has(doc.id)) {
1168
+ docMap.set(doc.id, {
1169
+ id: doc.id,
1170
+ content: doc.content,
1171
+ metadata: doc.metadata,
1172
+ score: 0,
1173
+ created_at: new Date().toISOString(),
1174
+ });
1175
+ }
1176
+ }
1177
+ // Extract top k results using min-heap pattern - O(n log k)
1178
+ // Since JavaScript doesn't have a built-in heap, we use an efficient approach:
1179
+ // Convert to array and sort only if results exceed limit significantly
1180
+ const scoreEntries = Array.from(scores.entries());
1181
+ let mergedResults;
1182
+ if (scoreEntries.length <= limit * 2) {
1183
+ // Small dataset: standard sort is fine
1184
+ mergedResults = scoreEntries
1185
+ .sort((a, b) => b[1] - a[1]) // O(n log n) but n is small
1186
+ .slice(0, limit)
1187
+ .map(([id, score]) => {
1188
+ const doc = docMap.get(id);
1189
+ return doc ? { ...doc, score } : null;
1190
+ })
1191
+ .filter((d) => d !== null);
1192
+ }
1193
+ else {
1194
+ // Large dataset: use partial selection sort (O(n*k) but k is small)
1195
+ // This is more efficient than full sort when we only need top k results
1196
+ const topK = [];
1197
+ for (const entry of scoreEntries) {
1198
+ if (topK.length < limit) {
1199
+ topK.push(entry);
1200
+ // Keep topK sorted in descending order
1201
+ topK.sort((a, b) => b[1] - a[1]);
1202
+ }
1203
+ else if (entry[1] > topK[topK.length - 1][1]) {
1204
+ // Replace smallest in topK if current is larger
1205
+ topK[limit - 1] = entry;
1206
+ topK.sort((a, b) => b[1] - a[1]);
1207
+ }
1208
+ }
1209
+ mergedResults = topK
1210
+ .map(([id, score]) => {
1211
+ const doc = docMap.get(id);
1212
+ return doc ? { ...doc, score } : null;
1213
+ })
1214
+ .filter((d) => d !== null);
1215
+ }
1216
+ const normalizedResults = this._normalizeScores(mergedResults);
1217
+ if (useCache) {
1218
+ const cacheKey = this._generateCacheKey(query, { limit, filter });
1219
+ this._cacheResult(cacheKey, normalizedResults);
1220
+ }
1221
+ if (this.enableYamo) {
1222
+ this._emitYamoBlock("recall", undefined, YamoEmitter.buildRecallBlock({
1223
+ query,
1224
+ resultCount: normalizedResults.length,
1225
+ limit,
1226
+ agentId: this.agentId,
1227
+ searchType: "hybrid",
1228
+ })).catch((error) => {
1229
+ // Log emission failures in debug mode but don't throw
1230
+ if (process.env.YAMO_DEBUG === "true") {
1231
+ logger.warn({ err: error }, "Failed to emit YAMO block (recall)");
1232
+ }
1233
+ });
1234
+ }
1235
+ return normalizedResults;
1236
+ }
1237
+ catch (error) {
1238
+ throw error instanceof Error ? error : new Error(String(error));
1239
+ }
923
1240
  }
924
- }
925
-
926
- /**
927
- * Health check for MemoryMesh
928
- * @returns {Promise<Object>} Health status with checks for all components
929
- */
930
- async healthCheck() {
931
- const health = {
932
- status: 'healthy',
933
- timestamp: new Date().toISOString(),
934
- checks: {}
935
- };
936
-
937
- // Check 1: Database connectivity
938
- try {
939
- const startDb = Date.now();
940
- await this.init();
941
- const dbLatency = Date.now() - startDb;
942
-
943
- // @ts-ignore
944
- health.checks.database = {
945
- status: 'up',
946
- latency: dbLatency,
947
- isConnected: this.client?.isConnected || false,
948
- tableName: this.client?.tableName || 'unknown'
949
- };
950
- } catch (error) {
951
- const message = error instanceof Error ? error.message : String(error);
952
- // @ts-ignore
953
- health.checks.database = {
954
- status: 'error',
955
- error: message
956
- };
957
- health.status = 'degraded';
1241
+ _normalizeScores(results) {
1242
+ if (results.length === 0) {
1243
+ return [];
1244
+ }
1245
+ return results.map((r) => {
1246
+ // LanceDB _distance is squared L2 or cosine distance
1247
+ // For cosine distance in MiniLM, it ranges from 0 to 2
1248
+ const rawDistance = r._distance !== undefined ? r._distance : 1.0;
1249
+ // Convert to similarity score [0, 1]
1250
+ const score = Math.max(0, Math.min(1.0, 1 - rawDistance / 2));
1251
+ return {
1252
+ ...r,
1253
+ score: parseFloat(score.toFixed(2)),
1254
+ };
1255
+ });
958
1256
  }
959
-
960
- // Check 2: Embedding service
961
- try {
962
- const startEmbedding = Date.now();
963
- const testEmbedding = await this.embeddingFactory.embed('health check');
964
- const embeddingLatency = Date.now() - startEmbedding;
965
-
966
- // @ts-ignore
967
- health.checks.embedding = {
968
- status: 'up',
969
- latency: embeddingLatency,
970
- dimension: testEmbedding.length,
971
- configured: true
972
- };
973
- } catch (error) {
974
- const message = error instanceof Error ? error.message : String(error);
975
- // @ts-ignore
976
- health.checks.embedding = {
977
- status: 'error',
978
- error: message
979
- };
980
- health.status = 'degraded';
1257
+ /**
1258
+ * Tokenize query for keyword matching (private helper for searchSkills)
1259
+ * Converts text to lowercase tokens, filtering out short tokens and punctuation.
1260
+ * Handles camelCase/PascalCase by splitting on uppercase letters.
1261
+ */
1262
+ _tokenizeQuery(text) {
1263
+ return text
1264
+ .replace(/([a-z])([A-Z])/g, "$1 $2") // Split camelCase: "targetSkill" → "target Skill"
1265
+ .toLowerCase()
1266
+ .replace(/[^\w\s]/g, "")
1267
+ .split(/\s+/)
1268
+ .filter((t) => t.length > 2); // Filter out very short tokens
981
1269
  }
982
-
983
- // Check 3: Get stats (verifies read operations work)
984
- try {
985
- const stats = await this.stats();
986
- // @ts-ignore
987
- health.checks.stats = {
988
- status: 'up',
989
- recordCount: stats.count || 0
990
- };
991
- } catch (error) {
992
- const message = error instanceof Error ? error.message : String(error);
993
- // @ts-ignore
994
- health.checks.stats = {
995
- status: 'warning',
996
- error: message
997
- };
998
- // Don't degrade status for stats failure - it's not critical
1270
+ formatResults(results) {
1271
+ if (results.length === 0) {
1272
+ return "No relevant memories found.";
1273
+ }
1274
+ let output = `[ATTENTION DIRECTIVE]\nThe following [MEMORY CONTEXT] is weighted by relevance.
1275
+ - ALIGN attention to entries with [IMPORTANCE >= 0.8].
1276
+ - TREAT entries with [IMPORTANCE <= 0.4] as auxiliary background info.
1277
+
1278
+ [MEMORY CONTEXT]`;
1279
+ results.forEach((res, i) => {
1280
+ const metadata = typeof res.metadata === "string"
1281
+ ? JSON.parse(res.metadata)
1282
+ : res.metadata;
1283
+ output += `\n\n--- MEMORY ${i + 1}: ${res.id} [IMPORTANCE: ${res.score}] ---\nType: ${metadata.type || "event"} | Source: ${metadata.source || "unknown"}\n${res.content}`;
1284
+ });
1285
+ return output;
999
1286
  }
1000
-
1001
- // Check 4: Cache status (if caching enabled)
1002
- if (this.queryCache) {
1003
- // @ts-ignore
1004
- health.checks.cache = {
1005
- status: 'up',
1006
- size: this.queryCache.size || 0,
1007
- max: this.cacheConfig?.maxSize || 'unknown'
1008
- };
1287
+ async get(id) {
1288
+ await this.init();
1289
+ if (!this.client) {
1290
+ throw new Error("Database client not initialized");
1291
+ }
1292
+ const record = await this.client.getById(id);
1293
+ return record
1294
+ ? {
1295
+ id: record.id,
1296
+ content: record.content,
1297
+ metadata: record.metadata,
1298
+ created_at: record.created_at,
1299
+ updated_at: record.updated_at,
1300
+ }
1301
+ : null;
1009
1302
  }
1010
-
1011
- return health;
1012
- }
1013
-
1014
- /**
1015
- * Parse embedding configuration from environment
1016
- * @private
1017
- */
1018
- _parseEmbeddingConfig() {
1019
- const configs = [];
1020
-
1021
- // Primary: from EMBEDDING_MODEL_TYPE
1022
- configs.push({
1023
- modelType: process.env.EMBEDDING_MODEL_TYPE || 'local',
1024
- modelName: process.env.EMBEDDING_MODEL_NAME || 'Xenova/all-MiniLM-L6-v2',
1025
- dimension: parseInt(process.env.EMBEDDING_DIMENSION || '384'),
1026
- priority: 1,
1027
- apiKey: process.env.EMBEDDING_API_KEY || process.env.OPENAI_API_KEY || process.env.COHERE_API_KEY
1028
- });
1029
-
1030
- // Fallback 1: local model (if primary is API)
1031
- if (configs[0].modelType !== 'local') {
1032
- configs.push({
1033
- modelType: 'local',
1034
- modelName: 'Xenova/all-MiniLM-L6-v2',
1035
- dimension: 384,
1036
- priority: 2
1037
- });
1303
+ async getAll(options = {}) {
1304
+ await this.init();
1305
+ if (!this.client) {
1306
+ throw new Error("Database client not initialized");
1307
+ }
1308
+ return this.client.getAll(options);
1038
1309
  }
1039
-
1040
- // Fallback 2: OpenAI (if key available)
1041
- if (process.env.OPENAI_API_KEY && configs[0].modelType !== 'openai') {
1042
- configs.push({
1043
- modelType: 'openai',
1044
- modelName: 'text-embedding-3-small',
1045
- dimension: 1536,
1046
- priority: 3,
1047
- apiKey: process.env.OPENAI_API_KEY
1048
- });
1310
+ async stats() {
1311
+ await this.init();
1312
+ if (!this.enableMemory || !this.client) {
1313
+ return {
1314
+ count: 0,
1315
+ totalMemories: 0,
1316
+ totalSkills: 0,
1317
+ tableName: "N/A",
1318
+ uri: "N/A",
1319
+ isConnected: false,
1320
+ embedding: { configured: false, primary: null, fallbacks: [] },
1321
+ status: "disabled",
1322
+ };
1323
+ }
1324
+ const dbStats = await this.client.getStats();
1325
+ // Enrich embedding stats with total persisted count
1326
+ const embeddingStats = this.embeddingFactory.getStats();
1327
+ if (embeddingStats.primary) {
1328
+ embeddingStats.primary.totalPersisted = dbStats.count;
1329
+ }
1330
+ // Get skill count
1331
+ let totalSkills = 0;
1332
+ if (this.skillTable) {
1333
+ try {
1334
+ const skills = await this.skillTable.query().limit(10000).toArray();
1335
+ totalSkills = skills.length;
1336
+ }
1337
+ catch (_e) {
1338
+ // Ignore errors
1339
+ }
1340
+ }
1341
+ return {
1342
+ count: dbStats.count,
1343
+ totalMemories: dbStats.count,
1344
+ totalSkills,
1345
+ tableName: dbStats.tableName,
1346
+ uri: dbStats.uri,
1347
+ isConnected: dbStats.isConnected,
1348
+ embedding: embeddingStats,
1349
+ };
1049
1350
  }
1050
-
1051
- return configs;
1052
- }
1053
-
1054
- /**
1055
- * Build a LanceDB filter expression from an object
1056
- * Supports basic filtering on metadata fields
1057
- * @param {Object} filter - Filter object
1058
- * @returns {string|null} LanceDB filter expression
1059
- * @private
1060
- */
1061
- _buildFilter(filter) {
1062
- if (!filter || typeof filter !== 'object') {
1063
- return null;
1351
+ _parseEmbeddingConfig() {
1352
+ const configs = [
1353
+ {
1354
+ modelType: process.env.EMBEDDING_MODEL_TYPE || "local",
1355
+ modelName: process.env.EMBEDDING_MODEL_NAME || "Xenova/all-MiniLM-L6-v2",
1356
+ dimension: parseInt(process.env.EMBEDDING_DIMENSION || "384"),
1357
+ priority: 1,
1358
+ apiKey: process.env.EMBEDDING_API_KEY ||
1359
+ process.env.OPENAI_API_KEY ||
1360
+ process.env.COHERE_API_KEY,
1361
+ },
1362
+ ];
1363
+ if (configs[0].modelType !== "local") {
1364
+ configs.push({
1365
+ modelType: "local",
1366
+ modelName: "Xenova/all-MiniLM-L6-v2",
1367
+ dimension: 384,
1368
+ priority: 2,
1369
+ apiKey: undefined,
1370
+ });
1371
+ }
1372
+ return configs;
1064
1373
  }
1065
-
1066
- const conditions = [];
1067
-
1068
- for (const [key, value] of Object.entries(filter)) {
1069
- if (typeof value === 'string') {
1070
- conditions.push(`${key} == '${value}'`);
1071
- } else if (typeof value === 'number') {
1072
- conditions.push(`${key} == ${value}`);
1073
- } else if (typeof value === 'boolean') {
1074
- conditions.push(`${key} == ${value}`);
1075
- }
1076
- // Note: Complex filtering on JSON metadata field not supported
1077
- // Filters work on top-level schema fields only
1374
+ /**
1375
+ * Close database connections and release resources
1376
+ *
1377
+ * This should be called when done with the MemoryMesh to properly:
1378
+ * - Close LanceDB connections
1379
+ * - Release file handles
1380
+ * - Clean up resources
1381
+ *
1382
+ * Important for tests and cleanup to prevent connection leaks.
1383
+ *
1384
+ * @returns {Promise<void>}
1385
+ *
1386
+ * @example
1387
+ * ```typescript
1388
+ * const mesh = new MemoryMesh();
1389
+ * await mesh.init();
1390
+ * // ... use mesh ...
1391
+ * await mesh.close(); // Clean up
1392
+ * ```
1393
+ */
1394
+ // eslint-disable-next-line @typescript-eslint/require-await
1395
+ async close() {
1396
+ try {
1397
+ // Close LanceDB client connection
1398
+ if (this.client) {
1399
+ this.client.disconnect();
1400
+ this.client = null;
1401
+ }
1402
+ // Clear extension table references
1403
+ this.yamoTable = null;
1404
+ this.skillTable = null;
1405
+ // Reset initialization state
1406
+ this.isInitialized = false;
1407
+ logger.debug("MemoryMesh closed successfully");
1408
+ }
1409
+ catch (error) {
1410
+ const e = error instanceof Error ? error : new Error(String(error));
1411
+ logger.warn({ err: e }, "Error closing MemoryMesh");
1412
+ // Don't throw - cleanup should always succeed
1413
+ }
1078
1414
  }
1079
-
1080
- // @ts-ignore
1081
- return conditions.length > 0 ? conditions.join(' AND ') : null;
1082
- }
1083
1415
  }
1084
-
1085
1416
  /**
1086
1417
  * Main CLI handler
1087
1418
  */
1088
- async function run() {
1089
- let action, input;
1090
-
1091
- // Check if arguments are provided via CLI
1092
- if (process.argv.length > 3) {
1093
- action = process.argv[2];
1094
- try {
1095
- input = JSON.parse(process.argv[3]);
1096
- } catch (e) {
1097
- const error = e instanceof Error ? e : new Error(String(e));
1098
- const errorResponse = handleError(error, { context: 'CLI argument parsing' });
1099
- console.error(`❌ Error: Invalid JSON argument: ${error.message}`);
1100
- console.error(`Received: ${process.argv[3]}`);
1101
- console.error(JSON.stringify(errorResponse, null, 2));
1102
- process.exit(1);
1419
+ export async function run() {
1420
+ let action, input;
1421
+ if (process.argv.length > 3) {
1422
+ action = process.argv[2];
1423
+ try {
1424
+ input = JSON.parse(process.argv[3]);
1425
+ }
1426
+ catch (e) {
1427
+ logger.error({ err: e }, "Invalid JSON argument");
1428
+ process.exit(1);
1429
+ }
1430
+ }
1431
+ else {
1432
+ try {
1433
+ const rawInput = fs.readFileSync(0, "utf8");
1434
+ input = JSON.parse(rawInput);
1435
+ action = input.action || action;
1436
+ }
1437
+ catch (_e) {
1438
+ logger.error("No input provided");
1439
+ process.exit(1);
1440
+ }
1103
1441
  }
1104
- } else {
1105
- // Fallback to STDIN for System Skill compatibility
1442
+ const mesh = new MemoryMesh({
1443
+ llmProvider: process.env.LLM_PROVIDER ||
1444
+ (process.env.OPENAI_API_KEY ? "openai" : "ollama"),
1445
+ llmApiKey: process.env.LLM_API_KEY || process.env.OPENAI_API_KEY,
1446
+ llmModel: process.env.LLM_MODEL,
1447
+ });
1106
1448
  try {
1107
- const rawInput = fs.readFileSync(0, 'utf8');
1108
- const data = JSON.parse(rawInput);
1109
- action = data.action || action;
1110
- input = data;
1111
- } catch (e) {
1112
- const error = e instanceof Error ? e : new Error(String(e));
1113
- const errorResponse = handleError(error, { context: 'STDIN parsing' });
1114
- console.error("❌ Error: No input provided via CLI or STDIN.");
1115
- console.error(`Details: ${error.message}`);
1116
- console.error(JSON.stringify(errorResponse, null, 2));
1117
- process.exit(1);
1449
+ if (action === "ingest" || action === "store") {
1450
+ const record = await mesh.add(input.content, input.metadata || {});
1451
+ process.stdout.write(`[MemoryMesh] Ingested record ${record.id}\n${JSON.stringify({ status: "ok", record })}\n`);
1452
+ }
1453
+ else if (action === "search") {
1454
+ const results = await mesh.search(input.query, {
1455
+ limit: input.limit || 10,
1456
+ filter: input.filter || null,
1457
+ });
1458
+ process.stdout.write(`[MemoryMesh] Found ${results.length} matches.\n**Formatted Context**:\n\`\`\`yamo\n${mesh.formatResults(results)}\n\`\`\`\n**Output**: memory_results.json\n\`\`\`json\n${JSON.stringify(results, null, 2)}\n\`\`\`\n${JSON.stringify({ status: "ok", results })}\n`);
1459
+ }
1460
+ else if (action === "synthesize") {
1461
+ const result = await mesh.synthesize({
1462
+ topic: input.topic,
1463
+ lookback: input.limit || 20,
1464
+ });
1465
+ process.stdout.write(`[MemoryMesh] Synthesis Outcome: ${result.status}\n${JSON.stringify(result, null, 2)}\n`);
1466
+ }
1467
+ else if (action === "ingest-skill") {
1468
+ const record = await mesh.ingestSkill(input.yamo_text, input.metadata || {});
1469
+ process.stdout.write(`[MemoryMesh] Ingested skill ${record.name} (${record.id})\n${JSON.stringify({ status: "ok", record })}\n`);
1470
+ }
1471
+ else if (action === "search-skills") {
1472
+ await mesh.init();
1473
+ const vector = await mesh.embeddingFactory.embed(input.query);
1474
+ if (mesh.skillTable) {
1475
+ const results = await mesh.skillTable
1476
+ .search(vector)
1477
+ .limit(input.limit || 5)
1478
+ .toArray();
1479
+ process.stdout.write(`[MemoryMesh] Found ${results.length} synthesized skills.\n${JSON.stringify({ status: "ok", results }, null, 2)}\n`);
1480
+ }
1481
+ else {
1482
+ process.stdout.write(`[MemoryMesh] Skill table not initialized.\n`);
1483
+ }
1484
+ }
1485
+ else if (action === "skill-feedback") {
1486
+ const result = await mesh.updateSkillReliability(input.id, input.success !== false);
1487
+ process.stdout.write(`[MemoryMesh] Feedback recorded for ${input.id}: Reliability now ${result.reliability}\n${JSON.stringify({ status: "ok", ...result })}\n`);
1488
+ }
1489
+ else if (action === "skill-prune") {
1490
+ const result = await mesh.pruneSkills(input.threshold || 0.3);
1491
+ process.stdout.write(`[MemoryMesh] Pruning complete. Removed ${result.pruned_count} unreliable skills.\n${JSON.stringify({ status: "ok", ...result })}\n`);
1492
+ }
1493
+ else if (action === "stats") {
1494
+ process.stdout.write(`[MemoryMesh] Database Statistics:\n${JSON.stringify({ status: "ok", stats: await mesh.stats() }, null, 2)}\n`);
1495
+ }
1496
+ else {
1497
+ logger.error({ action }, "Unknown action");
1498
+ process.exit(1);
1499
+ }
1118
1500
  }
1119
- }
1120
-
1121
- // Create MemoryMesh instance
1122
- const mesh = new MemoryMesh();
1123
-
1124
- try {
1125
- // Route to appropriate action
1126
- if (action === 'ingest' || action === 'store') {
1127
- // Validate required fields
1128
- if (!input.content) {
1129
- console.error('❌ Error: "content" field is required for ingest action');
1130
- process.exit(1);
1131
- }
1132
-
1133
- const record = await mesh.add(input.content, input.metadata || {});
1134
- console.log(`[MemoryMesh] Ingested record ${record.id}`);
1135
- console.log(JSON.stringify({ status: "ok", record }));
1136
-
1137
- } else if (action === 'search') {
1138
- // Validate required fields
1139
- if (!input.query) {
1140
- console.error('❌ Error: "query" field is required for search action');
1141
- process.exit(1);
1142
- }
1143
-
1144
- const options = {
1145
- limit: input.limit || 10,
1146
- filter: input.filter || null
1147
- };
1148
-
1149
-
1150
- const results = await mesh.search(input.query, options);
1151
- console.log(`[MemoryMesh] Found ${results.length} matches.`);
1152
-
1153
- const jsonResult = JSON.stringify(results, null, 2);
1154
- // YAMO Skill compatibility: Output as a marked block for auto-saving
1155
- console.log(`\n**Output**: memory_results.json
1156
- \`\`\`json
1157
- ${jsonResult}
1158
- \`\`\`
1159
- `);
1160
- // Also output raw JSON for STDIN callers
1161
- console.log(JSON.stringify({ status: "ok", results }));
1162
-
1163
- } else if (action === 'get') {
1164
- // Validate required fields
1165
- if (!input.id) {
1166
- console.error('❌ Error: "id" field is required for get action');
1167
- process.exit(1);
1168
- }
1169
-
1170
- const record = await mesh.get(input.id);
1171
-
1172
- if (!record) {
1173
- console.log(JSON.stringify({ status: "ok", record: null }));
1174
- } else {
1175
- console.log(JSON.stringify({ status: "ok", record }));
1176
- }
1177
-
1178
- } else if (action === 'delete') {
1179
- // Validate required fields
1180
- if (!input.id) {
1181
- console.error('❌ Error: "id" field is required for delete action');
1501
+ catch (error) {
1502
+ const errorResponse = handleError(error, {
1503
+ action,
1504
+ input: { ...input, content: input.content ? "[REDACTED]" : undefined },
1505
+ });
1506
+ logger.error({ err: error, errorResponse }, "Fatal Error");
1182
1507
  process.exit(1);
1183
- }
1184
-
1185
- const result = await mesh.delete(input.id);
1186
- console.log(`[MemoryMesh] Deleted record ${result.deleted}`);
1187
- console.log(JSON.stringify({ status: "ok", ...result }));
1188
-
1189
- } else if (action === 'export') {
1190
- const records = await mesh.getAll({ limit: input.limit || 10000 });
1191
- console.log(JSON.stringify({ status: "ok", count: records.length, records }));
1192
-
1193
- } else if (action === 'reflect') {
1194
- // Enhanced reflect with LLM support
1195
- const enableLLM = input.llm !== false; // Default true
1196
- const result = await mesh.reflect({
1197
- topic: input.topic,
1198
- lookback: input.limit || 10,
1199
- generate: enableLLM
1200
- });
1201
-
1202
- if (result.reflection) {
1203
- // New format with LLM-generated reflection
1204
- console.log(JSON.stringify({
1205
- status: "ok",
1206
- reflection: result.reflection,
1207
- confidence: result.confidence,
1208
- id: result.id,
1209
- topic: result.topic,
1210
- sourceMemoryCount: result.sourceMemoryCount,
1211
- yamoBlock: result.yamoBlock,
1212
- createdAt: result.createdAt
1213
- }));
1214
- } else {
1215
- // Old format for backward compatibility (prompt-only mode)
1216
- console.log(JSON.stringify({ status: "ok", ...result }));
1217
- }
1218
-
1219
- } else if (action === 'stats') {
1220
- const stats = await mesh.stats();
1221
- console.log('[MemoryMesh] Database Statistics:');
1222
- console.log(JSON.stringify({ status: "ok", stats }, null, 2));
1223
-
1224
- } else {
1225
- console.error(`❌ Error: Unknown action "${action}". Valid actions: ingest, search, get, delete, stats`);
1226
- process.exit(1);
1227
- }
1228
-
1229
- } catch (error) {
1230
- // Handle errors using the error handler
1231
- const e = error instanceof Error ? error : new Error(String(error));
1232
- const errorResponse = handleError(e, { action, input: { ...input, content: input.content ? '[REDACTED]' : undefined } });
1233
-
1234
- if (errorResponse.success === false) {
1235
- console.error(`❌ Fatal Error: ${errorResponse.error.message}`);
1236
- if (process.env.NODE_ENV === 'development' && errorResponse.error.details) {
1237
- console.error(`Details:`, errorResponse.error.details);
1238
- }
1239
- console.error(JSON.stringify(errorResponse, null, 2));
1240
- } else {
1241
- console.error(`❌ Fatal Error: ${e.message}`);
1242
- console.error(e.stack);
1243
1508
  }
1244
-
1245
- process.exit(1);
1246
- }
1247
1509
  }
1248
-
1249
- // Export for testing and CLI usage
1250
- export { MemoryMesh, run };
1251
1510
  export default MemoryMesh;
1252
-
1253
- // Run CLI if called directly
1254
1511
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
1255
- run().catch(err => {
1256
- console.error(`❌ Fatal Error: ${err.message}`);
1257
- console.error(err.stack);
1258
- process.exit(1);
1259
- });
1512
+ run().catch((err) => {
1513
+ logger.error({ err }, "Fatal Error");
1514
+ process.exit(1);
1515
+ });
1260
1516
  }