tor-browser

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

commit d4071f36c4f14f3563fdf5b7a82cee035faa4805
parent b3e388efc9e652f2919e27295196afd977b80392
Author: Chidam Gopal <cgopal@mozilla.com>
Date:   Thu,  4 Dec 2025 22:20:50 +0000

Bug 2002906 - Add insights storage r=mak,ai-ondevice-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D274370

Diffstat:
Mbrowser/base/content/test/static/browser_all_files_referenced.js | 4++++
Abrowser/components/aiwindow/services/InsightStore.sys.mjs | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/services/moz.build | 6++++++
Abrowser/components/aiwindow/services/tests/xpcshell/head.js | 4++++
Abrowser/components/aiwindow/services/tests/xpcshell/test_InsightStore.js | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/services/tests/xpcshell/xpcshell.toml | 7+++++++
6 files changed, 650 insertions(+), 0 deletions(-)

diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js @@ -350,6 +350,10 @@ var allowlist = [ { file: "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs", }, + // Bug 2002906 - Add insights storage + { + file: "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs", + }, ]; if (AppConstants.NIGHTLY_BUILD) { diff --git a/browser/components/aiwindow/services/InsightStore.sys.mjs b/browser/components/aiwindow/services/InsightStore.sys.mjs @@ -0,0 +1,382 @@ +/* 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 Insight store + */ + +import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; + +/** + * InsightStore + * + * In-memory JSON state + persisted JSON file, modeled after SessionStore. + * + * File format (on disk): + * { + * "insights": [ { ... } ], + * "meta": { + * "last_history_insight_ts": 0, + * "last_chat_insight_ts": 0, + * }, + * "version": 1 + * } + */ + +const INSIGHT_STORE_FILE = "insights.json.lz4"; +const INSIGHT_STORE_VERSION = 1; + +// In-memory state +let gState = { + insights: [], + meta: { + last_history_insight_ts: 0, + last_chat_insight_ts: 0, + }, + version: INSIGHT_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, INSIGHT_STORE_FILE); +}); + +/** + * Internal helper to load (and possibly migrate) insight data from disk. + * + * @returns {Promise<void>} + */ +async function loadInsights() { + gJSONFile = new JSONFile({ + path: lazy.gStorePath, + saveDelayMs: 1000, + compression: "lz4", + sanitizedBasename: "insights", + }); + + try { + await gJSONFile.load(); + } catch (ex) { + console.error("InsightStore: 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 = { + insights: Array.isArray(data.insights) ? data.insights : [], + meta: { + last_history_insight_ts: data.meta?.last_history_insight_ts || 0, + last_chat_insight_ts: data.meta?.last_chat_insight_ts || 0, + }, + version: + typeof data.version === "number" ? data.version : INSIGHT_STORE_VERSION, + }; + // Ensure JSONFile.data points at our normalized state object. + gJSONFile.data = gState; + } + + gInitialized = true; +} + +// Public API object +export const InsightStore = { + /** + * Initialize the store: set up JSONFile and load from disk. + * + * @returns {Promise<void>} + */ + async ensureInitialized() { + if (gInitialized) { + return; + } + + if (!gInitPromise) { + gInitPromise = loadInsights(); + } + + 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} Insight + * @property {string} id - Unique identifier for the insight. + * @property {string} insight_summary - Short human-readable summary of the insight. + * @property {string} category - Category label for the insight. + * @property {string} intent - Intent label associated with the insight. + * @property {number} score - Numeric score representing the insight's relevance. + * @property {number} updated_at - Last-updated time in milliseconds since Unix epoch. + * @property {boolean} is_deleted - Whether the insight is marked as deleted. + */ + /** + * @typedef {object} InsightPartial + * @property {string} [id] Optional identifier; if omitted, one is derived by makeInsightId. + * @property {string} [insight_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 insight, or update an existing one with the same id. + * + * Any missing fields on {@link InsightPartial} are defaulted. + * + * @param {InsightPartial} insightPartial + * @returns {Promise<Insight>} + */ + async addInsight(insightPartial) { + await this.ensureInitialized(); + + const now = Date.now(); + const id = makeInsightId(insightPartial); + + let insight = gState.insights.find(i => i.id === id); + + if (insight) { + const simpleProperties = ["insight_summary", "category", "intent"]; + for (const prop of simpleProperties) { + if (prop in insightPartial) { + insight[prop] = insightPartial[prop]; + } + } + + const validatedProperties = [ + ["score", v => Number.isFinite(v)], + ["is_deleted", v => typeof v === "boolean"], + ]; + + for (const [prop, validator] of validatedProperties) { + if (prop in insightPartial && validator(insightPartial[prop])) { + insight[prop] = insightPartial[prop]; + } + } + + insight.updated_at = insightPartial.updated_at || now; + + gJSONFile?.saveSoon(); + return insight; + } + + // Otherwise create a new one + insight = { + id, + insight_summary: insightPartial.insight_summary || "", + category: insightPartial.category || "", + intent: insightPartial.intent || "", + score: Number.isFinite(insightPartial.score) ? insightPartial.score : 0, + updated_at: insightPartial.updated_at || now, + is_deleted: insightPartial.is_deleted ?? false, + }; + + gState.insights.push(insight); + gJSONFile?.saveSoon(); + return insight; + }, + + /** + * Update an existing insight by id. + * + * @param {string} id + * @param {object} updates + * @returns {Promise<Insight|null>} + */ + async updateInsight(id, updates) { + await this.ensureInitialized(); + + const insight = gState.insights.find(i => i.id === id); + if (!insight) { + return null; + } + + const simpleProperties = ["insight_summary", "category", "intent"]; + for (const prop of simpleProperties) { + if (prop in updates) { + insight[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])) { + insight[prop] = updates[prop]; + } + } + + insight.updated_at = updates.updated_at || Date.now(); + + gJSONFile?.saveSoon(); + return insight; + }, + + /** + * Soft delete an insight (set is_deleted = true). + * + * soft deleted insights will be filtered from getInsights + * + * @param {string} id + * @returns {Promise<Insight|null>} + */ + async softDeleteInsight(id) { + return this.updateInsight(id, { is_deleted: true }); + }, + + /** + * hard delete (remove from array). + * + * @param {string} id + * @returns {Promise<boolean>} + */ + async hardDeleteInsight(id) { + await this.ensureInitialized(); + const idx = gState.insights.findIndex(i => i.id === id); + if (idx === -1) { + return false; + } + gState.insights.splice(idx, 1); + gJSONFile?.saveSoon(); + return true; + }, + + /** + * Get all insights (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. + * @returns {Promise<Insight[]>} + */ + async getInsights({ sortBy = "updated_at", sortDir = "desc" } = {}) { + await this.ensureInitialized(); + + let res = gState.insights; + 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<object>} + */ + async getMeta() { + await this.ensureInitialized(); + return structuredClone(gState.meta); + }, + + /** + * Update meta information (last timestamps, top_* info, etc). + * + * Example payload: + * { + * last_history_insight_ts: 12345, + * } + * + * @param {object} partialMeta + * @returns {Promise<void>} + */ + async updateMeta(partialMeta) { + await this.ensureInitialized(); + const meta = gState.meta; + const validatedProps = [ + ["last_history_insight_ts", v => Number.isFinite(v)], + ["last_chat_insight_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 insight id from its core fields. + * If the caller passes an explicit id, we honor that instead. + * + * @param {object} insightPartial + */ +function makeInsightId(insightPartial) { + if (insightPartial.id) { + return insightPartial.id; + } + + const summary = (insightPartial.insight_summary || "").trim().toLowerCase(); + const category = (insightPartial.category || "").trim().toLowerCase(); + const intent = (insightPartial.intent || "").trim().toLowerCase(); + + const key = `${summary}||${category}||${intent}`; + const hex = hashStringToHex(key); + + return `ins-${hex}`; +} diff --git a/browser/components/aiwindow/services/moz.build b/browser/components/aiwindow/services/moz.build @@ -4,3 +4,9 @@ with Files("**"): BUG_COMPONENT = ("Core", "Machine Learning: On Device") + +MOZ_SRC_FILES += ["InsightStore.sys.mjs"] + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/xpcshell/xpcshell.toml", +] diff --git a/browser/components/aiwindow/services/tests/xpcshell/head.js b/browser/components/aiwindow/services/tests/xpcshell/head.js @@ -0,0 +1,4 @@ +"use strict"; + +// Ensure a profile directory exists; InsightStore uses the profile dir for its file. +do_get_profile(); diff --git a/browser/components/aiwindow/services/tests/xpcshell/test_InsightStore.js b/browser/components/aiwindow/services/tests/xpcshell/test_InsightStore.js @@ -0,0 +1,247 @@ +/* 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/. */ + +"use strict"; + +const { InsightStore } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs" +); + +add_task(async function test_init_empty_state() { + // First init should succeed and not throw. + await InsightStore.ensureInitialized(); + + let insights = await InsightStore.getInsights(); + equal(insights.length, 0, "New store should start with no insights"); + + const meta = await InsightStore.getMeta(); + equal( + meta.last_history_insight_ts, + 0, + "Default last_history_insight_ts should be 0" + ); + equal( + meta.last_chat_insight_ts, + 0, + "Default last_chat_insight_ts should be 0" + ); +}); + +add_task(async function test_addInsight() { + await InsightStore.ensureInitialized(); + + const insight1 = await InsightStore.addInsight({ + insight_summary: "i love driking coffee", + category: "Food & Drink", + intent: "Plan / Organize", + score: 3, + }); + + equal( + insight1.insight_summary, + "i love driking coffee", + "insight summary should match input" + ); + equal(insight1.category, "Food & Drink", "Category should match input"); + equal(insight1.intent, "Plan / Organize", "Intent should match with input"); + equal(insight1.score, 3, "Score should match input"); + await InsightStore.hardDeleteInsight(insight1.id); +}); + +add_task(async function test_addInsight_and_upsert_by_content() { + await InsightStore.ensureInitialized(); + + const insight1 = await InsightStore.addInsight({ + insight_summary: "trip plans to Italy", + category: "Travel & Transportation", + intent: "Plan / Organize", + score: 3, + }); + + ok(insight1.id, "Insight should have an id"); + equal( + insight1.insight_summary, + "trip plans to Italy", + "Insight summary should be stored" + ); + + // Add another insight with same (summary, category, intent) – should upsert, not duplicate. + const insight2 = await InsightStore.addInsight({ + insight_summary: "trip plans to Italy", + category: "Travel & Transportation", + intent: "Plan / Organize", + score: 5, + }); + + equal( + insight1.id, + insight2.id, + "Same (summary, category, intent) should produce same deterministic id" + ); + equal( + insight2.score, + 5, + "Second addInsight call for same id should update score" + ); + + const insights = await InsightStore.getInsights(); + equal(insights.length, 1, "Store should still have only one insight"); + await InsightStore.hardDeleteInsight(insight1.id); +}); + +add_task(async function test_addInsight_different_intent_produces_new_id() { + await InsightStore.ensureInitialized(); + + const a = await InsightStore.addInsight({ + insight_summary: "trip plans to Italy", + category: "Travel & Transportation", + intent: "trip_planning", + score: 3, + }); + + const b = await InsightStore.addInsight({ + insight_summary: "trip plans to Italy", + category: "Travel & Transportation", + intent: "travel_budgeting", + score: 4, + }); + + notEqual(a.id, b.id, "Different intent should yield different ids"); + + const insights = await InsightStore.getInsights(); + equal( + insights.length == 2, + true, + "Store should contain at least two insights now" + ); +}); + +add_task(async function test_updateInsight_and_soft_delete() { + await InsightStore.ensureInitialized(); + + const insight = await InsightStore.addInsight({ + insight_summary: "debug insight", + category: "debug", + intent: "Monitor / Track", + score: 1, + }); + + const updated = await InsightStore.updateInsight(insight.id, { + score: 4, + }); + equal(updated.score, 4, "updateInsight should update fields"); + + const deleted = await InsightStore.softDeleteInsight(insight.id); + + ok(deleted, "softDeleteInsight should return the updated insight"); + equal( + deleted.is_deleted, + true, + "Soft-deleted insight should have is_deleted = true" + ); + + const nonDeleted = await InsightStore.getInsights(); + const notFound = nonDeleted.find(i => i.id === insight.id); + equal( + notFound, + undefined, + "Soft-deleted insight should be filtered out by getInsights()" + ); +}); + +add_task(async function test_hard_delete() { + await InsightStore.ensureInitialized(); + + const insight = await InsightStore.addInsight({ + insight_summary: "to be hard deleted", + category: "debug", + intent: "Monitor / Track", + score: 2, + }); + + let insights = await InsightStore.getInsights(); + const beforeCount = insights.length; + + const removed = await InsightStore.hardDeleteInsight(insight.id); + equal( + removed, + true, + "hardDeleteInsight should return true when removing existing insight" + ); + + insights = await InsightStore.getInsights(); + const afterCount = insights.length; + + equal( + beforeCount - 1, + afterCount, + "hardDeleteInsight should physically remove entry from array" + ); +}); + +add_task(async function test_updateMeta_and_persistence_roundtrip() { + await InsightStore.ensureInitialized(); + + const now = Date.now(); + + await InsightStore.updateMeta({ + last_history_insight_ts: now, + }); + + let meta = await InsightStore.getMeta(); + equal( + meta.last_history_insight_ts, + now, + "updateMeta should update last_history_insight_ts" + ); + equal( + meta.last_chat_insight_ts, + 0, + "updateMeta should not touch last_chat_insight_ts when not provided" + ); + + const chatTime = now + 1000; + await InsightStore.updateMeta({ + last_chat_insight_ts: chatTime, + }); + + meta = await InsightStore.getMeta(); + equal( + meta.last_history_insight_ts, + now, + "last_history_insight_ts should remain unchanged when only chat ts updated" + ); + equal( + meta.last_chat_insight_ts, + chatTime, + "last_chat_insight_ts should be updated" + ); + + // Force a write to disk. + await InsightStore.testOnlyFlush(); + + // Simulate a fresh import by reloading module. + // This uses the xpcshell helper to bypass module caching. + const { InsightStore: FreshStore } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs", + { ignoreCache: true } + ); + + await FreshStore.ensureInitialized(); + + const meta2 = await FreshStore.getMeta(); + equal( + meta2.last_history_insight_ts, + now, + "last_history_insight_ts should survive roundtrip to disk" + ); + equal( + meta2.last_chat_insight_ts, + chatTime, + "last_chat_insight_ts should survive roundtrip to disk" + ); + + const insights = await FreshStore.getInsights(); + ok(Array.isArray(insights), "Insights should be an array after reload"); +}); diff --git a/browser/components/aiwindow/services/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/services/tests/xpcshell/xpcshell.toml @@ -0,0 +1,7 @@ +[DEFAULT] +run-if = ["os != 'android'"] +head = "head.js" +firefox-appdir = "browser" +support-files = [] + +["test_InsightStore.js"]