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:
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"]