feat(research): add APP_TO_CATEGORY support for non-browser apps - #136
Conversation
Non-browser apps previously kept the raw app name as-is while the window title was dropped. Matthias's classifier specifies that app names should be replaced by broad study categories (or 'Excluded' for unmapped apps), which is stricter: unknown apps stop being named at all. Changes: - research_filter.py: add classify_app() + update transform() to accept an optional app_category_map kwarg; non-browser branch now maps app → category when the map is supplied, preserving backward-compat when it is absent - config.py: add [aw-watcher-window.research_app_category_map] TOML section and expose it as parsed_args.research_app_category_map - macos_cli.py: pass --research-app-category app category pairs to the Swift helper when a map is provided - macos.swift: add --research-app-category argument parsing, classifyApp() function, and update applyResearchFilter() non-browser branch to use it - main.py: thread research_app_category_map through heartbeat_loop(), build_swift_command(), and transform_window() - tests: add TestClassifyApp (7 cases) and TestTransformWithAppMap (8 cases); all 43 tests pass Closes the spec gap flagged in phase 1 of the research-edition goal arc. The CI patch script in ActivityWatch/activitywatch also needs updating to inject the app map — that is a separate commit in the activitywatch repo.
Greptile SummaryThe PR adds configurable Research Edition categorization for non-browser applications while retaining legacy behavior when no application map is configured.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (3): Last reviewed commit: "fix(research): trim configured app keys ..." | Re-trigger Greptile |
| # otherwise keep the raw app name and drop the title. | ||
| if app_category_map is not None: | ||
| app_category = classify_app(app, app_category_map) | ||
| result = {"app": app, "title": app_category} |
There was a problem hiding this comment.
Raw application names remain exposed
When Research mode uses an application-category map, this branch keeps the original application name in app and writes the category to title, causing mapped and unknown application identities to remain recorded instead of being replaced. How this was verified: The transformed dictionary is sent directly as event data, and the Swift path preserves the same raw app field.
| if app_category_map is not None: | ||
| app_category = classify_app(app, app_category_map) | ||
| result = {"app": app, "title": app_category} |
There was a problem hiding this comment.
Empty map disables legacy behavior
When Research mode uses the default empty application map, {} passes this non-None check and classifies every non-browser application as Excluded, causing Python strategies to emit a different event shape than the documented legacy behavior and the Swift strategy.
| if app_category_map is not None: | |
| app_category = classify_app(app, app_category_map) | |
| result = {"app": app, "title": app_category} | |
| if app_category_map: | |
| app_category = classify_app(app, app_category_map) | |
| result = {"app": app, "title": app_category} |
| app_lower = app.strip().lower() | ||
| return app_category_map.get(app_lower, "Excluded") |
There was a problem hiding this comment.
Configured keys remain case-sensitive
When the application map contains a mixed-case key such as Spotify, this code lowercases only the observed app before performing an exact dictionary lookup, causing Python strategies to emit Excluded while the Swift strategy resolves the same mapping.
| app_lower = app.strip().lower() | |
| return app_category_map.get(app_lower, "Excluded") | |
| app_lower = app.strip().lower() | |
| for configured_app, category in app_category_map.items(): | |
| if configured_app.strip().lower() == app_lower: | |
| return category | |
| return "Excluded" |
|
@TimeToBuildBob fix |
…e cases
Addresses Greptile review defects on the APP_TO_CATEGORY feature:
1. Privacy: non-browser app names were retained in 'app' while the category
was written to 'title', leaking raw application identities. The category
now REPLACES 'app' and the title is dropped, matching the documented
anonymization contract ("otherwise replaced with 'Excluded'").
2. Empty map treated as active: an empty research_app_category_map (the
default when research_enabled but no map configured) classified every
non-browser app as 'Excluded' on the Python path. Empty map now falls back
to legacy behaviour (keep app name, drop title), consistent with Swift.
3. Case-insensitive contract: classify_app assumed lowercased map keys,
failing for mixed-case configured keys. Lookup now normalizes both sides.
Swift applyResearchFilter updated to match (replace app with category).
|
Fixed the three Greptile-flagged defects in
Swift |
|
@greptileai review |
| // Case-insensitive exact lookup of app name in the app category map. | ||
| // Returns the mapped category, or "Excluded" when the app is not in the map. | ||
| let appLower = app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| for item in researchAppCategoryMap { |
There was a problem hiding this comment.
Configured app keys are not trimmed
When a valid quoted TOML application key contains leading or trailing whitespace, Swift lowercases the configured key without trimming it while Python trims both sides, causing the Swift strategy to emit Excluded for an application that the Python strategy maps to its configured category.
|
@greptileai review |
|
Fixed the Swift/Python normalization parity gap in
This is load-bearing for the study: the Research Edition participant who reported the original issue is on macOS, so the Swift path is the hot path there. Both sides now trim and lowercase. |
| [aw-watcher-window.research_category_map] | ||
|
|
||
| [aw-watcher-window.research_app_category_map] |
There was a problem hiding this comment.
Why add both these options/configs? Shouldn't they be a single option? And we already have research_enabled = false? Why are they in default config at all? (dev/research options shouldn't get persisted as default/initial config)
There was a problem hiding this comment.
Fixed in #137.
Why they were in default config at all — that was the real bug, and worse than it looked: load_config_toml() comments out keys but not table headers, so the two [aw-watcher-window.research_*_category_map] tables landed uncommented in every fresh install's config file. They were the only live lines in it. Now moved to a separate template that is read but never written to disk; a fresh config has zero research keys (verified by running against an empty config dir, not by reading the diff).
Should they be one option — I think not, because the two maps match differently: research_category_map is a case-insensitive substring match on browser URL/title, research_app_category_map is an exact match on app name. One table would make a key like "mail" both at once. Also research_enabled is the switch and the maps are its data, so not redundant. Say the word if you still want them collapsed — I'd pick one matching rule for both first.
One thing worth knowing: the Research Edition build seds this exact file from the superproject (s/^research_enabled = false$/research_enabled = true/). Kept the target byte-identical and pinned it with a test, since that coupling is cross-repo and would break silently.
* fix(config): keep research options out of the first-run config
load_config_toml() writes default_config into the user's config file on first
run. It comments out keys, but deliberately does NOT comment out table headers,
so `[aw-watcher-window.research_category_map]` and
`[aw-watcher-window.research_app_category_map]` landed verbatim in every fresh
install's aw-watcher-window.toml — study knobs persisted into the config of
users who are not in any study.
Move the research defaults out of default_config into a separate
`research_defaults` template that is merged in code but never written to disk.
User-set research options are still read exactly as before.
The Research Edition release build patches this file with
`sed -i 's/^research_enabled = false$/research_enabled = true/'`
(ActivityWatch/activitywatch .github/workflows/release.yml), so the sed target
is kept byte-identical and pinned by a test — that coupling lives in another
repo and would otherwise break silently.
Verified by symptom on an empty config dir, before and after:
before: [aw-watcher-window.research_category_map] + _app_ written to disk
after: 0 research keys written; research_enabled still True in a sed-patched
Research Edition build; user-set maps still read
Follow-up to #136 (review comment r3791621767).
* test(config): pin the sed target in the source file, not the evaluated string
Greptile P2: the guard read config_module.research_defaults, but the release
build's sed anchors ^research_enabled = false$ in the source FILE. Indenting the
line inside the triple-quoted literal strips to the same value, so the old check
stayed green while the Research Edition build broke.
Now reads the module source and asserts the anchored line, plus that the flip
still yields an enabled template. Mutation-verified: indenting the line fails
this test (it passed before).
* test(config): verify release sed against source file, not runtime string
The previous second assertion applied the sed pattern to the evaluated
config_module.research_defaults string. .strip() makes that value
identical to the source content today, but it would stay green even if the
source line got indented — exactly the gap the first assertion was added to
close.
Add an intermediate step that applies the sed to the raw source file and
asserts the replacement appears at column 0 there, directly simulating
what ActivityWatch/activitywatch release.yml sed actually does.
Co-Authored-By: Bob <bob@superuserlabs.org>
#1397) * feat(research): inject APP_TO_CATEGORY map into research config at build time Adds APP_CATEGORY_MAP (72 entries, faithfully derived from Matthias Lehner's APP_TO_CATEGORY dict) to the CI patch script. When [aw-watcher-window.research_app_category_map] is present in the submodule's config.py (requires ActivityWatch/aw-watcher-window#136 in the pin), the patch script now injects the app map alongside the existing URL/title category map. Non-browser apps are replaced by a broad study category (e.g. 'Microsoft Outlook' → 'Email', 'Spotify' → 'Music & Audio') instead of keeping the raw app name. Unmapped apps become 'Excluded'. The injection is non-fatal when the section is absent so this commit stays compatible with older submodule pins before #136 merges. * fix(tests): unpack tuple return from patch_config in test assertions * fix(research): bump aw-watcher-window past #136 and fail closed on stale pins The submodule was pinned at 624823a7, exactly one commit behind the merged APP_TO_CATEGORY support (a297914c, #136). That pin has no [aw-watcher-window.research_app_category_map] section, and the injection step treated a missing section as non-fatal — so the build went green, printed a note into the CI log, and produced an artifact where non-browser apps keep their raw names. That is the exact symptom the map exists to fix. - bump aw-watcher-window 624823a7 -> a297914c (merged #136) - patch_config() now raises when the app-map section is absent - regression test for the pre-#136 pin Verified by symptom against both pins' real config.py: new pin injects 570 patterns + 72 app entries (exit 0); old pin aborts (exit 1).
…edition ready) (#1) * fix(release): consistent release asset naming across editions (ActivityWatch#1393) Unify all asset filenames to: activitywatch[-tauri][-research]-<version>-<os>-<arch>[-setup].<ext> Previously research-edition assets mixed three conventions (edition token before the version in zips, after the arch in dmg/deb/rpm, and one AppImage without a version), and said 'research' twice since the tag already carries a -research suffix. - Strip the -research tag suffix from the version part of filenames (and from the Debian control version, where it would parse as a revision); the edition lives in its own token after the product name. - Rename the edition token from '-research-edition' to '-research'. - Move edition naming for AppImage/deb into the package scripts, dropping the post-hoc mv hacks in the workflow. - Document why the Qt AppImage filename is unversioned (stable releases/latest/download URL). * ci(build-tauri): fail fast if aw-server-rust submodule is on an older release line (ActivityWatch#1391) * ci(activitywatch): verify aw-server-rust submodule matches release tag in build-tauri Adds a fast-fail step in build-tauri that reads the bundled aw-server version from aw-server-rust/aw-server/Cargo.toml and compares the major.minor prefix against the AW release tag. If the submodule is pinned to an older release line, the build fails immediately rather than producing a 30-min Tauri bundle with the wrong backend (the Windows 0.14 release shipped with aw-server v0.13.1 due to this). Fixes ActivityWatch#1380 * ci(build-tauri): add missing aw-server-rust version guard The version-freshness check landed in build-qt but not build-tauri — the job that actually shipped the mismatched bundle in ActivityWatch#1380. Add the same fail-fast step to build-tauri so all five Tauri matrix jobs are guarded, not just the independent Qt build. --------- Co-authored-by: Bob <bob@bob.local> * feat(research): inject APP_TO_CATEGORY map for non-browser app privacy (ActivityWatch#1397) * feat(research): inject APP_TO_CATEGORY map into research config at build time Adds APP_CATEGORY_MAP (72 entries, faithfully derived from Matthias Lehner's APP_TO_CATEGORY dict) to the CI patch script. When [aw-watcher-window.research_app_category_map] is present in the submodule's config.py (requires ActivityWatch/aw-watcher-window#136 in the pin), the patch script now injects the app map alongside the existing URL/title category map. Non-browser apps are replaced by a broad study category (e.g. 'Microsoft Outlook' → 'Email', 'Spotify' → 'Music & Audio') instead of keeping the raw app name. Unmapped apps become 'Excluded'. The injection is non-fatal when the section is absent so this commit stays compatible with older submodule pins before ActivityWatch#136 merges. * fix(tests): unpack tuple return from patch_config in test assertions * fix(research): bump aw-watcher-window past ActivityWatch#136 and fail closed on stale pins The submodule was pinned at 624823a7, exactly one commit behind the merged APP_TO_CATEGORY support (a297914c, ActivityWatch#136). That pin has no [aw-watcher-window.research_app_category_map] section, and the injection step treated a missing section as non-fatal — so the build went green, printed a note into the CI log, and produced an artifact where non-browser apps keep their raw names. That is the exact symptom the map exists to fix. - bump aw-watcher-window 624823a7 -> a297914c (merged ActivityWatch#136) - patch_config() now raises when the app-map section is absent - regression test for the pre-ActivityWatch#136 pin Verified by symptom against both pins' real config.py: new pin injects 570 patterns + 72 app entries (exit 0); old pin aborts (exit 1). * fix(research): survive the ActivityWatch#137 config layout, and anchor the flag rewrite aw-watcher-window#137 moved the research knobs out of `default_config` into a separate `research_defaults` template, so they stop being persisted into every fresh install's config file. That is the right fix, but it removes both TOML table headers the Research Edition patch script anchors on. Two defects follow. 1. Injection breaks on the next submodule bump. `patch_config()` requires exactly one `[aw-watcher-window.research_category_map]` and one `[aw-watcher-window.research_app_category_map]` header. Neither exists past ActivityWatch#137, so the script aborts and the Research Edition build fails. The pin is currently a297914 (the ActivityWatch#136 merge), where both headers still exist -- so the build works today and breaks the moment anything, including a dependabot submodule bump, moves it forward. The fail-closed message would also misdiagnose it as 'submodule predates ActivityWatch#136' when the real cause is the opposite. 2. The flag rewrite could silently disable the Research Edition. ActivityWatch#137 documents the release-time rewrite in a comment containing the literal text `sed -i 's/^research_enabled = false$/research_enabled = true/'`, and that comment sits *above* the real flag. The unanchored `text.replace(..., 1)` therefore patches the comment and leaves `research_enabled = false`, producing a green build with research disabled and no error anywhere. Both layouts are now handled: post-ActivityWatch#137 the maps are injected into `research_defaults` with unprefixed headers (that template is parsed standalone and merged into the section key by key, so a prefixed header would create a nested key nothing reads); pre-ActivityWatch#137 the existing prefixed headers are used. The flag rewrite is line-anchored via regex, matching the contract ActivityWatch#137's own comment documents, and refuses to guess if it finds anything other than exactly one match. The pre-ActivityWatch#136 guard now keys on the runtime lookup `config.get("research_app_category_map"` rather than a table header, so it tests the capability that actually matters and survives further reshuffling of the config templates. Verified against both real config.py revisions: 570 category patterns and 72 app entries inject correctly, and the output parses as Python and as TOML in each. * build(deps): bump aw-watcher-window past ActivityWatch#137 Advance the aw-watcher-window submodule from a297914 to a7690ac, picking up ActivityWatch/aw-watcher-window#137 (fix(config): keep research options out of the first-run config). This is the prerequisite for the post-ActivityWatch#137 config layout the patch_research_edition_config.py script (c328757, in fix/re-patch- config-shape) was rewritten to handle. Without this bump, the Research Edition build would still pin to the pre-ActivityWatch#137 layout and patch in category maps that no longer exist after ActivityWatch#137 moved research knobs into a separate research_defaults template. Verified: scripts/tests/test_patch_research_edition_config.py (7 tests, all pass); live patch run against aw-watcher-window/aw_watcher_window/ config.py injects 570 unique URL/title patterns across 17 categories and 72 app-name entries, and flips research_enabled from false to true. Refs: ActivityWatch/aw-watcher-window#137, ErikBjare/bob#599, tasks/aw-research-edition-study-ops-goal-arc --------- Co-authored-by: Erik Bjäreholt <erik@bjareho.lt> Co-authored-by: Bob <bob@bob.local>
Summary
Implements
APP_TO_CATEGORYsupport for Research Edition, closing the spec gap identified in Matthias's test report (ErikBjare/bob#1108).Non-browser apps previously kept the raw app name while the window title was dropped. Matthias's classifier specifies that app names should be replaced by broad study categories (or
Excludedfor unmapped apps), which is strictly more private: unknown apps stop being named at all.Changes
Python filter (
research_filter.py)classify_app(app, app_category_map)function — case-insensitive exact lookup; returns"Excluded"on misstransform()gains an optionalapp_category_mapkeyword argument; non-browser branch uses it when supplied, falling back to the previous behaviour (title dropped, raw app name kept) when it is absentConfig (
config.py)[aw-watcher-window.research_app_category_map]TOML section todefault_configparse_args()loads and exposes it asparsed_args.research_app_category_mapmacOS CLI bridge (
macos_cli.py)build_swift_command()acceptsresearch_app_category_mapand passes--research-app-category app_name categorypairs to the Swift helperSwift helper (
macos.swift)researchAppCategoryMapglobal and--research-app-categoryargument parsingclassifyApp(_ app: String) -> Stringfunction (case-insensitive lookup, returns"Excluded"on miss)applyResearchFilter()non-browser branch now returns the mapped category when the map is non-empty; falls back totitle: nil(legacy) when emptyHeartbeat loop (
main.py)research_app_category_mapthreaded throughheartbeat_loop(),build_swift_command(), andtransform_window()Tests (
tests/test_research_filter.py)TestClassifyApp(7 cases): exact match, case insensitivity, exe suffix, spaces, empty map, explicit Excluded entry, unmapped → ExcludedTestTransformWithAppMap(8 cases): mapped, unmapped→Excluded, URL/title stripped, incognito preserved, browser unaffected, legacy (no map) backward compat, explicit Excluded map entry, input not mutatedAll 43 tests pass.
Context
The CI patch script in
ActivityWatch/activitywatchalso needs updating to inject Matthias's 63-entry app map into[aw-watcher-window.research_app_category_map]at build time — that change is tracked separately in the activitywatch repo and will land in a follow-up PR once this one merges.Ref: ErikBjare/bob#1108