tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }