diff --git a/src/examples/agents-and-ai.rst b/src/examples/agents-and-ai.rst index 5aa1773..6ff4d4e 100644 --- a/src/examples/agents-and-ai.rst +++ b/src/examples/agents-and-ai.rst @@ -191,6 +191,195 @@ ActivityWatch workflow. Before uploading data: * keep the exact sent payload in your own notes if you need auditability * use a shorter time range than you would use for local analysis +AI-generated local interfaces +----------------------------- + +ActivityWatch's existing collection and query tools can support a different +presentation without rebuilding the tracker. Customization complements improving +the default UI; it should not be necessary to get a usable activity summary. + +The smallest integration is an **offline HTML viewer of a local summary**. +Generate code from synthetic data, test it, then optionally load a bounded summary +from your own server. The viewer needs no API connection, CORS changes or build +step. This is a desktop, single-host example, not a multi-device timeline merger. + +**What to ask for.** Give your assistant only this prompt and synthetic fixture, +not access to real ActivityWatch events. A locally running agent can still use a +cloud model or tools that upload files; inspect its configuration first: + +.. code-block:: text + + Build a single-file HTML dashboard with inline JavaScript and CSS. + Render the synthetic summary below first. Add a local JSON file picker + (FileReader) for another summary in the same schema, never an API fetch. + Show categories, apps, active/AFK/tracked seconds, hostname and the range + with its explicit timezone offsets. Show zero totals for empty data. + Keep hosts separate; never sum overlapping devices as personal active time. + Reject negative/nonfinite durations and invalid/reversed/offset-free dates. + Treat loaded labels as untrusted text, not HTML. + No network requests, imports, CDN, external fonts, analytics or telemetry. + No storage, forwarding or logging of loaded data. No npm or build step. + Add a CSP meta tag with default-src 'none'; connect-src 'none'; + script-src 'unsafe-inline'; style-src 'unsafe-inline'. + + Synthetic fixture (use this before loading a local summary): + { + "hostname": "my-machine", + "start": "2024-01-15T08:00:00+00:00", + "end": "2024-01-15T12:00:00+00:00", + "categories": [ + {"name": ["Coding"], "seconds": 7200}, + {"name": ["Communication"], "seconds": 1800}, + {"name": ["Uncategorized"], "seconds": 600} + ], + "apps": [ + {"app": "Code", "seconds": 7200}, + {"app": "Firefox", "seconds": 1800}, + {"app": "Terminal", "seconds": 600} + ], + "active_seconds": 9600, + "afk_seconds": 600, + "tracked_seconds": 10200 + } + +Download the :download:`tested example HTML ` for a reference +implementation. Save it locally and open it with your browser (``file://``). +The initial sample is synthetic; it does not describe your actual activity. +A prompt is not a guarantee: review and test any newly generated variation too. + +Optional: load your own local summary +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use Python 3.11 or newer and install ``aw-client``, then download +:download:`ai-dashboard-summary.py ` to that environment. +Discover your bucket suffix with ``aw-client buckets`` and select **one desktop +hostname** with both ``aw-watcher-window_HOSTNAME`` and +``aw-watcher-afk_HOSTNAME`` buckets. With your existing AW server listening on +loopback port 5666, run: + +.. code-block:: sh + + python3 ai-dashboard-summary.py HOSTNAME \ + 2024-01-15T08:00:00+00:00 2024-01-15T12:00:00+00:00 > summary.json + +Replace the sample dates and hostname. Use explicit offsets (or ``Z``); for a +local day across a daylight-saving transition, the two offsets can differ. +The exporter applies canonical desktop AFK filtering and category rules from +that server (or the client's bundled defaults if no rules are configured), then +clips intervals to the requested range before aggregation. +It does not export titles or URLs. Category/app names can still be sensitive; +review ``summary.json`` locally and load it using the HTML file picker, **not by +pasting it back into the code-generation chat**. + +``tracked_seconds`` means observed window-event duration, not the entire selected +wall-clock range. AFK is observed AFK overlapping those windows. A gap in AFK +coverage is unknown, not automatically active or AFK; ``unknown_seconds`` in the +JSON records the nonnegative remainder. For a non-empty range, missing buckets +produce an error rather than a misleading zero. This assumes normal, +nonoverlapping window events per host. Canonical processing can bridge short +heartbeat gaps; do not treat these estimates as precise time accounting. +Synced buckets for other hosts are not included: compare hosts separately, do not +add their active durations without a separately defined overlap/precedence policy. + +The exporter uses GET for bucket/settings discovery and ``POST /api/0/query/`` +for read-only calculations. POST is required by the query API; it does not imply +an event write. The script does not call mutation endpoints. **Read-only is a +client convention, not a server-enforced permission boundary.** Keep the server +on loopback and do not loosen CORS or give unreviewed generated code API access. + +Testing the generated page +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Before using real data: + +1. Open the HTML file directly. Check Coding = 7200 s, Communication = 1800 s, + Uncategorized = 600 s, active = 9600 s, AFK = 600 s, tracked = 10200 s. + Category and app totals both equal active time, not active plus AFK. +2. Open browser DevTools → Network and reload. Loading the document itself is + expected; it must make no HTTP(S), WebSocket, telemetry or model requests, + including when choosing a local JSON file. CSP is defense in depth, not a + substitute for reviewing the page. +3. Load a summary with empty category/app arrays and zero durations. Expect + zero totals and a clear no-activity state. Try invalid JSON and a reversed + range: expect a visible error, not a silent stale-data success. +4. Test the exporter on synthetic window/AFK events before trusting changed + aggregation code. Check events straddling range boundaries, equivalent times + with different UTC offsets, unknown AFK gaps, and two overlapping devices. + +The reproducible :download:`verification script ` starts +an isolated in-memory Python ``aw-server`` on a random loopback port; it never +connects to your normal server. In a disposable Python environment with +``aw-client``, ``aw-server`` and ``playwright`` installed, place it beside the +exporter and HTML file, install Playwright Chromium, then run +``python3 verify-ai-dashboard.py``. The reference was checked on Python 3.12 with +aw-client 0.5.15, aw-core 0.5.17 and aw-server 0.13.2 source (with Flask 2.2); +this is not a compatibility claim for all installed versions. It checks the known fixture, canonical query +results, range boundaries and browser file loading, and records attempted +network destinations. It does not prove that arbitrary future generated code or +every released server implementation behaves identically. + +Using the page as a custom visualization +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This is **experimental and not required for the offline recipe above**. There +is no automatic ``/custom_vis/`` discovery path. The Python server +can explicitly serve a directory (containing ``index.html``), for example: + +.. code-block:: sh + + aw-server --host 127.0.0.1 --port 5666 \ + --custom-static dashboard=/absolute/path/to/reviewed-page + +Use this when configuring your server, not while another server already owns +that port. The page is then at ``http://127.0.0.1:5666/pages/dashboard/``. +The equivalent persistent Python-server setting is a ``dashboard`` path entry +under ``[server.custom_static]`` in ``aw-server.toml``. Check the installed +server's help/config; do not assume Python CLI flags apply to the Rust server. +The Python-server path is covered by the verification script. + +The web UI's custom visualization selector can embed configured pages and passes +``hostname``, ``start`` and ``end`` URL parameters. The offline reference viewer +**does not consume those parameters or automatically follow the selected range**; +its displayed range is the loaded summary. Building a live embedded viewer would +require separate query and security review, not just copying this HTML. + +.. warning:: + + Custom visualizations run in the ActivityWatch server's origin. A malicious + page could read your data or make API calls. Only use pages you wrote or + reviewed. + +Capability map +-------------- + +ActivityWatch covers the local data collection layer. Before building a custom +interface, confirm which features are available in your installed version: + +.. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Feature + - Notes + * - Desktop activity (window titles, app names) + - Collected by ``aw-watcher-window``; titles available but sensitive + * - AFK / active time + - Collected by ``aw-watcher-afk``; used in canonical queries + * - Rule-based categories + - Set in the web UI; applied at query time; not automatic AI inference + * - Android activity + - Collected by the Android app; synced via :doc:`../features/syncing` + * - iOS activity + - Not available + * - Automatic project attribution + - Not built-in; rule-based only unless you add custom processing + * - Seamless multi-device sync + - Sync is manual/scheduled; see :doc:`../features/syncing` for current status + +Rule-based categories are not AI-generated labels. They match patterns you +define against app names and window titles. Describing them as automatic +AI understanding overstates what is collected. + Related docs ------------ diff --git a/src/examples/ai-dashboard-summary.py b/src/examples/ai-dashboard-summary.py new file mode 100644 index 0000000..2477d4b --- /dev/null +++ b/src/examples/ai-dashboard-summary.py @@ -0,0 +1,121 @@ +"""Export one desktop host's bounded, canonical activity as a local summary. + +Requires aw-client. Never sends events to a model or writes to the AW API. +""" + +import argparse +import json +from datetime import datetime, timedelta + +from aw_client import ActivityWatchClient +from aw_client.classes import default_classes +from aw_client.queries import DesktopQueryParams, canonicalEvents + + +def summarize(client, hostname, start, end): + if start.utcoffset() is None or end.utcoffset() is None: + raise ValueError("Use ISO timestamps with explicit timezone offsets") + if end < start: + raise ValueError("End must not precede start") + summary = { + "hostname": hostname, + "start": start.isoformat(), + "end": end.isoformat(), + "categories": [], + "apps": [], + "active_seconds": 0, + "afk_seconds": 0, + "tracked_seconds": 0, + "unknown_seconds": 0, + } + if start == end: + return summary + buckets = client.get_buckets() + window = f"aw-watcher-window_{hostname}" + afk = f"aw-watcher-afk_{hostname}" + if window not in buckets or afk not in buckets: + raise ValueError("Select one host with both window and AFK buckets") + try: + settings = client.get_setting("classes") + except Exception as exc: + # Only absent settings are expected; connection and other errors must surface. + if getattr(getattr(exc, "response", None), "status_code", None) != 404: + raise + settings = None + classes = ( + [(c["name"], c["rule"]) for c in settings] if settings else default_classes + ) + # Explicit classes avoid aw-client's implicit lookup on the default server. + query = canonicalEvents( + DesktopQueryParams(bid_window=window, bid_afk=afk, classes=classes) + ) + # canonicalEvents uses substring-based find_bucket(); bind the exact IDs + # already verified above so similarly named synced hosts cannot win. + for bucket in (window, afk): + query = query.replace(f'find_bucket("{bucket}")', json.dumps(bucket)) + query += "\nRETURN = events;" + active = client.query(query, [(start, end)])[0] + # These queries do not mutate data. POST /query/ is a read computation, + # not an HTTP-method-based permission boundary. + raw_query = f"windows = query_bucket({json.dumps(window)});" + afk_query = ( + raw_query + + f" afk = query_bucket({json.dumps(afk)});" + + ' afk = filter_keyvals(afk, "status", ["afk"]);' + + " RETURN = filter_period_intersect(windows, afk);" + ) + raw = client.query(raw_query + " RETURN = windows;", [(start, end)])[0] + away = client.query(afk_query, [(start, end)])[0] + + # Bucket queries return intersecting events, not necessarily clipped events. + # Canonical queries also flood intervals; clip before aggregating durations. + def clipped(events): + result = [] + for event in events: + timestamp = datetime.fromisoformat( + event["timestamp"].replace("Z", "+00:00") + ) + stop = timestamp + timedelta(seconds=event["duration"]) + seconds = max(0, (min(stop, end) - max(timestamp, start)).total_seconds()) + if seconds: + result.append({**event, "duration": seconds}) + return result + + active, raw, away = clipped(active), clipped(raw), clipped(away) + categories = {} + apps = {} + for event in active: + seconds = event["duration"] + category = tuple(event["data"].get("$category", ["Uncategorized"])) + app = event["data"].get("app", "unknown") + categories[category] = categories.get(category, 0) + seconds + apps[app] = apps.get(app, 0) + seconds + summary.update( + categories=[ + {"name": list(k), "seconds": v} for k, v in sorted(categories.items()) + ], + apps=[{"app": k, "seconds": v} for k, v in sorted(apps.items())], + active_seconds=sum(e["duration"] for e in active), + afk_seconds=sum(e["duration"] for e in away), + tracked_seconds=sum(e["duration"] for e in raw), + ) + summary["unknown_seconds"] = max( + 0, + summary["tracked_seconds"] - summary["active_seconds"] - summary["afk_seconds"], + ) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("hostname") + parser.add_argument("start", type=datetime.fromisoformat) + parser.add_argument("end", type=datetime.fromisoformat) + parser.add_argument("--port", type=int, default=5666) + args = parser.parse_args() + client = ActivityWatchClient("local-dashboard", host="127.0.0.1", port=args.port) + print(json.dumps(summarize(client, args.hostname, args.start, args.end), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/examples/ai-dashboard.html b/src/examples/ai-dashboard.html new file mode 100644 index 0000000..86e1ab0 --- /dev/null +++ b/src/examples/ai-dashboard.html @@ -0,0 +1,316 @@ + + + + + + +ActivityWatch summary dashboard + + + +
+
+

ActivityWatch summary

+

Synthetic sample data

+

Host: my-machine (one explicit host; not a combined view across devices)

+

Range: 2024-01-15T08:00:00+00:00 → 2024-01-15T12:00:00+00:00

+

Timezone offsets: start +00:00, end +00:00

+
+ +
+ + +
+ + + +
+
+
+ Active + 9600 + seconds + 2h 40m 00s +
+
+ AFK + 600 + seconds + 10m 00s +
+
+ Tracked + 10200 + seconds + 2h 50m 00s +
+
+
+ +
+
+

Categories

+
+
+
+

Apps

+
+
+
+ +
+

About this data

+
    +
  • The summary is read locally in this browser. Nothing is uploaded, stored, or downloaded.
  • +
  • It covers one explicit host. It does not combine data from multiple devices.
  • +
  • Categories are rule-based groupings, not AI inference.
  • +
  • This page is generated code. Review it before relying on it.
  • +
+
+
+ + + + diff --git a/src/examples/verify-ai-dashboard.py b/src/examples/verify-ai-dashboard.py new file mode 100644 index 0000000..8cdb5a5 --- /dev/null +++ b/src/examples/verify-ai-dashboard.py @@ -0,0 +1,283 @@ +"""Synthetic-only integration check. Requires aw-server, aw-client, playwright. + +Run beside ai-dashboard-summary.py and ai-dashboard.html after installing +Playwright Chromium. No existing ActivityWatch instance is used. +""" + +import importlib.util +import json +import os +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urlsplit + +from aw_client import ActivityWatchClient +from aw_core.models import Event +from aw_server.server import AWFlask +from flask import request +from playwright.sync_api import sync_playwright +from werkzeug.serving import make_server + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location( + "summary", ROOT / "ai-dashboard-summary.py" +) +summary_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(summary_module) +summarize = summary_module.summarize + + +def verify(client): + t = datetime(2024, 1, 15, 8, tzinfo=timezone.utc) + # Insert the longer prefix match first to catch substring bucket discovery. + for host in ["fixture-a-other", "fixture-a", "fixture-b"]: + for watcher, kind in [("window", "currentwindow"), ("afk", "afkstatus")]: + client.create_bucket(f"aw-watcher-{watcher}_{host}", event_type=kind) + client.insert_events( + f"aw-watcher-window_{host}", + [ + Event( + timestamp=t, + duration=timedelta(hours=1), + data={"app": "Code", "title": "synthetic"}, + ), + Event( + timestamp=t + timedelta(hours=1), + duration=timedelta(hours=1), + data={"app": "Firefox", "title": "synthetic"}, + ), + ], + ) + client.insert_events( + f"aw-watcher-afk_{host}", + [ + Event( + timestamp=t, + duration=timedelta(minutes=30), + data={"status": "not-afk"}, + ), + Event( + timestamp=t + timedelta(minutes=30), + duration=timedelta(minutes=30), + data={"status": "afk"}, + ), + Event( + timestamp=t + timedelta(hours=1), + duration=timedelta(hours=1), + data={"status": "not-afk"}, + ), + ], + ) + # The similarly named host has different AFK coverage and must not be chosen. + client.delete_bucket("aw-watcher-afk_fixture-a-other") + client.create_bucket("aw-watcher-afk_fixture-a-other", event_type="afkstatus") + client.insert_events( + "aw-watcher-afk_fixture-a-other", + [Event(timestamp=t, duration=timedelta(hours=2), data={"status": "afk"})], + ) + client.set_setting( + "classes", + [ + {"name": ["Coding"], "rule": {"type": "regex", "regex": "Code"}}, + {"name": ["Communication"], "rule": {"type": "regex", "regex": "Firefox"}}, + ], + ) + a = summarize(client, "fixture-a", t, t + timedelta(hours=2)) + assert (a["active_seconds"], a["afk_seconds"], a["tracked_seconds"]) == ( + 5400, + 1800, + 7200, + ), a + assert a["categories"] == [ + {"name": ["Coding"], "seconds": 1800}, + {"name": ["Communication"], "seconds": 3600}, + ], a + assert a["apps"] == [ + {"app": "Code", "seconds": 1800}, + {"app": "Firefox", "seconds": 3600}, + ], a + # Both devices overlap completely; selecting A must not include B. + assert ( + summarize(client, "fixture-b", t, t + timedelta(hours=2))["active_seconds"] + == 5400 + ) + offset = timezone(timedelta(hours=2)) + assert ( + summarize( + client, + "fixture-a", + t.astimezone(offset), + (t + timedelta(hours=2)).astimezone(offset), + )["active_seconds"] + == 5400 + ) + clipped = summarize( + client, "fixture-a", t + timedelta(minutes=15), t + timedelta(minutes=75) + ) + assert ( + clipped["active_seconds"], + clipped["afk_seconds"], + clipped["tracked_seconds"], + ) == (1800, 1800, 3600), clipped + for start, end in [(t, t), (t + timedelta(days=1), t + timedelta(days=1, hours=1))]: + empty = summarize(client, "fixture-a", start, end) + assert ( + empty["active_seconds"] + == empty["afk_seconds"] + == empty["tracked_seconds"] + == 0 + ), empty + for host, start, end in [ + ("fixture-a", t.replace(tzinfo=None), t), + ("fixture-a", t + timedelta(hours=1), t), + ("missing", t, t + timedelta(hours=1)), + ]: + try: + summarize(client, host, start, end) + except ValueError: + pass + else: + raise AssertionError("Invalid host/range accepted") + # A window interval without any AFK coverage remains unknown. + gap = t + timedelta(days=2) + client.insert_events( + "aw-watcher-window_fixture-a", + [Event(timestamp=gap, duration=timedelta(minutes=10), data={"app": "Code"})], + ) + unknown = summarize(client, "fixture-a", gap, gap + timedelta(minutes=10)) + assert ( + unknown["active_seconds"], + unknown["afk_seconds"], + unknown["unknown_seconds"], + ) == (0, 0, 600), unknown + return a, empty + + +def verify_browser(summary, empty, base_url): + destinations, errors = [], [] + with sync_playwright() as p, tempfile.TemporaryDirectory() as tmp: + browser = p.chromium.launch() + context = browser.new_context() + page = context.new_page() + page.on("request", lambda req: destinations.append(req.url)) + page.on("pageerror", lambda err: errors.append(str(err))) + page.on("websocket", lambda ws: destinations.append(ws.url)) + try: + page.goto((ROOT / "ai-dashboard.html").as_uri()) + assert page.locator("#active-seconds").inner_text() == "9600" + assert page.locator("#afk-seconds").inner_text() == "600" + assert page.locator("#tracked-seconds").inner_text() == "10200" + for key, value in [ + ("Coding", "7200"), + ("Communication", "1800"), + ("Uncategorized", "600"), + ]: + row = page.locator("#categories .row").filter(has_text=key) + assert value in row.inner_text() + + def load(data): + path = Path(tmp) / "summary.json" + path.write_text(json.dumps(data)) + page.locator("#summary-file").set_input_files([]) + page.locator("#summary-file").set_input_files(str(path)) + page.wait_for_timeout(100) + + load({**summary, "active_seconds": 3599.6}) + assert page.locator("#active-fmt").inner_text() == "1h 00m 00s" + load(summary) + assert page.locator("#active-seconds").inner_text() == "5400" + assert page.locator("#afk-seconds").inner_text() == "1800" + assert "+00:00" in page.locator("#range").inner_text() + load(empty) + assert page.locator("#active-seconds").inner_text() == "0" + assert "No activity" in page.locator("#categories").inner_text() + load({**summary, "active_seconds": -1}) + assert page.locator("#error").is_visible() + load({**summary, "end": "2024-01-01T00:00:00Z"}) + assert page.locator("#error").is_visible() + bad = Path(tmp) / "bad.json" + bad.write_text("{invalid") + page.locator("#summary-file").set_input_files(str(bad)) + page.wait_for_timeout(100) + assert page.locator("#error").is_visible() + load( + { + **summary, + "hostname": "", + } + ) + assert page.locator("#host").inner_text().startswith("