Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,36 @@ Commands:
heartbeat Send a heartbeat to bucket with ID `bucket_id` with JSON `data`
query Run a query in file at `path` on the server
report Generate an activity report
summary Generate a bounded, privacy-safe activity summary without raw titles or URLs
```

### Privacy-safe summaries

The `summary` command aggregates data on the local ActivityWatch server and emits
category, application, and optional domain totals. It never includes raw events,
window titles, full URLs, document names, or message subjects in its output.

```bash
aw-client summary HOSTNAME --start 2026-08-17 --stop 2026-08-18
aw-client summary HOSTNAME --start 2026-08-17 --stop 2026-08-18 --format json
aw-client summary HOSTNAME --start 2026-08-17 --stop 2026-08-18 --format json --no-domains
aw-client summary HOSTNAME --start 2026-08-17 --stop 2026-08-18 --format json --no-apps --no-domains
```

Browser buckets are scoped to `HOSTNAME`. Buckets that report no hostname (or
`unknown`) cannot be attributed to a machine, so they are excluded by default — on a
server collecting from several machines they may belong to a different host. If you
run a single-machine server and want those legacy buckets counted, opt in:

```bash
aw-client summary HOSTNAME --start 2026-08-17 --stop 2026-08-18 --include-legacy-buckets
```

The JSON format is intended for bounded, review-before-send assistant workflows.
Category names, application names, and domains can still be sensitive. Review the
exact payload before sharing it with any third-party service, and use `--no-domains`
or `--no-apps --no-domains` when those totals are unnecessary.


## Debugging

Expand Down
19 changes: 15 additions & 4 deletions aw_client/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
Any,
Dict,
List,
Optional,
Protocol,
Tuple,
)

Expand All @@ -20,6 +22,11 @@
CategoryId = List[str]
CategorySpec = Dict[str, Any]


class SettingsClient(Protocol):
def get_setting(self, key: str) -> Any: ...
Comment thread
abouchard11 marked this conversation as resolved.


default_classes: List[Tuple[CategoryId, CategorySpec]] = [
(["Work"], {"type": "regex", "regex": "Google Docs|libreoffice|ReText"}),
(
Expand Down Expand Up @@ -65,14 +72,18 @@
]


def get_classes() -> List[Tuple[List[str], dict]]:
def get_classes(
client: Optional[SettingsClient] = None,
) -> List[Tuple[List[str], dict]]:
"""
Get classes from server-side settings.
Might throw a 404 if not set yet, in which case we use the default classes as a fallback.
"""
# NOTE: Always tries to fetch from prod server,
# which is potentially wrong if testing server is being used.
awc = aw_client.ActivityWatchClient(f"get-setting-{random.randint(0, 10000)}")
# Reuse the caller's client when available so host, port, testing mode, and
# authentication settings stay consistent with the query being performed.
awc = client or aw_client.ActivityWatchClient(
f"get-setting-{random.randint(0, 10000)}"
)
try:
classes = awc.get_setting("classes")
except Exception:
Expand Down
101 changes: 99 additions & 2 deletions aw_client/cli.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import json
import logging
import textwrap
import time
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from zoneinfo import ZoneInfo
Expand All @@ -16,6 +15,7 @@

from . import queries
from .classes import default_classes, get_classes
from .summary import build_summary, find_browser_buckets, format_summary

now = datetime.now(timezone.utc)
td1day = timedelta(days=1)
Expand Down Expand Up @@ -175,7 +175,7 @@ def report(

bid_browsers: List[str] = []

classes = get_classes()
classes = get_classes(obj.client)
params = queries.DesktopQueryParams(
bid_browsers=bid_browsers,
classes=classes,
Expand Down Expand Up @@ -316,5 +316,102 @@ def canonical(
)


@main.command(
help="Generate a bounded, privacy-safe activity summary without raw titles or URLs"
)
@click.argument("hostname")
@click.option("--cache", is_flag=True)
@click.option("--start", default=now - td1day, type=click.DateTime())
@click.option("--stop", default=now, type=click.DateTime())
@click.option("--limit", default=20, type=click.IntRange(min=1))
@click.option(
"--format",
"output_format",
default="table",
type=click.Choice(["table", "json"]),
show_default=True,
)
@click.option(
"--include-apps/--no-apps",
default=True,
help="Include application-name totals",
)
@click.option(
"--include-domains/--no-domains",
default=True,
help="Include domain-only browser totals; full URLs are always omitted",
)
@click.option(
"--include-legacy-buckets",
is_flag=True,
default=False,
help=(
"Include browser buckets that report no hostname. Only safe on a "
"single-machine server; on a shared server they may belong to another host"
),
)
@click.option(
"--timezone",
help="Time zone for start and stop options (for example, 'America/Chicago')",
)
@click.pass_obj
def summary(
obj: _Context,
hostname: str,
cache: bool,
start: datetime,
stop: datetime,
limit: int,
output_format: str,
include_apps: bool,
include_domains: bool,
include_legacy_buckets: bool,
timezone: Optional[str],
):
if timezone:
zone_info = ZoneInfo(timezone)
start = start.replace(tzinfo=zone_info)
stop = stop.replace(tzinfo=zone_info)

if not start.tzinfo:
start = start.astimezone()
if not stop.tzinfo:
stop = stop.astimezone()
if stop <= start:
raise click.ClickException("--stop must be later than --start")

buckets = obj.client.get_buckets()
browser_buckets = (
find_browser_buckets(buckets, hostname, include_legacy=include_legacy_buckets)
if include_domains
else []
)
params = queries.DesktopQueryParams(
bid_window=f"aw-watcher-window_{hostname}",
bid_afk=f"aw-watcher-afk_{hostname}",
bid_browsers=browser_buckets,
classes=get_classes(obj.client),
)
query = queries.privacySummary(params, limit=limit)
logger.debug("Query: \n" + queries.pretty_query(query))
result = obj.client.query(query, [(start, stop)], cache=cache)
if not result:
raise click.ClickException("ActivityWatch returned no summary data")

payload = build_summary(
result[0],
start,
stop,
include_apps=include_apps,
include_domains=include_domains,
limit=limit,
include_legacy_buckets=include_legacy_buckets,
)
if output_format == "json":
print(json.dumps(payload, indent=2))
else:
print(format_summary(payload))


if __name__ == "__main__":
main()
55 changes: 52 additions & 3 deletions aw_client/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,8 @@ def canonicalEvents(params: Union[DesktopQueryParams, AndroidQueryParams]) -> st
not_treat_as_afk = filter_keyvals_regex(events, "app", "%s");
not_afk = period_union(not_afk, not_treat_as_afk);
not_treat_as_afk = filter_keyvals_regex(events, "title", "%s");
not_afk = period_union(not_afk, not_treat_as_afk);"""
% (
params.always_active_pattern.replace('"', '\\"'),
not_afk = period_union(not_afk, not_treat_as_afk);""".replace(
"%s",
params.always_active_pattern.replace('"', '\\"'),
)
if params.always_active_pattern
Expand Down Expand Up @@ -313,6 +312,56 @@ def fullDesktopQuery(
return query


def privacySummary(params: DesktopQueryParams, limit: int = 20) -> str:
"""Build a bounded query that returns aggregates without titles or full URLs.

The query performs categorization and AFK filtering on the local server, then
returns only category, application, and optional domain totals. It is intended
for privacy-conscious reports and AI context payloads where raw event history
would be unnecessary and unsafe.
"""
if limit < 1:
raise ValueError("limit must be at least 1")

safe_params = dataclasses.replace(
params,
bid_window=escape_doublequote(params.bid_window),
bid_afk=escape_doublequote(params.bid_afk),
bid_browsers=[escape_doublequote(bucket) for bucket in params.bid_browsers],
)

query = f"""
{canonicalEvents(safe_params)}
category_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
app_events = sort_by_duration(merge_events_by_keys(events, ["app"]));
uncategorized_events = filter_keyvals(events, "$category", [["Uncategorized"]]);
uncategorized_seconds = sum_durations(uncategorized_events);
category_events = limit_events(category_events, {limit});
app_events = limit_events(app_events, {limit});
active_seconds = sum_durations(events);
"""

if safe_params.bid_browsers:
query += f"""
browser_events = filter_period_intersect(browser_events, not_afk);
domain_events = sort_by_duration(merge_events_by_keys(browser_events, ["$domain"]));
domain_events = limit_events(domain_events, {limit});
"""
else:
query += "domain_events = [];"

query += """
RETURN = {
"active_seconds": active_seconds,
"uncategorized_seconds": uncategorized_seconds,
"category_events": category_events,
"app_events": app_events,
"domain_events": domain_events
};
"""
return query


def test_fullDesktopQuery():
params = DesktopQueryParams(
bid_window="aw-watcher-window_",
Expand Down
Loading