/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * Implementation of all the disk I/O required by the Memory store */ import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; /** * MemoryStore * * In-memory JSON state + persisted JSON file, modeled after SessionStore. * * File format (on disk): * { * "memories": [ { ... } ], * "meta": { * "last_history_memory_ts": 0, * "last_chat_memory_ts": 0, * }, * "version": 1 * } */ const MEMORY_STORE_FILE = "memories.json.lz4"; const MEMORY_STORE_VERSION = 1; // In-memory state let gState = { memories: [], meta: { last_history_memory_ts: 0, last_chat_memory_ts: 0, }, version: MEMORY_STORE_VERSION, }; // Whether we've finished initial load let gInitialized = false; let lazy = {}; let gInitPromise = null; let gJSONFile = null; // Where we store the file (choose something similar to sessionstore) ChromeUtils.defineLazyGetter(lazy, "gStorePath", () => { const profD = Services.dirsvc.get("ProfD", Ci.nsIFile).path; return PathUtils.join(profD, MEMORY_STORE_FILE); }); /** * Internal helper to load (and possibly migrate) memory data from disk. * * @returns {Promise} */ async function loadMemories() { gJSONFile = new JSONFile({ path: lazy.gStorePath, saveDelayMs: 1000, compression: "lz4", sanitizedBasename: "memories", }); try { await gJSONFile.load(); } catch (ex) { console.error("MemoryStore: failed to load state", ex); // If load fails, fall back to default gState. gJSONFile.data = gState; gInitialized = true; return; } // Normalize the loaded data into our expected shape. const data = gJSONFile.data; if (!data || typeof data !== "object") { gJSONFile.data = gState; } else { gState = { memories: Array.isArray(data.memories) ? data.memories : [], meta: { last_history_memory_ts: data.meta?.last_history_memory_ts || 0, last_chat_memory_ts: data.meta?.last_chat_memory_ts || 0, }, version: typeof data.version === "number" ? data.version : MEMORY_STORE_VERSION, }; // Ensure JSONFile.data points at our normalized state object. gJSONFile.data = gState; } gInitialized = true; } // Public API object export const MemoryStore = { /** * Initialize the store: set up JSONFile and load from disk. * * @returns {Promise} */ async ensureInitialized() { if (gInitialized) { return; } if (!gInitPromise) { gInitPromise = loadMemories(); } await gInitPromise; }, /** * Force writing current in-memory state to disk immediately. * * This is intended for test only. */ async testOnlyFlush() { await this.ensureInitialized(); if (!gJSONFile) { return; } await gJSONFile._save(); }, /** * @typedef {object} Memory * @property {string} id - Unique identifier for the memory. * @property {string} memory_summary - Short human-readable summary of the memory. * @property {string} category - Category label for the memory. * @property {string} intent - Intent label associated with the memory. * @property {number} score - Numeric score representing the memory's relevance. * @property {number} updated_at - Last-updated time in milliseconds since Unix epoch. * @property {boolean} is_deleted - Whether the memory is marked as deleted. */ /** * @typedef {object} MemoryPartial * @property {string} [id] Optional identifier; if omitted, one is derived by makeMemoryId. * @property {string} [memory_summary] Optional summary; defaults to an empty string. * @property {string} [category] Optional category label; defaults to an empty string. * @property {string} [intent] Optional intent label; defaults to an empty string. * @property {number} [score] Optional numeric score; non-finite values are ignored. * @property {number} [updated_at] Optional last-updated time in milliseconds since Unix epoch. * @property {boolean} [is_deleted] Optional deleted flag; defaults to false. */ /** * Add a new memory, or update an existing one with the same id. * * Any missing fields on {@link MemoryPartial} are defaulted. * * @param {MemoryPartial} memoryPartial * @returns {Promise} */ async addMemory(memoryPartial) { await this.ensureInitialized(); const now = Date.now(); const id = makeMemoryId(memoryPartial); let memory = gState.memories.find(i => i.id === id); if (memory) { const simpleProperties = ["memory_summary", "category", "intent"]; for (const prop of simpleProperties) { if (prop in memoryPartial) { memory[prop] = memoryPartial[prop]; } } const validatedProperties = [ ["score", v => Number.isFinite(v)], ["is_deleted", v => typeof v === "boolean"], ]; for (const [prop, validator] of validatedProperties) { if (prop in memoryPartial && validator(memoryPartial[prop])) { memory[prop] = memoryPartial[prop]; } } memory.updated_at = memoryPartial.updated_at || now; gJSONFile?.saveSoon(); return memory; } // Otherwise create a new one memory = { id, memory_summary: memoryPartial.memory_summary || "", category: memoryPartial.category || "", intent: memoryPartial.intent || "", score: Number.isFinite(memoryPartial.score) ? memoryPartial.score : 0, updated_at: memoryPartial.updated_at || now, is_deleted: memoryPartial.is_deleted ?? false, }; gState.memories.push(memory); gJSONFile?.saveSoon(); return memory; }, /** * Update an existing memory by id. * * @param {string} id * @param {object} updates * @returns {Promise} */ async updateMemory(id, updates) { await this.ensureInitialized(); const memory = gState.memories.find(i => i.id === id); if (!memory) { return null; } const simpleProperties = ["memory_summary", "category", "intent"]; for (const prop of simpleProperties) { if (prop in updates) { memory[prop] = updates[prop]; } } const validatedProperties = [ ["score", v => Number.isFinite(v)], ["is_deleted", v => typeof v === "boolean"], ]; for (const [prop, validator] of validatedProperties) { if (prop in updates && validator(updates[prop])) { memory[prop] = updates[prop]; } } memory.updated_at = updates.updated_at || Date.now(); gJSONFile?.saveSoon(); return memory; }, /** * Soft delete an memory (set is_deleted = true). * * soft deleted memories will be filtered from getMemories * * @param {string} id * @returns {Promise} */ async softDeleteMemory(id) { return this.updateMemory(id, { is_deleted: true }); }, /** * hard delete (remove from array). * * @param {string} id * @returns {Promise} */ async hardDeleteMemory(id) { await this.ensureInitialized(); const idx = gState.memories.findIndex(i => i.id === id); if (idx === -1) { return false; } gState.memories.splice(idx, 1); gJSONFile?.saveSoon(); return true; }, /** * Get all memories (optionally filtered and sorted). * * @param {object} [options] * Optional sorting options. * @param {"score"|"updated_at"} [options.sortBy="updated_at"] * Field to sort by. * @param {"asc"|"desc"} [options.sortDir="desc"] * Sort direction. * @param {boolean} [options.includeSoftDeleted=false] * Whether to include soft-deleted memories. * @returns {Promise} */ async getMemories({ sortBy = "updated_at", sortDir = "desc", includeSoftDeleted = false, } = {}) { await this.ensureInitialized(); let res = gState.memories; if (!includeSoftDeleted) { res = res.filter(i => !i.is_deleted); } if (sortBy) { res = [...res].sort((a, b) => { const av = a[sortBy] ?? 0; const bv = b[sortBy] ?? 0; if (av === bv) { return 0; } const cmp = av < bv ? -1 : 1; return sortDir === "asc" ? cmp : -cmp; }); } return res; }, /** * Get current meta block. * * @returns {Promise} */ async getMeta() { await this.ensureInitialized(); return structuredClone(gState.meta); }, /** * Update meta information (last timestamps, top_* info, etc). * * Example payload: * { * last_history_memory_ts: 12345, * } * * @param {object} partialMeta * @returns {Promise} */ async updateMeta(partialMeta) { await this.ensureInitialized(); const meta = gState.meta; const validatedProps = [ ["last_history_memory_ts", v => Number.isFinite(v)], ["last_chat_memory_ts", v => Number.isFinite(v)], ]; for (const [prop, validator] of validatedProps) { if (prop in partialMeta && validator(partialMeta[prop])) { meta[prop] = partialMeta[prop]; } } gJSONFile?.saveSoon(); }, }; /** * Simple deterministic hash of a string → 8-char hex. * Based on a 32-bit FNV-1a-like hash. * * @param {string} str * @returns {string} */ function hashStringToHex(str) { // FNV offset basis let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); // FNV prime, keep 32-bit hash = (hash * 0x01000193) >>> 0; } // Convert to 8-digit hex return hash.toString(16).padStart(8, "0"); } /** * Build a deterministic memory id from its core fields. * If the caller passes an explicit id, we honor that instead. * * @param {object} memoryPartial */ function makeMemoryId(memoryPartial) { if (memoryPartial.id) { return memoryPartial.id; } const summary = (memoryPartial.memory_summary || "").trim().toLowerCase(); const category = (memoryPartial.category || "").trim().toLowerCase(); const intent = (memoryPartial.intent || "").trim().toLowerCase(); const key = `${summary}||${category}||${intent}`; const hex = hashStringToHex(key); return `ins-${hex}`; }