tor-browser

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

MemoriesHistorySource.sys.mjs (22823B)


      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 * This module handles the visit extraction data from browsing history
      7 */
      8 
      9 import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
     10 
     11 const MS_PER_DAY = 86_400_000;
     12 const MICROS_PER_MS = 1_000;
     13 const MS_PER_SEC = 1_000;
     14 const MICROS_PER_SEC = 1_000_000;
     15 const SECONDS_PER_DAY = 86_400;
     16 
     17 // History fetch defaults
     18 const DEFAULT_DAYS = 60;
     19 const DEFAULT_MAX_RESULTS = 3000;
     20 
     21 // Sessionization defaults
     22 const DEFAULT_GAP_SEC = 900;
     23 const DEFAULT_MAX_SESSION_SEC = 7200;
     24 
     25 // Recency defaults
     26 const DEFAULT_HALFLIFE_DAYS = 14;
     27 const DEFAULT_RECENCY_FLOOR = 0.5;
     28 const DEFAULT_SESSION_WEIGHT = 1.0;
     29 
     30 const SEARCH_ENGINE_DOMAINS = [
     31  "google",
     32  "bing",
     33  "duckduckgo",
     34  "search.brave",
     35  "yahoo",
     36  "startpage",
     37  "ecosia",
     38  "baidu",
     39  "yandex",
     40 ];
     41 
     42 function escapeRe(s) {
     43  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
     44 }
     45 
     46 const SEARCH_ENGINE_PATTERN = new RegExp(
     47  `(^|\\.)(${SEARCH_ENGINE_DOMAINS.map(escapeRe).join("|")})\\.`,
     48  "i"
     49 );
     50 
     51 /**
     52 * Fetch recent browsing history from Places (SQL), aggregate by URL,
     53 * tag "search" vs "history", and attach simple frequency percentiles.
     54 *
     55 * This API is designed to support both:
     56 *   - Initial ("Day 0") backfills over a fixed time window, and
     57 *   - Incremental reads using a visit_date watermark (`sinceMicros`).
     58 *
     59 * Callers can either:
     60 *   1. Pass `sinceMicros` (microseconds since epoch, Places visit_date-style)
     61 *      to fetch visits with `visit_date >= sinceMicros`, or
     62 *   2. Omit `sinceMicros` and let `days` define a relative cutoff window
     63 *      from "now" (e.g., last 60 days).
     64 *
     65 * Typical usage:
     66 *   - Day 0:   getRecentHistory({ sinceMicros: 0, maxResults: 3000 })
     67 *              // or: getRecentHistory({ days: 60, maxResults: 3000 })
     68 *   - Incremental:
     69 *        const rows = await getRecentHistory({ sinceMicros: lastWatermark });
     70 *        const nextWatermark = Math.max(...rows.map(r => r.visitDateMicros));
     71 *
     72 * NOTE: `visitDateMicros` in the returned objects is the raw Places
     73 *       visit_date (microseconds since epoch, UTC).
     74 *
     75 * @param {object} [opts]
     76 * @param {number} [opts.sinceMicros=null]
     77 *        Optional absolute cutoff in microseconds since epoch (Places
     78 *        visit_date). If provided, this is used directly as the cutoff:
     79 *        only visits with `visit_date >= sinceMicros` are returned.
     80 *
     81 *        This is the recommended way to implement incremental reads:
     82 *        store the max `visitDateMicros` from the previous run and pass
     83 *        it (or max + 1) back in as `sinceMicros`.
     84 *
     85 * @param {number} [opts.days=DEFAULT_DAYS]
     86 *        How far back to look if `sinceMicros` is not provided.
     87 *        The cutoff is computed as:
     88 *          cutoff = now() - days * MS_PER_DAY
     89 *
     90 *        Ignored when `sinceMicros` is non-null.
     91 *
     92 * @param {number} [opts.maxResults=DEFAULT_MAX_RESULTS]
     93 *        Maximum number of rows to return from the SQL query (after
     94 *        sorting by most recent visit). Note that this caps the number
     95 *        of visits, not distinct URLs.
     96 *
     97 * @returns {Promise<Array<{
     98 *   url: string,
     99 *   title: string,
    100 *   domain: string,
    101 *   visitDateMicros: number,
    102 *   frequencyPct: number,
    103 *   domainFrequencyPct: number,
    104 *   source: 'history'|'search'
    105 * }>>}
    106 */
    107 export async function getRecentHistory(opts = {}) {
    108  // If provided, this is a Places visit_date-style cutoff in microseconds
    109  // When non-null, `days` is ignored and we use `sinceMicros` directly.
    110  const {
    111    sinceMicros = null,
    112    days = DEFAULT_DAYS,
    113    maxResults = DEFAULT_MAX_RESULTS,
    114  } = opts;
    115 
    116  // Places stores visit_date in microseconds since epoch.
    117  let cutoffMicros;
    118  if (sinceMicros != null) {
    119    cutoffMicros = Math.max(0, sinceMicros);
    120  } else {
    121    cutoffMicros = Math.max(
    122      0,
    123      (Date.now() - days * MS_PER_DAY) * MICROS_PER_MS
    124    );
    125  }
    126 
    127  const isSearchVisit = urlStr => {
    128    try {
    129      const { hostname, pathname, search } = new URL(urlStr);
    130      const isSearchEngine = SEARCH_ENGINE_PATTERN.test(hostname);
    131      const looksLikeSearch =
    132        /search|results|query/i.test(pathname) ||
    133        /[?&](q|query|p)=/i.test(search);
    134      return isSearchEngine && looksLikeSearch;
    135    } catch (e) {
    136      console.error("isSearchVisit: failed to parse URL", {
    137        error: String(e),
    138        urlLength: typeof urlStr === "string" ? urlStr.length : -1,
    139      });
    140      return false;
    141    }
    142  };
    143 
    144  const SQL = `
    145    WITH visit_info AS (
    146      SELECT
    147        p.id                     AS place_id,
    148        p.url                    AS url,
    149        o.host                   AS host,
    150        p.title                  AS title,
    151        v.visit_date             AS visit_date,
    152        p.frecency               AS frecency,
    153        CASE WHEN o.frecency = -1 THEN 1 ELSE o.frecency END AS domain_frecency
    154      FROM moz_places p
    155      JOIN moz_historyvisits v ON v.place_id = p.id
    156      JOIN moz_origins o       ON p.origin_id = o.id
    157      WHERE v.visit_date >= :cutoff
    158        AND p.title IS NOT NULL
    159        AND p.frecency IS NOT NULL
    160      ORDER BY v.visit_date DESC
    161      LIMIT :limit
    162    ),
    163 
    164    /* Collapse to one row per place to compute percentiles (like your groupby/place_id mean) */
    165    per_place AS (
    166      SELECT
    167        place_id,
    168        MAX(frecency)         AS frecency,
    169        MAX(domain_frecency)  AS domain_frecency
    170      FROM visit_info
    171      GROUP BY place_id
    172    ),
    173 
    174    /* Percentiles using window function CUME_DIST() */
    175    per_place_with_pct AS (
    176      SELECT
    177        place_id,
    178        ROUND(100.0 * CUME_DIST() OVER (ORDER BY frecency), 2) AS frecency_pct,
    179        ROUND(100.0 * CUME_DIST() OVER (ORDER BY domain_frecency), 2) AS domain_frecency_pct
    180      FROM per_place
    181    )
    182 
    183    /* Final rows: original visits + joined percentiles + source label */
    184    SELECT
    185      v.url,
    186      v.host,
    187      v.title,
    188      v.visit_date,
    189      p.frecency_pct,
    190      p.domain_frecency_pct
    191    FROM visit_info v
    192    JOIN per_place_with_pct p USING (place_id)
    193    ORDER BY v.visit_date DESC
    194  `;
    195 
    196  try {
    197    const rows = await PlacesUtils.withConnectionWrapper(
    198      "smartwindow-getRecentHistory",
    199      async db => {
    200        const stmt = await db.execute(SQL, {
    201          cutoff: cutoffMicros,
    202          limit: maxResults,
    203        });
    204 
    205        const out = [];
    206        for (const row of stmt) {
    207          const url = row.getResultByName("url");
    208          const host = row.getResultByName("host");
    209          const title = row.getResultByName("title") || "";
    210          const visitDateMicros = row.getResultByName("visit_date") || 0;
    211          const frequencyPct = row.getResultByName("frecency_pct") || 0;
    212          const domainFrequencyPct =
    213            row.getResultByName("domain_frecency_pct") || 0;
    214 
    215          out.push({
    216            url,
    217            domain: host,
    218            title,
    219            visitDateMicros,
    220            frequencyPct,
    221            domainFrequencyPct,
    222            source: isSearchVisit(url) ? "search" : "history",
    223          });
    224        }
    225        return out;
    226      }
    227    );
    228    return rows;
    229  } catch (error) {
    230    console.error("Failed to fetch Places history via SQL:", error);
    231    return [];
    232  }
    233 }
    234 
    235 /**
    236 * Sessionize visits using a gap and max session length.
    237 * Returns a new array sorted by ascending time and adds:
    238 *  - session_id
    239 *  - session_start_ms
    240 *  - session_start_iso
    241 *
    242 * @param {Array<{visitDateMicros:number,title?:string,domain?:string,frequencyPct?:number,domainFrequencyPct?:number,source?:'history'|'search'}>} rows
    243 * @param {object} [opts]
    244 * @param {number} [opts.gapSec=900]        Max allowed gap between consecutive visits in a session (seconds)
    245 * @param {number} [opts.maxSessionSec=7200] Max session duration from first to current visit (seconds)
    246 * @returns {Array}
    247 */
    248 export function sessionizeVisits(rows, opts = {}) {
    249  const GAP_MS = (opts.gapSec ?? DEFAULT_GAP_SEC) * MS_PER_SEC;
    250  const MAX_SESSION_MS =
    251    (opts.maxSessionSec ?? DEFAULT_MAX_SESSION_SEC) * MS_PER_SEC;
    252 
    253  // Normalize and keep only visits with a valid timestamp
    254  const normalized = rows
    255    // Keep only rows with a valid timestamp
    256    .filter(row => Number.isFinite(row.visitDateMicros))
    257    .map(row => ({
    258      ...row,
    259      visitTimeMs: Math.floor(row.visitDateMicros / MICROS_PER_MS),
    260    }))
    261    .sort((a, b) => a.visitTimeMs - b.visitTimeMs);
    262 
    263  let curStartMs = null;
    264  let prevMs = null;
    265 
    266  for (const row of normalized) {
    267    const timeMs = row.visitTimeMs;
    268 
    269    const startNew =
    270      prevMs === null ||
    271      timeMs - prevMs > GAP_MS ||
    272      timeMs - curStartMs > MAX_SESSION_MS;
    273 
    274    if (startNew) {
    275      curStartMs = timeMs;
    276    }
    277 
    278    row.session_start_ms = curStartMs;
    279    row.session_start_iso = new Date(curStartMs).toISOString();
    280    row.session_id = curStartMs;
    281 
    282    prevMs = timeMs;
    283  }
    284 
    285  return normalized;
    286 }
    287 
    288 /**
    289 * Build per-session feature records from sessionized rows.
    290 *
    291 * Output record shape:
    292 * {
    293 *   session_id: number,
    294 *   title_scores: { [title: string]: number },
    295 *   domain_scores: { [domain: string]: number },
    296 *   session_start_time: number | null, // epoch seconds
    297 *   session_end_time: number | null,   // epoch seconds
    298 *   search_events: {
    299 *     session_id: number,
    300 *     search_count: number,
    301 *     search_titles: string[],
    302 *     last_searched: number,           // epoch micros
    303 *   } | {}
    304 * }
    305 *
    306 * @param {Array} rows  sessionized visits
    307 * @returns {Array}
    308 */
    309 export function generateProfileInputs(rows) {
    310  const bySession = new Map();
    311  for (const row of rows) {
    312    const sessionId = row.session_id;
    313    if (!bySession.has(sessionId)) {
    314      bySession.set(sessionId, []);
    315    }
    316    bySession.get(sessionId).push(row);
    317  }
    318 
    319  // session_id -> { title: frecency_pct }
    320  const titleScoresBySession = {};
    321  for (const [sessionId, items] of bySession) {
    322    const m = {};
    323    for (const r of items) {
    324      const title = r.title ?? "";
    325      const pct = r.frequencyPct;
    326      if (title && isFiniteNumber(pct)) {
    327        m[title] = pct;
    328      }
    329    }
    330    if (Object.keys(m).length) {
    331      titleScoresBySession[sessionId] = m;
    332    }
    333  }
    334 
    335  // session_id -> { domain: domain_frecency_pct }
    336  const domainScoresBySession = {};
    337  for (const [sessionId, items] of bySession) {
    338    const m = {};
    339    for (const r of items) {
    340      const domain = r.domain ?? r.host ?? "";
    341      const pct = r.domainFrequencyPct;
    342      if (domain && isFiniteNumber(pct)) {
    343        m[domain] = pct;
    344      }
    345    }
    346    if (Object.keys(m).length) {
    347      domainScoresBySession[sessionId] = m;
    348    }
    349  }
    350 
    351  // session_id -> { search_count, search_titles (unique), last_searched }
    352  const searchSummaryBySession = {};
    353  for (const [sessionId, items] of bySession) {
    354    const searchItems = items.filter(r => r.source === "search");
    355    if (!searchItems.length) {
    356      continue;
    357    }
    358    const search_titles = [
    359      ...new Set(searchItems.map(r => r.title).filter(Boolean)),
    360    ];
    361    const last_searched_raw = Math.max(
    362      ...searchItems.map(r => Number(r.visitDateMicros) || 0)
    363    );
    364    searchSummaryBySession[sessionId] = {
    365      session_id: sessionId,
    366      search_count: searchItems.length,
    367      search_titles,
    368      last_searched: last_searched_raw,
    369    };
    370  }
    371 
    372  // session start/end times
    373  const sessionTimes = { start_time: {}, end_time: {} };
    374  for (const [sessionId, items] of bySession) {
    375    const tsList = items
    376      .filter(Number.isFinite)
    377      .map(r => Number(r.visitDateMicros));
    378    if (tsList.length) {
    379      sessionTimes.start_time[sessionId] = Math.min(...tsList);
    380      sessionTimes.end_time[sessionId] = Math.max(...tsList);
    381    } else {
    382      sessionTimes.start_time[sessionId] = null;
    383      sessionTimes.end_time[sessionId] = null;
    384    }
    385  }
    386 
    387  // final prepared inputs
    388  const preparedInputs = [];
    389  for (const sessionId of bySession.keys()) {
    390    const rawRecord = {
    391      session_id: sessionId,
    392      title_scores: titleScoresBySession[sessionId] || {},
    393      domain_scores: domainScoresBySession[sessionId] || {},
    394      session_start_time: normalizeEpochSeconds(
    395        sessionTimes.start_time[sessionId]
    396      ),
    397      session_end_time: normalizeEpochSeconds(sessionTimes.end_time[sessionId]),
    398      search_events: searchSummaryBySession[sessionId] || {},
    399    };
    400    const record = {};
    401    for (const [key, value] of Object.entries(rawRecord)) {
    402      if (value !== undefined) {
    403        record[key] = value;
    404      }
    405    }
    406    preparedInputs.push(record);
    407  }
    408  return preparedInputs;
    409 }
    410 
    411 /**
    412 * Aggregate over sessions into three dictionaries:
    413 *   - agg_domains: domain -> { score, last_seen, num_sessions, session_importance }
    414 *   - agg_titles:  title  -> { score, last_seen, num_sessions, session_importance }
    415 *   - agg_searches: session_id -> { search_count, search_titles[], last_searched(sec) }
    416 *
    417 * Notes:
    418 * - "last value wins" semantics for scores (matches your Python loop)
    419 * - session_importance ~ (#sessions total / #sessions item appears in), rounded 2dp
    420 *
    421 * @param {Array} preparedInputs
    422 * @returns {[Record<string, any>, Record<string, any>, Record<string, any>]}
    423 */
    424 export function aggregateSessions(preparedInputs) {
    425  // domain -> { score, last_seen, sessions:Set }
    426  const domainAgg = Object.create(null);
    427 
    428  // title -> { score, last_seen, sessions:Set }
    429  const titleAgg = Object.create(null);
    430 
    431  // sid -> { search_count, search_titles:Set, last_searched }
    432  const searchAgg = Object.create(null);
    433 
    434  const nowSec = Date.now() / 1000;
    435  const totalSessions = preparedInputs.length;
    436 
    437  for (const session of preparedInputs) {
    438    const sessionId = session.session_id;
    439    const startSec = session.session_start_time;
    440    const endSec = session.session_end_time;
    441    const lastSeenSec = endSec ?? startSec ?? nowSec;
    442 
    443    // domains
    444    const domainScores = session.domain_scores || {};
    445    for (const [domain, scoreVal] of Object.entries(domainScores)) {
    446      const rec = getOrInit(domainAgg, domain, () => ({
    447        score: 0.0,
    448        last_seen: 0,
    449        sessions: new Set(),
    450      }));
    451      rec.score = Number(scoreVal); // last value wins
    452      rec.last_seen = Math.max(rec.last_seen, lastSeenSec);
    453      rec.sessions.add(sessionId);
    454    }
    455 
    456    // titles
    457    const titleScores = session.title_scores || {};
    458    for (const [title, scoreVal] of Object.entries(titleScores)) {
    459      const rec = getOrInit(titleAgg, title, () => ({
    460        score: 0.0,
    461        last_seen: 0,
    462        sessions: new Set(),
    463      }));
    464      rec.score = Number(scoreVal); // last value wins
    465      rec.last_seen = Math.max(rec.last_seen, lastSeenSec);
    466      rec.sessions.add(sessionId);
    467    }
    468 
    469    // searches
    470    const searchEvents = session.search_events || {};
    471    const { search_count, search_titles, last_searched } = searchEvents;
    472 
    473    const hasSearchContent =
    474      (search_count && search_count > 0) ||
    475      (Array.isArray(search_titles) && search_titles.length) ||
    476      Number.isFinite(last_searched);
    477 
    478    if (hasSearchContent) {
    479      const rec = getOrInit(searchAgg, sessionId, () => ({
    480        search_count: 0,
    481        search_titles: new Set(),
    482        last_searched: 0.0,
    483      }));
    484      rec.search_count += Number(search_count || 0);
    485      for (const title of search_titles || []) {
    486        rec.search_titles.add(title);
    487      }
    488      rec.last_searched = Math.max(rec.last_searched, toSeconds(last_searched));
    489    }
    490  }
    491 
    492  for (const rec of Object.values(domainAgg)) {
    493    const n = rec.sessions.size;
    494    rec.num_sessions = n;
    495    rec.session_importance = n > 0 ? round2(totalSessions / n) : 0.0;
    496    delete rec.sessions;
    497  }
    498  for (const rec of Object.values(titleAgg)) {
    499    const n = rec.sessions.size;
    500    rec.num_sessions = n;
    501    rec.session_importance = n > 0 ? round2(totalSessions / n) : 0.0;
    502    delete rec.sessions;
    503  }
    504 
    505  for (const key of Object.keys(searchAgg)) {
    506    const rec = searchAgg[key];
    507    rec.search_titles = [...rec.search_titles];
    508  }
    509 
    510  return [domainAgg, titleAgg, searchAgg];
    511 }
    512 
    513 /**
    514 * Compute top-k domains, titles, and searches from aggregate structures.
    515 *
    516 * Input shapes:
    517 *   aggDomains: {
    518 *     [domain: string]: {
    519 *       score: number,
    520 *       last_seen: number,
    521 *       num_sessions: number,
    522 *       session_importance: number,
    523 *     }
    524 *   }
    525 *
    526 *   aggTitles: {
    527 *     [title: string]: {
    528 *       score: number,
    529 *       last_seen: number,
    530 *       num_sessions: number,
    531 *       session_importance: number,
    532 *     }
    533 *   }
    534 *
    535 *   aggSearches: {
    536 *     [sessionId: string|number]: {
    537 *       search_count: number,
    538 *       search_titles: string[],
    539 *       last_searched: number,
    540 *     }
    541 *   }
    542 *
    543 * Output shape:
    544 *   [
    545 *     [ [domain, rank], ... ],         // domains, length <= kDomains
    546 *     [ [title, rank], ... ],          // titles,  length <= kTitles
    547 *     [ { sid, cnt, q, ls, r }, ... ], // searches, length <= kSearches
    548 *   ]
    549 *
    550 * @param {{[domain: string]: any}} aggDomains
    551 * @param {{[title: string]: any}} aggTitles
    552 * @param {{[sessionId: string]: any}} aggSearches
    553 * @param {object} [options]
    554 * @param {number} [options.k_domains=30]
    555 * @param {number} [options.k_titles=60]
    556 * @param {number} [options.k_searches=10]
    557 * @param {number} [options.now]  Current time; seconds or ms, normalized internally.
    558 */
    559 export function topkAggregates(
    560  aggDomains,
    561  aggTitles,
    562  aggSearches,
    563  { k_domains = 30, k_titles = 60, k_searches = 10, now = undefined } = {}
    564 ) {
    565  // Normalize `now` to epoch seconds.
    566  let nowSec;
    567  if (now == null) {
    568    nowSec = Date.now() / 1000;
    569  } else {
    570    const asNum = Number(now);
    571    // Heuristic: treat 1e12+ as ms, otherwise seconds.
    572    nowSec = asNum > 1e12 ? asNum / MS_PER_SEC : asNum;
    573  }
    574 
    575  // Domains: [{key, rank, num_sessions, last_seen}]
    576  const domainRanked = Object.entries(aggDomains).map(([domain, info]) => {
    577    const score = Number(info.score || 0);
    578    const importance = Number(info.session_importance || 0);
    579    const lastSeen = Number(info.last_seen || 0);
    580    const numSessions = Number(info.num_sessions || 0);
    581 
    582    const rank = withRecency(score, importance, lastSeen, { now: nowSec });
    583 
    584    return {
    585      key: domain,
    586      rank,
    587      num_sessions: numSessions,
    588      last_seen: lastSeen,
    589    };
    590  });
    591 
    592  // Titles: [{key, rank, num_sessions, last_seen}]
    593  const titleRanked = Object.entries(aggTitles).map(([title, info]) => {
    594    const score = Number(info.score || 0);
    595    const importance = Number(info.session_importance || 0);
    596    const lastSeen = Number(info.last_seen || 0);
    597    const numSessions = Number(info.num_sessions || 0);
    598 
    599    const rank = withRecency(score, importance, lastSeen, { now: nowSec });
    600 
    601    return {
    602      key: title,
    603      rank,
    604      num_sessions: numSessions,
    605      last_seen: lastSeen,
    606    };
    607  });
    608 
    609  // Searches: [{sid, cnt, q, ls, rank}]
    610  const searchRanked = Object.entries(aggSearches).map(([sidRaw, info]) => {
    611    const sid = Number.isFinite(Number(sidRaw)) ? Number(sidRaw) : sidRaw;
    612    const count = Number(info.search_count || 0);
    613    // `last_searched` is already seconds (aggregateSessions uses toSeconds).
    614    const lastSearchedSec = Number(info.last_searched || 0);
    615    const titles = Array.isArray(info.search_titles) ? info.search_titles : [];
    616 
    617    const rank = withRecency(count, 1.0, lastSearchedSec, { now: nowSec });
    618 
    619    return {
    620      sid,
    621      cnt: count,
    622      q: titles,
    623      ls: lastSearchedSec,
    624      rank,
    625    };
    626  });
    627 
    628  // Sort with tie-breakers
    629  domainRanked.sort(
    630    (a, b) =>
    631      b.rank - a.rank ||
    632      b.num_sessions - a.num_sessions ||
    633      b.last_seen - a.last_seen
    634  );
    635 
    636  titleRanked.sort(
    637    (a, b) =>
    638      b.rank - a.rank ||
    639      b.num_sessions - a.num_sessions ||
    640      b.last_seen - a.last_seen
    641  );
    642 
    643  searchRanked.sort((a, b) => b.rank - a.rank || b.cnt - a.cnt || b.ls - a.ls);
    644 
    645  // Trim and emit compact structures
    646  const domainItems = domainRanked
    647    .slice(0, k_domains)
    648    .map(({ key, rank }) => [key, round2(rank)]);
    649 
    650  const titleItems = titleRanked
    651    .slice(0, k_titles)
    652    .map(({ key, rank }) => [key, round2(rank)]);
    653 
    654  const searchItems = searchRanked
    655    .slice(0, k_searches)
    656    .map(({ sid, cnt, q, ls, rank }) => ({
    657      sid,
    658      cnt,
    659      q,
    660      ls,
    661      r: round2(rank),
    662    }));
    663 
    664  return [domainItems, titleItems, searchItems];
    665 }
    666 
    667 /**
    668 * Blend a base score with session importance and a time-based decay.
    669 *
    670 * Intuition:
    671 *   rank ≈ score * sessionImportance * sessionWeight * recencyFactor
    672 *
    673 * where recencyFactor is in [floor, 1], decaying over time with a
    674 * half-life in days.
    675 *
    676 * @param {number} score
    677 *        Base score (e.g., frecency percentile).
    678 * @param {number} sessionImportance
    679 *        Importance derived from how many sessions the item appears in.
    680 * @param {number} lastSeenSec
    681 *        Last-seen timestamp (epoch seconds or micros/ms; normalized via toSeconds()).
    682 * @param {object} [options]
    683 * @param {number} [options.halfLifeDays=14]
    684 *        Half-life in days for recency decay; smaller → recency matters more.
    685 * @param {number} [options.floor=0.5]
    686 *        Minimum recency factor; keeps a base weight even for very old items.
    687 * @param {number} [options.sessionWeight=1.0]
    688 *        Additional multiplier on sessionImportance.
    689 * @param {number} [options.now]
    690 *        "Now" timestamp (sec/ms/µs); if omitted, Date.now() is used.
    691 * @returns {number}
    692 *        Rounded rank score (2 decimal places).
    693 */
    694 function withRecency(
    695  score,
    696  sessionImportance,
    697  lastSeenSec,
    698  {
    699    halfLifeDays = DEFAULT_HALFLIFE_DAYS,
    700    floor = DEFAULT_RECENCY_FLOOR,
    701    sessionWeight = DEFAULT_SESSION_WEIGHT,
    702    now = undefined,
    703  } = {}
    704 ) {
    705  const nowSec = now != null ? toSeconds(now) : Date.now() / 1000;
    706  const lastSec = toSeconds(lastSeenSec);
    707 
    708  const ageDays = Math.max(0, (nowSec - lastSec) / SECONDS_PER_DAY);
    709  const decay = Math.pow(0.5, ageDays / halfLifeDays);
    710  const importanceScore =
    711    Number(score) * (Number(sessionImportance) * Number(sessionWeight));
    712 
    713  return round2(importanceScore * (floor + (1 - floor) * decay));
    714 }
    715 
    716 function isFiniteNumber(n) {
    717  return typeof n === "number" && Number.isFinite(n);
    718 }
    719 
    720 /**
    721 * Convert epoch microseconds → integer epoch seconds.
    722 * If value is null/undefined/NaN, returns null.
    723 *
    724 * @param {number} micros
    725 */
    726 function normalizeEpochSeconds(micros) {
    727  if (!Number.isFinite(micros)) {
    728    return null;
    729  }
    730  return Math.floor(micros / MICROS_PER_SEC);
    731 }
    732 
    733 function toSeconds(epochMicrosOrMs) {
    734  if (!Number.isFinite(epochMicrosOrMs)) {
    735    return 0;
    736  }
    737  const v = Number(epochMicrosOrMs);
    738  return v > 1e13 ? v / MICROS_PER_SEC : v / MS_PER_SEC;
    739 }
    740 
    741 function getOrInit(mapObj, key, initFn) {
    742  if (!(key in mapObj)) {
    743    mapObj[key] = initFn();
    744  }
    745  return mapObj[key];
    746 }
    747 
    748 function round2(x) {
    749  return Math.round(Number(x) * 100) / 100;
    750 }