From 949705cbcf44e7e37dae5dffeee86090bebac65e Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 10:34:37 +0000 Subject: [PATCH 1/6] feat(ai-summary): typed, privacy-filtered activity context (closes #925) The dev-mode /analysis/activity page sent a single-pass LLM only total tracked duration plus a flat top-20 app list, built from an uncapped raw bucket download. This replaces it with a typed, bounded context derived through the query layer. - New `src/util/activityContext.ts`: a provider-independent `ActivityContext` shape plus pure builders (`buildActivityContext`, `computeFocusStats`, `formatActivityContext`). No store or query imports, so it is testable in node. - New `analysisContextQuery()` in `queries.ts`: AFK filtering and the user's category rules run server-side; the client receives the timeline, browser domains, and unfiltered tracked duration. - Context carries: tracked-vs-active coverage, category rollups, apps with share and distinct-title count, browser domains, per-day active time, and focus stats (app switches, block count, longest/median block). - Privacy filter (Erik's ask in #925): exclude uncategorized activity and exclude categories the user marked `data.private`. Privacy is user-controlled category metadata, not a hard-coded name list, so it survives renames. Withheld time and coverage are reported in the context and shown in the UI before generation. Domains are dropped entirely while a filter is active, since browser events carry no category and cannot be filtered by one. - Titles and full URLs never leave the device; titles are reduced to a per-app distinct count. Sensitive fields are documented in the module header. - Every bounded list reports `{shown, total, otherSeconds}` so a truncated section is visible to the model rather than silently partial. - 33 unit tests covering aggregation, missing buckets/rules, percentage denominators, truncation, privacy semantics, and formatting. The flat `aggregateEvents`/`buildSummaryText` helpers are superseded and removed. Co-Authored-By: Bob Git-Session-Id: c848d0b8-8cd6-51b0-bb94-afe6998916fc --- src/queries.ts | 29 ++ src/util/activityContext.ts | 498 +++++++++++++++++++++++++ src/util/aiSummary.ts | 47 +-- src/views/AISummaryView.vue | 80 +++- test/unit/activityContext.test.node.ts | 331 ++++++++++++++++ test/unit/aiSummary.test.node.ts | 99 +---- 6 files changed, 927 insertions(+), 157 deletions(-) create mode 100644 src/util/activityContext.ts create mode 100644 test/unit/activityContext.test.node.ts diff --git a/src/queries.ts b/src/queries.ts index af23e40d..b90978af 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -490,6 +490,34 @@ export function activityQueryAndroid(androidbucket: string): string[] { // Returns a query that yields a dict with a key "cat_events" which is an // array of one event per category, with the duration of each event set to the sum of the category durations. +// Query for the single-pass activity-analysis context (see AISummaryView). +// +// Returns the AFK-filtered, categorized timeline plus browser domains and the +// unfiltered tracked duration, so the client can derive bounded statistics locally +// without downloading a raw, uncapped bucket. Titles and URLs stay on the device; +// see src/util/activityContext.ts for what is actually exported. +export function analysisContextQuery(params: DesktopQueryParams): string[] { + return querystr_to_array( + ` + ${canonicalEvents({ + ...params, + bid_window: escape_doublequote(params.bid_window), + bid_afk: escape_doublequote(params.bid_afk), + bid_browsers: _.map(params.bid_browsers, escape_doublequote), + })} + events = sort_by_timestamp(events); + browser_events = split_url_events(browser_events); + browser_domains = sort_by_duration(merge_events_by_keys(browser_events, ["$domain"])); + browser_domains = limit_events(browser_domains, ${default_limit}); + tracked_events = ${queryBucket(escape_doublequote(params.bid_window))}; + RETURN = { + "events": events, + "browser_domains": browser_domains, + "tracked_duration": sum_durations(tracked_events) + };` + ); +} + export function categoryQuery( params: MultiQueryParams | DesktopQueryParams | AndroidQueryParams ): string[] { @@ -503,6 +531,7 @@ export function categoryQuery( export default { fullDesktopQuery, + analysisContextQuery, multideviceQuery, appQuery, activityQuery, diff --git a/src/util/activityContext.ts b/src/util/activityContext.ts new file mode 100644 index 00000000..9bd8e17d --- /dev/null +++ b/src/util/activityContext.ts @@ -0,0 +1,498 @@ +/** + * Provider-independent activity context for single-pass LLM analysis. + * + * Builds a compact, bounded summary from AFK-filtered and categorized events so a + * model with no ActivityWatch tools can answer useful questions without receiving + * raw personal event data. + * + * ## Privacy + * + * All statistics are derived locally; only the derived shape below leaves the device. + * + * Fields that MAY contain sensitive information: + * - `apps[].app` — application names (e.g. a therapy or banking app) + * - `domains[].domain` — browser domains (host only, never the full URL) + * - `categories[].category` — the user's own category names + * + * Fields that never leave the device: window titles, full URLs, event timestamps. + * Titles are reduced to a per-app distinct count (`apps[].titleCount`). + * + * Two opt-in filters narrow what is exported (see {@link PrivacyOptions}): + * - `excludeUncategorized` — drop activity that matched no category rule + * - `excludePrivateCategories` — drop categories the user marked `data.private` + * + * Both report their effect in {@link ActivityContext.privacy} so the user can see + * exactly how much activity was withheld before generating. + */ + +/** A single AFK-filtered, categorized window event. */ +export interface ContextEvent { + timestamp: string; + duration: number; // seconds + data: { + app?: string; + title?: string; + $category?: string[]; + [key: string]: unknown; + }; +} + +/** A browser event merged by domain (`$domain`), as returned by the query layer. */ +export interface DomainEvent { + duration: number; // seconds + data: { + $domain?: string; + [key: string]: unknown; + }; +} + +/** A category the user marked as private/sensitive. */ +export type CategoryName = string[]; + +export interface PrivacyOptions { + /** Drop activity that matched no category rule. */ + excludeUncategorized: boolean; + /** Drop activity in these categories (and their sub-categories). */ + privateCategories: CategoryName[]; +} + +export interface BuildContextInput { + events: ContextEvent[]; + domainEvents?: DomainEvent[]; + /** Total time the window watcher recorded, before AFK filtering. */ + trackedSeconds: number; + start: Date; + end: Date; + hosts: string[]; + timezone: string; + privacy: PrivacyOptions; + limits?: Partial; +} + +export interface ContextLimits { + apps: number; + domains: number; + categories: number; +} + +export const DEFAULT_LIMITS: ContextLimits = { apps: 15, domains: 10, categories: 15 }; + +export interface AppStat { + app: string; + duration: number; + /** Share of exported active time, 0-1. */ + share: number; + /** Number of distinct window titles seen. The titles themselves are not exported. */ + titleCount: number; +} + +export interface DomainStat { + domain: string; + duration: number; + share: number; +} + +export interface CategoryStat { + /** Full category path, e.g. `['Work', 'Programming']`. */ + category: string[]; + duration: number; + share: number; +} + +export interface DayStat { + /** ISO date (YYYY-MM-DD) in the context timezone. */ + date: string; + duration: number; +} + +export interface FocusStats { + /** + * Number of transitions between different apps in the AFK-filtered timeline. + * Consecutive events in the same app count as one block, not a switch. + */ + appSwitches: number; + /** Number of same-app blocks. */ + blockCount: number; + /** Duration of the longest uninterrupted same-app block, in seconds. */ + longestBlockSeconds: number; + /** Median block duration, in seconds. */ + medianBlockSeconds: number; +} + +export interface TruncationNote { + shown: number; + total: number; + /** Time in the entries that were dropped by the limit. */ + otherSeconds: number; +} + +export interface PrivacyReport { + excludeUncategorized: boolean; + /** Categories excluded because the user marked them private. */ + excludedCategories: string[][]; + /** Active time withheld by the privacy filters, in seconds. */ + excludedSeconds: number; + /** Share of active time that survived the filters, 0-1. */ + coverage: number; +} + +export interface ActivityContext { + range: { + start: string; + end: string; + days: number; + timezone: string; + }; + hosts: string[]; + coverage: { + /** Time recorded by the window watcher, before AFK filtering. */ + trackedSeconds: number; + /** Time left after AFK filtering, before privacy filters. */ + activeSeconds: number; + /** Time actually summarized below, after privacy filters. */ + exportedSeconds: number; + /** activeSeconds / trackedSeconds, 0-1. `null` when nothing was tracked. */ + activeShare: number | null; + }; + apps: AppStat[]; + domains: DomainStat[]; + categories: CategoryStat[]; + daily: DayStat[]; + focus: FocusStats; + privacy: PrivacyReport; + truncation: { + apps: TruncationNote; + domains: TruncationNote; + categories: TruncationNote; + }; +} + +export const UNCATEGORIZED = ['Uncategorized']; + +function isUncategorized(category: string[] | undefined): boolean { + if (!category || category.length === 0) return true; + return category.length === UNCATEGORIZED.length && category[0] === UNCATEGORIZED[0]; +} + +/** + * True when `category` is `parent` or a sub-category of it. + * `['Work', 'Programming']` is under `['Work']`, but `['Workout']` is not. + */ +export function isUnderCategory(category: string[], parent: string[]): boolean { + if (parent.length === 0 || category.length < parent.length) return false; + return parent.every((segment, i) => category[i] === segment); +} + +/** + * Categories the user marked private via category metadata (`data.private === true`). + * + * Privacy is user-controlled metadata rather than a hard-coded list of names, so it + * survives renames and works for categories we have never heard of. + */ +export function privateCategoriesFrom( + classes: { name: string[]; data?: Record }[] +): CategoryName[] { + return classes.filter(c => c.data?.private === true).map(c => c.name); +} + +function shouldExclude(category: string[] | undefined, privacy: PrivacyOptions): boolean { + if (privacy.excludeUncategorized && isUncategorized(category)) return true; + if (!category) return false; + return privacy.privateCategories.some(p => isUnderCategory(category, p)); +} + +function share(duration: number, total: number): number { + return total > 0 ? duration / total : 0; +} + +/** Take the top `limit` entries and describe what the cut dropped. */ +function truncate( + entries: T[], + limit: number +): [T[], TruncationNote] { + const shown = entries.slice(0, limit); + const dropped = entries.slice(limit); + return [ + shown, + { + shown: shown.length, + total: entries.length, + otherSeconds: dropped.reduce((sum, e) => sum + e.duration, 0), + }, + ]; +} + +function dayKey(timestamp: string, timezone: string): string { + const date = new Date(timestamp); + if (isNaN(date.getTime())) return 'unknown'; + try { + // en-CA renders as YYYY-MM-DD. + return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(date); + } catch { + return date.toISOString().slice(0, 10); + } +} + +/** + * Focus statistics over the AFK-filtered timeline. + * + * Events are assumed to be sorted by timestamp. Consecutive events in the same app + * are merged into one block, so `appSwitches` counts real context switches rather + * than watcher heartbeats. + */ +export function computeFocusStats(events: ContextEvent[]): FocusStats { + const blocks: number[] = []; + let currentApp: string | null = null; + let currentDuration = 0; + + for (const event of events) { + const app = event.data?.app || 'unknown'; + if (app === currentApp) { + currentDuration += event.duration || 0; + } else { + if (currentApp !== null) blocks.push(currentDuration); + currentApp = app; + currentDuration = event.duration || 0; + } + } + if (currentApp !== null) blocks.push(currentDuration); + + const sorted = [...blocks].sort((a, b) => a - b); + const median = sorted.length + ? sorted.length % 2 === 1 + ? sorted[(sorted.length - 1) / 2] + : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 + : 0; + + return { + appSwitches: Math.max(0, blocks.length - 1), + blockCount: blocks.length, + longestBlockSeconds: blocks.length ? Math.max(...blocks) : 0, + medianBlockSeconds: median, + }; +} + +export function buildActivityContext(input: BuildContextInput): ActivityContext { + const limits = { ...DEFAULT_LIMITS, ...(input.limits || {}) }; + const events = input.events || []; + + const activeSeconds = events.reduce((sum, e) => sum + (e.duration || 0), 0); + + const kept: ContextEvent[] = []; + const excludedCategoryKeys = new Set(); + let excludedSeconds = 0; + for (const event of events) { + const category = event.data?.$category; + if (shouldExclude(category, input.privacy)) { + excludedSeconds += event.duration || 0; + excludedCategoryKeys.add(JSON.stringify(category ?? UNCATEGORIZED)); + } else { + kept.push(event); + } + } + const exportedSeconds = kept.reduce((sum, e) => sum + (e.duration || 0), 0); + + // Apps, with distinct-title counts (the titles themselves stay local). + const appDurations: Record = {}; + const appTitles: Record> = {}; + for (const event of kept) { + const app = event.data?.app || 'unknown'; + appDurations[app] = (appDurations[app] || 0) + (event.duration || 0); + if (!appTitles[app]) appTitles[app] = new Set(); + if (event.data?.title) appTitles[app].add(event.data.title); + } + const allApps: AppStat[] = Object.entries(appDurations) + .map(([app, duration]) => ({ + app, + duration, + share: share(duration, exportedSeconds), + titleCount: appTitles[app]?.size ?? 0, + })) + .sort((a, b) => b.duration - a.duration); + + // Categories. + const categoryDurations: Record = {}; + for (const event of kept) { + const key = JSON.stringify(event.data?.$category ?? UNCATEGORIZED); + categoryDurations[key] = (categoryDurations[key] || 0) + (event.duration || 0); + } + const allCategories: CategoryStat[] = Object.entries(categoryDurations) + .map(([key, duration]) => ({ + category: JSON.parse(key) as string[], + duration, + share: share(duration, exportedSeconds), + })) + .sort((a, b) => b.duration - a.duration); + + // Domains. Browser events carry no category, so only the uncategorized filter + // can apply — and it cannot, so domains are dropped entirely when either filter + // is on rather than leaking activity the user asked to withhold. + const privacyFilterActive = + input.privacy.excludeUncategorized || input.privacy.privateCategories.length > 0; + const domainDurations: Record = {}; + if (!privacyFilterActive) { + for (const event of input.domainEvents || []) { + const domain = event.data?.$domain; + if (!domain) continue; + domainDurations[domain] = (domainDurations[domain] || 0) + (event.duration || 0); + } + } + const domainTotal = Object.values(domainDurations).reduce((sum, d) => sum + d, 0); + const allDomains: DomainStat[] = Object.entries(domainDurations) + .map(([domain, duration]) => ({ domain, duration, share: share(duration, domainTotal) })) + .sort((a, b) => b.duration - a.duration); + + // Daily distribution. + const dayDurations: Record = {}; + for (const event of kept) { + const key = dayKey(event.timestamp, input.timezone); + dayDurations[key] = (dayDurations[key] || 0) + (event.duration || 0); + } + const daily: DayStat[] = Object.entries(dayDurations) + .map(([date, duration]) => ({ date, duration })) + .sort((a, b) => (a.date < b.date ? -1 : 1)); + + const [apps, appsTruncation] = truncate(allApps, limits.apps); + const [domains, domainsTruncation] = truncate(allDomains, limits.domains); + const [categories, categoriesTruncation] = truncate(allCategories, limits.categories); + + const days = Math.max( + 1, + Math.round((input.end.getTime() - input.start.getTime()) / (24 * 60 * 60 * 1000)) + ); + + return { + range: { + start: input.start.toISOString(), + end: input.end.toISOString(), + days, + timezone: input.timezone, + }, + hosts: input.hosts, + coverage: { + trackedSeconds: input.trackedSeconds, + activeSeconds, + exportedSeconds, + activeShare: input.trackedSeconds > 0 ? activeSeconds / input.trackedSeconds : null, + }, + apps, + domains, + categories, + daily, + focus: computeFocusStats(kept), + privacy: { + excludeUncategorized: input.privacy.excludeUncategorized, + excludedCategories: Array.from(excludedCategoryKeys).map(k => JSON.parse(k) as string[]), + excludedSeconds, + coverage: activeSeconds > 0 ? exportedSeconds / activeSeconds : 1, + }, + truncation: { + apps: appsTruncation, + domains: domainsTruncation, + categories: categoriesTruncation, + }, + }; +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +function formatShare(value: number): string { + return `${Math.round(value * 100)}%`; +} + +/** Render the context as the compact text actually sent to the provider. */ +export function formatActivityContext(ctx: ActivityContext): string { + const lines: string[] = []; + + lines.push( + `Activity context — ${ctx.range.start.slice(0, 10)} to ${ctx.range.end.slice(0, 10)} ` + + `(${ctx.range.days} day(s), ${ctx.range.timezone})` + ); + lines.push(`Host(s): ${ctx.hosts.join(', ') || 'unknown'}`); + const activeShare = + ctx.coverage.activeShare === null ? 'n/a' : formatShare(ctx.coverage.activeShare); + lines.push( + `Tracked: ${formatDuration(ctx.coverage.trackedSeconds)} — ` + + `active after AFK filtering: ${formatDuration(ctx.coverage.activeSeconds)} (${activeShare})` + ); + lines.push(`Summarized below: ${formatDuration(ctx.coverage.exportedSeconds)}`); + + if (ctx.privacy.excludedSeconds > 0 || ctx.privacy.excludedCategories.length > 0) { + lines.push(''); + lines.push( + `Privacy filter: withheld ${formatDuration(ctx.privacy.excludedSeconds)} ` + + `(${formatShare(1 - ctx.privacy.coverage)} of active time) across ` + + ctx.privacy.excludedCategories.map(c => c.join(' > ')).join(', ') + ); + } + + if (ctx.categories.length) { + lines.push(''); + lines.push('Categories:'); + for (const c of ctx.categories) { + lines.push( + ` ${c.category.join(' > ')}: ${formatDuration(c.duration)} (${formatShare(c.share)})` + ); + } + if (ctx.truncation.categories.otherSeconds > 0) { + lines.push( + ` (+${ctx.truncation.categories.total - ctx.truncation.categories.shown} more, ` + + `${formatDuration(ctx.truncation.categories.otherSeconds)})` + ); + } + } + + if (ctx.apps.length) { + lines.push(''); + lines.push('Applications (distinct-title count in parentheses; titles not included):'); + for (const a of ctx.apps) { + lines.push( + ` ${a.app}: ${formatDuration(a.duration)} (${formatShare(a.share)}, ${ + a.titleCount + } titles)` + ); + } + if (ctx.truncation.apps.otherSeconds > 0) { + lines.push( + ` (+${ctx.truncation.apps.total - ctx.truncation.apps.shown} more, ` + + `${formatDuration(ctx.truncation.apps.otherSeconds)})` + ); + } + } + + if (ctx.domains.length) { + lines.push(''); + lines.push('Browser domains (host only, no paths or query strings):'); + for (const d of ctx.domains) { + lines.push(` ${d.domain}: ${formatDuration(d.duration)} (${formatShare(d.share)})`); + } + if (ctx.truncation.domains.otherSeconds > 0) { + lines.push( + ` (+${ctx.truncation.domains.total - ctx.truncation.domains.shown} more, ` + + `${formatDuration(ctx.truncation.domains.otherSeconds)})` + ); + } + } + + if (ctx.daily.length) { + lines.push(''); + lines.push('Active time per day:'); + for (const d of ctx.daily) { + lines.push(` ${d.date}: ${formatDuration(d.duration)}`); + } + } + + lines.push(''); + lines.push('Focus:'); + lines.push(` App switches: ${ctx.focus.appSwitches} across ${ctx.focus.blockCount} block(s)`); + lines.push(` Longest uninterrupted block: ${formatDuration(ctx.focus.longestBlockSeconds)}`); + lines.push(` Median block: ${formatDuration(ctx.focus.medianBlockSeconds)}`); + + return lines.join('\n'); +} diff --git a/src/util/aiSummary.ts b/src/util/aiSummary.ts index 1ba5d793..0322178b 100644 --- a/src/util/aiSummary.ts +++ b/src/util/aiSummary.ts @@ -1,45 +1,8 @@ -export interface AppUsage { - app: string; - duration: number; // seconds -} - -export interface ActivitySummaryData { - topApps: AppUsage[]; - totalDuration: number; // seconds - periodDays: number; -} - -export function aggregateEvents(events: any[]): AppUsage[] { - const byApp: Record = {}; - for (const event of events) { - const app = event.data?.app || event.data?.title || 'unknown'; - byApp[app] = (byApp[app] || 0) + (event.duration || 0); - } - return Object.entries(byApp) - .map(([app, duration]) => ({ app, duration })) - .sort((a, b) => b.duration - a.duration); -} - -export function formatDurationHuman(seconds: number): string { - if (seconds < 60) return `${Math.round(seconds)}s`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m`; - const h = Math.floor(seconds / 3600); - const m = Math.round((seconds % 3600) / 60); - return m > 0 ? `${h}h ${m}m` : `${h}h`; -} - -export function buildSummaryText(data: ActivitySummaryData): string { - const lines: string[] = [ - `Activity summary — past ${data.periodDays} day(s):`, - `Total tracked time: ${formatDurationHuman(data.totalDuration)}`, - '', - 'Top applications by time:', - ]; - for (const item of data.topApps.slice(0, 20)) { - lines.push(` ${item.app}: ${formatDurationHuman(item.duration)}`); - } - return lines.join('\n'); -} +/** + * LLM provider plumbing for the AI activity summary page. + * + * The activity context sent to the provider is built by `~/util/activityContext`. + */ export type LLMProvider = 'openai' | 'anthropic'; diff --git a/src/views/AISummaryView.vue b/src/views/AISummaryView.vue index 272fdd99..625d81a1 100644 --- a/src/views/AISummaryView.vue +++ b/src/views/AISummaryView.vue @@ -36,6 +36,20 @@ div b-form-group(label="Model" label-class="font-weight-bold") b-form-input(v-model="model" placeholder="e.g. gpt-4o-mini" @blur="persistConfig") + div.mb-3 + b-form-group(label="Privacy" label-class="font-weight-bold") + b-form-checkbox(v-model="excludeUncategorized") + | Exclude uncategorized activity + b-form-checkbox(v-model="excludePrivateCategories") + | Exclude categories marked private + span.text-muted.ml-1(v-if="privateCategories.length") + | ({{ privateCategories.map(c => c.join(' > ')).join(', ') }}) + span.text-muted.ml-1(v-else) + | (none marked yet — set #[code private: true] in a category's data) + small.text-muted + | Browser domains are omitted entirely while either filter is on, since browser + | events carry no category and cannot be filtered by it. + div.mb-3 b-form-group(label="Prompt" label-class="font-weight-bold") b-form-textarea(v-model="userPrompt" rows="3" max-rows="8") @@ -48,7 +62,7 @@ div v-if="aggregatedText" variant="outline-secondary" @click="dataVisible = !dataVisible" - ) {{ dataVisible ? 'Hide raw data' : 'Show raw data' }} + ) {{ dataVisible ? 'Hide context' : 'Show context sent' }} b-alert(v-if="error" variant="danger" show dismissible @dismissed="error = ''") | {{ error }} @@ -56,7 +70,7 @@ div div(v-if="dataVisible && aggregatedText") b-card.mb-3 template(slot="header") - strong Raw activity data sent to LLM + strong Exact context sent to the LLM pre.mb-0(style="white-space: pre-wrap; font-size: 0.85em") {{ aggregatedText }} div(v-if="llmResponse") @@ -72,15 +86,16 @@ div