privatecontext 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.
- privatecontext/__init__.py +3 -0
- privatecontext/capture.py +350 -0
- privatecontext/cli.py +228 -0
- privatecontext/dashboard.py +168 -0
- privatecontext/db.py +140 -0
- privatecontext/importers.py +572 -0
- privatecontext/mcp_server.py +123 -0
- privatecontext/schema.sql +53 -0
- privatecontext/summarize.py +260 -0
- privatecontext/tagging.py +83 -0
- privatecontext-0.1.0.dist-info/METADATA +269 -0
- privatecontext-0.1.0.dist-info/RECORD +16 -0
- privatecontext-0.1.0.dist-info/WHEEL +5 -0
- privatecontext-0.1.0.dist-info/entry_points.txt +2 -0
- privatecontext-0.1.0.dist-info/licenses/LICENSE +21 -0
- privatecontext-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Event-driven capture loop for PrivateContext.
|
|
2
|
+
|
|
3
|
+
Live mode:
|
|
4
|
+
* active-window watcher, once per second, per-OS with stdlib only
|
|
5
|
+
(Linux: xprop/X11, macOS: osascript, Windows: ctypes). Degrades to a
|
|
6
|
+
warning when the platform hooks are unavailable.
|
|
7
|
+
* click watcher via pynput — logs x, y, window, url, timestamp.
|
|
8
|
+
Key *contents* are never recorded; the keyboard listener exists only
|
|
9
|
+
to timestamp activity for AFK detection.
|
|
10
|
+
* AFK watcher: no input for 180 s -> 'afk' event; next input -> 'back'.
|
|
11
|
+
* URL capture: best-effort active-tab URL (macOS browsers via AppleScript
|
|
12
|
+
in v0); query params and fragments are ALWAYS stripped before storage.
|
|
13
|
+
* Screenshots: OFF by default. With --screenshots, the primary monitor is
|
|
14
|
+
captured on app-switch / URL-change only, downscaled to 640 px wide and
|
|
15
|
+
stored as JPEG (quality 70, Pillow) under shots/YYYY-MM-DD/.
|
|
16
|
+
|
|
17
|
+
Simulate mode (--simulate): writes a realistic synthetic workday straight
|
|
18
|
+
into the DB. No OS hooks, no pynput, no mss — runs anywhere, including
|
|
19
|
+
headless CI. Used to test the whole pipeline end to end.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ctypes
|
|
24
|
+
import platform
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from datetime import date, datetime, timedelta
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .db import connect, data_dir, init_db, log_event, strip_url
|
|
35
|
+
|
|
36
|
+
POLL_S = 1.0
|
|
37
|
+
IDLE_AFTER_S = 180
|
|
38
|
+
SHOT_MAX_W = 640
|
|
39
|
+
|
|
40
|
+
_warned: set[str] = set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def warn_once(key: str, msg: str) -> None:
|
|
44
|
+
if key not in _warned:
|
|
45
|
+
_warned.add(key)
|
|
46
|
+
print(f"[privatecontext] {msg}", file=sys.stderr)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(cmd: list[str], timeout: int = 2) -> str:
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
52
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
53
|
+
except Exception:
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Active window, per OS (stdlib only)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _active_window_linux() -> dict | None:
|
|
62
|
+
if not shutil.which("xprop"):
|
|
63
|
+
warn_once("xprop", "'xprop' not found: window watcher disabled "
|
|
64
|
+
"(v0 supports X11 only, not Wayland)")
|
|
65
|
+
return None
|
|
66
|
+
root = _run(["xprop", "-root", "_NET_ACTIVE_WINDOW"])
|
|
67
|
+
m = re.search(r"0x[0-9a-fA-F]+", root)
|
|
68
|
+
if not m or m.group(0) == "0x0":
|
|
69
|
+
return None
|
|
70
|
+
out = _run(["xprop", "-id", m.group(0), "WM_CLASS", "_NET_WM_NAME"])
|
|
71
|
+
app_m = re.search(r'WM_CLASS\(STRING\) = "(?:[^"]+)", "([^"]+)"', out)
|
|
72
|
+
title_m = re.search(r'_NET_WM_NAME\(UTF8_STRING\) = "(.*)"', out)
|
|
73
|
+
return {"app": app_m.group(1) if app_m else None,
|
|
74
|
+
"title": title_m.group(1) if title_m else None}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _active_window_macos() -> dict | None:
|
|
78
|
+
app = _run(["osascript", "-e",
|
|
79
|
+
'tell application "System Events" to get name of first '
|
|
80
|
+
'process whose frontmost is true'])
|
|
81
|
+
if not app:
|
|
82
|
+
warn_once("macos", "osascript failed: window watcher disabled")
|
|
83
|
+
return None
|
|
84
|
+
title = _run(["osascript", "-e",
|
|
85
|
+
'tell application "System Events" to tell '
|
|
86
|
+
'(first process whose frontmost is true) to get name of front window'])
|
|
87
|
+
return {"app": app or None, "title": title or None}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _active_window_windows() -> dict | None:
|
|
91
|
+
try:
|
|
92
|
+
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
93
|
+
except Exception:
|
|
94
|
+
warn_once("win", "ctypes.windll unavailable: window watcher disabled")
|
|
95
|
+
return None
|
|
96
|
+
hwnd = user32.GetForegroundWindow()
|
|
97
|
+
if not hwnd:
|
|
98
|
+
return None
|
|
99
|
+
tbuf = ctypes.create_unicode_buffer(512)
|
|
100
|
+
cbuf = ctypes.create_unicode_buffer(256)
|
|
101
|
+
user32.GetWindowTextW(hwnd, tbuf, 512)
|
|
102
|
+
user32.GetClassNameW(hwnd, cbuf, 256)
|
|
103
|
+
return {"app": cbuf.value or None, "title": tbuf.value or None}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def active_window() -> dict | None:
|
|
107
|
+
"""Return {'app': ..., 'title': ...} or None. Never raises."""
|
|
108
|
+
sysname = platform.system()
|
|
109
|
+
try:
|
|
110
|
+
if sysname == "Linux":
|
|
111
|
+
return _active_window_linux()
|
|
112
|
+
if sysname == "Darwin":
|
|
113
|
+
return _active_window_macos()
|
|
114
|
+
if sysname == "Windows":
|
|
115
|
+
return _active_window_windows()
|
|
116
|
+
warn_once("os", f"unsupported OS ({sysname}): window watcher disabled")
|
|
117
|
+
return None
|
|
118
|
+
except Exception as e: # a watcher must never kill the loop
|
|
119
|
+
warn_once("watcher-err", f"window watcher error: {e}")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Browser URL (best effort; always stripped of query/fragment by log_event)
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_MAC_BROWSERS = {
|
|
128
|
+
"Google Chrome": 'tell application "Google Chrome" to get URL of active tab of front window',
|
|
129
|
+
"Safari": 'tell application "Safari" to get URL of front document',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def browser_url(app: str | None, title: str | None) -> str | None:
|
|
134
|
+
if platform.system() != "Darwin" or app not in _MAC_BROWSERS:
|
|
135
|
+
return None # v0: no browser extension yet; titles still logged
|
|
136
|
+
url = _run(["osascript", "-e", _MAC_BROWSERS[app]])
|
|
137
|
+
return url or None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Screenshots (opt-in): downscaled JPEG via Pillow
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
JPEG_QUALITY = 70
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _downscale_rgb(rgb: bytes, w: int, h: int, target_w: int = SHOT_MAX_W):
|
|
148
|
+
if w <= target_w:
|
|
149
|
+
return rgb, w, h
|
|
150
|
+
scale = target_w / w
|
|
151
|
+
tw, th = target_w, max(1, int(h * scale))
|
|
152
|
+
out = bytearray(tw * th * 3)
|
|
153
|
+
for y in range(th):
|
|
154
|
+
sy = min(h - 1, int(y / scale))
|
|
155
|
+
for x in range(tw):
|
|
156
|
+
sx = min(w - 1, int(x / scale))
|
|
157
|
+
si, di = (sy * w + sx) * 3, (y * tw + x) * 3
|
|
158
|
+
out[di:di + 3] = rgb[si:si + 3]
|
|
159
|
+
return bytes(out), tw, th
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _write_jpeg(path: Path, rgb: bytes, w: int, h: int,
|
|
163
|
+
quality: int = JPEG_QUALITY) -> None:
|
|
164
|
+
from PIL import Image
|
|
165
|
+
Image.frombytes("RGB", (w, h), rgb).save(path, "JPEG", quality=quality)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def take_screenshot(ts: float) -> str | None:
|
|
169
|
+
"""Capture primary monitor, downscaled. Returns path relative to data dir."""
|
|
170
|
+
try:
|
|
171
|
+
import mss
|
|
172
|
+
except Exception as e:
|
|
173
|
+
warn_once("mss", f"mss unavailable ({e}): screenshots disabled")
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
from PIL import Image # noqa: F401 (import check; used in _write_jpeg)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
warn_once("pil", f"Pillow unavailable ({e}): screenshots disabled")
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
with mss.mss() as sct:
|
|
182
|
+
mon = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
|
|
183
|
+
shot = sct.grab(mon)
|
|
184
|
+
rgb, w, h = _downscale_rgb(shot.rgb, shot.width, shot.height)
|
|
185
|
+
dt = datetime.fromtimestamp(ts).astimezone()
|
|
186
|
+
rel = Path("shots") / dt.strftime("%Y-%m-%d") / (dt.strftime("%H%M%S") + ".jpg")
|
|
187
|
+
p = data_dir() / rel
|
|
188
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
_write_jpeg(p, rgb, w, h)
|
|
190
|
+
return str(rel)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
warn_once("shot-err", f"screenshot failed: {e}")
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Input watchers (pynput, imported lazily so --simulate never needs it)
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
class WatchState:
|
|
201
|
+
def __init__(self) -> None:
|
|
202
|
+
self.lock = threading.Lock()
|
|
203
|
+
self.last_input = time.time()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def start_input_watchers(con, state: WatchState) -> list:
|
|
207
|
+
"""Mouse + keyboard listeners. The keyboard listener records a timestamp
|
|
208
|
+
only — key contents are never stored, anywhere, ever."""
|
|
209
|
+
try:
|
|
210
|
+
from pynput import keyboard, mouse
|
|
211
|
+
except Exception as e:
|
|
212
|
+
warn_once("pynput", f"pynput unavailable ({e}): click + AFK watchers disabled")
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
def touch() -> None:
|
|
216
|
+
with state.lock:
|
|
217
|
+
state.last_input = time.time()
|
|
218
|
+
|
|
219
|
+
def on_click(x, y, button, pressed) -> None:
|
|
220
|
+
if not pressed:
|
|
221
|
+
return
|
|
222
|
+
touch()
|
|
223
|
+
w = active_window() or {}
|
|
224
|
+
url = browser_url(w.get("app"), w.get("title"))
|
|
225
|
+
with state.lock: # sqlite handle is shared with the main loop
|
|
226
|
+
log_event(con, "click", app=w.get("app"), title=w.get("title"),
|
|
227
|
+
url=url, x=int(x), y=int(y))
|
|
228
|
+
|
|
229
|
+
def on_press(key) -> None:
|
|
230
|
+
touch() # timestamp only — key contents are never recorded
|
|
231
|
+
|
|
232
|
+
ml = mouse.Listener(on_click=on_click)
|
|
233
|
+
kl = keyboard.Listener(on_press=on_press)
|
|
234
|
+
ml.daemon = kl.daemon = True
|
|
235
|
+
ml.start()
|
|
236
|
+
kl.start()
|
|
237
|
+
return [ml, kl]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Live capture loop
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def run_capture(db_path: str | None = None, screenshots: bool = False) -> None:
|
|
245
|
+
con = connect(db_path)
|
|
246
|
+
init_db(con)
|
|
247
|
+
target = db_path or "default location"
|
|
248
|
+
print(f"[privatecontext] capturing -> {target} "
|
|
249
|
+
f"(screenshots={'ON' if screenshots else 'off'}) — Ctrl-C to stop")
|
|
250
|
+
state = WatchState()
|
|
251
|
+
listeners = start_input_watchers(con, state)
|
|
252
|
+
|
|
253
|
+
last_sig: tuple = (None, None)
|
|
254
|
+
last_url: str | None = None
|
|
255
|
+
afk, afk_start = False, 0.0
|
|
256
|
+
try:
|
|
257
|
+
while True:
|
|
258
|
+
now = time.time()
|
|
259
|
+
with state.lock:
|
|
260
|
+
idle = now - state.last_input
|
|
261
|
+
if listeners and not afk and idle > IDLE_AFTER_S:
|
|
262
|
+
afk, afk_start = True, now
|
|
263
|
+
log_event(con, "afk")
|
|
264
|
+
print("[privatecontext] idle: AFK")
|
|
265
|
+
elif afk and idle <= IDLE_AFTER_S:
|
|
266
|
+
afk = False
|
|
267
|
+
log_event(con, "back", duration_ms=int((now - afk_start) * 1000))
|
|
268
|
+
print(f"[privatecontext] back after {int(now - afk_start)}s idle")
|
|
269
|
+
|
|
270
|
+
w = active_window() or {}
|
|
271
|
+
sig = (w.get("app"), w.get("title"))
|
|
272
|
+
if any(sig) and sig != last_sig:
|
|
273
|
+
url = browser_url(w.get("app"), w.get("title"))
|
|
274
|
+
log_event(con, "window", app=sig[0], title=sig[1], url=url)
|
|
275
|
+
if screenshots and (sig[0] != last_sig[0] or url != last_url):
|
|
276
|
+
rel = take_screenshot(now)
|
|
277
|
+
log_event(con, "shot", app=sig[0], title=sig[1], url=url)
|
|
278
|
+
# shot path is derivable from ts: shots/YYYY-MM-DD/HHMMSS.jpg
|
|
279
|
+
_ = rel
|
|
280
|
+
last_sig, last_url = sig, url
|
|
281
|
+
time.sleep(POLL_S)
|
|
282
|
+
except KeyboardInterrupt:
|
|
283
|
+
print("\n[privatecontext] stopped.")
|
|
284
|
+
finally:
|
|
285
|
+
for lst in listeners:
|
|
286
|
+
lst.stop()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Simulate mode: a realistic synthetic workday, zero OS hooks
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def simulate(db_path: str | None = None, day: date | None = None) -> None:
|
|
294
|
+
import random
|
|
295
|
+
|
|
296
|
+
rng = random.Random(20261013)
|
|
297
|
+
con = connect(db_path)
|
|
298
|
+
init_db(con)
|
|
299
|
+
day = day or date.today()
|
|
300
|
+
base = datetime(day.year, day.month, day.day).astimezone() # local midnight
|
|
301
|
+
|
|
302
|
+
def ts(h: int, m: int, s: int = 0) -> float:
|
|
303
|
+
return (base + timedelta(hours=h, minutes=m, seconds=s)).timestamp()
|
|
304
|
+
|
|
305
|
+
# (start, end, app, title, url, clicks_per_min); app=None -> AFK block
|
|
306
|
+
blocks = [
|
|
307
|
+
((9, 2), (9, 24), "Slack", "OIT Help Desk — #general", None, 3),
|
|
308
|
+
((9, 24), (11, 38), "Google Chrome",
|
|
309
|
+
"INC0012345 — Printer jam, Hill Library | ServiceNow",
|
|
310
|
+
"https://ncsu.service-now.com/nav_to.do?uri=incident.do&sys_id=9f3a1c", 7),
|
|
311
|
+
((11, 38), (11, 55), "Google Chrome",
|
|
312
|
+
"eduroam keeps dropping on Dell 5420 — Ask Ubuntu",
|
|
313
|
+
"https://askubuntu.com/questions/12345/eduroam-keeps-dropping?utm_source=x&x=1", 5),
|
|
314
|
+
((11, 55), (12, 8), "Code", "privatecontext — capture.py", None, 9),
|
|
315
|
+
((12, 8), (12, 52), None, None, None, 0), # lunch
|
|
316
|
+
((12, 52), (14, 5), "Google Chrome",
|
|
317
|
+
"INC0012371 — VPN split tunnel question | ServiceNow",
|
|
318
|
+
"https://ncsu.service-now.com/incident.do?sys_id=77aa01&sysparm_nostack=true", 6),
|
|
319
|
+
((14, 5), (14, 50), "Zoom", "OIT weekly standup", None, 1),
|
|
320
|
+
((14, 50), (16, 20), "Code", "privatecontext — dashboard.py", None, 8),
|
|
321
|
+
((16, 20), (16, 35), "Terminal", "zsh — ~/workspace/privatecontext", None, 4),
|
|
322
|
+
((16, 35), (17, 10), "Spotify", "Deep Focus", None, 1),
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
n_win = n_click = n_afk = 0
|
|
326
|
+
for (sh, sm), (eh, em), app, title, url, rate in blocks:
|
|
327
|
+
t0, t1 = ts(sh, sm), ts(eh, em)
|
|
328
|
+
if app is None:
|
|
329
|
+
log_event(con, "afk", ts=t0, commit=False)
|
|
330
|
+
log_event(con, "back", ts=t1, duration_ms=int((t1 - t0) * 1000), commit=False)
|
|
331
|
+
n_afk += 1
|
|
332
|
+
continue
|
|
333
|
+
log_event(con, "window", ts=t0, app=app, title=title, url=url, commit=False)
|
|
334
|
+
n_win += 1
|
|
335
|
+
if rng.random() < 0.7: # mid-block title refinement, like a ticket updating
|
|
336
|
+
tmid = t0 + (t1 - t0) * rng.uniform(0.3, 0.7)
|
|
337
|
+
log_event(con, "window", ts=tmid, app=app, title=title,
|
|
338
|
+
url=url, commit=False)
|
|
339
|
+
n_win += 1
|
|
340
|
+
if rate:
|
|
341
|
+
t = t0 + rng.uniform(20, 90)
|
|
342
|
+
while t < t1 - 10:
|
|
343
|
+
log_event(con, "click", ts=t, app=app, title=title, url=url,
|
|
344
|
+
x=rng.randint(100, 1800), y=rng.randint(80, 950),
|
|
345
|
+
commit=False)
|
|
346
|
+
n_click += 1
|
|
347
|
+
t += (60.0 / rate) * rng.uniform(0.5, 1.5)
|
|
348
|
+
con.commit()
|
|
349
|
+
print(f"[privatecontext] simulated {day.isoformat()}: "
|
|
350
|
+
f"{n_win} window events, {n_click} clicks, {n_afk} AFK periods")
|
privatecontext/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""pctx — command line interface for PrivateContext."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .capture import run_capture, simulate
|
|
13
|
+
from .dashboard import serve
|
|
14
|
+
from .db import connect, counts, default_db_path, init_db
|
|
15
|
+
from .importers import (RefusedError, import_activitywatch, import_browser,
|
|
16
|
+
import_csv, import_json, import_screenpipe)
|
|
17
|
+
from .summarize import DEFAULT_MODEL, summarize_day
|
|
18
|
+
from .tagging import extract_tags
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_db(path: str) -> str:
|
|
22
|
+
return os.path.abspath(path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_capture(args: argparse.Namespace) -> int:
|
|
26
|
+
db = _resolve_db(args.db) if args.db else None
|
|
27
|
+
if args.simulate:
|
|
28
|
+
simulate(db)
|
|
29
|
+
else:
|
|
30
|
+
run_capture(db, screenshots=args.screenshots)
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
35
|
+
db = _resolve_db(args.db) if args.db else None
|
|
36
|
+
serve(db, port=args.port)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
41
|
+
if not args.scrubbed:
|
|
42
|
+
print("v0 only supports scrubbed exports: `pctx export --scrubbed`",
|
|
43
|
+
file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
src_path = _resolve_db(args.db) if args.db else default_db_path()
|
|
46
|
+
if not Path(src_path).exists():
|
|
47
|
+
print(f"no database at {src_path} — run `pctx capture` first",
|
|
48
|
+
file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
out = args.out or f"./privatecontext-export-{date.today().isoformat()}.db"
|
|
51
|
+
out_path = os.path.abspath(out)
|
|
52
|
+
if Path(out_path).exists():
|
|
53
|
+
os.remove(out_path)
|
|
54
|
+
|
|
55
|
+
# Build the export through the real schema so FTS triggers fire on copy.
|
|
56
|
+
dst = sqlite3.connect(out_path)
|
|
57
|
+
schema = Path(__file__).resolve().parent / "schema.sql"
|
|
58
|
+
if not schema.exists(): # running from a source checkout
|
|
59
|
+
schema = Path(__file__).resolve().parent.parent / "schema.sql"
|
|
60
|
+
dst.executescript(schema.read_text())
|
|
61
|
+
# Quote the path for ATTACH: sqlite string literal, ''-escaped.
|
|
62
|
+
lit = "'" + src_path.replace("'", "''") + "'"
|
|
63
|
+
dst.execute(f"ATTACH DATABASE {lit} AS src")
|
|
64
|
+
# Column-explicit copy so pre-tags databases still export cleanly.
|
|
65
|
+
src_cols = [r[1] for r in dst.execute("PRAGMA src.table_info(events)")]
|
|
66
|
+
cols = ["ts", "type", "app", "title", "url", "x", "y", "duration_ms"]
|
|
67
|
+
if "tags" in src_cols:
|
|
68
|
+
cols.append("tags")
|
|
69
|
+
col_list = ",".join(cols)
|
|
70
|
+
dst.execute(f"INSERT INTO events({col_list}) SELECT {col_list} FROM src.events"
|
|
71
|
+
" WHERE type != 'shot'")
|
|
72
|
+
dst.execute("INSERT INTO days SELECT * FROM src.days")
|
|
73
|
+
dst.execute("INSERT INTO kv SELECT * FROM src.kv")
|
|
74
|
+
dst.commit()
|
|
75
|
+
n = dst.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
|
76
|
+
dst.close()
|
|
77
|
+
print(f"[privatecontext] exported {n} scrubbed events -> {out_path}")
|
|
78
|
+
print("screenshots and 'shot' events were excluded.")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_summarize(args: argparse.Namespace) -> int:
|
|
83
|
+
from datetime import date as _date
|
|
84
|
+
db = _resolve_db(args.db) if args.db else None
|
|
85
|
+
return summarize_day(db, args.date or _date.today().isoformat(),
|
|
86
|
+
model=args.model, dry_run=args.dry_run)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
90
|
+
try:
|
|
91
|
+
from .mcp_server import main as mcp_main
|
|
92
|
+
except ImportError:
|
|
93
|
+
print("the 'mcp' package is required for `pctx mcp`:\n"
|
|
94
|
+
" pip install 'privatecontext[mcp]' (or: pip install mcp)",
|
|
95
|
+
file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
db = _resolve_db(args.db) if args.db else None
|
|
98
|
+
return mcp_main(db)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
102
|
+
ap = argparse.ArgumentParser(
|
|
103
|
+
prog="pctx",
|
|
104
|
+
description=f"PrivateContext v{__version__} — lightweight local activity tracker.")
|
|
105
|
+
ap.add_argument("--db", default=None,
|
|
106
|
+
help=f"database path (default: {default_db_path()})")
|
|
107
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
108
|
+
|
|
109
|
+
c = sub.add_parser("capture", help="run the event capture loop")
|
|
110
|
+
c.add_argument("--simulate", action="store_true",
|
|
111
|
+
help="write a synthetic workday instead of watching the OS")
|
|
112
|
+
c.add_argument("--screenshots", action="store_true",
|
|
113
|
+
help="capture downscaled screenshots on app/URL switch (OFF by default)")
|
|
114
|
+
c.set_defaults(func=cmd_capture)
|
|
115
|
+
|
|
116
|
+
d = sub.add_parser("dashboard", help="serve the local dashboard")
|
|
117
|
+
d.add_argument("--port", type=int, default=18790)
|
|
118
|
+
d.set_defaults(func=cmd_dashboard)
|
|
119
|
+
|
|
120
|
+
e = sub.add_parser("export", help="export a shareable copy of the database")
|
|
121
|
+
e.add_argument("--scrubbed", action="store_true",
|
|
122
|
+
help="exclude screenshots and shot events (required in v0)")
|
|
123
|
+
e.add_argument("--out", default=None, help="output .db path")
|
|
124
|
+
e.set_defaults(func=cmd_export)
|
|
125
|
+
|
|
126
|
+
s = sub.add_parser("summarize", help="summarize a day with a local Ollama model")
|
|
127
|
+
s.add_argument("--date", default=None, help="YYYY-MM-DD (default: today)")
|
|
128
|
+
s.add_argument("--model", default=DEFAULT_MODEL,
|
|
129
|
+
help=f"Ollama model (default: {DEFAULT_MODEL})")
|
|
130
|
+
s.add_argument("--dry-run", action="store_true",
|
|
131
|
+
help="print the condensed digest without calling Ollama")
|
|
132
|
+
s.set_defaults(func=cmd_summarize)
|
|
133
|
+
|
|
134
|
+
m = sub.add_parser("mcp", help="run the MCP server over stdio")
|
|
135
|
+
m.set_defaults(func=cmd_mcp)
|
|
136
|
+
|
|
137
|
+
r = sub.add_parser("retag", help="backfill auto-tags on untagged events")
|
|
138
|
+
r.add_argument("--since", default=None,
|
|
139
|
+
help="only retag events on/after YYYY-MM-DD")
|
|
140
|
+
r.set_defaults(func=cmd_retag)
|
|
141
|
+
|
|
142
|
+
i = sub.add_parser("import", help="import history from browsers or other recorders")
|
|
143
|
+
i.add_argument("--browser", choices=["chrome", "edge", "firefox"], default=None,
|
|
144
|
+
help="import this browser's history")
|
|
145
|
+
i.add_argument("--profile", default=None,
|
|
146
|
+
help="path to the browser profile History / places.sqlite file")
|
|
147
|
+
i.add_argument("--activitywatch", default=None, metavar="PATH",
|
|
148
|
+
help="path to an ActivityWatch SQLite database")
|
|
149
|
+
i.add_argument("--screenpipe", default=None, metavar="PATH",
|
|
150
|
+
help="path to a Screenpipe SQLite database")
|
|
151
|
+
i.add_argument("--csv", default=None, metavar="PATH",
|
|
152
|
+
help="path to a CSV export (columns: timestamp, type, "
|
|
153
|
+
"app, title, url)")
|
|
154
|
+
i.add_argument("--json", default=None, metavar="PATH",
|
|
155
|
+
help="path to a JSON export (array of event objects)")
|
|
156
|
+
i.add_argument("--dry-run", action="store_true",
|
|
157
|
+
help="print what would be imported without writing anything")
|
|
158
|
+
i.set_defaults(func=cmd_import)
|
|
159
|
+
return ap
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
163
|
+
sources = [s for s in (args.browser, args.activitywatch, args.screenpipe,
|
|
164
|
+
args.csv, args.json) if s]
|
|
165
|
+
if len(sources) != 1:
|
|
166
|
+
print("pick exactly one source: --browser chrome|edge|firefox, "
|
|
167
|
+
"--activitywatch PATH, --screenpipe PATH, --csv PATH, or "
|
|
168
|
+
"--json PATH", file=sys.stderr)
|
|
169
|
+
return 2
|
|
170
|
+
db = _resolve_db(args.db) if args.db else default_db_path()
|
|
171
|
+
con = connect(db)
|
|
172
|
+
init_db(con)
|
|
173
|
+
try:
|
|
174
|
+
if args.browser:
|
|
175
|
+
return import_browser(con, args.browser, profile=args.profile,
|
|
176
|
+
dry_run=args.dry_run)
|
|
177
|
+
if args.activitywatch:
|
|
178
|
+
return import_activitywatch(con, args.activitywatch,
|
|
179
|
+
dry_run=args.dry_run)
|
|
180
|
+
if args.screenpipe:
|
|
181
|
+
return import_screenpipe(con, args.screenpipe, dry_run=args.dry_run)
|
|
182
|
+
if args.csv:
|
|
183
|
+
return import_csv(con, args.csv, dry_run=args.dry_run)
|
|
184
|
+
return import_json(con, args.json, dry_run=args.dry_run)
|
|
185
|
+
except RefusedError as e:
|
|
186
|
+
print(f"error: {e}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_retag(args: argparse.Namespace) -> int:
|
|
191
|
+
from datetime import datetime as _dt
|
|
192
|
+
db = _resolve_db(args.db) if args.db else default_db_path()
|
|
193
|
+
if not Path(db).exists():
|
|
194
|
+
print(f"no database at {db} — run `pctx capture` first", file=sys.stderr)
|
|
195
|
+
return 1
|
|
196
|
+
con = connect(db)
|
|
197
|
+
init_db(con)
|
|
198
|
+
q = ("SELECT id, app, title, url FROM events"
|
|
199
|
+
" WHERE (tags IS NULL OR tags = '')")
|
|
200
|
+
params: list = []
|
|
201
|
+
if args.since:
|
|
202
|
+
try:
|
|
203
|
+
since_ts = _dt.strptime(args.since, "%Y-%m-%d").astimezone().timestamp()
|
|
204
|
+
except ValueError:
|
|
205
|
+
print("--since must be YYYY-MM-DD", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
q += " AND ts >= ?"
|
|
208
|
+
params.append(since_ts)
|
|
209
|
+
rows = con.execute(q, params).fetchall()
|
|
210
|
+
tagged = 0
|
|
211
|
+
for r in rows:
|
|
212
|
+
found = extract_tags(r["app"], r["title"], r["url"])
|
|
213
|
+
tag_str = ",".join(found) if found else None
|
|
214
|
+
con.execute("UPDATE events SET tags = ? WHERE id = ?", (tag_str, r["id"]))
|
|
215
|
+
if tag_str:
|
|
216
|
+
tagged += 1
|
|
217
|
+
con.commit()
|
|
218
|
+
print(f"[privatecontext] retagged {tagged} of {len(rows)} scanned events")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv: list[str] | None = None) -> int:
|
|
223
|
+
args = build_parser().parse_args(argv)
|
|
224
|
+
return args.func(args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|