feat(ai-summary): typed, privacy-filtered activity context (#925) - #948
feat(ai-summary): typed, privacy-filtered activity context (#925)#948TimeToBuildBob wants to merge 6 commits into
Conversation
…tivityWatch#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 ActivityWatch#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 <bob@superuserlabs.org>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #948 +/- ##
==========================================
+ Coverage 49.36% 51.76% +2.39%
==========================================
Files 45 46 +1
Lines 2769 2942 +173
Branches 620 686 +66
==========================================
+ Hits 1367 1523 +156
- Misses 1322 1338 +16
- Partials 80 81 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Greptile SummaryThe PR replaces the uncapped raw-event AI summary with a typed, bounded, privacy-filtered context derived through the query layer.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
B[Selected host buckets] --> Q[AFK-filtered categorized query]
C[Category metadata] --> Q
Q --> P[Local privacy filtering]
P --> A[Bounded activity context]
A --> V[Exact-context preview]
A --> L[Selected LLM provider]
L --> R[Displayed summary]
Reviews (3): Last reviewed commit: "fix(ai-summary): use bucketsBrowser() to..." | Re-trigger Greptile |
The aw-client query() signature takes `string | {start, end}`, not a
`[Date, Date]` tuple. Caught by fork-ts-checker in the webpack build;
the vite build and `tsc --noEmit` do not typecheck .vue script blocks.
- queries.ts: empty filter_categories=[] was passed through as a truthy
allow-list, causing filter_keyvals to drop every event. Now skipped
when the array is empty so no-op filtering leaves events untouched.
- queries.ts: missing AFK bucket (bid_afk='') still emitted
query_bucket("") which is rejected by the server. Now emits
'not_afk = [];' as a safe fallback when bid_afk is empty.
- AISummaryView.vue: browser buckets were collected from all hosts, not
just the selected host. Cross-host domains could be sent to the LLM
under a different device's summary. Fixed by scoping the filter to
b.hostname === selectedHost.
- activityContext.ts: formatActivityContext included excluded category
names (e.g. 'Health', 'Finance') in the provider-bound text, defeating
the privacy intent. Now emits only the count of excluded categories.
- activityContext.ts: events spanning local midnight were fully attributed
to the start day. Added accumulateByDay() which splits durations at
midnight via binary-search on the timezone boundary, and replaces the
single-key dayDurations loop.
Tests: updated formatActivityContext privacy test to assert names are NOT
present; added midnight-spanning split test; added plural-category test.
All 35 unit tests pass.
|
Addressed the five P1 issues from the Greptile review (commit 415e985): 1. Empty 2. Missing AFK bucket caused query failure — When no AFK bucket exists, 3. Browser buckets crossed hosts — 4. Excluded private category names leaked to the LLM — 5. Events spanning midnight misattributed — Duration was fully assigned to the start day. Added Tests updated accordingly; all 35 unit tests pass. |
|
@greptileai review |
CI runs eslint with --max-warnings 0, so the four @typescript-eslint/no-non-null-assertion warnings introduced in 415e985 failed the Lint job and, through it, Build and all three Test matrices. `expect(x).toBeDefined()` does not narrow the type, so assert against a date->duration map instead — same coverage, no assertions.
- Replace !-assertions in activityContext midnight-split test with
Object.fromEntries to satisfy @typescript-eslint/no-non-null-assertion
- Update queries.test.node.js.snap: remove filter_keyvals('$category')
line that was correctly dropped when filter_categories is empty (P1 fix)
|
Fixed the remaining CI failures (commit a00f383): 1. Lint: non-null assertion warnings — 2. Test snapshot mismatch — |
…er buckets The inline web.tab.current filter used strict hostname equality, which silently dropped browser buckets stored under the 'unknown' hostname (the established AW convention for watcher-side unresolved hosts). Replace with this.bucketsStore.bucketsBrowser(host), which already implements the fallback_unknown_host logic used by all other bucket getters in the store. Fixes Greptile P1: 'Browser domains omitted'
Greptile convergence adjudication — merge recommendationContext: This PR hit the Greptile attempt cap (2 review rounds). Adjudicating the remaining P1 finding inline rather than triggering another review. Fixed this session
Root cause: The inline filter Tests: All 39 existing unit tests pass against the fix ( Remaining findingsAll prior P1 findings from round 1 (midnight interval allocation, private-name exposure, empty-filter zeroing events, missing AFK bucket, cross-host browser buckets) were resolved in commit 415e985 and confirmed resolved by the round-2 review. No P2 or P3 findings remain unresolved. CICI is running against the new head (d8b3873). Prior CI passed on a00f383 (three commits before the fix). The change is a one-line substitution with no query-side impact. Domain riskThe The Convergence
Status: Ready for maintainer review and merge. No open blocking findings. CI pending on d8b3873. |
|
@greptileai review |
|
Addressed the remaining Greptile P1 (commit d8b3873): "Browser domains omitted on Greptile re-review triggered on the current HEAD. Old review thread resolved. CI running. |
|
CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click. This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted. |
Follow-up to #922, implementing #925.
The dev-mode
/analysis/activitypage sent a single-pass LLM only total trackedduration plus a flat top-20 app list, built from an uncapped raw bucket download.
This replaces that with a typed, bounded context derived through the query layer.
What's in the context
src/util/activityContext.tsdefines a provider-independentActivityContextand pure builders over it (no store/query imports, so it's node-testable):
actually exported after privacy filtering
analysisContextQuery()inqueries.tsdoes AFK filtering and categorizationserver-side and returns the timeline, browser domains, and the unfiltered
tracked duration — instead of
getEvents(..., {limit: -1}).Privacy
@ErikBjare asked in #925 for "a way to query with a privacy-aware/scrubbed
subset (exclude uncategorized, exclude private/sensitive categories)". Both are
implemented as opt-in checkboxes:
metadata (
data.private === true), not a hard-coded name list, so itsurvives renames and covers categories we've never heard of. Sub-categories
of a private category are excluded too (
['Private','Messaging']under['Private'], while['Workout']is not under['Work']).The context reports exactly how much was withheld (
privacy.excludedSeconds,privacy.coverage) and renders it in the text, so the user can verify whatleft the device before generating.
Browser domains are dropped entirely while either filter is active: browser
events carry no
$category, so they can't be filtered by one, and leaking themwould defeat the filter the user just enabled.
Fields that may contain sensitive information
Documented in the module header:
apps[].appdomains[].domaincategories[].categoryNever exported: window titles, full URLs, event timestamps. Titles are reduced
to a per-app distinct count.
Truncation
Every bounded list reports
{shown, total, otherSeconds}and renders a(+N more, Xh Ym)line, so a truncated section is visible to the model ratherthan silently partial.
Acceptance criteria
Testing
33 new unit tests in
test/unit/activityContext.test.node.ts;tsc --noEmitclean; eslint clean. The superseded
aggregateEvents/buildSummaryTexthelpers and their tests are removed — nothing else referenced them.