activity-frames 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ """activity-frames: episodic memory for AI agents.
2
+
3
+ Compiles raw screen-capture data (a local capture database) into
4
+ structured, deterministic activity frames that any agent can consume.
5
+ No LLM in the loop: same input, same output, every time.
6
+
7
+ Quickstart:
8
+
9
+ from activity_frames import ActivityLog
10
+
11
+ log = ActivityLog() # finds the local capture DB
12
+ doc = log.day() # today's activity frames
13
+ print(log.context(hours=2)) # paste-ready agent context
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .db import Database, RecorderDBNotFound, find_default_db
18
+ from .emit import context_block, to_json, to_markdown, to_yaml
19
+ from .entities import PageRef, parse_url
20
+ from .frames import (
21
+ SCHEMA_VERSION,
22
+ ActivityDocument,
23
+ ActivityFrame,
24
+ build_day,
25
+ build_frames,
26
+ build_recent,
27
+ )
28
+ from .patterns import WorkPattern, detect as detect_patterns
29
+ from .sessionize import Coverage, Segment, app_ledger, coverage, segments
30
+
31
+ __version__ = "0.1.0"
32
+ __all__ = [
33
+ "ActivityLog",
34
+ "ActivityDocument",
35
+ "ActivityFrame",
36
+ "Coverage",
37
+ "Database",
38
+ "PageRef",
39
+ "RecorderDBNotFound",
40
+ "SCHEMA_VERSION",
41
+ "Segment",
42
+ "WorkPattern",
43
+ "app_ledger",
44
+ "build_day",
45
+ "build_frames",
46
+ "build_recent",
47
+ "context_block",
48
+ "coverage",
49
+ "detect_patterns",
50
+ "find_default_db",
51
+ "parse_url",
52
+ "segments",
53
+ "to_json",
54
+ "to_markdown",
55
+ "to_yaml",
56
+ ]
57
+
58
+
59
+ class ActivityLog:
60
+ """High-level facade over a local capture database."""
61
+
62
+ def __init__(
63
+ self,
64
+ db_path: str | None = None,
65
+ *,
66
+ layout: str | dict | None = None,
67
+ min_minutes: float = 0.5,
68
+ ):
69
+ self.db = Database(db_path)
70
+ self.layout = layout
71
+ self.min_minutes = min_minutes
72
+
73
+ # ---- documents ----
74
+
75
+ def day(self, day: str | None = None, **kwargs) -> ActivityDocument:
76
+ """Activity frames for a local calendar day ("YYYY-MM-DD", default today)."""
77
+ kwargs.setdefault("min_minutes", self.min_minutes)
78
+ kwargs.setdefault("layout", self.layout)
79
+ return build_day(self.db, day, **kwargs)
80
+
81
+ def recent(self, hours: float = 2.0, **kwargs) -> ActivityDocument:
82
+ """Activity frames for the last N hours."""
83
+ kwargs.setdefault("min_minutes", self.min_minutes)
84
+ kwargs.setdefault("layout", self.layout)
85
+ return build_recent(self.db, hours, **kwargs)
86
+
87
+ def window(self, start_utc: str, end_utc: str, **kwargs) -> ActivityDocument:
88
+ """Activity frames for an explicit UTC window."""
89
+ kwargs.setdefault("min_minutes", self.min_minutes)
90
+ kwargs.setdefault("layout", self.layout)
91
+ return build_frames(self.db, start_utc, end_utc, **kwargs)
92
+
93
+ # ---- agent-ready strings ----
94
+
95
+ def context(self, hours: float = 2.0, *, max_frames: int = 40, **kwargs) -> str:
96
+ """Compact plaintext context block for the last N hours."""
97
+ return context_block(self.recent(hours, **kwargs), max_frames=max_frames)
98
+
99
+ def day_context(self, day: str | None = None, *, max_frames: int = 40, **kwargs) -> str:
100
+ """Compact plaintext context block for a local day."""
101
+ return context_block(self.day(day, **kwargs), max_frames=max_frames)
102
+
103
+ # ---- extras ----
104
+
105
+ def patterns(self, days: int = 7, *, include_text: bool = False) -> list[WorkPattern]:
106
+ """Repetitive workflow patterns over the last N days.
107
+
108
+ include_text enables the repeated-text detector, whose labels
109
+ quote raw typed content. Off by default.
110
+ """
111
+ from datetime import datetime, timedelta, timezone
112
+
113
+ from ._time import utc_string
114
+
115
+ now = datetime.now(timezone.utc)
116
+ return detect_patterns(
117
+ self.db, utc_string(now - timedelta(days=days)), utc_string(now),
118
+ include_text=include_text,
119
+ )
120
+
121
+ def apps(self, day: str | None = None):
122
+ """Per-app usage ledger for a local day."""
123
+ from ._time import local_day_string, local_day_window_utc
124
+
125
+ start, end = local_day_window_utc(day or local_day_string())
126
+ return app_ledger(self.db, start, end)
127
+
128
+ def close(self) -> None:
129
+ self.db.close()
@@ -0,0 +1,86 @@
1
+ """Timestamp helpers.
2
+
3
+ The capture engine stores UTC ISO timestamps like
4
+ "2026-07-04T23:10:43.399302+00:00" in both `frames` and `ui_events`.
5
+ All window boundaries we pass to SQL are plain "YYYY-MM-DDTHH:MM:SS"
6
+ prefixes, which compare correctly against the stored strings.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+
13
+ def parse_epoch(ts: str) -> float:
14
+ """Parse a recorder timestamp to epoch seconds. Returns 0.0 on garbage.
15
+
16
+ Handles "2026-07-04T23:10:43", with optional fractional seconds and
17
+ optional "+00:00"/"Z" suffix. Timestamps are always UTC.
18
+ """
19
+ if not ts or len(ts) < 19:
20
+ return 0.0
21
+ base = ts[:19]
22
+ try:
23
+ dt = datetime.strptime(base, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
24
+ except ValueError:
25
+ return 0.0
26
+ epoch = dt.timestamp()
27
+ if len(ts) > 20 and ts[19] == ".":
28
+ frac = ""
29
+ for ch in ts[20:]:
30
+ if ch.isdigit():
31
+ frac += ch
32
+ else:
33
+ break
34
+ if frac:
35
+ epoch += float("0." + frac)
36
+ return epoch
37
+
38
+
39
+ def utc_string(dt: datetime) -> str:
40
+ """Format a datetime as the recorder's comparable UTC prefix."""
41
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
42
+
43
+
44
+ def now_utc_string() -> str:
45
+ return utc_string(datetime.now(timezone.utc))
46
+
47
+
48
+ def local_day_string(dt: datetime | None = None) -> str:
49
+ """Local calendar day "YYYY-MM-DD" for a datetime (default: now)."""
50
+ d = dt if dt is not None else datetime.now()
51
+ return d.astimezone().strftime("%Y-%m-%d")
52
+
53
+
54
+ def local_day_window_utc(day: str) -> tuple[str, str]:
55
+ """UTC window [start, end) covering a local calendar day.
56
+
57
+ Each boundary gets the local UTC offset in effect ON THAT DATE
58
+ (naive .astimezone() applies the platform's timezone rules per
59
+ date), so past days across a DST change and 23/25-hour transition
60
+ days are handled correctly.
61
+
62
+ Raises ValueError on a malformed day string.
63
+ """
64
+ start_naive = datetime.strptime(day, "%Y-%m-%d")
65
+ start_local = start_naive.astimezone()
66
+ end_local = (start_naive + timedelta(days=1)).astimezone()
67
+ return utc_string(start_local), utc_string(end_local)
68
+
69
+
70
+ def hours_ago_window_utc(hours: float) -> tuple[str, str]:
71
+ """UTC window [now - hours, now]."""
72
+ now = datetime.now(timezone.utc)
73
+ return utc_string(now - timedelta(hours=hours)), utc_string(now)
74
+
75
+
76
+ def fmt_local_hm(epoch: float) -> str:
77
+ """Format an epoch as local "HH:MM"."""
78
+ if epoch <= 0:
79
+ return "?"
80
+ return datetime.fromtimestamp(epoch).astimezone().strftime("%H:%M")
81
+
82
+
83
+ def fmt_local_hms(epoch: float) -> str:
84
+ if epoch <= 0:
85
+ return "?"
86
+ return datetime.fromtimestamp(epoch).astimezone().strftime("%H:%M:%S")
@@ -0,0 +1,251 @@
1
+ """Built-in capture management: give the system its eyes.
2
+
3
+ `aframes record` provisions and runs a local, open-source capture engine
4
+ (event-driven screen + accessibility-tree + input recording into SQLite).
5
+ The engine binary is fetched once, pinned to a known MIT-licensed
6
+ version for reproducibility, and runs entirely on-device. Audio capture
7
+ is OFF by default.
8
+
9
+ The compilation layer (the rest of this package) is recorder-agnostic;
10
+ this module just makes the default pipeline self-contained:
11
+
12
+ pip install activity-frames
13
+ aframes record # start capturing
14
+ aframes context # your last 2 hours, agent-ready
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import platform
20
+ import signal
21
+ import subprocess
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ HOME_DIR = Path("~/.activity-frames").expanduser()
29
+ BIN_DIR = HOME_DIR / "bin"
30
+ PID_FILE = HOME_DIR / "recorder.pid"
31
+ LOG_FILE = HOME_DIR / "recorder.log"
32
+
33
+ # Pinned engine: screenpipe v0.3.324, the last MIT-licensed npm release.
34
+ # Each platform tarball is verified against its npm dist integrity hash
35
+ # (sha512) before extraction; a mismatch aborts the install.
36
+ _ENGINE_VERSION = "0.3.324"
37
+ _ENGINE_PACKAGES = {
38
+ ("darwin", "arm64"): (
39
+ "cli-darwin-arm64",
40
+ "uWdkCmGxO8jPHT8A91PzPQe7paFHQZmZHIqIRtW3Ikeb5G2H/XLUHmq6CYMpgUio"
41
+ "GAr8dSvIEbc00Tx9/qYYQg==",
42
+ ),
43
+ ("darwin", "x86_64"): (
44
+ "cli-darwin-x64",
45
+ "OpbKGyL5i7ep1IXG2wFrVlB5075hHeSjyrQh3ytfdMOz5DaqAsMAC6eLbQ2TnrGh"
46
+ "K3EGPDqgn1pgLvA2RcmL3A==",
47
+ ),
48
+ ("linux", "x86_64"): (
49
+ "cli-linux-x64",
50
+ "gdEu4RWpTtn92LK6ySNRurmH27lYsbR0bfvWRWb0DDOC3HLn4yumII49PxhZ1dSU"
51
+ "bNTKM1tBkCoHieY8iPLf2g==",
52
+ ),
53
+ }
54
+ _REGISTRY = "https://registry.npmjs.org/@screenpipe/{pkg}/-/{pkg}-{ver}.tgz"
55
+
56
+
57
+ class CaptureError(RuntimeError):
58
+ pass
59
+
60
+
61
+ def _platform_key() -> tuple[str, str]:
62
+ sysname = platform.system().lower()
63
+ machine = platform.machine().lower()
64
+ if machine in ("aarch64",):
65
+ machine = "arm64"
66
+ if machine in ("amd64",):
67
+ machine = "x86_64"
68
+ return sysname, machine
69
+
70
+
71
+ def engine_path() -> Path:
72
+ # The engine keeps its real name: it is a pinned screenpipe build.
73
+ return BIN_DIR / "screenpipe"
74
+
75
+
76
+ def _find_system_engine() -> str | None:
77
+ """An already-installed engine on PATH also works."""
78
+ from shutil import which
79
+
80
+ return which("screenpipe")
81
+
82
+
83
+ def _verify_sha512(path: Path, expected_b64: str) -> None:
84
+ import base64
85
+ import hashlib
86
+
87
+ h = hashlib.sha512()
88
+ with open(path, "rb") as f:
89
+ for chunk in iter(lambda: f.read(1 << 20), b""):
90
+ h.update(chunk)
91
+ actual = base64.b64encode(h.digest()).decode()
92
+ if actual != expected_b64:
93
+ raise CaptureError(
94
+ "Engine download failed integrity verification "
95
+ f"(expected sha512-{expected_b64[:16]}..., got sha512-{actual[:16]}...). "
96
+ "Refusing to install. Retry, or install screenpipe yourself and "
97
+ "re-run; anything named 'screenpipe' on PATH is used as-is."
98
+ )
99
+
100
+
101
+ def ensure_engine(quiet: bool = False) -> str:
102
+ """Return a runnable capture-engine binary, fetching it if needed.
103
+
104
+ Downloads are pinned to a specific version AND verified against its
105
+ published sha512 before anything is extracted or executed.
106
+ """
107
+ local = engine_path()
108
+ if local.exists() and os.access(local, os.X_OK):
109
+ return str(local)
110
+ system = _find_system_engine()
111
+ if system:
112
+ return system
113
+
114
+ key = _platform_key()
115
+ entry = _ENGINE_PACKAGES.get(key)
116
+ if not entry:
117
+ raise CaptureError(
118
+ f"No prebuilt capture engine for {key[0]}/{key[1]} yet. "
119
+ "You can point activity-frames at any existing capture database "
120
+ "via $AFRAMES_DB instead."
121
+ )
122
+ pkg, sha512_b64 = entry
123
+ url = _REGISTRY.format(pkg=pkg, ver=_ENGINE_VERSION)
124
+ if not quiet:
125
+ print(f"Fetching capture engine (screenpipe {_ENGINE_VERSION}, "
126
+ f"{key[0]}/{key[1]}, one-time, ~50MB)...", file=sys.stderr)
127
+ BIN_DIR.mkdir(parents=True, exist_ok=True)
128
+ with tempfile.TemporaryDirectory() as td:
129
+ tgz = Path(td) / "engine.tgz"
130
+ urllib.request.urlretrieve(url, tgz)
131
+ _verify_sha512(tgz, sha512_b64)
132
+ with tarfile.open(tgz) as tf:
133
+ member = next(
134
+ (m for m in tf.getmembers() if m.name.endswith("bin/screenpipe")),
135
+ None,
136
+ )
137
+ # Regular files only: symlinks/hardlinks in an archive could
138
+ # otherwise redirect the extract or the chmod below.
139
+ if member is None or not member.isreg():
140
+ raise CaptureError("Unexpected engine archive layout.")
141
+ member.name = "screenpipe"
142
+ try:
143
+ tf.extract(member, BIN_DIR, filter="data") # Python 3.12+
144
+ except TypeError:
145
+ tf.extract(member, BIN_DIR)
146
+ local.chmod(0o755)
147
+ if not quiet:
148
+ print(f"Capture engine installed at {local}", file=sys.stderr)
149
+ return str(local)
150
+
151
+
152
+ def _pid_is_engine(pid: int) -> bool:
153
+ """Verify the PID actually belongs to the capture engine, so a stale
154
+ pidfile after reboot/PID-reuse never points us at an innocent process."""
155
+ try:
156
+ out = subprocess.run(
157
+ ["ps", "-p", str(pid), "-o", "comm="],
158
+ capture_output=True, text=True, timeout=5,
159
+ ).stdout.strip()
160
+ except Exception:
161
+ return True # cannot verify: fail open for status, stop() re-checks
162
+ return "screenpipe" in out or "capture-engine" in out
163
+
164
+
165
+ def _read_pid() -> int | None:
166
+ try:
167
+ pid = int(PID_FILE.read_text().strip())
168
+ except (FileNotFoundError, ValueError):
169
+ return None
170
+ try:
171
+ os.kill(pid, 0)
172
+ except OSError:
173
+ PID_FILE.unlink(missing_ok=True) # stale
174
+ return None
175
+ if not _pid_is_engine(pid):
176
+ PID_FILE.unlink(missing_ok=True) # PID reused by another process
177
+ return None
178
+ return pid
179
+
180
+
181
+ def _last_frame_age_s() -> float | None:
182
+ """Seconds since the most recent captured frame, or None if unknown."""
183
+ try:
184
+ from ._time import parse_epoch
185
+ from .db import Database
186
+
187
+ db = Database()
188
+ ts = db.scalar("SELECT MAX(timestamp) FROM frames", default=None)
189
+ db.close()
190
+ if not ts:
191
+ return None
192
+ import time
193
+
194
+ return max(0.0, time.time() - parse_epoch(ts))
195
+ except Exception:
196
+ return None
197
+
198
+
199
+ def status() -> str:
200
+ pid = _read_pid()
201
+ if not pid:
202
+ return "not recording"
203
+ age = _last_frame_age_s()
204
+ base = f"recording (pid {pid}, log: {LOG_FILE})"
205
+ if age is None:
206
+ return (base + "\nwarning: no capture database found yet. If this "
207
+ "persists, grant Screen Recording and Accessibility permission "
208
+ "(System Settings > Privacy & Security) and restart capture.")
209
+ if age > 300:
210
+ return (base + f"\nwarning: last captured frame is {int(age/60)} min old. "
211
+ "The engine may lack Screen Recording / Accessibility permission "
212
+ "(System Settings > Privacy & Security), or the screen is idle.")
213
+ return base + f" - healthy, last frame {int(age)}s ago"
214
+
215
+
216
+ def start(*, audio: bool = False, foreground: bool = False) -> None:
217
+ if _read_pid():
218
+ print(f"Already {status()}", file=sys.stderr)
219
+ return
220
+ binary = ensure_engine()
221
+ args = [binary, "record"]
222
+ args += ["--audio-chunk-duration", "300"] if audio else ["--disable-audio"]
223
+
224
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
225
+ if foreground:
226
+ os.execv(binary, args)
227
+
228
+ log = open(LOG_FILE, "ab")
229
+ proc = subprocess.Popen(
230
+ args, stdout=log, stderr=log,
231
+ start_new_session=True,
232
+ )
233
+ PID_FILE.write_text(str(proc.pid))
234
+ hint = ""
235
+ if platform.system() == "Darwin":
236
+ hint = ("\nmacOS: the engine needs Screen Recording and Accessibility "
237
+ "permission (System Settings > Privacy & Security). Check with: "
238
+ "aframes record --status")
239
+ print(f"Capture started (pid {proc.pid}). Audio: {'on' if audio else 'off'}.\n"
240
+ f"Data stays on this machine. Stop with: aframes record --stop{hint}",
241
+ file=sys.stderr)
242
+
243
+
244
+ def stop() -> None:
245
+ pid = _read_pid()
246
+ if not pid:
247
+ print("Not recording.", file=sys.stderr)
248
+ return
249
+ os.kill(pid, signal.SIGTERM)
250
+ PID_FILE.unlink(missing_ok=True)
251
+ print("Capture stopped.", file=sys.stderr)
activity_frames/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """aframes: command-line interface.
2
+
3
+ aframes today today's frames (yaml)
4
+ aframes day 2026-07-04 -f json a specific day
5
+ aframes context --hours 3 paste-ready agent context block
6
+ aframes apps per-app ledger for today
7
+ aframes patterns --days 7 repetitive workflows
8
+ aframes mcp run the MCP stdio server
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+
15
+ from . import ActivityLog, __version__
16
+ from .db import RecorderDBNotFound
17
+ from .emit import context_block, to_json, to_markdown, to_yaml
18
+
19
+
20
+ def _add_common(p: argparse.ArgumentParser) -> None:
21
+ p.add_argument("--db", help="path to the capture SQLite database")
22
+ p.add_argument("-f", "--format", choices=["yaml", "json", "md", "context"],
23
+ default="yaml", help="output format (default yaml)")
24
+ p.add_argument("--min-minutes", type=float, default=0.5,
25
+ help="drop frames shorter than this (default 0.5)")
26
+ p.add_argument("--include-text", action="store_true",
27
+ help="include typed text snippets (off by default)")
28
+ p.add_argument("--layout", default=None,
29
+ help="keyboard layout decode map (e.g. azerty)")
30
+
31
+
32
+ def _emit(doc, fmt: str, include_text: bool) -> str:
33
+ if fmt == "json":
34
+ return to_json(doc, include_input_text=include_text)
35
+ if fmt == "md":
36
+ return to_markdown(doc, include_input_text=include_text)
37
+ if fmt == "context":
38
+ return context_block(doc)
39
+ try:
40
+ return to_yaml(doc, include_input_text=include_text)
41
+ except ImportError:
42
+ print("note: PyYAML not installed, emitting JSON "
43
+ "(pip install activity-frames[yaml])", file=sys.stderr)
44
+ return to_json(doc, include_input_text=include_text)
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ parser = argparse.ArgumentParser(
49
+ prog="aframes",
50
+ description="activity-frames: episodic memory for AI agents. "
51
+ "Compiles screen capture into structured activity frames.",
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"aframes {__version__}")
54
+ sub = parser.add_subparsers(dest="cmd")
55
+
56
+ p_today = sub.add_parser("today", help="activity frames for today")
57
+ _add_common(p_today)
58
+
59
+ p_day = sub.add_parser("day", help="activity frames for a local day")
60
+ p_day.add_argument("date", help="YYYY-MM-DD")
61
+ _add_common(p_day)
62
+
63
+ p_ctx = sub.add_parser("context", help="compact agent context block")
64
+ p_ctx.add_argument("--hours", type=float, default=2.0,
65
+ help="how many hours back (default 2)")
66
+ p_ctx.add_argument("--max-frames", type=int, default=40)
67
+ _add_common(p_ctx)
68
+
69
+ p_apps = sub.add_parser("apps", help="per-app usage ledger")
70
+ p_apps.add_argument("date", nargs="?", help="YYYY-MM-DD (default today)")
71
+ _add_common(p_apps)
72
+
73
+ p_pat = sub.add_parser("patterns", help="repetitive workflow patterns")
74
+ p_pat.add_argument("--days", type=int, default=7)
75
+ _add_common(p_pat)
76
+
77
+ p_mcp = sub.add_parser("mcp", help="run the MCP stdio server")
78
+ p_mcp.add_argument("--db", help="path to the capture SQLite database")
79
+ p_mcp.add_argument("--layout", default=None)
80
+
81
+ p_rec = sub.add_parser("record", help="start/stop the built-in capture engine")
82
+ p_rec.add_argument("--stop", action="store_true", help="stop capturing")
83
+ p_rec.add_argument("--status", action="store_true", help="show capture status")
84
+ p_rec.add_argument("--audio", action="store_true",
85
+ help="also capture audio (off by default)")
86
+ p_rec.add_argument("--foreground", action="store_true",
87
+ help="run in the foreground instead of detaching")
88
+
89
+ args = parser.parse_args(argv)
90
+ if not args.cmd:
91
+ parser.print_help()
92
+ return 1
93
+
94
+ if args.cmd == "record":
95
+ from . import capture
96
+
97
+ try:
98
+ if args.stop:
99
+ capture.stop()
100
+ elif args.status:
101
+ print(capture.status())
102
+ else:
103
+ capture.start(audio=args.audio, foreground=args.foreground)
104
+ except capture.CaptureError as e:
105
+ print(f"error: {e}", file=sys.stderr)
106
+ return 2
107
+ return 0
108
+
109
+ if args.cmd == "mcp":
110
+ from .mcp_server import MCPServer
111
+
112
+ MCPServer(args.db, args.layout).serve()
113
+ return 0
114
+
115
+ if getattr(args, "layout", None):
116
+ from .enrich import LAYOUTS
117
+
118
+ if args.layout not in LAYOUTS:
119
+ known = ", ".join(sorted(LAYOUTS))
120
+ print(f"error: unknown layout '{args.layout}' (known: {known})",
121
+ file=sys.stderr)
122
+ return 2
123
+
124
+ try:
125
+ log = ActivityLog(args.db, layout=args.layout, min_minutes=args.min_minutes)
126
+ except RecorderDBNotFound as e:
127
+ print(f"error: {e}", file=sys.stderr)
128
+ return 2
129
+
130
+ kw = dict(min_minutes=args.min_minutes, include_text=args.include_text)
131
+
132
+ try:
133
+ if args.cmd == "today":
134
+ print(_emit(log.day(**kw), args.format, args.include_text))
135
+ elif args.cmd == "day":
136
+ print(_emit(log.day(args.date, **kw), args.format, args.include_text))
137
+ elif args.cmd == "context":
138
+ doc = log.recent(args.hours, **kw)
139
+ print(context_block(doc, max_frames=args.max_frames))
140
+ elif args.cmd == "apps":
141
+ for a in log.apps(args.date):
142
+ wins = f" ({'; '.join(a.top_windows[:2])})" if a.top_windows else ""
143
+ print(f"{a.minutes:8.1f} min {a.app} "
144
+ f"[{a.sessions} sessions, longest {a.longest_session_min}m]{wins}")
145
+ elif args.cmd == "patterns":
146
+ for p in log.patterns(args.days, include_text=args.include_text):
147
+ print(f"[{p.kind}] {p.label}")
148
+ except ValueError as e:
149
+ print(f"error: {e} (dates are YYYY-MM-DD)", file=sys.stderr)
150
+ return 2
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())