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/__init__.py +129 -0
- activity_frames/_time.py +86 -0
- activity_frames/capture.py +251 -0
- activity_frames/cli.py +155 -0
- activity_frames/db.py +86 -0
- activity_frames/emit.py +118 -0
- activity_frames/enrich.py +342 -0
- activity_frames/entities.py +416 -0
- activity_frames/frames.py +358 -0
- activity_frames/mcp_server.py +262 -0
- activity_frames/patterns.py +253 -0
- activity_frames/sessionize.py +356 -0
- activity_frames-0.1.0.dist-info/METADATA +157 -0
- activity_frames-0.1.0.dist-info/RECORD +17 -0
- activity_frames-0.1.0.dist-info/WHEEL +4 -0
- activity_frames-0.1.0.dist-info/entry_points.txt +2 -0
- activity_frames-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|
activity_frames/emit.py
ADDED
|
@@ -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"
|