Differential Revision: https://phabricator.services.mozilla.com/D310686
1000 lines
32 KiB
JavaScript
1000 lines
32 KiB
JavaScript
/* 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";
|
|
import {
|
|
MEMORY_FILTER_COMPARATOR,
|
|
MEMORY_FILTER_PREDICATES,
|
|
MEMORY_FILTER_VALIDATORS,
|
|
AGGREGATE_FIELD_REGEX,
|
|
} from "moz-src:///browser/components/aiwindow/services/MemoryStoreConstants.sys.mjs";
|
|
import {
|
|
HISTORY,
|
|
CONVERSATION,
|
|
SESSION,
|
|
MEMORY_TYPES,
|
|
MEMORY_SENSIVITITY_CATEGORIES,
|
|
MEMORY_TYPE_SHORT_TERM_MEMORY,
|
|
MEMORY_SENSITIVITY_CATEGORY_NOT_SENSITIVE,
|
|
MEMORY_FRECENCY_MAX_DAYS,
|
|
DEFAULT_RELEVANT_MEMORIES_TOP_K,
|
|
DEFAULT_RELEVANT_MEMORIES_SIMILARITY_THRESHOLD,
|
|
} from "moz-src:///browser/components/aiwindow/models/memories/MemoriesConstants.sys.mjs";
|
|
import {
|
|
computeMemoryFrecency,
|
|
computeMemoryStrength,
|
|
} from "moz-src:///browser/components/aiwindow/models/memories/Memories.sys.mjs";
|
|
import { EmbeddingsGenerator } from "chrome://global/content/ml/EmbeddingsGenerator.sys.mjs";
|
|
import { cosSim } from "chrome://global/content/ml/NLPUtils.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 = 2;
|
|
|
|
// Observer notification topic
|
|
const MEMORY_STORE_CHANGED = "memory-store-changed";
|
|
|
|
// In-memory state
|
|
let gState = {
|
|
memories: [],
|
|
meta: {
|
|
last_history_memory_ts: 0,
|
|
last_chat_memory_ts: 0,
|
|
last_session_memory_ts: 0,
|
|
},
|
|
version: MEMORY_STORE_VERSION,
|
|
};
|
|
|
|
/**
|
|
* Normalizes a raw source-IDs bundle to the canonical
|
|
* `{ history_source_ids, conversation_source_ids }` shape with deduped arrays.
|
|
* Memories created before source-ID tracking normalize to empty arrays.
|
|
*
|
|
* @param {object} [raw]
|
|
* @returns {{history_source_ids: Array<string|number>, conversation_source_ids: Array<string>}}
|
|
*/
|
|
function normalizeSourceIds(raw) {
|
|
return {
|
|
history_source_ids: Array.isArray(raw?.history_source_ids)
|
|
? [...new Set(raw.history_source_ids)]
|
|
: [],
|
|
conversation_source_ids: Array.isArray(raw?.conversation_source_ids)
|
|
? [...new Set(raw.conversation_source_ids)]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Unions two source-ID bundles (used when a memory is regenerated and we want
|
|
* to accumulate, not replace, its provenance).
|
|
*
|
|
* @param {object} a
|
|
* @param {object} b
|
|
* @returns {{history_source_ids: Array<string|number>, conversation_source_ids: Array<string>}}
|
|
*/
|
|
function unionSourceIds(a, b) {
|
|
const an = normalizeSourceIds(a);
|
|
const bn = normalizeSourceIds(b);
|
|
return {
|
|
history_source_ids: [
|
|
...new Set([...an.history_source_ids, ...bn.history_source_ids]),
|
|
],
|
|
conversation_source_ids: [
|
|
...new Set([
|
|
...an.conversation_source_ids,
|
|
...bn.conversation_source_ids,
|
|
]),
|
|
],
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "console", () =>
|
|
console.createInstance({
|
|
prefix: "MemoryStore",
|
|
maxLogLevelPref: "browser.smartwindow.conversation.logLevel",
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Migrates memories saved using the memory store v1 schema to v2
|
|
*
|
|
* @param {Array[object]} memories Memories stored in the v1 schama
|
|
* @returns {Array[object]} Memories converted to the v2 schema
|
|
*/
|
|
export function migrateMemoryStoreVersionOneToTwo(memories) {
|
|
return memories.map(m => {
|
|
/** System fields */
|
|
// Add missing type, assume short term memory
|
|
if (!m.type) {
|
|
m.type = MEMORY_TYPE_SHORT_TERM_MEMORY;
|
|
}
|
|
// Move source into sources array
|
|
if (m.source) {
|
|
m.sources = [m.source];
|
|
delete m.source;
|
|
}
|
|
// Normalize source_ids
|
|
m.source_ids = normalizeSourceIds(m.source_ids);
|
|
// Add sensitivity category, assume not sensitive
|
|
if (!m.sensitivity_category) {
|
|
m.sensitivity_category = MEMORY_SENSITIVITY_CATEGORY_NOT_SENSITIVE;
|
|
}
|
|
|
|
/** Descriptive fields */
|
|
// Add tags
|
|
if (!m.tags) {
|
|
m.tags = [];
|
|
// Move category and intent into tags if they exist as separate keys
|
|
if (m.hasOwnProperty("category")) {
|
|
if (m.category) {
|
|
m.tags.push(`category:${m.category}`);
|
|
}
|
|
}
|
|
if (m.hasOwnProperty("intent")) {
|
|
if (m.intent) {
|
|
m.tags.push(`intent:${m.intent}`);
|
|
}
|
|
}
|
|
}
|
|
// Delete category and intent
|
|
if (m.category) {
|
|
delete m.category;
|
|
}
|
|
if (m.intent) {
|
|
delete m.intent;
|
|
}
|
|
// Rename entities to keywords if it exists, otherwise add the keywords array
|
|
if (m.entities) {
|
|
m.keywords = m.entities;
|
|
delete m.entities;
|
|
} else {
|
|
m.keywords = [];
|
|
}
|
|
// Add component summaries
|
|
if (!m.component_summaries) {
|
|
m.component_summaries = [];
|
|
}
|
|
|
|
/** Tracker fields */
|
|
// If created_at doesn't exist, set it to updated_at
|
|
// By this point, we've lost the actual created_at timestamp
|
|
if (!m.created_at) {
|
|
m.created_at = m.updated_at;
|
|
}
|
|
// If last_accessed doesn't exist, set it to null (never used)
|
|
// By this point, we don't have any evidence the memory was ever used
|
|
if (!m.last_accessed) {
|
|
m.last_accessed = null;
|
|
}
|
|
// If recent_accessed_counts doesn't exist, create it
|
|
if (!m.recent_accessed_counts) {
|
|
m.recent_accessed_counts = Object.fromEntries(
|
|
Array.from({ length: MEMORY_FRECENCY_MAX_DAYS }, (_, i) => [i, 0])
|
|
);
|
|
}
|
|
// Create counts and set to 0 if they don't exist
|
|
for (const countField of ["lifetime_accessed_count", "merge_count"]) {
|
|
if (!m[countField]) {
|
|
m[countField] = 0;
|
|
}
|
|
}
|
|
|
|
// Delete score
|
|
if (m.score) {
|
|
delete m.score;
|
|
}
|
|
|
|
// Add frecency & strength last
|
|
m.frecency = computeMemoryFrecency(m);
|
|
m.strength = computeMemoryStrength(m);
|
|
|
|
return m;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Internal helper to load (and possibly migrate) memory data from disk.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function loadMemories() {
|
|
gJSONFile = new JSONFile({
|
|
path: lazy.gStorePath,
|
|
saveDelayMs: 1000,
|
|
compression: "lz4",
|
|
sanitizedBasename: "memories",
|
|
});
|
|
|
|
let markerData = null;
|
|
if (Services.profiler.IsActive()) {
|
|
let sizeLabel = "0 B";
|
|
try {
|
|
const stat = await IOUtils.stat(lazy.gStorePath);
|
|
sizeLabel = `${(stat.size / 1048576).toFixed(1)} MiB`;
|
|
} catch (_e) {}
|
|
markerData = { startTime: ChromeUtils.now(), sizeLabel };
|
|
}
|
|
|
|
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;
|
|
} finally {
|
|
if (markerData) {
|
|
ChromeUtils.addProfilerMarker(
|
|
"SmartWindow",
|
|
{ startTime: markerData.startTime },
|
|
`MemoryStore:load_db(${markerData.sizeLabel})`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Normalize the loaded data into our expected shape.
|
|
const data = gJSONFile.data;
|
|
let isMemoriesMigrated = false;
|
|
if (!data || typeof data !== "object") {
|
|
gJSONFile.data = gState;
|
|
} else {
|
|
let memories;
|
|
// Handle backwards compatibility by converting memories stored in older schemas to the current version
|
|
if (data.version === MEMORY_STORE_VERSION && Array.isArray(data.memories)) {
|
|
memories = data.memories;
|
|
} else if (
|
|
typeof data.version === "number" &&
|
|
data.version === 1 &&
|
|
Array.isArray(data.memories)
|
|
) {
|
|
memories = migrateMemoryStoreVersionOneToTwo(data.memories);
|
|
isMemoriesMigrated = true;
|
|
data.version = 2;
|
|
} else {
|
|
// All memory store JSONs are saved with a version number, but if something went wrong and it wasn't, clear the store
|
|
lazy.console.warn(
|
|
"Could not determine MemoryStore version; clearing saved memories"
|
|
);
|
|
memories = [];
|
|
}
|
|
|
|
gState = {
|
|
memories,
|
|
meta: {
|
|
last_history_memory_ts: data.meta?.last_history_memory_ts || 0,
|
|
last_chat_memory_ts: data.meta?.last_chat_memory_ts || 0,
|
|
last_session_memory_ts: data.meta?.last_session_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;
|
|
|
|
// If memories were migrated from an older schema, save
|
|
if (isMemoriesMigrated) {
|
|
gJSONFile?.saveSoon();
|
|
Services.obs.notifyObservers(null, MEMORY_STORE_CHANGED);
|
|
}
|
|
}
|
|
|
|
gInitialized = true;
|
|
}
|
|
|
|
// Public API object
|
|
export const MemoryStore = {
|
|
// Observer notification topic
|
|
MEMORY_STORE_CHANGED,
|
|
|
|
// Embeddings cache for semantic memory search
|
|
embeddingsGenerator: null,
|
|
memoryEmbeddingsCache: null,
|
|
memoryCacheKey: null,
|
|
|
|
/**
|
|
* Initialize the store: set up JSONFile and load from disk.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
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} type - Type of the memory.
|
|
* @property {Array[string]} sources - List of where the memory originated (history or conversation).
|
|
* @property {object} source_ids - Specific browsing history and conversations from which the memory was generated.
|
|
* @property {string} sensitivity_category - Sensitivity type of the memory.
|
|
* @property {boolean} is_deleted - Whether the memory is marked as deleted.
|
|
* @property {string} memory_summary - Short human-readable summary of the memory.
|
|
* @property {string} reasoning - Explanation of why this memory was created.
|
|
* @property {Array[string]} tags - List of tags for the memory.
|
|
* @property {Array[string]} keywords - List of keywords for the memory.
|
|
* @property {Array[string]} component_summaries - List of summaries from the memories merged to create this one.
|
|
* @property {number} created_at - When the memory was created in milliseconds since Unix epoch.
|
|
* @property {number} updated_at - Last-updated time in milliseconds since Unix epoch.
|
|
* @property {number} last_accessed - Last-accessed time in milliseconds since Unix epoch.
|
|
* @property {object} recent_accessed_counts - Rolling 7-day count of how often the memory was used.
|
|
* @property {number} lifetime_accessed_count - Total number of times the memory was used across its life
|
|
* @property {number} frecency - Computed frecency for the memory.
|
|
* @property {number} merge_count - How many memories were merged to create thie one.
|
|
*/
|
|
/**
|
|
* @typedef {object} MemoryPartial
|
|
* @property {string} id - Unique identifier for the memory.
|
|
* @property {string} type - Type of the memory.
|
|
* @property {Array[string]} sources - List of where the memory originated (history or conversation).
|
|
* @property {object} source_ids - Specific browsing history and conversations from which the memory was generated.
|
|
* @property {string} sensitivity_category - Sensitivity type of the memory.
|
|
* @property {boolean} is_deleted - Whether the memory is marked as deleted.
|
|
* @property {string} memory_summary - Short human-readable summary of the memory.
|
|
* @property {string} reasoning - Explanation of why this memory was created.
|
|
* @property {Array[string]} tags - List of tags for the memory.
|
|
* @property {Array[string]} keywords - List of keywords for the memory.
|
|
* @property {Array[string]} component_summaries - List of summaries from the memories merged to create this one.
|
|
* @property {number} created_at - When the memory was created in milliseconds since Unix epoch.
|
|
* @property {number} updated_at - Last-updated time in milliseconds since Unix epoch.
|
|
* @property {number} last_accessed - Last-accessed time in milliseconds since Unix epoch.
|
|
* @property {object} recent_accessed_counts - Rolling 7-day count of how often the memory was used.
|
|
* @property {number} lifetime_accessed_count - Total number of times the memory was used across its life
|
|
* @property {number} frecency - Computed frecency for the memory.
|
|
* @property {number} merge_count - How many memories were merged to create thie one.
|
|
*/
|
|
/**
|
|
* 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<Memory>}
|
|
*/
|
|
async addMemory(memoryPartial) {
|
|
await this.ensureInitialized();
|
|
|
|
const now = Date.now();
|
|
const id = makeMemoryId(memoryPartial);
|
|
|
|
// Try to update an existing memory first
|
|
let memory = await this.updateMemory(id, memoryPartial);
|
|
|
|
// Otherwise create a new one
|
|
if (!memory) {
|
|
const source_ids = normalizeSourceIds(memoryPartial.source_ids);
|
|
let sources;
|
|
// If the partial memory doesn't have a sources list, impute it
|
|
if (!memoryPartial.sources) {
|
|
if (
|
|
source_ids.history_source_ids.length &&
|
|
source_ids.conversation_source_ids.length
|
|
) {
|
|
sources = [SESSION];
|
|
} else if (source_ids.history_source_ids.length) {
|
|
sources = [HISTORY];
|
|
} else if (source_ids.conversation_source_ids.length) {
|
|
sources = [CONVERSATION];
|
|
} else {
|
|
// Can't identify the partial memory's sources
|
|
sources = [];
|
|
}
|
|
} else {
|
|
sources = memoryPartial.sources;
|
|
}
|
|
|
|
memory = {
|
|
// System fields
|
|
id,
|
|
type: memoryPartial.type || MEMORY_TYPE_SHORT_TERM_MEMORY,
|
|
sources,
|
|
source_ids,
|
|
sensitivity_category:
|
|
memoryPartial.sensitivity_category ||
|
|
MEMORY_SENSITIVITY_CATEGORY_NOT_SENSITIVE,
|
|
is_deleted: memoryPartial.is_deleted ?? false,
|
|
|
|
// Descriptive fields
|
|
memory_summary: memoryPartial.memory_summary || "",
|
|
reasoning: memoryPartial.reasoning || "",
|
|
tags: memoryPartial.tags || [],
|
|
keywords: memoryPartial.keywords || [],
|
|
component_summaries: memoryPartial.component_summaries || [],
|
|
|
|
// Tracker fields
|
|
created_at: memoryPartial.created_at || now,
|
|
updated_at: memoryPartial.updated_at || now,
|
|
last_accessed: memoryPartial.last_accessed || null,
|
|
recent_accessed_counts:
|
|
memoryPartial.recent_accessed_counts ||
|
|
Object.fromEntries(
|
|
Array.from({ length: MEMORY_FRECENCY_MAX_DAYS }, (_, i) => [i, 0])
|
|
),
|
|
lifetime_accessed_count: memoryPartial.lifetime_accessed_count || 0,
|
|
frecency: memoryPartial.frecency || 0,
|
|
merge_count: memoryPartial.merge_count || 0,
|
|
};
|
|
|
|
gState.memories.push(memory);
|
|
gJSONFile?.saveSoon();
|
|
Services.obs.notifyObservers(null, MEMORY_STORE_CHANGED);
|
|
}
|
|
updateMemoriesCountMetric();
|
|
return memory;
|
|
},
|
|
|
|
/**
|
|
* Update an existing memory by id.
|
|
*
|
|
* @param {string} id
|
|
* @param {object} updates
|
|
* @returns {Promise<Memory|null>}
|
|
*/
|
|
async updateMemory(id, updates) {
|
|
await this.ensureInitialized();
|
|
|
|
const memory = gState.memories.find(i => i.id === id);
|
|
if (!memory) {
|
|
return null;
|
|
}
|
|
|
|
// Validate that each propery in the memory updates has the right shape before using it
|
|
const validatedProperties = [
|
|
// System fields
|
|
["type", v => typeof v === "string" && MEMORY_TYPES.includes(v)],
|
|
["sources", v => Array.isArray(v) && v.every(i => typeof i === "string")],
|
|
[
|
|
"source_ids",
|
|
v =>
|
|
!!v &&
|
|
typeof v === "object" &&
|
|
!Array.isArray(v) &&
|
|
Object.values(v).every(
|
|
val => Array.isArray(val) && val.every(i => typeof i === "string")
|
|
),
|
|
],
|
|
[
|
|
"sensitivity_category",
|
|
v => typeof v === "string" && MEMORY_SENSIVITITY_CATEGORIES.includes(v),
|
|
],
|
|
["is_deleted", v => typeof v === "boolean"],
|
|
|
|
// Descriptive fields
|
|
["memory_summary", v => typeof v === "string"],
|
|
["reasoning", v => typeof v === "string"],
|
|
["tags", v => Array.isArray(v) && v.every(i => typeof i === "string")],
|
|
[
|
|
"keywords",
|
|
v => Array.isArray(v) && v.every(i => typeof i === "string"),
|
|
],
|
|
[
|
|
"component_summaries",
|
|
v => Array.isArray(v) && v.every(i => typeof i === "string"),
|
|
],
|
|
|
|
// Tracker fields
|
|
["created_at", v => Number.isFinite(v)],
|
|
["last_accessed", v => Number.isFinite(v)],
|
|
[
|
|
"recent_accessed_counts",
|
|
v =>
|
|
!!v &&
|
|
typeof v === "object" &&
|
|
!Array.isArray(v) &&
|
|
Object.values(v).every(val => Number.isFinite(val)),
|
|
],
|
|
["lifetime_accessed_count", v => Number.isFinite(v)],
|
|
["frecency", v => Number.isFinite(v)],
|
|
["merge_count", v => Number.isFinite(v)],
|
|
];
|
|
|
|
for (const [prop, validator] of validatedProperties) {
|
|
if (prop in updates && validator(updates[prop])) {
|
|
if (Array.isArray(updates[prop])) {
|
|
// Merge the update with the existing value if arrays
|
|
memory[prop] = [...new Set([...memory[prop], ...updates[prop]])];
|
|
} else {
|
|
// Otherwise overwrite
|
|
memory[prop] = updates[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updates.source_ids) {
|
|
// Smarter merging for source IDs
|
|
memory.source_ids = unionSourceIds(memory.source_ids, updates.source_ids);
|
|
}
|
|
|
|
memory.updated_at = updates.updated_at || Date.now();
|
|
|
|
gJSONFile?.saveSoon();
|
|
Services.obs.notifyObservers(null, MEMORY_STORE_CHANGED);
|
|
return memory;
|
|
},
|
|
|
|
/**
|
|
* Soft delete an memory (set is_deleted = true).
|
|
*
|
|
* soft deleted memories will be filtered from getMemories
|
|
*
|
|
* @param {string} id
|
|
* @returns {Promise<Memory|null>}
|
|
*/
|
|
async softDeleteMemory(id) {
|
|
let memory = await this.updateMemory(id, { is_deleted: true });
|
|
Services.obs.notifyObservers(null, MEMORY_STORE_CHANGED);
|
|
updateMemoriesCountMetric();
|
|
return memory;
|
|
},
|
|
|
|
/**
|
|
* hard delete (remove from array).
|
|
*
|
|
* @param {string} id
|
|
* @param {string} trigger
|
|
* @param {number|null} inUse
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async hardDeleteMemory(id, trigger = "other", inUse = null) {
|
|
await this.ensureInitialized();
|
|
const idx = gState.memories.findIndex(i => i.id === id);
|
|
if (idx === -1) {
|
|
return false;
|
|
}
|
|
gState.memories.splice(idx, 1);
|
|
gJSONFile?.saveSoon();
|
|
Glean.smartWindow.memoryRemovedPanel.record({
|
|
memories: gState.memories.length,
|
|
trigger,
|
|
in_use: inUse,
|
|
});
|
|
Services.obs.notifyObservers(null, MEMORY_STORE_CHANGED);
|
|
updateMemoriesCountMetric();
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Computes a hash of memories for cache invalidation.
|
|
* Uses incremental FNV-1a hashing to avoid allocating large concatenated strings
|
|
* based on https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
|
|
*
|
|
* @param {Array} memories Array of memory objects with id and updated_at fields
|
|
* @returns {number} 32-bit hash representing the memories state
|
|
*/
|
|
computeMemoriesHash(memories) {
|
|
// FNV-1a offset basis (32-bit)
|
|
let hash = 0x811c9dc5;
|
|
|
|
for (const m of memories) {
|
|
const str = `${m.id}-${m.updated_at}`;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash ^= str.charCodeAt(i);
|
|
// FNV prime, keep 32-bit
|
|
hash = (hash * 0x01000193) >>> 0;
|
|
}
|
|
}
|
|
|
|
return hash;
|
|
},
|
|
|
|
/**
|
|
* Clears the embeddings cache. Used for testing.
|
|
*/
|
|
_clearEmbeddingsCache() {
|
|
this.memoryEmbeddingsCache = null;
|
|
this.memoryCacheKey = null;
|
|
},
|
|
|
|
/**
|
|
* Fetches relevant memories for a given user message using semantic similarity.
|
|
* Uses embeddings and cosine similarity for fast, accurate memory retrieval.
|
|
*
|
|
* @param {string} message User message to find relevant memories for
|
|
* @param {number} topK Number of top relevant memories to return (default: 5)
|
|
* @param {number} similarityThreshold Minimum similarity score (0-1) to include (default: 0.22)
|
|
* @returns {Promise<Array<object>>} List of relevant memories sorted by similarity
|
|
*/
|
|
async getRelevantMemories(
|
|
message,
|
|
topK = DEFAULT_RELEVANT_MEMORIES_TOP_K,
|
|
similarityThreshold = DEFAULT_RELEVANT_MEMORIES_SIMILARITY_THRESHOLD
|
|
) {
|
|
const memories = await this.getMemories({ includeSoftDeleted: false });
|
|
|
|
if (memories.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Lazy initialize embeddings generator
|
|
if (!this.embeddingsGenerator) {
|
|
this.embeddingsGenerator = EmbeddingsGenerator.forGeneral();
|
|
}
|
|
|
|
// Re-embed memories only if cache is invalid
|
|
const currentCacheKey = this.computeMemoriesHash(memories);
|
|
if (
|
|
!this.memoryEmbeddingsCache ||
|
|
this.memoryCacheKey !== currentCacheKey
|
|
) {
|
|
const memoryTexts = memories.map(m => {
|
|
const summary = m.memory_summary?.toLowerCase() || "";
|
|
const reasoning = m.reasoning?.toLowerCase() || "";
|
|
return reasoning ? `${summary}. ${reasoning}` : summary;
|
|
});
|
|
const result = await this.embeddingsGenerator.embedMany(memoryTexts);
|
|
this.memoryEmbeddingsCache = result.output || result;
|
|
this.memoryCacheKey = currentCacheKey;
|
|
}
|
|
|
|
const queryResult = await this.embeddingsGenerator.embed(
|
|
message.toLowerCase()
|
|
);
|
|
let queryEmbedding = queryResult.output || queryResult;
|
|
|
|
if (Array.isArray(queryEmbedding) && queryEmbedding.length === 1) {
|
|
queryEmbedding = queryEmbedding[0];
|
|
}
|
|
|
|
// Calculate cosine similarity
|
|
const similarities = this.memoryEmbeddingsCache.map((memEmb, idx) => ({
|
|
...memories[idx],
|
|
similarity: cosSim(queryEmbedding, memEmb),
|
|
}));
|
|
|
|
// Filter by threshold, sort by similarity, and return top K
|
|
return similarities
|
|
.filter(m => m.similarity >= similarityThreshold)
|
|
.sort((a, b) => b.similarity - a.similarity)
|
|
.slice(0, topK);
|
|
},
|
|
|
|
/**
|
|
* Expands a day list like "0-2,4" into a deduped array of day indices, dropping
|
|
* any out of the [0, MEMORY_FRECENCY_MAX_DAYS) window. An empty list yields an empty array.
|
|
* Used for the recent_accessed_counts attribute.
|
|
*
|
|
* @param {string} dayList // String list of day indices (i.e. [1], [1,2], [1-3,5])
|
|
* @returns {number[]} // Parsed integer list of indicies
|
|
*/
|
|
parseAggregateDays(dayList) {
|
|
const days = new Set();
|
|
for (const token of dayList.split(",")) {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const range = trimmed.match(/^(\d+)-(\d+)$/);
|
|
if (range) {
|
|
for (let day = Number(range[1]); day <= Number(range[2]); day++) {
|
|
days.add(day);
|
|
}
|
|
} else {
|
|
days.add(Number(trimmed));
|
|
}
|
|
}
|
|
return [...days].filter(day => day >= 0 && day < MEMORY_FRECENCY_MAX_DAYS);
|
|
},
|
|
|
|
/**
|
|
* Resolves a filter field against a memory.
|
|
* Plain fields return `memory[field]` directly. Aggregate fields (i.e. recent_accessed_counts)
|
|
* sum the requested components (i.e. days) of the referenced count map into a single number
|
|
* to filter against.
|
|
*
|
|
* @param {object} memory // Memory object from which to retrieve field value
|
|
* @param {string} field // Memory attribute
|
|
* @returns {*} // Resolved memory attribute value
|
|
*/
|
|
resolveFilterField(memory, field) {
|
|
// Check if the field needs to be aggregated
|
|
const match = field.match(AGGREGATE_FIELD_REGEX);
|
|
|
|
// Return directly if not
|
|
if (!match) {
|
|
return memory[field];
|
|
}
|
|
|
|
// Otherwise, aggregate the field's value and return
|
|
const [, mapField, dayList] = match;
|
|
const map = memory[mapField] ?? {};
|
|
return this.parseAggregateDays(dayList).reduce(
|
|
(sum, day) => sum + (map[day] ?? 0),
|
|
0
|
|
);
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Set<string>} [options.memoryIds=new Set()]
|
|
* Optional set of memory IDs; will return all if set is empty
|
|
* @param {Array<object>} [options.attributeFilters]
|
|
* Optional set of filter objects for filtering on specific memory attributes, each containing:
|
|
* 1. field: The memory field (attribute) on which the filter should be applied.
|
|
* A field may also address into a day-keyed count map and sum a window
|
|
* of days, e.g. "recent_accessed_counts.[0]" (today) or
|
|
* "recent_accessed_counts.[0-2,4]" (days 0, 1, 2 and 4 summed).
|
|
* 2. value: The value to filter against
|
|
* 3. comparator: The comparator to filter field against value.
|
|
* 4. options: Optional comparator-specific options. For the LIKE comparator, these are
|
|
* forwarded to getRelevantMemories: `{ topK, similarityThreshold }`. Either may be
|
|
* omitted to fall back to the getRelevantMemories defaults.
|
|
*
|
|
*
|
|
* Valid comparators are:
|
|
*
|
|
* ** Arrays against individual values **
|
|
* - INCLUDES: Memory field is an array and includes the filter value
|
|
* - IN: Filter value is an array and includes the memory field
|
|
*
|
|
* ** Individual values against individual values **
|
|
* - GREATER_THAN: Memory field is greater than the filter value
|
|
* - LESS_THAN: Memory field is less than the filter value
|
|
* - GREATER_THAN_OR_EQUAL_TO: Memory field is greater than or equal to the filter value
|
|
* - LESS_THAN_OR_EQUAL_TO: Memory field is less than or equal to the filter value
|
|
* - EQUAL_TO: Memory field is exactly the same as the filter value
|
|
* - LIKE: Memory field is semantically similar to the filter value (only memory_summary supported)
|
|
*
|
|
* ** Arrays against arrays **
|
|
* - SOME: Both the memory field and value are **arrays** and the memory field array includes some of the filter value array
|
|
* - ALL: Both the memory field and value are **arrays** and the memory field array is the same as the filter value array
|
|
* @returns {Promise<Memory[]>}
|
|
*/
|
|
async getMemories({
|
|
sortBy = "updated_at",
|
|
sortDir = "desc",
|
|
includeSoftDeleted = false,
|
|
memoryIds = new Set(),
|
|
attributeFilters = [],
|
|
} = {}) {
|
|
await this.ensureInitialized();
|
|
|
|
let res = gState.memories;
|
|
|
|
// Filter soft deletion first
|
|
if (!includeSoftDeleted) {
|
|
res = res.filter(i => !i.is_deleted);
|
|
}
|
|
|
|
// Then by specific memory IDs
|
|
if (memoryIds.size) {
|
|
res = res.filter(i => memoryIds.has(i.id));
|
|
}
|
|
|
|
// Next, filter by attribute filters
|
|
for (const { field, comparator, value, options } of attributeFilters) {
|
|
// Validate filters first
|
|
// Invalid comparator
|
|
if (!Object.values(MEMORY_FILTER_COMPARATOR).includes(comparator)) {
|
|
lazy.console.error(`Invalid filtering comparator: "${comparator}"`);
|
|
return [];
|
|
}
|
|
const validators = MEMORY_FILTER_VALIDATORS[comparator];
|
|
// Invalid filter field
|
|
if (!validators.field(field)) {
|
|
lazy.console.error(
|
|
`Invalid field for comparator "${comparator}": "${field}"`
|
|
);
|
|
return [];
|
|
}
|
|
// Invalid filter value
|
|
if (!validators.value(value)) {
|
|
lazy.console.error(
|
|
`Invalid value for comparator "${comparator}": "${value}"`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
// Apply filters
|
|
// Special case for semantic filters
|
|
if (comparator === MEMORY_FILTER_COMPARATOR.LIKE) {
|
|
const { topK, similarityThreshold } = options ?? {};
|
|
const semanticMatches = new Set(
|
|
(
|
|
await this.getRelevantMemories(value, topK, similarityThreshold)
|
|
).map(mem => mem[field])
|
|
);
|
|
res = res.filter(memory => semanticMatches.has(memory[field]));
|
|
continue;
|
|
}
|
|
// Other non-semantic filters
|
|
const predicate = MEMORY_FILTER_PREDICATES[comparator];
|
|
if (predicate) {
|
|
res = res.filter(memory =>
|
|
predicate(this.resolveFilterField(memory, field), value)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Sort the results
|
|
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<object>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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)],
|
|
["last_session_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();
|
|
updateMemoriesLastUpdatedMetric();
|
|
},
|
|
};
|
|
|
|
function updateMemoriesCountMetric() {
|
|
let historyCount = 0;
|
|
let conversationCount = 0;
|
|
let sessionCount = 0;
|
|
|
|
for (const memory of gState.memories) {
|
|
if (memory.is_deleted) {
|
|
continue;
|
|
}
|
|
if (memory.sources?.includes(CONVERSATION)) {
|
|
conversationCount++;
|
|
} else if (memory.sources?.includes(HISTORY)) {
|
|
historyCount++;
|
|
} else if (memory.sources?.includes(SESSION)) {
|
|
sessionCount++;
|
|
}
|
|
}
|
|
|
|
Glean.smartWindow.memoriesCount.history.set(historyCount);
|
|
Glean.smartWindow.memoriesCount.conversation.set(conversationCount);
|
|
Glean.smartWindow.memoriesCount.session.set(sessionCount);
|
|
|
|
updateMemoriesLastUpdatedMetric();
|
|
}
|
|
|
|
function updateMemoriesLastUpdatedMetric() {
|
|
const lastUpdated =
|
|
Math.max(
|
|
gState.meta.last_history_memory_ts || 0,
|
|
gState.meta.last_chat_memory_ts || 0,
|
|
gState.meta.last_session_memory_ts || 0
|
|
) || Date.now();
|
|
if (!lastUpdated || lastUpdated <= 0) {
|
|
return;
|
|
}
|
|
Glean.smartWindow.memoriesLastUpdated.set(new Date(lastUpdated));
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export function makeMemoryId(memoryPartial) {
|
|
if (memoryPartial.id) {
|
|
return memoryPartial.id;
|
|
}
|
|
|
|
const hex = hashStringToHex(
|
|
(memoryPartial.memory_summary || "").trim().toLowerCase()
|
|
);
|
|
return `mem.${hex}`;
|
|
}
|