MemoryStore.sys.mjs (10380B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * Implementation of all the disk I/O required by the Memory store 7 */ 8 9 import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; 10 11 /** 12 * MemoryStore 13 * 14 * In-memory JSON state + persisted JSON file, modeled after SessionStore. 15 * 16 * File format (on disk): 17 * { 18 * "memories": [ { ... } ], 19 * "meta": { 20 * "last_history_memory_ts": 0, 21 * "last_chat_memory_ts": 0, 22 * }, 23 * "version": 1 24 * } 25 */ 26 27 const MEMORY_STORE_FILE = "memories.json.lz4"; 28 const MEMORY_STORE_VERSION = 1; 29 30 // In-memory state 31 let gState = { 32 memories: [], 33 meta: { 34 last_history_memory_ts: 0, 35 last_chat_memory_ts: 0, 36 }, 37 version: MEMORY_STORE_VERSION, 38 }; 39 40 // Whether we've finished initial load 41 let gInitialized = false; 42 let lazy = {}; 43 let gInitPromise = null; 44 let gJSONFile = null; 45 46 // Where we store the file (choose something similar to sessionstore) 47 ChromeUtils.defineLazyGetter(lazy, "gStorePath", () => { 48 const profD = Services.dirsvc.get("ProfD", Ci.nsIFile).path; 49 return PathUtils.join(profD, MEMORY_STORE_FILE); 50 }); 51 52 /** 53 * Internal helper to load (and possibly migrate) memory data from disk. 54 * 55 * @returns {Promise<void>} 56 */ 57 async function loadMemories() { 58 gJSONFile = new JSONFile({ 59 path: lazy.gStorePath, 60 saveDelayMs: 1000, 61 compression: "lz4", 62 sanitizedBasename: "memories", 63 }); 64 65 try { 66 await gJSONFile.load(); 67 } catch (ex) { 68 console.error("MemoryStore: failed to load state", ex); 69 // If load fails, fall back to default gState. 70 gJSONFile.data = gState; 71 gInitialized = true; 72 return; 73 } 74 75 // Normalize the loaded data into our expected shape. 76 const data = gJSONFile.data; 77 if (!data || typeof data !== "object") { 78 gJSONFile.data = gState; 79 } else { 80 gState = { 81 memories: Array.isArray(data.memories) ? data.memories : [], 82 meta: { 83 last_history_memory_ts: data.meta?.last_history_memory_ts || 0, 84 last_chat_memory_ts: data.meta?.last_chat_memory_ts || 0, 85 }, 86 version: 87 typeof data.version === "number" ? data.version : MEMORY_STORE_VERSION, 88 }; 89 // Ensure JSONFile.data points at our normalized state object. 90 gJSONFile.data = gState; 91 } 92 93 gInitialized = true; 94 } 95 96 // Public API object 97 export const MemoryStore = { 98 /** 99 * Initialize the store: set up JSONFile and load from disk. 100 * 101 * @returns {Promise<void>} 102 */ 103 async ensureInitialized() { 104 if (gInitialized) { 105 return; 106 } 107 108 if (!gInitPromise) { 109 gInitPromise = loadMemories(); 110 } 111 112 await gInitPromise; 113 }, 114 115 /** 116 * Force writing current in-memory state to disk immediately. 117 * 118 * This is intended for test only. 119 */ 120 async testOnlyFlush() { 121 await this.ensureInitialized(); 122 if (!gJSONFile) { 123 return; 124 } 125 await gJSONFile._save(); 126 }, 127 128 /** 129 * @typedef {object} Memory 130 * @property {string} id - Unique identifier for the memory. 131 * @property {string} memory_summary - Short human-readable summary of the memory. 132 * @property {string} category - Category label for the memory. 133 * @property {string} intent - Intent label associated with the memory. 134 * @property {number} score - Numeric score representing the memory's relevance. 135 * @property {number} updated_at - Last-updated time in milliseconds since Unix epoch. 136 * @property {boolean} is_deleted - Whether the memory is marked as deleted. 137 */ 138 /** 139 * @typedef {object} MemoryPartial 140 * @property {string} [id] Optional identifier; if omitted, one is derived by makeMemoryId. 141 * @property {string} [memory_summary] Optional summary; defaults to an empty string. 142 * @property {string} [category] Optional category label; defaults to an empty string. 143 * @property {string} [intent] Optional intent label; defaults to an empty string. 144 * @property {number} [score] Optional numeric score; non-finite values are ignored. 145 * @property {number} [updated_at] Optional last-updated time in milliseconds since Unix epoch. 146 * @property {boolean} [is_deleted] Optional deleted flag; defaults to false. 147 */ 148 /** 149 * Add a new memory, or update an existing one with the same id. 150 * 151 * Any missing fields on {@link MemoryPartial} are defaulted. 152 * 153 * @param {MemoryPartial} memoryPartial 154 * @returns {Promise<Memory>} 155 */ 156 async addMemory(memoryPartial) { 157 await this.ensureInitialized(); 158 159 const now = Date.now(); 160 const id = makeMemoryId(memoryPartial); 161 162 let memory = gState.memories.find(i => i.id === id); 163 164 if (memory) { 165 const simpleProperties = ["memory_summary", "category", "intent"]; 166 for (const prop of simpleProperties) { 167 if (prop in memoryPartial) { 168 memory[prop] = memoryPartial[prop]; 169 } 170 } 171 172 const validatedProperties = [ 173 ["score", v => Number.isFinite(v)], 174 ["is_deleted", v => typeof v === "boolean"], 175 ]; 176 177 for (const [prop, validator] of validatedProperties) { 178 if (prop in memoryPartial && validator(memoryPartial[prop])) { 179 memory[prop] = memoryPartial[prop]; 180 } 181 } 182 183 memory.updated_at = memoryPartial.updated_at || now; 184 185 gJSONFile?.saveSoon(); 186 return memory; 187 } 188 189 // Otherwise create a new one 190 memory = { 191 id, 192 memory_summary: memoryPartial.memory_summary || "", 193 category: memoryPartial.category || "", 194 intent: memoryPartial.intent || "", 195 score: Number.isFinite(memoryPartial.score) ? memoryPartial.score : 0, 196 updated_at: memoryPartial.updated_at || now, 197 is_deleted: memoryPartial.is_deleted ?? false, 198 }; 199 200 gState.memories.push(memory); 201 gJSONFile?.saveSoon(); 202 return memory; 203 }, 204 205 /** 206 * Update an existing memory by id. 207 * 208 * @param {string} id 209 * @param {object} updates 210 * @returns {Promise<Memory|null>} 211 */ 212 async updateMemory(id, updates) { 213 await this.ensureInitialized(); 214 215 const memory = gState.memories.find(i => i.id === id); 216 if (!memory) { 217 return null; 218 } 219 220 const simpleProperties = ["memory_summary", "category", "intent"]; 221 for (const prop of simpleProperties) { 222 if (prop in updates) { 223 memory[prop] = updates[prop]; 224 } 225 } 226 227 const validatedProperties = [ 228 ["score", v => Number.isFinite(v)], 229 ["is_deleted", v => typeof v === "boolean"], 230 ]; 231 232 for (const [prop, validator] of validatedProperties) { 233 if (prop in updates && validator(updates[prop])) { 234 memory[prop] = updates[prop]; 235 } 236 } 237 238 memory.updated_at = updates.updated_at || Date.now(); 239 240 gJSONFile?.saveSoon(); 241 return memory; 242 }, 243 244 /** 245 * Soft delete an memory (set is_deleted = true). 246 * 247 * soft deleted memories will be filtered from getMemories 248 * 249 * @param {string} id 250 * @returns {Promise<Memory|null>} 251 */ 252 async softDeleteMemory(id) { 253 return this.updateMemory(id, { is_deleted: true }); 254 }, 255 256 /** 257 * hard delete (remove from array). 258 * 259 * @param {string} id 260 * @returns {Promise<boolean>} 261 */ 262 async hardDeleteMemory(id) { 263 await this.ensureInitialized(); 264 const idx = gState.memories.findIndex(i => i.id === id); 265 if (idx === -1) { 266 return false; 267 } 268 gState.memories.splice(idx, 1); 269 gJSONFile?.saveSoon(); 270 return true; 271 }, 272 273 /** 274 * Get all memories (optionally filtered and sorted). 275 * 276 * @param {object} [options] 277 * Optional sorting options. 278 * @param {"score"|"updated_at"} [options.sortBy="updated_at"] 279 * Field to sort by. 280 * @param {"asc"|"desc"} [options.sortDir="desc"] 281 * Sort direction. 282 * @param {boolean} [options.includeSoftDeleted=false] 283 * Whether to include soft-deleted memories. 284 * @returns {Promise<Memory[]>} 285 */ 286 async getMemories({ 287 sortBy = "updated_at", 288 sortDir = "desc", 289 includeSoftDeleted = false, 290 } = {}) { 291 await this.ensureInitialized(); 292 293 let res = gState.memories; 294 295 if (!includeSoftDeleted) { 296 res = res.filter(i => !i.is_deleted); 297 } 298 299 if (sortBy) { 300 res = [...res].sort((a, b) => { 301 const av = a[sortBy] ?? 0; 302 const bv = b[sortBy] ?? 0; 303 if (av === bv) { 304 return 0; 305 } 306 const cmp = av < bv ? -1 : 1; 307 return sortDir === "asc" ? cmp : -cmp; 308 }); 309 } 310 311 return res; 312 }, 313 314 /** 315 * Get current meta block. 316 * 317 * @returns {Promise<object>} 318 */ 319 async getMeta() { 320 await this.ensureInitialized(); 321 return structuredClone(gState.meta); 322 }, 323 324 /** 325 * Update meta information (last timestamps, top_* info, etc). 326 * 327 * Example payload: 328 * { 329 * last_history_memory_ts: 12345, 330 * } 331 * 332 * @param {object} partialMeta 333 * @returns {Promise<void>} 334 */ 335 async updateMeta(partialMeta) { 336 await this.ensureInitialized(); 337 const meta = gState.meta; 338 const validatedProps = [ 339 ["last_history_memory_ts", v => Number.isFinite(v)], 340 ["last_chat_memory_ts", v => Number.isFinite(v)], 341 ]; 342 343 for (const [prop, validator] of validatedProps) { 344 if (prop in partialMeta && validator(partialMeta[prop])) { 345 meta[prop] = partialMeta[prop]; 346 } 347 } 348 349 gJSONFile?.saveSoon(); 350 }, 351 }; 352 353 /** 354 * Simple deterministic hash of a string → 8-char hex. 355 * Based on a 32-bit FNV-1a-like hash. 356 * 357 * @param {string} str 358 * @returns {string} 359 */ 360 function hashStringToHex(str) { 361 // FNV offset basis 362 let hash = 0x811c9dc5; 363 for (let i = 0; i < str.length; i++) { 364 hash ^= str.charCodeAt(i); 365 // FNV prime, keep 32-bit 366 hash = (hash * 0x01000193) >>> 0; 367 } 368 // Convert to 8-digit hex 369 return hash.toString(16).padStart(8, "0"); 370 } 371 372 /** 373 * Build a deterministic memory id from its core fields. 374 * If the caller passes an explicit id, we honor that instead. 375 * 376 * @param {object} memoryPartial 377 */ 378 function makeMemoryId(memoryPartial) { 379 if (memoryPartial.id) { 380 return memoryPartial.id; 381 } 382 383 const summary = (memoryPartial.memory_summary || "").trim().toLowerCase(); 384 const category = (memoryPartial.category || "").trim().toLowerCase(); 385 const intent = (memoryPartial.intent || "").trim().toLowerCase(); 386 387 const key = `${summary}||${category}||${intent}`; 388 const hex = hashStringToHex(key); 389 390 return `ins-${hex}`; 391 }