diff --git a/README.md b/README.md index 2ca1223..cff3864 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/aw_client/classes.py b/aw_client/classes.py index 45038cc..205dc16 100644 --- a/aw_client/classes.py +++ b/aw_client/classes.py @@ -10,6 +10,8 @@ Any, Dict, List, + Optional, + Protocol, Tuple, ) @@ -20,6 +22,11 @@ CategoryId = List[str] CategorySpec = Dict[str, Any] + +class SettingsClient(Protocol): + def get_setting(self, key: str) -> Any: ... + + default_classes: List[Tuple[CategoryId, CategorySpec]] = [ (["Work"], {"type": "regex", "regex": "Google Docs|libreoffice|ReText"}), ( @@ -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: diff --git a/aw_client/cli.py b/aw_client/cli.py old mode 100755 new mode 100644 index 724bbc7..46a3e84 --- a/aw_client/cli.py +++ b/aw_client/cli.py @@ -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 @@ -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) @@ -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, @@ -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() diff --git a/aw_client/queries.py b/aw_client/queries.py index 4b8d761..3e67f80 100644 --- a/aw_client/queries.py +++ b/aw_client/queries.py @@ -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 @@ -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_", diff --git a/aw_client/summary.py b/aw_client/summary.py new file mode 100644 index 0000000..9b8f840 --- /dev/null +++ b/aw_client/summary.py @@ -0,0 +1,169 @@ +"""Privacy-safe ActivityWatch summary helpers.""" + +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List + +from tabulate import tabulate + + +Summary = Dict[str, Any] + + +# Hostname values that carry no usable attribution. Legacy watchers report these +# instead of a real host, so such buckets cannot be assigned to any machine. +_UNATTRIBUTED_HOSTNAMES = (None, "", "unknown") + + +def find_browser_buckets( + buckets: Dict[str, dict], hostname: str, include_legacy: bool = False +) -> List[str]: + """Return browser bucket IDs for a host. + + Buckets whose hostname metadata is missing or "unknown" cannot be attributed + to a machine. On a server collecting from several machines they may belong to + a different host, so folding them into this host's summary would leak another + machine's browsing domains. They are excluded unless the caller opts in with + ``include_legacy``, which is only safe on a single-machine server. + """ + matches = [] + for bucket_id, bucket in buckets.items(): + if bucket.get("type") != "web.tab.current": + continue + + data = bucket.get("data") or {} + bucket_hostname = bucket.get("hostname") or data.get("hostname") + if bucket_hostname == hostname: + pass + elif include_legacy and bucket_hostname in _UNATTRIBUTED_HOSTNAMES: + pass + else: + continue + + matches.append(bucket.get("id") or bucket_id) + return sorted(set(matches)) + + +def _seconds(value: Any) -> float: + return round(float(value or 0), 3) + + +def _aggregate_rows( + events: Iterable[dict], data_key: str, output_key: str +) -> List[dict]: + rows = [] + for event in events: + value = (event.get("data") or {}).get(data_key) + if value in (None, "", []): + continue + if data_key == "$category" and not isinstance(value, list): + value = [str(value)] + rows.append({output_key: value, "seconds": _seconds(event.get("duration"))}) + return sorted(rows, key=lambda row: row["seconds"], reverse=True) + + +def build_summary( + result: dict, + start: datetime, + stop: datetime, + include_apps: bool = True, + include_domains: bool = True, + limit: int = 20, + include_legacy_buckets: bool = False, +) -> Summary: + """Normalize an aggregate query result into a provider-neutral payload.""" + categories = _aggregate_rows(result.get("category_events", []), "$category", "name") + apps = ( + _aggregate_rows(result.get("app_events", []), "app", "app") + if include_apps + else [] + ) + domains = ( + _aggregate_rows(result.get("domain_events", []), "$domain", "domain") + if include_domains + else [] + ) + + active_seconds = _seconds(result.get("active_seconds")) + uncategorized_seconds = _seconds(result.get("uncategorized_seconds")) + categorized_seconds = _seconds(max(0, active_seconds - uncategorized_seconds)) + categorized_ratio = ( + round(categorized_seconds / active_seconds, 4) if active_seconds else 0 + ) + + return { + "source": "activitywatch", + "schema_version": 1, + "range": { + "start": start.isoformat(), + "end": stop.isoformat(), + "timezone": str(start.tzinfo), + }, + "totals": { + "active_seconds": active_seconds, + "categorized_seconds": categorized_seconds, + "uncategorized_seconds": uncategorized_seconds, + "categorized_ratio": categorized_ratio, + }, + "categories": categories, + "apps": apps, + "domains": domains, + "truncation": { + "per_section_limit": limit, + "policy": "top_by_duration", + }, + "redaction": { + "category_names": "included", + "application_names": "included" if include_apps else "omitted", + "window_titles": "omitted", + "full_urls": "omitted", + "document_names": "omitted", + "chat_or_email_subjects": "omitted", + "domains": "included" if include_domains else "omitted", + "raw_events": "omitted", + "legacy_unknown_host_buckets": ( + "included" if include_legacy_buckets else "excluded" + ), + }, + } + + +def _format_seconds(seconds: float) -> str: + return str(timedelta(seconds=round(seconds))) + + +def _table(rows: List[dict], label_key: str, label: str) -> str: + if not rows: + return "" + values = [] + for row in rows: + value = row[label_key] + if isinstance(value, list): + value = " > ".join(value) + values.append((value, _format_seconds(row["seconds"]))) + return tabulate(values, headers=[label, "Active time"]) + + +def format_summary(summary: Summary) -> str: + """Render a privacy-safe summary for humans without expanding sensitive data.""" + totals = summary["totals"] + ratio = totals["categorized_ratio"] * 100 + sections = [ + "ActivityWatch privacy-safe summary", + f"Range: {summary['range']['start']} to {summary['range']['end']}", + f"Active time: {_format_seconds(totals['active_seconds'])}", + f"Categorized: {ratio:.1f}%", + ] + + for title, rows, key, label in ( + ("Categories", summary["categories"], "name", "Category"), + ("Applications", summary["apps"], "app", "Application"), + ("Domains", summary["domains"], "domain", "Domain"), + ): + table = _table(rows, key, label) + if table: + sections.extend((title, table)) + + sections.append( + "Redacted: window titles, full URLs, document names, message subjects, raw events" + ) + return "\n\n".join(sections) diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..a926578 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,269 @@ +import json +from datetime import datetime, timezone + +from click.testing import CliRunner + +from aw_client import cli, queries +from aw_client.classes import default_classes, get_classes +from aw_client.summary import build_summary, find_browser_buckets, format_summary + + +START = datetime(2026, 8, 17, 9, tzinfo=timezone.utc) +STOP = datetime(2026, 8, 17, 12, tzinfo=timezone.utc) + + +AGGREGATE_RESULT = { + "active_seconds": 7200, + "uncategorized_seconds": 1800, + "category_events": [ + {"duration": 5400, "data": {"$category": ["Work", "Programming"]}}, + {"duration": 1800, "data": {"$category": ["Uncategorized"]}}, + ], + "app_events": [ + {"duration": 5000, "data": {"app": "Code"}}, + {"duration": 2200, "data": {"app": "Firefox"}}, + ], + "domain_events": [ + {"duration": 1200, "data": {"$domain": "github.com"}}, + ], +} + + +def test_find_browser_buckets_excludes_unattributed_buckets_by_default(): + buckets = { + "chrome-laptop": { + "id": "chrome-laptop", + "type": "web.tab.current", + "hostname": "laptop", + }, + "firefox-legacy": { + "id": "firefox-legacy", + "type": "web.tab.current", + "hostname": "unknown", + }, + "chrome-desktop": { + "id": "chrome-desktop", + "type": "web.tab.current", + "hostname": "desktop", + }, + "window-laptop": { + "id": "window-laptop", + "type": "currentwindow", + "hostname": "laptop", + }, + } + + # A bucket with no usable hostname may belong to another machine on a shared + # server, so it must not be folded into this host's summary by default. + assert find_browser_buckets(buckets, "laptop") == ["chrome-laptop"] + + +def test_find_browser_buckets_includes_unattributed_buckets_when_opted_in(): + buckets = { + "chrome-laptop": { + "id": "chrome-laptop", + "type": "web.tab.current", + "hostname": "laptop", + }, + "firefox-legacy": { + "id": "firefox-legacy", + "type": "web.tab.current", + "hostname": "unknown", + }, + "firefox-nohost": { + "id": "firefox-nohost", + "type": "web.tab.current", + }, + "chrome-desktop": { + "id": "chrome-desktop", + "type": "web.tab.current", + "hostname": "desktop", + }, + } + + assert find_browser_buckets(buckets, "laptop", include_legacy=True) == [ + "chrome-laptop", + "firefox-legacy", + "firefox-nohost", + ] + # Opting in must still never pull in a bucket attributed to another host. + assert "chrome-desktop" not in find_browser_buckets( + buckets, "laptop", include_legacy=True + ) + + +def test_build_summary_records_legacy_bucket_policy(): + excluded = build_summary(AGGREGATE_RESULT, START, STOP) + assert excluded["redaction"]["legacy_unknown_host_buckets"] == "excluded" + + included = build_summary( + AGGREGATE_RESULT, START, STOP, include_legacy_buckets=True + ) + assert included["redaction"]["legacy_unknown_host_buckets"] == "included" + + +def test_build_summary_is_aggregate_only_and_tracks_category_coverage(): + payload = build_summary(AGGREGATE_RESULT, START, STOP) + + assert payload["totals"] == { + "active_seconds": 7200.0, + "categorized_seconds": 5400.0, + "uncategorized_seconds": 1800.0, + "categorized_ratio": 0.75, + } + assert payload["categories"][0]["name"] == ["Work", "Programming"] + assert payload["domains"] == [{"domain": "github.com", "seconds": 1200.0}] + assert payload["redaction"]["raw_events"] == "omitted" + + serialized = json.dumps(payload) + assert "window_title" in serialized # redaction policy is explicit + assert '"title"' not in serialized + assert '"url"' not in serialized + + +def test_build_summary_can_omit_domains(): + payload = build_summary(AGGREGATE_RESULT, START, STOP, include_domains=False) + + assert payload["domains"] == [] + assert payload["redaction"]["domains"] == "omitted" + + +def test_build_summary_can_omit_apps_and_domains(): + payload = build_summary( + AGGREGATE_RESULT, + START, + STOP, + include_apps=False, + include_domains=False, + ) + + assert payload["apps"] == [] + assert payload["domains"] == [] + assert payload["redaction"]["application_names"] == "omitted" + + +def test_privacy_summary_query_returns_only_aggregate_collections(): + query = queries.privacySummary( + queries.DesktopQueryParams( + bid_window="aw-watcher-window-laptop", + bid_afk="aw-watcher-afk-laptop", + bid_browsers=["aw-watcher-web-chrome-laptop"], + classes=default_classes, + ), + limit=7, + ) + return_clause = query.rsplit("RETURN =", 1)[1] + + assert "filter_period_intersect(browser_events, not_afk)" in query + assert "limit_events(category_events, 7)" in query + assert '"category_events": category_events' in return_clause + assert '"uncategorized_seconds": uncategorized_seconds' in return_clause + assert '"app_events": app_events' in return_clause + assert '"domain_events": domain_events' in return_clause + assert '"events":' not in return_clause + assert '"title_events"' not in return_clause + assert '"url_events"' not in return_clause + + +def test_category_coverage_uses_untruncated_server_total(): + result = dict(AGGREGATE_RESULT) + result["category_events"] = [ + {"duration": 5400, "data": {"$category": ["Work", "Programming"]}} + ] + + payload = build_summary(result, START, STOP, limit=1) + + assert payload["totals"]["uncategorized_seconds"] == 1800 + assert payload["totals"]["categorized_ratio"] == 0.75 + assert payload["truncation"] == { + "per_section_limit": 1, + "policy": "top_by_duration", + } + + +def test_privacy_summary_rejects_non_positive_limits(): + params = queries.DesktopQueryParams( + bid_window="window", bid_afk="afk", classes=default_classes + ) + + try: + queries.privacySummary(params, limit=0) + except ValueError as error: + assert str(error) == "limit must be at least 1" + else: + raise AssertionError("privacySummary accepted a zero limit") + + +def test_format_summary_remains_redacted(): + rendered = format_summary(build_summary(AGGREGATE_RESULT, START, STOP)) + + assert "Work > Programming" in rendered + assert "github.com" in rendered + assert "window titles" in rendered + assert "full URLs" in rendered + + +def test_get_classes_reuses_caller_client(): + class SettingsClient: + def get_setting(self, key): + assert key == "classes" + return [ + { + "name": ["Products", "Example"], + "rule": {"type": "regex", "regex": "Example"}, + } + ] + + assert get_classes(SettingsClient()) == [ + (["Products", "Example"], {"type": "regex", "regex": "Example"}) + ] + + +def test_summary_cli_emits_json_without_sensitive_fields(monkeypatch): + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def get_setting(self, key): + assert key == "classes" + return [ + { + "name": ["Work"], + "rule": {"type": "regex", "regex": "Code"}, + } + ] + + def get_buckets(self): + return { + "aw-watcher-web-chrome-laptop": { + "type": "web.tab.current", + "hostname": "laptop", + } + } + + def query(self, query, periods, cache=False): + return [AGGREGATE_RESULT] + + monkeypatch.setattr(cli.aw_client, "ActivityWatchClient", FakeClient) + runner = CliRunner() + result = runner.invoke( + cli.main, + [ + "summary", + "laptop", + "--start", + "2026-08-17T09:00:00", + "--stop", + "2026-08-17T12:00:00", + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["source"] == "activitywatch" + assert payload["redaction"]["full_urls"] == "omitted" + assert "github.com" in result.stdout + assert '"title"' not in result.stdout + assert '"url"' not in result.stdout