experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(frecency): improve scoring with 5 quick wins

+90 -19
+30 -7
app/lib/frecency.js
··· 20 20 21 21 /** 22 22 * Weight multipliers for different visit types. 23 + * "typed" = user typed the URL from memory (strongest intent signal). 24 + * "bookmark" = opened via bookmark (explicit save, moderate signal). 25 + * "direct" = legacy source, treated as typed for backwards compat. 23 26 */ 24 27 export const VISIT_WEIGHTS = { 25 28 /** User interacted with the page (clicked, scrolled, typed) */ 26 29 interacted: 2, 27 - /** Direct navigation (typed URL, bookmark) */ 28 - direct: 0.5, 30 + /** User typed the URL directly (strongest navigation intent) */ 31 + typed: 3, 32 + /** Opened via bookmark */ 33 + bookmark: 1.5, 34 + /** Legacy "direct" source — treat as typed */ 35 + direct: 3, 29 36 /** Default weight (link click, redirect, etc.) */ 30 37 default: 1 31 38 }; ··· 40 47 * Array of visit records. Each visit has: 41 48 * - timestamp: Unix epoch milliseconds of the visit 42 49 * - interacted: 1 if user interacted with the page, 0 otherwise 43 - * - source: visit source string ('direct' gets lower weight) 50 + * - source: visit source string ('typed', 'bookmark', 'direct', etc.) 51 + * @param {number} [totalVisitCount] - Total visits (for extrapolation when sampling) 44 52 * @returns {number} Integer frecency score (higher = more relevant) 45 53 */ 46 - export function calculateItemFrecency(visits) { 54 + export function calculateItemFrecency(visits, totalVisitCount) { 55 + const SAMPLE_CAP = 20; 56 + const GRACE_PERIOD_DAYS = 3; 57 + 58 + // Cap sample size for performance; extrapolate if needed 59 + const sample = visits.length > SAMPLE_CAP ? visits.slice(0, SAMPLE_CAP) : visits; 60 + const total = totalVisitCount || visits.length; 61 + 47 62 let score = 0; 48 - for (const visit of visits) { 49 - const ageDays = (Date.now() - visit.timestamp) / (1000 * 60 * 60 * 24); 63 + for (const visit of sample) { 64 + const rawAgeDays = (Date.now() - visit.timestamp) / (1000 * 60 * 60 * 24); 65 + // 3-day grace period: visits within grace period get full relevance 66 + const ageDays = Math.max(0, rawAgeDays - GRACE_PERIOD_DAYS); 50 67 const decay = 1 / (1 + Math.pow(ageDays / DECAY_HALF_LIFE_DAYS, 0.5)); 51 68 const weight = visit.interacted 52 69 ? VISIT_WEIGHTS.interacted 53 - : (visit.source === 'direct' ? VISIT_WEIGHTS.direct : VISIT_WEIGHTS.default); 70 + : (VISIT_WEIGHTS[visit.source] || VISIT_WEIGHTS.default); 54 71 score += weight * decay; 55 72 } 73 + 74 + // Extrapolate if we sampled fewer than total visits 75 + if (sample.length < total && sample.length > 0) { 76 + score = score * total / sample.length; 77 + } 78 + 56 79 return Math.round(score * 10); 57 80 } 58 81
+38 -12
backend/electron/datastore.ts
··· 1046 1046 * Uses a time-decay algorithm where recent visits contribute more. 1047 1047 * NOTE: Shared copy lives in app/lib/frecency.js for frontend/Tauri use. 1048 1048 */ 1049 - export function calculateItemFrecency(visits: Array<{ timestamp: number; interacted: number; source: string }>): number { 1049 + /** 1050 + * Visit source weights. "typed" = user typed URL (strongest intent). 1051 + * "direct" treated as typed for backwards compat. 1052 + */ 1053 + const VISIT_WEIGHTS: Record<string, number> = { 1054 + interacted: 2, 1055 + typed: 3, 1056 + bookmark: 1.5, 1057 + direct: 3, 1058 + default: 1 1059 + }; 1060 + 1061 + export function calculateItemFrecency(visits: Array<{ timestamp: number; interacted: number; source: string }>, totalVisitCount?: number): number { 1062 + const SAMPLE_CAP = 20; 1063 + const GRACE_PERIOD_DAYS = 3; 1064 + const HALF_LIFE = 7; 1065 + 1066 + const sample = visits.length > SAMPLE_CAP ? visits.slice(0, SAMPLE_CAP) : visits; 1067 + const total = totalVisitCount || visits.length; 1068 + 1050 1069 let score = 0; 1051 - for (const visit of visits) { 1052 - const ageDays = (Date.now() - visit.timestamp) / (1000 * 60 * 60 * 24); 1053 - const decay = 1 / (1 + Math.pow(ageDays / 7, 0.5)); 1054 - const weight = visit.interacted ? 2 : (visit.source === 'direct' ? 0.5 : 1); 1070 + for (const visit of sample) { 1071 + const rawAgeDays = (Date.now() - visit.timestamp) / (1000 * 60 * 60 * 24); 1072 + const ageDays = Math.max(0, rawAgeDays - GRACE_PERIOD_DAYS); 1073 + const decay = 1 / (1 + Math.pow(ageDays / HALF_LIFE, 0.5)); 1074 + const weight = visit.interacted ? VISIT_WEIGHTS.interacted : (VISIT_WEIGHTS[visit.source] || VISIT_WEIGHTS.default); 1055 1075 score += weight * decay; 1056 1076 } 1077 + 1078 + if (sample.length < total && sample.length > 0) { 1079 + score = score * total / sample.length; 1080 + } 1081 + 1057 1082 return Math.round(score * 10); 1058 1083 } 1059 1084 ··· 3073 3098 let orderBy: string; 3074 3099 switch (filter.sortBy) { 3075 3100 case 'frecency': 3076 - orderBy = 'frecencyScore DESC, lastVisitAt DESC'; 3101 + orderBy = 'starred DESC, frecencyScore DESC, lastVisitAt DESC'; 3077 3102 break; 3078 3103 case 'lastVisit': 3079 3104 orderBy = 'lastVisitAt DESC'; ··· 3276 3301 const d = getDb(); 3277 3302 const timestamp = now(); 3278 3303 3279 - // Get all visits for this item 3280 - const visits = d.prepare('SELECT timestamp, interacted, source FROM item_visits WHERE itemId = ?').all(itemId) as Array<{ timestamp: number; interacted: number; source: string }>; 3304 + // Get total visit count and last 20 visits (most recent first) for frecency sampling 3305 + const countResult = d.prepare('SELECT COUNT(*) as cnt FROM item_visits WHERE itemId = ?').get(itemId) as { cnt: number }; 3306 + const visitCount = countResult.cnt; 3307 + const visits = d.prepare('SELECT timestamp, interacted, source FROM item_visits WHERE itemId = ? ORDER BY timestamp DESC LIMIT 20').all(itemId) as Array<{ timestamp: number; interacted: number; source: string }>; 3281 3308 3282 - const visitCount = visits.length; 3283 - const lastVisitAt = visits.length > 0 ? Math.max(...visits.map(v => v.timestamp)) : 0; 3284 - const frecencyScore = calculateItemFrecency(visits); 3309 + const lastVisitAt = visits.length > 0 ? visits[0].timestamp : 0; 3310 + const frecencyScore = calculateItemFrecency(visits, visitCount); 3285 3311 3286 3312 d.prepare('UPDATE items SET visitCount = ?, lastVisitAt = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?').run( 3287 3313 visitCount, ··· 3407 3433 const limit = filter.limit ? `LIMIT ${filter.limit}` : 'LIMIT 50'; 3408 3434 3409 3435 return getDb().prepare( 3410 - `SELECT * FROM items ${whereClause} ORDER BY frecencyScore DESC, lastVisitAt DESC ${limit}` 3436 + `SELECT * FROM items ${whereClause} ORDER BY starred DESC, frecencyScore DESC, lastVisitAt DESC ${limit}` 3411 3437 ).all(...values) as Item[]; 3412 3438 } 3413 3439
+22
extensions/cmd/panel.js
··· 364 364 365 365 function updateAdaptiveFeedback(typed, name) { 366 366 log('cmd:panel', 'updateAdaptiveFeedback', typed, '->', name); 367 + 368 + // Apply decay to existing entries for this typed prefix (adapts to changing behavior) 369 + const DECAY = 0.95; 370 + const PRUNE_THRESHOLD = 0.1; 371 + if (state.adaptiveFeedback[typed]) { 372 + for (const key of Object.keys(state.adaptiveFeedback[typed])) { 373 + state.adaptiveFeedback[typed][key] *= DECAY; 374 + if (state.adaptiveFeedback[typed][key] < PRUNE_THRESHOLD) { 375 + delete state.adaptiveFeedback[typed][key]; 376 + } 377 + } 378 + } 379 + 367 380 if (!state.adaptiveFeedback[typed]) { 368 381 state.adaptiveFeedback[typed] = {}; 369 382 } ··· 380 393 if (lowerTyped.startsWith(word) || word.startsWith(lowerTyped)) continue; 381 394 for (let len = 2; len <= word.length; len++) { 382 395 const sub = word.substring(0, len); 396 + // Decay sub-prefix entries too 397 + if (state.adaptiveFeedback[sub]) { 398 + for (const key of Object.keys(state.adaptiveFeedback[sub])) { 399 + state.adaptiveFeedback[sub][key] *= DECAY; 400 + if (state.adaptiveFeedback[sub][key] < PRUNE_THRESHOLD) { 401 + delete state.adaptiveFeedback[sub][key]; 402 + } 403 + } 404 + } 383 405 if (!state.adaptiveFeedback[sub]) state.adaptiveFeedback[sub] = {}; 384 406 if (!state.adaptiveFeedback[sub][name]) state.adaptiveFeedback[sub][name] = 0; 385 407 state.adaptiveFeedback[sub][name] += 0.25;