Skip to content

feat(research): add APP_TO_CATEGORY support for non-browser apps - #136

Merged
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/research-app-category-map
Aug 17, 2026
Merged

feat(research): add APP_TO_CATEGORY support for non-browser apps#136
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/research-app-category-map

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Summary

Implements APP_TO_CATEGORY support 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 Excluded for unmapped apps), which is strictly more private: unknown apps stop being named at all.

Changes

Python filter (research_filter.py)

  • New classify_app(app, app_category_map) function — case-insensitive exact lookup; returns "Excluded" on miss
  • transform() gains an optional app_category_map keyword argument; non-browser branch uses it when supplied, falling back to the previous behaviour (title dropped, raw app name kept) when it is absent

Config (config.py)

  • Added [aw-watcher-window.research_app_category_map] TOML section to default_config
  • parse_args() loads and exposes it as parsed_args.research_app_category_map

macOS CLI bridge (macos_cli.py)

  • build_swift_command() accepts research_app_category_map and passes --research-app-category app_name category pairs to the Swift helper

Swift helper (macos.swift)

  • Added researchAppCategoryMap global and --research-app-category argument parsing
  • New classifyApp(_ app: String) -> String function (case-insensitive lookup, returns "Excluded" on miss)
  • applyResearchFilter() non-browser branch now returns the mapped category when the map is non-empty; falls back to title: nil (legacy) when empty

Heartbeat loop (main.py)

  • research_app_category_map threaded through heartbeat_loop(), build_swift_command(), and transform_window()

Tests (tests/test_research_filter.py)

  • TestClassifyApp (7 cases): exact match, case insensitivity, exe suffix, spaces, empty map, explicit Excluded entry, unmapped → Excluded
  • TestTransformWithAppMap (8 cases): mapped, unmapped→Excluded, URL/title stripped, incognito preserved, browser unaffected, legacy (no map) backward compat, explicit Excluded map entry, input not mutated

All 43 tests pass.

Context

The CI patch script in ActivityWatch/activitywatch also 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

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-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds configurable Research Edition categorization for non-browser applications while retaining legacy behavior when no application map is configured.

  • Loads and propagates the application-category map through Python and macOS Swift strategies.
  • Replaces raw non-browser application identities with configured categories or Excluded.
  • Aligns empty-map, case-insensitive, and surrounding-whitespace behavior across both implementations.
  • Adds focused Python tests for classification, privacy filtering, and backward compatibility.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
aw_watcher_window/research_filter.py Adds normalized non-browser application classification and now replaces raw application identities while preserving the legacy empty-map behavior.
aw_watcher_window/macos.swift Adds equivalent Swift classification, argv parsing, identity replacement, and configured-key trimming.
aw_watcher_window/macos_cli.py Serializes configured application-category entries as discrete Swift-helper arguments.
aw_watcher_window/main.py Threads the application-category map through both Python and Swift Research Edition paths.
aw_watcher_window/config.py Defines and loads the new application-category configuration section.
tests/test_research_filter.py Covers mapping, fallback, normalization, privacy-sensitive field removal, and input immutability.
tests/test_main.py Updates the Research Edition startup fixture for the newly exposed configuration value.

Reviews (3): Last reviewed commit: "fix(research): trim configured app keys ..." | Re-trigger Greptile

Comment thread aw_watcher_window/research_filter.py Outdated
# 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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Comment thread aw_watcher_window/research_filter.py Outdated
Comment on lines +136 to +138
if app_category_map is not None:
app_category = classify_app(app, app_category_map)
result = {"app": app, "title": app_category}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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}

Comment thread aw_watcher_window/research_filter.py Outdated
Comment on lines +96 to +97
app_lower = app.strip().lower()
return app_category_map.get(app_lower, "Excluded")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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"

@ErikBjare

Copy link
Copy Markdown
Member

@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).
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed the three Greptile-flagged defects in 121cea8:

  1. Privacy — non-browser app names were retained in app while the category was written to title, leaking raw app 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". Empty map now falls back to legacy behaviour (keep app name, drop title), consistent with Swift.
  3. Case-insensitive contractclassify_app assumed lowercased map keys, so mixed-case configured keys missed. Lookup now normalizes both sides.

Swift applyResearchFilter updated to match. Tests updated to codify the corrected behaviour (including new cases for empty-map fallback and mixed-case keys); pytest tests/ → 59 passed.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed the Swift/Python normalization parity gap in 9e381ae.

classify_app (Python) trimmed both the incoming app name and the configured map key (map_app.strip().lower()), while Swift classifyApp trimmed only the incoming name (item.app.lowercased()). A configured entry carrying stray whitespace — plausible given the map is injected into TOML at build time from a 63-entry dict — would classify correctly on Linux/Windows and silently fall through to "Excluded" on the macOS Swift path for the same config.

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.

Comment on lines 13 to +15
[aw-watcher-window.research_category_map]

[aw-watcher-window.research_app_category_map]

@ErikBjare ErikBjare Aug 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ErikBjare
ErikBjare merged commit a297914 into ActivityWatch:master Aug 17, 2026
7 checks passed
ErikBjare pushed a commit that referenced this pull request Aug 17, 2026
* 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>
ErikBjare pushed a commit to ActivityWatch/activitywatch that referenced this pull request Aug 17, 2026
#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).
TimeToBuildBob added a commit to TimeToBuildBob/activitywatch that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants