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
|
@@ -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()
|
activity_frames/_time.py
ADDED
|
@@ -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())
|