Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | /** * Shared history between a child (player) and a number on the number line. * * Loads past moments from previous calls and selects the most memorable * moments for system prompt injection. Unculled sessions are skipped * (not blocked on) and culled in the background so they're ready next time. */ import { db } from '@/db' import * as schema from '@/db/schema' import { eq, and, desc } from 'drizzle-orm' import { startMomentCull } from '../tasks/moment-cull' export interface SharedHistoryMoment { caption: string category: string significance: number /** Fuzzy temporal label: "last time", "a few calls ago", "a while back" */ recencyLabel: string } export interface SharedHistory { totalCalls: number firstCallDate: Date | null sessionSummaries: string[] moments: SharedHistoryMoment[] /** True if background culls were triggered — client should re-fetch after a delay */ pendingCulls: boolean } const MAX_MOMENTS = 8 const MAX_SESSION_SUMMARIES = 3 /** * Load shared history between a player and a caller number. * * Only includes moments from culled sessions. Unculled sessions * (dirty disconnects) are kicked off for background culling but * NOT awaited — the call connects immediately. Those moments will * be available by the next call. */ export async function getSharedHistory( playerId: string, callerNumber: number, userId?: string ): Promise<SharedHistory | null> { // Load all sessions for this pair const sessions = await db .select() .from(schema.numberLineSessions) .where( and( eq(schema.numberLineSessions.playerId, playerId), eq(schema.numberLineSessions.callerNumber, callerNumber) ) ) .orderBy(desc(schema.numberLineSessions.createdAt)) if (sessions.length === 0) return null // Dead man's switch: kick off cull for any unculled sessions (fire-and-forget). // Skip sessions that already have a cull task running to avoid duplicate work. const unculled = sessions.filter((s) => !s.isCulled && s.momentCount > 0 && !s.cullTaskId) for (const session of unculled) { startMomentCull( { sessionId: session.id, playerId: session.playerId, callerNumber: session.callerNumber, }, userId ).catch((err) => console.warn(`[shared-history] Background cull failed for session ${session.id}:`, err) ) } const hasPendingCulls = unculled.length > 0 // Only use culled sessions for history (unculled ones aren't ready yet) const culledSessions = sessions.filter((s) => s.isCulled) // If no culled sessions, return minimal result so the client knows to re-fetch if (culledSessions.length === 0) { if (!hasPendingCulls) return null return { totalCalls: sessions.length, firstCallDate: sessions[sessions.length - 1].createdAt, sessionSummaries: [], moments: [], pendingCulls: true, } } // Load kept moments from culled sessions only const culledSessionIds = new Set(culledSessions.map((s) => s.id)) const allMoments = await db .select() .from(schema.numberLineMoments) .where( and( eq(schema.numberLineMoments.playerId, playerId), eq(schema.numberLineMoments.callerNumber, callerNumber), eq(schema.numberLineMoments.keep, true) ) ) .orderBy(desc(schema.numberLineMoments.createdAt)) // Filter to only moments from culled sessions const moments = allMoments.filter((m) => culledSessionIds.has(m.sessionId)) if (moments.length === 0 && culledSessions.every((s) => !s.sessionSummary)) { return null } // Select moments with category diversity and temporal spread const selected = selectMoments(moments, culledSessions) // Collect session summaries (most recent culled first) const summaries = culledSessions .filter((s) => s.sessionSummary) .slice(0, MAX_SESSION_SUMMARIES) .map((s) => s.sessionSummary!) return { totalCalls: sessions.length, // include unculled in total count firstCallDate: sessions.length > 0 ? sessions[sessions.length - 1].createdAt : null, sessionSummaries: summaries, moments: selected, pendingCulls: hasPendingCulls, } } /** * Select the best moments for prompt injection. * * Scoring: longTermSignificance × recencyMultiplier * Then greedy selection with category penalty to ensure diversity. */ function selectMoments( moments: (typeof schema.numberLineMoments.$inferSelect)[], sessions: (typeof schema.numberLineSessions.$inferSelect)[] ): SharedHistoryMoment[] { if (moments.length === 0) return [] // Build session index map (most recent = 0) const sessionOrder = new Map<string, number>() sessions.forEach((s, i) => sessionOrder.set(s.id, i)) // Score each moment const scored = moments.map((m) => { const sessionIdx = sessionOrder.get(m.sessionId) ?? sessions.length const recencyMultiplier = getRecencyMultiplier(sessionIdx) const significance = m.longTermSignificance ?? m.rawSignificance const score = significance * recencyMultiplier return { ...m, score, sessionIdx, recencyLabel: getRecencyLabel(sessionIdx), } }) // Sort by score descending scored.sort((a, b) => b.score - a.score) // Greedy selection with category penalty const categoryCounts = new Map<string, number>() const sessionCounts = new Map<number, number>() const selected: SharedHistoryMoment[] = [] for (const m of scored) { if (selected.length >= MAX_MOMENTS) break const catCount = categoryCounts.get(m.category) ?? 0 const sessCount = sessionCounts.get(m.sessionIdx) ?? 0 // Penalize over-represented categories (>2 of same type feels monotone) // and over-represented sessions (>3 from one call crowds out other visits) let adjustedScore = m.score if (catCount >= 2) adjustedScore *= 0.5 if (sessCount >= 3) adjustedScore *= 0.5 // Always take the first 3 (guarantees some history even with low scores), // after that only keep moments with meaningful adjusted scores if (adjustedScore > 1.5 || selected.length < 3) { selected.push({ caption: m.caption, category: m.category, significance: m.longTermSignificance ?? m.rawSignificance, recencyLabel: m.recencyLabel, }) categoryCounts.set(m.category, catCount + 1) sessionCounts.set(m.sessionIdx, sessCount + 1) } } return selected } function getRecencyMultiplier(sessionIdx: number): number { if (sessionIdx === 0) return 1.0 // last call if (sessionIdx <= 2) return 0.8 // 2-3 calls ago if (sessionIdx <= 6) return 0.5 // 4-7 calls ago return 0.3 // 8+ calls ago } function getRecencyLabel(sessionIdx: number): string { if (sessionIdx === 0) return 'last time' if (sessionIdx === 1) return 'a couple calls ago' if (sessionIdx <= 3) return 'a few calls ago' return 'a while back' } |