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.
activity_frames/db.py ADDED
@@ -0,0 +1,86 @@
1
+ """Read-only access to the local capture database.
2
+
3
+ The capture engine owns this database; activity-frames never writes to
4
+ it. Connections are opened with SQLite's read-only URI flag so a bug
5
+ here cannot corrupt capture data.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sqlite3
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+
14
+ DEFAULT_DB_CANDIDATES = (
15
+ "~/.screenpipe/db.sqlite", # default engine data dir
16
+ "~/.screenpipe/screenpipe.db",
17
+ )
18
+
19
+
20
+ class RecorderDBNotFound(FileNotFoundError):
21
+ """Raised when no capture database can be located."""
22
+
23
+
24
+ def find_default_db() -> str:
25
+ """Locate the capture DB, honoring $AFRAMES_DB (or $SCREENPIPE_DB)."""
26
+ for var in ("AFRAMES_DB", "SCREENPIPE_DB"):
27
+ env = os.environ.get(var)
28
+ if env:
29
+ p = Path(env).expanduser()
30
+ if p.exists():
31
+ return str(p)
32
+ raise RecorderDBNotFound(f"${var} points to a missing file: {env}")
33
+ for cand in DEFAULT_DB_CANDIDATES:
34
+ p = Path(cand).expanduser()
35
+ if p.exists():
36
+ return str(p)
37
+ raise RecorderDBNotFound(
38
+ "No capture database found. Start recording with: aframes record "
39
+ "(or point $AFRAMES_DB at an existing capture database)."
40
+ )
41
+
42
+
43
+ class Database:
44
+ """Minimal read-only SQLite wrapper (port of Nocta's SQLiteDB.swift)."""
45
+
46
+ def __init__(self, path: str | None = None):
47
+ self.path = path or find_default_db()
48
+ p = Path(self.path).expanduser()
49
+ if not p.exists():
50
+ raise RecorderDBNotFound(f"Database not found: {self.path}")
51
+ # as_uri() percent-encodes special characters and handles Windows
52
+ # drive letters, unlike naive f"file:{path}" interpolation.
53
+ uri = p.resolve().as_uri() + "?mode=ro"
54
+ self._conn = sqlite3.connect(uri, uri=True, timeout=3.0)
55
+ self._conn.execute("PRAGMA query_only = ON")
56
+
57
+ def rows(self, sql: str, params: Iterable[Any] = ()) -> list[tuple]:
58
+ cur = self._conn.execute(sql, tuple(params))
59
+ try:
60
+ return cur.fetchall()
61
+ finally:
62
+ cur.close()
63
+
64
+ def scalar(self, sql: str, params: Iterable[Any] = (), default: Any = 0) -> Any:
65
+ rows = self.rows(sql, params)
66
+ if rows and rows[0] and rows[0][0] is not None:
67
+ return rows[0][0]
68
+ return default
69
+
70
+ def table_exists(self, name: str) -> bool:
71
+ return (
72
+ self.scalar(
73
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
74
+ (name,),
75
+ )
76
+ > 0
77
+ )
78
+
79
+ def close(self) -> None:
80
+ self._conn.close()
81
+
82
+ def __enter__(self) -> "Database":
83
+ return self
84
+
85
+ def __exit__(self, *exc: Any) -> None:
86
+ self.close()
@@ -0,0 +1,118 @@
1
+ """Output formats: JSON, YAML, markdown, and the agent context block.
2
+
3
+ The context block is the flagship output: a compact, token-efficient
4
+ plaintext representation of an ActivityDocument designed to be pasted
5
+ into an agent's system prompt or returned from an MCP tool.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from .frames import ActivityDocument
12
+
13
+
14
+ def to_json(doc: ActivityDocument, *, include_input_text: bool = False, indent: int = 2) -> str:
15
+ return json.dumps(doc.to_dict(include_input_text), indent=indent, ensure_ascii=False)
16
+
17
+
18
+ def to_yaml(doc: ActivityDocument, *, include_input_text: bool = False) -> str:
19
+ try:
20
+ import yaml
21
+ except ImportError as e:
22
+ raise ImportError(
23
+ "PyYAML is required for YAML output: pip install activity-frames[yaml]"
24
+ ) from e
25
+ return yaml.safe_dump(
26
+ doc.to_dict(include_input_text),
27
+ sort_keys=False,
28
+ allow_unicode=True,
29
+ width=100,
30
+ )
31
+
32
+
33
+ def to_markdown(doc: ActivityDocument, *, include_input_text: bool = False) -> str:
34
+ d = doc.to_dict(include_input_text)
35
+ cov = d["coverage"]
36
+ lines = [
37
+ f"# Activity {d['window'].get('day', d['window']['start_utc'][:10])}",
38
+ "",
39
+ f"**Coverage:** {cov['first_activity']} to {cov['last_activity']} local, "
40
+ f"{cov['active_minutes']} active min across {cov['distinct_apps']} apps "
41
+ f"({cov['coverage_pct']}% of span)",
42
+ "",
43
+ "| # | Time | App / Site | Active | What was on screen |",
44
+ "|---|------|-----------|--------|--------------------|",
45
+ ]
46
+ for f in d["frames"]:
47
+ where = f["app"] + (f" ({f['site']})" if f.get("site") else "")
48
+ what = "; ".join(
49
+ (f"{p['kind']}" + (f": {p['entity']}" if p.get("entity") else ""))
50
+ for p in f.get("pages", [])[:3]
51
+ ) or "; ".join(f.get("windows", [])[:2])
52
+ lines.append(
53
+ f"| {f['id']} | {f['start'][:5]}-{f['end'][:5]} | {where} "
54
+ f"| {f['duration_min']}m | {what} |"
55
+ )
56
+ if cov.get("gaps"):
57
+ lines.append("")
58
+ gaps = ", ".join(f"{g['start']}-{g['end']} ({g['minutes']}m)" for g in cov["gaps"])
59
+ lines.append(f"**Away:** {gaps}")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def context_block(doc: ActivityDocument, *, max_frames: int = 40) -> str:
64
+ """Compact plaintext block for an agent's prompt.
65
+
66
+ Chronological, one line per frame, entities inline. Roughly 15-25
67
+ tokens per frame; a full working day fits in well under 1.5k tokens.
68
+ """
69
+ d = doc.to_dict(False)
70
+ cov = d["coverage"]
71
+ frames = d["frames"]
72
+
73
+ # If over budget, keep the longest frames but preserve chronology.
74
+ if len(frames) > max_frames:
75
+ keep = sorted(frames, key=lambda f: -f["duration_min"])[:max_frames]
76
+ keep_ids = {f["id"] for f in keep}
77
+ dropped = len(frames) - len(keep)
78
+ frames = [f for f in frames if f["id"] in keep_ids]
79
+ else:
80
+ dropped = 0
81
+
82
+ day = d["window"].get("day", d["window"]["start_utc"][:10])
83
+ lines = [
84
+ f"USER ACTIVITY ({day}, local time; measured from screen capture, "
85
+ "no interpretation):",
86
+ f"coverage: {cov['first_activity']}-{cov['last_activity']}, "
87
+ f"{cov['active_minutes']} active min, {cov['distinct_apps']} apps",
88
+ ]
89
+ for g in cov.get("gaps", []):
90
+ lines.append(f"away: {g['start']}-{g['end']} ({g['minutes']}m)")
91
+ for f in frames:
92
+ where = f["app"] + (f"/{f['site']}" if f.get("site") else "")
93
+ bits = []
94
+ for p in f.get("pages", [])[:4]:
95
+ b = p["kind"]
96
+ if p.get("entity"):
97
+ b += f":{p['entity']}"
98
+ if p.get("count"):
99
+ b += f" x{p['count']}"
100
+ bits.append(b)
101
+ if not bits and f.get("windows"):
102
+ bits = [f["windows"][0][:60]]
103
+ inp = f.get("input", {})
104
+ if inp.get("keys", 0) > 50:
105
+ bits.append(f"typed ~{inp['keys']} chars")
106
+ lines.append(
107
+ f"- {f['start'][:5]}-{f['end'][:5]} {where} ({f['duration_min']}m): "
108
+ + ("; ".join(bits) if bits else "on screen")
109
+ )
110
+ if dropped:
111
+ lines.append(f"(+{dropped} frames over the size budget omitted)")
112
+ omitted = d.get("omitted", {}).get("below_min_minutes", 0)
113
+ if omitted:
114
+ lines.append(
115
+ f"(+{omitted} brief frames under "
116
+ f"{d['omitted']['min_minutes']} min omitted)"
117
+ )
118
+ return "\n".join(lines)
@@ -0,0 +1,342 @@
1
+ """Deterministic event enrichment (port of Nocta's EnrichmentEngine.swift).
2
+
3
+ Raw `ui_events` rows have three reliability problems this module fixes
4
+ with plain code:
5
+
6
+ 1. Stale app attribution: the recorder sometimes tags an event with the
7
+ previously focused app. Fix: attribute each event to the nearest
8
+ frame in time (frames carry authoritative app/window/url).
9
+ 2. Keyboard-layout mismatch: some capture setups record physical key
10
+ positions interpreted as QWERTY while the user types another layout
11
+ (e.g. AZERTY). Fix: an optional, explicit translation map. Off by
12
+ default; never guessed.
13
+ 3. Anonymous clicks: many click events carry no element name. Fix:
14
+ resolve the click's coordinates against the frame's accessibility /
15
+ OCR element tree (exact containment, then tolerance, then a coarse
16
+ screen-zone). Every resolution is tagged with how it was obtained.
17
+
18
+ Everything is confidence-tagged. Nothing is invented: an unresolvable
19
+ click stays unresolved.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from bisect import bisect_left
24
+ from dataclasses import dataclass
25
+
26
+ from ._time import parse_epoch
27
+ from .db import Database
28
+
29
+ # ---- Keyboard layout translation --------------------------------------
30
+
31
+ # Maps are "recorded char -> actually typed char". The azerty map covers
32
+ # the mismatched positions between AZERTY hardware and QWERTY decoding;
33
+ # characters not in the map pass through unchanged.
34
+ LAYOUTS: dict[str, dict[str, str]] = {
35
+ "azerty": {
36
+ "q": "a", "a": "q", "w": "z", "z": "w",
37
+ "Q": "A", "A": "Q", "W": "Z", "Z": "W",
38
+ ";": "m", "m": ",", ",": ";",
39
+ },
40
+ }
41
+
42
+
43
+ def decode_text(text: str, layout: str | dict[str, str] | None) -> str:
44
+ """Translate recorded text through a layout map. None = identity."""
45
+ if not text or layout is None:
46
+ return text
47
+ mapping = LAYOUTS[layout] if isinstance(layout, str) else layout
48
+ return "".join(mapping.get(ch, ch) for ch in text)
49
+
50
+
51
+ # ---- Enriched events ---------------------------------------------------
52
+
53
+ @dataclass
54
+ class EnrichedEvent:
55
+ epoch: float
56
+ event_type: str # click | key | text | app_switch | clipboard
57
+ app: str # authoritative (nearest frame), falls back to event's
58
+ window: str | None
59
+ url: str | None
60
+ frame_id: int | None
61
+ frame_dt_ms: int | None # distance to the attributing frame
62
+ label: str | None # element name (native or resolved)
63
+ resolution: str # native | exact | tolerance | zone | none
64
+ text: str | None # decoded text content (None unless requested)
65
+ confidence: str # high | medium | low
66
+
67
+
68
+ # Primary-monitor logical resolution used to normalize click coordinates.
69
+ # The capture engine stores element bounds normalized to 0..1; clicks are
70
+ # absolute points. Detected from the main display when possible (macOS
71
+ # CoreGraphics via ctypes, no dependency); override with $AFRAMES_SCREEN
72
+ # ("1728x1117") or the enrich_events() arguments. The fallback constant
73
+ # matches a 16-inch MacBook Pro.
74
+ def _detect_screen() -> tuple[float, float]:
75
+ import os
76
+
77
+ env = os.environ.get("AFRAMES_SCREEN", "")
78
+ if "x" in env:
79
+ try:
80
+ w, h = env.lower().split("x", 1)
81
+ return float(w), float(h)
82
+ except ValueError:
83
+ pass
84
+ try: # macOS main display, logical points
85
+ import ctypes
86
+
87
+ cg = ctypes.CDLL(
88
+ "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"
89
+ )
90
+ cg.CGMainDisplayID.restype = ctypes.c_uint32
91
+ cg.CGDisplayPixelsWide.restype = ctypes.c_size_t
92
+ cg.CGDisplayPixelsHigh.restype = ctypes.c_size_t
93
+ did = cg.CGMainDisplayID()
94
+ w, h = cg.CGDisplayPixelsWide(did), cg.CGDisplayPixelsHigh(did)
95
+ if w > 0 and h > 0:
96
+ return float(w), float(h)
97
+ except Exception:
98
+ pass
99
+ return 1728.0, 1117.0
100
+
101
+
102
+ DEFAULT_SCREEN_W, DEFAULT_SCREEN_H = _detect_screen()
103
+ _TOL_PX = 40.0 # tolerance ring in logical pixels
104
+
105
+
106
+ def nearest_index(sorted_epochs: list[float], target: float) -> int | None:
107
+ """Index of the value nearest to target in a sorted list."""
108
+ if not sorted_epochs:
109
+ return None
110
+ lo = bisect_left(sorted_epochs, target)
111
+ if lo >= len(sorted_epochs):
112
+ return len(sorted_epochs) - 1
113
+ if lo > 0 and abs(sorted_epochs[lo - 1] - target) <= abs(sorted_epochs[lo] - target):
114
+ return lo - 1
115
+ return lo
116
+
117
+
118
+ # A click on a frame with no recorded element tree can often be resolved
119
+ # against a neighboring frame captured moments earlier/later that DOES have
120
+ # one (capture is event-driven; the tree is not extracted on every frame).
121
+ ELEMENT_RESCUE_WINDOW_S = 5.0
122
+
123
+
124
+ def enrich_events(
125
+ db: Database,
126
+ start_utc: str,
127
+ end_utc: str,
128
+ *,
129
+ layout: str | dict[str, str] | None = None,
130
+ include_text: bool = False,
131
+ resolve_clicks: bool = True,
132
+ screen_w: float = DEFAULT_SCREEN_W,
133
+ screen_h: float = DEFAULT_SCREEN_H,
134
+ element_rescue_window: float = ELEMENT_RESCUE_WINDOW_S,
135
+ ) -> list[EnrichedEvent]:
136
+ """Enrich all ui_events in [start_utc, end_utc). In-memory, read-only."""
137
+ if not db.table_exists("ui_events"):
138
+ return []
139
+
140
+ frame_rows = db.rows(
141
+ """
142
+ SELECT id, timestamp, app_name, window_name, browser_url FROM frames
143
+ WHERE timestamp >= ? AND timestamp < ?
144
+ AND app_name IS NOT NULL AND app_name != ''
145
+ ORDER BY timestamp ASC
146
+ """,
147
+ (start_utc, end_utc),
148
+ )
149
+ frames = [
150
+ (int(r[0]), e, r[2] or "", r[3], r[4])
151
+ for r in frame_rows
152
+ if (e := parse_epoch(r[1])) > 0 # malformed rows would unsort the bisect
153
+ ]
154
+ frame_epochs = [f[1] for f in frames]
155
+
156
+ # Sorted (epoch, frame_id) of frames that actually have an element tree,
157
+ # used to rescue click resolution when the nearest frame has none.
158
+ elem_frames: list[tuple[float, int]] = []
159
+ if resolve_clicks and frames and db.table_exists("elements"):
160
+ lo_id = min(f[0] for f in frames)
161
+ hi_id = max(f[0] for f in frames)
162
+ with_elements = {
163
+ int(r[0])
164
+ for r in db.rows(
165
+ "SELECT DISTINCT frame_id FROM elements"
166
+ " WHERE frame_id >= ? AND frame_id <= ?",
167
+ (lo_id, hi_id),
168
+ )
169
+ }
170
+ elem_frames = sorted(
171
+ (f[1], f[0]) for f in frames if f[0] in with_elements
172
+ )
173
+ elem_epochs = [e for e, _ in elem_frames]
174
+
175
+ event_rows = db.rows(
176
+ """
177
+ SELECT timestamp, event_type, x, y, element_name, element_role,
178
+ text_content, app_name
179
+ FROM ui_events
180
+ WHERE timestamp >= ? AND timestamp < ?
181
+ ORDER BY timestamp ASC
182
+ """,
183
+ (start_utc, end_utc),
184
+ )
185
+
186
+ elem_cache: dict[int, list[tuple[str, float, float, float, float]]] = {}
187
+ out: list[EnrichedEvent] = []
188
+
189
+ for ts, etype, x, y, elem_name, elem_role, text_content, event_app in event_rows:
190
+ epoch = parse_epoch(ts or "")
191
+ etype = etype or ""
192
+
193
+ # 1. Authoritative context from the nearest frame.
194
+ app = event_app or ""
195
+ window = url = None
196
+ frame_id = frame_dt_ms = None
197
+ idx = nearest_index(frame_epochs, epoch)
198
+ if idx is not None:
199
+ fid, fepoch, fapp, fwindow, furl = frames[idx]
200
+ frame_id = fid
201
+ frame_dt_ms = int(abs(epoch - fepoch) * 1000)
202
+ if fapp:
203
+ app = fapp
204
+ window, url = fwindow, furl
205
+
206
+ # 3. Click-label resolution. Resolve against the nearest frame that
207
+ # HAS an element tree (within the rescue window); the truly nearest
208
+ # frame often has none because tree extraction is event-driven.
209
+ native = (elem_name or "").strip()
210
+ label: str | None = native or None
211
+ resolution = "native" if native else "none"
212
+ if (
213
+ not native
214
+ and resolve_clicks
215
+ and etype == "click"
216
+ and x is not None
217
+ and y is not None
218
+ and frame_id is not None
219
+ ):
220
+ res_frame_id = frame_id
221
+ res_dt_ms = frame_dt_ms
222
+ ei = nearest_index(elem_epochs, epoch)
223
+ if ei is not None and abs(elem_epochs[ei] - epoch) <= element_rescue_window:
224
+ res_frame_id = elem_frames[ei][1]
225
+ res_dt_ms = int(abs(epoch - elem_epochs[ei]) * 1000)
226
+ label, resolution = _resolve_click(
227
+ db, res_frame_id, float(x), float(y), elem_cache, screen_w, screen_h
228
+ )
229
+ if resolution != "none":
230
+ # Confidence should reflect distance to the frame we actually
231
+ # resolved against, not the attribution frame.
232
+ frame_dt_for_conf = res_dt_ms
233
+ else:
234
+ frame_dt_for_conf = frame_dt_ms
235
+ else:
236
+ frame_dt_for_conf = frame_dt_ms
237
+
238
+ # 2. Layout decode (opt-in).
239
+ text = None
240
+ if text_content and include_text:
241
+ text = decode_text(text_content, layout)
242
+
243
+ out.append(
244
+ EnrichedEvent(
245
+ epoch=epoch,
246
+ event_type=etype,
247
+ app=app,
248
+ window=window,
249
+ url=url,
250
+ frame_id=frame_id,
251
+ frame_dt_ms=frame_dt_ms,
252
+ label=label,
253
+ resolution=resolution,
254
+ text=text,
255
+ confidence=_confidence(etype, resolution, frame_dt_for_conf),
256
+ )
257
+ )
258
+ return out
259
+
260
+
261
+ def _resolve_click(
262
+ db: Database,
263
+ frame_id: int,
264
+ x: float,
265
+ y: float,
266
+ cache: dict,
267
+ screen_w: float,
268
+ screen_h: float,
269
+ ) -> tuple[str | None, str]:
270
+ # Only the primary monitor is calibrated; off-screen coords stay unresolved.
271
+ if not (0 <= x <= screen_w and 0 <= y <= screen_h):
272
+ return None, "none"
273
+ if not db.table_exists("elements"):
274
+ return None, "none"
275
+ xn, yn = x / screen_w, y / screen_h
276
+
277
+ elems = cache.get(frame_id)
278
+ if elems is None:
279
+ rows = db.rows(
280
+ """
281
+ SELECT text, left_bound, top_bound, width_bound, height_bound
282
+ FROM elements WHERE frame_id = ? AND text IS NOT NULL AND text != ''
283
+ """,
284
+ (frame_id,),
285
+ )
286
+ elems = [
287
+ (r[0], float(r[1]), float(r[2]), float(r[3]), float(r[4]))
288
+ for r in rows
289
+ if r[1] is not None and r[2] is not None and r[3] is not None and r[4] is not None
290
+ ]
291
+ cache[frame_id] = elems
292
+ if not elems:
293
+ return None, "none"
294
+
295
+ # Exact containment; smallest area wins (most specific element).
296
+ best = None
297
+ for text, l, t, w, h in elems:
298
+ if l <= xn <= l + w and t <= yn <= t + h:
299
+ area = w * h
300
+ if best is None or area < best[1]:
301
+ best = (text, area)
302
+ if best:
303
+ return _clean(best[0]), "exact"
304
+
305
+ # Tolerance: expanded boxes (ring in logical pixels, normalized here).
306
+ tol_n = _TOL_PX / screen_w
307
+ tol = None
308
+ for text, l, t, w, h in elems:
309
+ if l - tol_n <= xn <= l + w + tol_n and t - tol_n <= yn <= t + h + tol_n:
310
+ area = w * h
311
+ if tol is None or area < tol[1]:
312
+ tol = (text, area)
313
+ if tol:
314
+ return _clean(tol[0]), "tolerance"
315
+
316
+ # Coarse screen zone (low confidence, still honest).
317
+ if yn < 0.04:
318
+ return "menu bar", "zone"
319
+ if yn < 0.08:
320
+ return "browser chrome", "zone"
321
+ if xn < 0.15:
322
+ return "left sidebar", "zone"
323
+ return "main content", "zone"
324
+
325
+
326
+ def _clean(s: str) -> str:
327
+ t = s.strip()
328
+ return t[:80] + "..." if len(t) > 80 else t
329
+
330
+
331
+ def _confidence(etype: str, resolution: str, frame_dt_ms: int | None) -> str:
332
+ if etype in ("text", "app_switch", "clipboard"):
333
+ return "high"
334
+ if etype == "click":
335
+ if resolution == "native":
336
+ return "high"
337
+ if resolution == "exact":
338
+ return "high" if (frame_dt_ms or 9999) < 2000 else "medium"
339
+ if resolution == "tolerance":
340
+ return "medium"
341
+ return "low"
342
+ return "low"