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
189 changes: 189 additions & 0 deletions src/examples/agents-and-ai.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ai-dashboard.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 <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 <verify-ai-dashboard.py>` 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 ``<aw-data-dir>/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
------------

Expand Down
121 changes: 121 additions & 0 deletions src/examples/ai-dashboard-summary.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading