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 }