import type { ClickEvent, SessionStepRecord, TimelineMarker } from "../../lib/api"; const MIN_STEP_DURATION_MS = 1_800; const MAX_GENERATED_STEPS = 18; const CLICK_DEDUP_MS = 500; const SCROLL_BURST_GAP_MS = 350; const MAX_MERGED_STEP_DURATION_MS = 20_000; const LOW_NOVELTY_SCORE_THRESHOLD = 2; type MarkerType = TimelineMarker["type"]; export interface CaptureInsights { totalMarkers: number; clickCount: number; shortcutCount: number; keypressCount: number; scrollCount: number; appFocusCount: number; appCount: number; topApps: string[]; } interface TimeRange { startMs: number; endMs: number; } interface ActivitySegment { range: TimeRange; markers: TimelineMarker[]; clicks: ClickEvent[]; } interface AppFocusContext { app: string; window: string; } const BROWSER_APPS = [ "google chrome", "chrome", "arc", "safari", "firefox", "microsoft edge", "edge", "brave browser", "brave", ]; const QUERY_PARAM_CANDIDATES = ["q", "query", "search", "st", "term", "keyword", "s"]; const KNOWN_HOST_SITES: Array<{ host: string; name: string; tag: string }> = [ { host: "mail.google.com", name: "Gmail", tag: "gmail" }, { host: "www.bestbuy.com", name: "Best Buy", tag: "best_buy" }, { host: "bestbuy.com", name: "Best Buy", tag: "best_buy" }, { host: "www.linkedin.com", name: "LinkedIn", tag: "linkedin" }, { host: "linkedin.com", name: "LinkedIn", tag: "linkedin" }, { host: "github.com", name: "GitHub", tag: "github" }, { host: "www.github.com", name: "GitHub", tag: "github" }, { host: "notion.so", name: "Notion", tag: "notion" }, { host: "www.notion.so", name: "Notion", tag: "notion" }, { host: "slack.com", name: "Slack", tag: "slack" }, { host: "app.slack.com", name: "Slack", tag: "slack" }, { host: "figma.com", name: "Figma", tag: "figma" }, { host: "www.figma.com", name: "Figma", tag: "figma" }, ]; function parseHost(url: string): string | null { try { return new URL(url).hostname || null; } catch { return null; } } function inferSiteFromHost(host: string | null): { name: string; tag: string } | null { if (!host) return null; const normalized = host.toLowerCase(); const known = KNOWN_HOST_SITES.find((entry) => entry.host === normalized); if (known) return { name: known.name, tag: known.tag }; const withoutWww = normalized.startsWith("www.") ? normalized.slice(4) : normalized; const primary = withoutWww.split(".")[0] || withoutWww; const name = primary ? primary.charAt(0).toUpperCase() + primary.slice(1) : withoutWww; const tag = withoutWww .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); return name ? { name, tag: tag || primary } : null; } function extractSearchQueryFromUrl(url: string): string | null { try { const parsed = new URL(url); for (const key of QUERY_PARAM_CANDIDATES) { const value = parsed.searchParams.get(key); if (!value) continue; const trimmed = value.trim(); if (!trimmed) continue; if (trimmed.length > 80) continue; return trimmed; } } catch { return null; } return null; } function getMetaValue(marker: TimelineMarker, key: string): unknown { return marker.meta ? marker.meta[key] : undefined; } function getDomUrl(marker: TimelineMarker): string | null { const url = getMetaValue(marker, "url"); return typeof url === "string" ? url : null; } function getDomTitle(marker: TimelineMarker): string | null { const title = getMetaValue(marker, "title"); return typeof title === "string" ? title : null; } function getDomAction(marker: TimelineMarker): string | null { const action = getMetaValue(marker, "action"); return typeof action === "string" ? action : null; } function getDomTarget(marker: TimelineMarker): Record | null { const target = getMetaValue(marker, "target"); if (!target || typeof target !== "object") return null; return target as Record; } function getDomTargetName(marker: TimelineMarker): string | null { const target = getDomTarget(marker); const name = target ? target.name : null; return typeof name === "string" && name.trim() ? name.trim() : null; } function getDomTargetRole(marker: TimelineMarker): string | null { const target = getDomTarget(marker); const role = target ? target.role : null; return typeof role === "string" && role.trim() ? role.trim() : null; } function deriveDomIntentTitle(segmentMarkers: TimelineMarker[]): string | null { const domNav = segmentMarkers.filter((marker) => marker.type === "dom_navigation").slice(-1)[0]; const domAction = segmentMarkers.filter((marker) => marker.type === "dom_action").slice(-1)[0]; const url = (domNav && getDomUrl(domNav)) || (domAction && getDomUrl(domAction)) || null; if (!url) return null; const host = parseHost(url); const site = inferSiteFromHost(host); const siteName = site?.name ?? "website"; const lastClick = segmentMarkers .filter((marker) => marker.type === "dom_action" && getDomAction(marker) === "click") .slice(-1)[0]; const clickName = lastClick ? getDomTargetName(lastClick) : null; // Gmail-specific: clicking an email row tends to have an accessible name with the subject. if (site?.tag === "gmail" && clickName && clickName.length >= 8) { const trimmed = clickName.length > 72 ? `${clickName.slice(0, 69)}...` : clickName; return `Open email in Gmail: ${trimmed}`; } const query = extractSearchQueryFromUrl(url); if (query) { return `Search for "${query}" on ${siteName}`; } const action = domAction ? getDomAction(domAction) : null; const role = domAction ? getDomTargetRole(domAction) : null; const looksLikeSearch = role === "searchbox" || (domAction && (() => { const target = getDomTarget(domAction); const inputType = target && typeof target.input_type === "string" ? target.input_type : ""; return inputType.toLowerCase() === "search"; })()); if ( looksLikeSearch && (action === "enter" || action === "submit" || action === "input_change") ) { return `Search on ${siteName}`; } const title = domNav ? getDomTitle(domNav) : null; if (site && title && /\binbox\b/i.test(title) && site.tag === "gmail") { return "Open Gmail inbox"; } if (site) return `Work in ${siteName}`; return "Work in browser"; } function clampTimestamp(value: number, durationMs: number): number { if (!Number.isFinite(value)) return 0; if (durationMs <= 0) return Math.max(0, Math.round(value)); return Math.max(0, Math.min(Math.round(value), durationMs)); } function uniqueSorted(values: number[]): number[] { const unique = Array.from(new Set(values)); unique.sort((a, b) => a - b); return unique; } function parseScrollBurstCount(label: string): number { const match = label.match(/x(\d+)$/i); if (!match) return 1; const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : 1; } function parseAppFocusLabel(label: string): AppFocusContext { const separator = label.indexOf(":"); if (separator < 0) { return { app: label.trim(), window: "" }; } return { app: label.slice(0, separator).trim(), window: label.slice(separator + 1).trim(), }; } function isBrowserApp(appName: string): boolean { const normalized = appName.toLowerCase(); return BROWSER_APPS.some((browser) => normalized.includes(browser)); } function inferBrowserSite(windowTitle: string): string | null { const lower = windowTitle.toLowerCase(); if (lower.includes("gmail")) return "Gmail"; if (lower.includes("linkedin")) return "LinkedIn"; if (lower.includes("github")) return "GitHub"; if (lower.includes("notion")) return "Notion"; if (lower.includes("slack")) return "Slack"; if (lower.includes("figma")) return "Figma"; if (lower.includes("docs.google") || lower.includes("google docs")) return "Google Docs"; if (lower.includes("calendar.google") || lower.includes("google calendar")) { return "Google Calendar"; } if (lower.includes("best buy")) return "Best Buy"; if (lower.includes("amazon")) return "Amazon"; if (lower.includes("walmart")) return "Walmart"; if (lower.includes("target")) return "Target"; if (lower.includes("costco")) return "Costco"; return null; } function parseTitleSuffix(windowTitle: string): { subject: string; site: string } | null { const trimmed = windowTitle.trim(); const match = trimmed.match(/^(.+?)\s+-\s+([^-\n]+)$/); if (!match) return null; const subject = match[1].trim(); const site = match[2].trim(); if (!subject || !site) return null; return { subject, site }; } function looksLikeSearchQuery(subject: string): boolean { const normalized = subject.trim(); if (!normalized) return false; if (normalized.length > 60) return false; const words = normalized.split(/\s+/); if (words.length > 8) return false; if (/[:|/@]/.test(normalized)) return false; return true; } function looksLikeProductTitle(subject: string): boolean { const normalized = subject.trim(); if (normalized.length < 18) return false; const words = normalized.split(/\s+/).length; if (words >= 4) return true; return /\d/.test(normalized) && normalized.length >= 14; } function extractGmailSubject(windowTitle: string): string | null { if (!/gmail/i.test(windowTitle)) return null; if (/^\s*inbox\b/i.test(windowTitle)) return null; if (/^\s*gmail\s*$/i.test(windowTitle)) return null; const withoutGmailSuffix = windowTitle.replace(/\s*-\s*gmail\s*$/i, "").trim(); const withoutEmailSuffix = withoutGmailSuffix.replace(/\s*-\s*[^-]+@[^-]+\.[^-]+\s*$/i, ""); const candidate = withoutEmailSuffix.trim(); if (!candidate) return null; if (/^\s*(google chrome|new tab)\s*$/i.test(candidate)) return null; if (/^\s*(inbox|starred|sent|drafts|trash)\b/i.test(candidate)) return null; return candidate.length > 72 ? `${candidate.slice(0, 69)}...` : candidate; } function deriveBrowserIntentTitle( segmentMarkers: TimelineMarker[], segmentClicks: ClickEvent[] ): string | null { const appFocusContexts = segmentMarkers .filter((marker) => marker.type === "app_focus") .map((marker) => parseAppFocusLabel(marker.label)) .filter((context) => isBrowserApp(context.app)); if (appFocusContexts.length === 0) return null; const windows = appFocusContexts.map((context) => context.window); const latestContext = appFocusContexts[appFocusContexts.length - 1]; const siteCandidates = windows .map((windowTitle) => inferBrowserSite(windowTitle)) .filter((site): site is string => Boolean(site)); const site = siteCandidates.length > 0 ? siteCandidates[siteCandidates.length - 1] : null; const gmailSubjectCandidates = windows .map((windowTitle) => extractGmailSubject(windowTitle)) .filter((subject): subject is string => Boolean(subject)); const gmailSubject = gmailSubjectCandidates.length > 0 ? gmailSubjectCandidates[gmailSubjectCandidates.length - 1] : null; const shortcutLabels = segmentMarkers .filter((marker) => marker.type === "shortcut") .map((marker) => marker.label.toLowerCase()); const keypressLabels = segmentMarkers .filter((marker) => marker.type === "keypress") .map((marker) => marker.label.toLowerCase()); const hasAddressBarShortcut = shortcutLabels.some((label) => /(cmd|meta|ctrl)\+l\b/.test(label) ); const hasEnterKey = keypressLabels.some((label) => label.includes("key enter")); const typedInputCount = keypressLabels.length; const hasNavigationSignal = hasAddressBarShortcut || (hasEnterKey && typedInputCount >= 2); const hasNewTabWindow = windows.some((windowTitle) => /\bnew tab\b/i.test(windowTitle)); const hasGmailInboxWindow = windows.some( (windowTitle) => /gmail/i.test(windowTitle) && /\binbox\b/i.test(windowTitle) ); const hasPermissionPrompt = windows.some((windowTitle) => /wants to$/i.test(windowTitle)); const titleSuffixes = windows .map((windowTitle) => parseTitleSuffix(windowTitle)) .filter((item): item is { subject: string; site: string } => Boolean(item)); const latestSuffix = titleSuffixes.length > 0 ? titleSuffixes[titleSuffixes.length - 1] : null; const latestRecognizedSite = latestSuffix?.site ?? site ?? latestContext.app; if (hasPermissionPrompt) { return "Handle browser permission prompt"; } if (gmailSubject) { return `Open email in Gmail: ${gmailSubject}`; } if (hasGmailInboxWindow && segmentClicks.length > 0) { return "Open email from Gmail inbox"; } if (hasGmailInboxWindow) { return hasNavigationSignal || hasNewTabWindow ? "Go to Gmail inbox" : "Review Gmail inbox"; } if (site === "Gmail" && hasNavigationSignal) { return "Go to gmail.com"; } if ( latestSuffix && hasEnterKey && typedInputCount >= 3 && looksLikeSearchQuery(latestSuffix.subject) ) { return `Search for "${latestSuffix.subject}" on ${latestSuffix.site}`; } if (latestSuffix && looksLikeProductTitle(latestSuffix.subject)) { return `Open product page on ${latestSuffix.site}`; } if (latestSuffix && looksLikeSearchQuery(latestSuffix.subject)) { return `Review "${latestSuffix.subject}" results on ${latestSuffix.site}`; } if (site && hasNavigationSignal) { return `Navigate to ${site}`; } if (hasNavigationSignal || hasNewTabWindow) { return "Navigate in browser"; } if (site) { return `Work in ${site}`; } return `Work in ${latestRecognizedSite}`; } export function extractAppNameFromLabel(label: string): string { const [first] = label.split(":"); return first.trim() || "Unknown app"; } export function markerTypeLabel(type: MarkerType): string { switch (type) { case "app_focus": return "App Focus"; case "mouse_scroll": return "Scroll"; case "file_change": return "File Change"; case "keypress": return "Key Press"; case "shortcut": return "Shortcut"; case "dom_navigation": return "Web Nav"; case "dom_action": return "Web Action"; case "click": return "Click"; case "command": return "Command"; case "marker": return "Marker"; default: return "Event"; } } export function buildCaptureInsights( markers: TimelineMarker[], clicks: ClickEvent[] ): CaptureInsights { const counts: Record = { command: 0, click: 0, file_change: 0, marker: 0, keypress: 0, shortcut: 0, mouse_scroll: 0, app_focus: 0, dom_action: 0, dom_navigation: 0, }; const appFrequency = new Map(); for (const marker of markers) { counts[marker.type] += 1; if (marker.type === "app_focus") { const app = extractAppNameFromLabel(marker.label); appFrequency.set(app, (appFrequency.get(app) ?? 0) + 1); } } const topApps = Array.from(appFrequency.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([app]) => app); return { totalMarkers: markers.length, clickCount: clicks.length, shortcutCount: counts.shortcut, keypressCount: counts.keypress, scrollCount: markers .filter((marker) => marker.type === "mouse_scroll") .reduce((total, marker) => total + parseScrollBurstCount(marker.label), 0), appFocusCount: counts.app_focus, appCount: appFrequency.size, topApps, }; } export function normalizeTimelineMarkers(markers: TimelineMarker[]): TimelineMarker[] { if (markers.length === 0) return []; const sorted = [...markers].sort((a, b) => a.timestamp_ms - b.timestamp_ms); const normalized: TimelineMarker[] = []; let i = 0; while (i < sorted.length) { const marker = sorted[i]; if (marker.type !== "mouse_scroll") { normalized.push(marker); i += 1; continue; } let burstCount = 1; let endTimestamp = marker.timestamp_ms; let j = i + 1; while (j < sorted.length) { const next = sorted[j]; if (next.type !== "mouse_scroll") break; if (next.timestamp_ms - endTimestamp > SCROLL_BURST_GAP_MS) break; burstCount += 1; endTimestamp = next.timestamp_ms; j += 1; } const direction = marker.label.toLowerCase().includes("down") ? "down" : "up"; normalized.push({ ...marker, label: `scroll ${direction} x${burstCount}`, }); i = j; } return normalized; } function collectRangeMarkers(markers: TimelineMarker[], range: TimeRange): TimelineMarker[] { return markers.filter( (marker) => marker.timestamp_ms >= range.startMs && (marker.timestamp_ms < range.endMs || marker.timestamp_ms === range.startMs) ); } function collectRangeClicks(clicks: ClickEvent[], range: TimeRange): ClickEvent[] { return clicks.filter( (click) => click.timestamp_ms >= range.startMs && (click.timestamp_ms < range.endMs || click.timestamp_ms === range.startMs) ); } function dedupeClickTimestamps(clicks: ClickEvent[], durationMs: number): number[] { const sorted = [...clicks].sort((a, b) => a.timestamp_ms - b.timestamp_ms); const deduped: number[] = []; let lastKept = -Infinity; for (const click of sorted) { const ts = clampTimestamp(click.timestamp_ms, durationMs); if (ts - lastKept < CLICK_DEDUP_MS) continue; deduped.push(ts); lastKept = ts; } return deduped; } function buildBoundaries( markers: TimelineMarker[], clicks: ClickEvent[], durationMs: number ): number[] { const candidateBoundaries: number[] = [0, durationMs]; const markerBoundaryTypes: MarkerType[] = [ "app_focus", "shortcut", "command", "marker", "click", "dom_navigation", "dom_action", ]; for (const marker of markers) { if (!markerBoundaryTypes.includes(marker.type)) continue; candidateBoundaries.push(clampTimestamp(marker.timestamp_ms, durationMs)); } candidateBoundaries.push(...dedupeClickTimestamps(clicks, durationMs)); const sorted = uniqueSorted(candidateBoundaries); const merged: number[] = []; for (const boundary of sorted) { const last = merged[merged.length - 1]; if (last == null || boundary - last >= MIN_STEP_DURATION_MS) { merged.push(boundary); continue; } if (boundary === durationMs) { merged[merged.length - 1] = durationMs; } } if (merged[0] !== 0) merged.unshift(0); if (merged[merged.length - 1] !== durationMs) merged.push(durationMs); return uniqueSorted(merged); } function coalesceShortRanges(ranges: TimeRange[]): TimeRange[] { if (ranges.length <= 1) return ranges; const merged: TimeRange[] = [{ ...ranges[0] }]; for (let i = 1; i < ranges.length; i += 1) { const current = ranges[i]; const previous = merged[merged.length - 1]; const previousDuration = previous.endMs - previous.startMs; const currentDuration = current.endMs - current.startMs; if (currentDuration < MIN_STEP_DURATION_MS || previousDuration < MIN_STEP_DURATION_MS) { previous.endMs = current.endMs; continue; } merged.push({ ...current }); } if (merged.length >= 2) { const last = merged[merged.length - 1]; const lastDuration = last.endMs - last.startMs; if (lastDuration < MIN_STEP_DURATION_MS) { merged[merged.length - 2].endMs = last.endMs; merged.pop(); } } return merged; } function normalizeStepTitleForComparison(title: string): string { const normalized = title.trim().toLowerCase().replace(/\s+/g, " "); if (normalized.startsWith("work in ")) { return `work in ${normalized.slice("work in ".length).trim()}`; } if (normalized.startsWith("navigate to ")) { return `navigate to ${normalized.slice("navigate to ".length).trim()}`; } if (normalized.startsWith("open email in gmail:")) { return "open email in gmail"; } return normalized; } function isGenericStepTitle(title: string): boolean { const normalized = title.trim().toLowerCase(); if (normalized.startsWith("work in ")) return true; if (normalized === "navigate in browser") return true; if (normalized.startsWith("navigate to ")) return true; if (normalized === "click through workflow") return true; if (normalized === "interact with interface") return true; return false; } function findTagValue(tags: string[], prefix: string): string { const tag = tags.find((value) => value.startsWith(prefix)); return tag ? tag.slice(prefix.length) : ""; } function deriveSegmentContextKey(markers: TimelineMarker[], clicks: ClickEvent[]): string { const tags = deriveTags(markers, clicks); const app = findTagValue(tags, "app:"); const site = findTagValue(tags, "site:"); if (app || site) { return `${app}|${site}`; } const latestFocus = markers.filter((marker) => marker.type === "app_focus").slice(-1)[0]; if (!latestFocus) return ""; return extractAppNameFromLabel(latestFocus.label).toLowerCase().trim(); } function computeSegmentNoveltyScore(markers: TimelineMarker[], clicks: ClickEvent[]): number { let score = 0; if (markers.some((marker) => marker.type === "app_focus")) score += 2; if (markers.some((marker) => marker.type === "dom_navigation")) score += 2; if (markers.some((marker) => marker.type === "shortcut")) score += 2; if (markers.some((marker) => marker.type === "command")) score += 2; if (clicks.length >= 2) score += 1; const keypressCount = markers.filter((marker) => marker.type === "keypress").length; if (keypressCount >= 5) score += 1; return score; } function hasStrongTransitionSignal(markers: TimelineMarker[]): boolean { return markers.some((marker) => { return ( marker.type === "app_focus" || marker.type === "shortcut" || marker.type === "command" || marker.type === "dom_navigation" ); }); } function shouldMergeAdjacentSegments(previous: ActivitySegment, current: ActivitySegment): boolean { const combinedDuration = current.range.endMs - previous.range.startMs; if (combinedDuration > MAX_MERGED_STEP_DURATION_MS) return false; if (hasStrongTransitionSignal(current.markers)) return false; const previousTitle = deriveStepTitle(0, previous.markers, previous.clicks); const currentTitle = deriveStepTitle(0, current.markers, current.clicks); const sameTitle = normalizeStepTitleForComparison(previousTitle) === normalizeStepTitleForComparison(currentTitle); const bothGeneric = isGenericStepTitle(previousTitle) && isGenericStepTitle(currentTitle); const previousContext = deriveSegmentContextKey(previous.markers, previous.clicks); const currentContext = deriveSegmentContextKey(current.markers, current.clicks); const contextCompatible = previousContext.length > 0 && (currentContext.length === 0 || previousContext === currentContext); if (!sameTitle && !(bothGeneric && contextCompatible)) return false; const previousNovelty = computeSegmentNoveltyScore(previous.markers, previous.clicks); const currentNovelty = computeSegmentNoveltyScore(current.markers, current.clicks); return ( previousNovelty <= LOW_NOVELTY_SCORE_THRESHOLD || currentNovelty <= LOW_NOVELTY_SCORE_THRESHOLD ); } function mergeLowNoveltySegments(segments: ActivitySegment[]): ActivitySegment[] { if (segments.length <= 1) return segments; const merged: ActivitySegment[] = [ { range: { ...segments[0].range }, markers: [...segments[0].markers], clicks: [...segments[0].clicks], }, ]; for (let i = 1; i < segments.length; i += 1) { const current = segments[i]; const previous = merged[merged.length - 1]; if (!shouldMergeAdjacentSegments(previous, current)) { merged.push({ range: { ...current.range }, markers: [...current.markers], clicks: [...current.clicks], }); continue; } previous.range.endMs = current.range.endMs; previous.markers = [...previous.markers, ...current.markers].sort( (a, b) => a.timestamp_ms - b.timestamp_ms ); previous.clicks = [...previous.clicks, ...current.clicks].sort( (a, b) => a.timestamp_ms - b.timestamp_ms ); } return merged; } function deriveStepTitle( stepIndex: number, segmentMarkers: TimelineMarker[], segmentClicks: ClickEvent[] ): string { const domIntentTitle = deriveDomIntentTitle(segmentMarkers); if (domIntentTitle) { return domIntentTitle; } const appFocusMarkers = segmentMarkers.filter((marker) => marker.type === "app_focus"); const latestAppFocus = appFocusMarkers[appFocusMarkers.length - 1]; if (latestAppFocus) { const windowLabel = latestAppFocus.label; if (windowLabel.includes("| LinkedIn")) { const subject = windowLabel.split("|")[0].trim(); if (subject && !/^feed$/i.test(subject) && !/^jobs$/i.test(subject)) { return `Open LinkedIn profile: ${subject}`; } if (/feed/i.test(subject)) { return "Review LinkedIn feed"; } if (/jobs/i.test(subject)) { return "Review LinkedIn jobs"; } } } const browserIntentTitle = deriveBrowserIntentTitle(segmentMarkers, segmentClicks); if (browserIntentTitle) { return browserIntentTitle; } const scrollCount = segmentMarkers .filter((marker) => marker.type === "mouse_scroll") .reduce((total, marker) => total + parseScrollBurstCount(marker.label), 0); if (scrollCount >= 8) { return "Scroll and review content"; } const appFocus = segmentMarkers.find((marker) => marker.type === "app_focus"); if (appFocus) { return `Work in ${extractAppNameFromLabel(appFocus.label)}`; } const shortcut = segmentMarkers.find((marker) => marker.type === "shortcut"); if (shortcut) { return `Use shortcut: ${shortcut.label}`; } const command = segmentMarkers.find((marker) => marker.type === "command"); if (command) { return "Run command"; } if (segmentClicks.length >= 3) { return "Interact with interface"; } if (segmentClicks.length > 0) { return "Click through workflow"; } const keypresses = segmentMarkers.filter((marker) => marker.type === "keypress"); if (keypresses.length > 0) { return "Enter input"; } return `Step ${stepIndex + 1}`; } function pluralize(count: number, singular: string): string { return count === 1 ? singular : `${singular}s`; } function deriveStepBody(segmentMarkers: TimelineMarker[], segmentClicks: ClickEvent[]): string { const lines: string[] = []; const appFocus = segmentMarkers.find((marker) => marker.type === "app_focus"); if (appFocus) { lines.push(`App context: ${appFocus.label}`); } const domNav = segmentMarkers.filter((marker) => marker.type === "dom_navigation").slice(-1)[0]; const domUrl = domNav ? getDomUrl(domNav) : null; const domHost = domUrl ? parseHost(domUrl) : null; if (domHost && domUrl) { lines.push(`Web context: ${domHost}`); const query = extractSearchQueryFromUrl(domUrl); if (query) lines.push(`Search query: ${query}`); } const domClick = segmentMarkers .filter((marker) => marker.type === "dom_action" && getDomAction(marker) === "click") .slice(-1)[0]; const domClickName = domClick ? getDomTargetName(domClick) : null; if (domClickName) { lines.push( `Clicked: ${domClickName.length > 120 ? `${domClickName.slice(0, 117)}...` : domClickName}` ); } const browserContexts = segmentMarkers .filter((marker) => marker.type === "app_focus") .map((marker) => parseAppFocusLabel(marker.label)) .filter((context) => isBrowserApp(context.app)); if (browserContexts.length > 0) { const site = browserContexts .map((context) => inferBrowserSite(context.window)) .filter((candidate): candidate is string => Boolean(candidate)) .slice(-1)[0]; if (site) { lines.push(`Website context: ${site}`); } const subject = browserContexts .map((context) => extractGmailSubject(context.window)) .filter((candidate): candidate is string => Boolean(candidate)) .slice(-1)[0]; if (subject) { lines.push(`Email subject: ${subject}`); } } const shortcuts = segmentMarkers .filter((marker) => marker.type === "shortcut") .slice(0, 2) .map((marker) => marker.label); if (shortcuts.length > 0) { lines.push(`Shortcuts: ${shortcuts.join(", ")}`); } const commands = segmentMarkers.filter((marker) => marker.type === "command"); if (commands.length > 0) { lines.push(`Commands: ${commands.length} detected`); } if (segmentClicks.length > 0) { lines.push(`${segmentClicks.length} ${pluralize(segmentClicks.length, "click")} captured`); } const keypressCount = segmentMarkers.filter((marker) => marker.type === "keypress").length; if (keypressCount > 0) { lines.push(`${keypressCount} ${pluralize(keypressCount, "keypress")} captured`); } const scrollCount = segmentMarkers .filter((marker) => marker.type === "mouse_scroll") .reduce((total, marker) => total + parseScrollBurstCount(marker.label), 0); if (scrollCount > 0) { lines.push(`${scrollCount} scroll ${pluralize(scrollCount, "event")} captured`); } if (lines.length === 0) { return "Review this segment and add details for users."; } return lines.join("\n"); } function deriveTags(segmentMarkers: TimelineMarker[], segmentClicks: ClickEvent[]): string[] { const tags = new Set(); const appFocus = segmentMarkers.find((marker) => marker.type === "app_focus"); if (appFocus) { const app = extractAppNameFromLabel(appFocus.label) .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); if (app) tags.add(`app:${app}`); } const domNav = segmentMarkers.filter((marker) => marker.type === "dom_navigation").slice(-1)[0]; const domAction = segmentMarkers.filter((marker) => marker.type === "dom_action").slice(-1)[0]; const domUrl = (domNav && getDomUrl(domNav)) || (domAction && getDomUrl(domAction)) || null; const domHost = domUrl ? parseHost(domUrl) : null; const domSite = inferSiteFromHost(domHost); if (domSite?.tag) { tags.add(`site:${domSite.tag}`); } const site = segmentMarkers .filter((marker) => marker.type === "app_focus") .map((marker) => parseAppFocusLabel(marker.label)) .filter((context) => isBrowserApp(context.app)) .map((context) => inferBrowserSite(context.window)) .filter((candidate): candidate is string => Boolean(candidate)) .slice(-1)[0]; if (site) { const normalizedSite = site .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); if (normalizedSite) tags.add(`site:${normalizedSite}`); } if (segmentMarkers.some((marker) => marker.type === "shortcut")) tags.add("shortcut"); if (segmentMarkers.some((marker) => marker.type === "command")) tags.add("command"); if (segmentClicks.length > 0) tags.add("click"); return Array.from(tags); } export function generateStepsFromActivity( markers: TimelineMarker[], clicks: ClickEvent[], durationMs: number ): SessionStepRecord[] { const safeDurationMs = Math.max(0, Math.round(durationMs)); if (safeDurationMs <= 0) return []; const sortedMarkers = [...markers].sort((a, b) => a.timestamp_ms - b.timestamp_ms); const sortedClicks = [...clicks].sort((a, b) => a.timestamp_ms - b.timestamp_ms); const boundaries = buildBoundaries(sortedMarkers, sortedClicks, safeDurationMs); const ranges: TimeRange[] = []; for (let i = 0; i < boundaries.length - 1; i += 1) { const startMs = boundaries[i]; const endMs = boundaries[i + 1]; if (endMs <= startMs) continue; ranges.push({ startMs, endMs }); } const coalescedRanges = coalesceShortRanges(ranges); const segments = coalescedRanges.map((range) => ({ range, markers: collectRangeMarkers(sortedMarkers, range), clicks: collectRangeClicks(sortedClicks, range), })); const mergedSegments = mergeLowNoveltySegments(segments); const truncatedSegments = mergedSegments.slice(0, MAX_GENERATED_STEPS); const generated = truncatedSegments.map((segment, idx) => { const stepEndMs = Math.min( safeDurationMs, Math.max(segment.range.endMs, segment.range.startMs + MIN_STEP_DURATION_MS) ); return { id: `step_${idx + 1}`, start_ms: segment.range.startMs, end_ms: stepEndMs, title: deriveStepTitle(idx, segment.markers, segment.clicks), body: deriveStepBody(segment.markers, segment.clicks), tags: deriveTags(segment.markers, segment.clicks), hotspot_ids: [], completion_mode: "none", required_hotspot_ids: [], autoplay: false, screenshot: null, } satisfies SessionStepRecord; }); if (generated.length === 0) { return [ { id: "step_1", start_ms: 0, end_ms: safeDurationMs, title: "Review recording", body: "No interaction markers were captured. Add steps manually for this session.", tags: [], hotspot_ids: [], completion_mode: "none", required_hotspot_ids: [], autoplay: false, screenshot: null, }, ]; } return generated; } export function isPlaceholderStepsDocument(steps: SessionStepRecord[]): boolean { if (steps.length !== 1) return false; const [step] = steps; return ( /^step 1$/i.test(step.title.trim()) && step.start_ms === 0 && step.hotspot_ids.length === 0 && (step.body ?? "").trim().length === 0 ); }