dev-recall 0.2.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.
- dev_recall-0.2.0.dist-info/METADATA +281 -0
- dev_recall-0.2.0.dist-info/RECORD +34 -0
- dev_recall-0.2.0.dist-info/WHEEL +5 -0
- dev_recall-0.2.0.dist-info/entry_points.txt +2 -0
- dev_recall-0.2.0.dist-info/top_level.txt +1 -0
- recall/__init__.py +3 -0
- recall/_hooks.py +211 -0
- recall/cli.py +1032 -0
- recall/collectors/__init__.py +1 -0
- recall/collectors/ai_chat.py +644 -0
- recall/collectors/containers.py +164 -0
- recall/collectors/git.py +540 -0
- recall/collectors/linux_process.py +230 -0
- recall/collectors/linux_session.py +229 -0
- recall/collectors/linux_window.py +199 -0
- recall/collectors/shell.py +300 -0
- recall/collectors/vscode.py +175 -0
- recall/config.py +257 -0
- recall/daemon.py +466 -0
- recall/daemon_main.py +25 -0
- recall/mcp_server.py +290 -0
- recall/models.py +225 -0
- recall/processor/__init__.py +1 -0
- recall/processor/embedder.py +213 -0
- recall/processor/enricher.py +213 -0
- recall/processor/session.py +142 -0
- recall/query/__init__.py +1 -0
- recall/query/context.py +130 -0
- recall/query/llm.py +85 -0
- recall/query/retriever.py +147 -0
- recall/query/timeparser.py +188 -0
- recall/storage/__init__.py +1 -0
- recall/storage/db.py +528 -0
- recall/storage/vectors.py +166 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Process and port tracker using psutil.
|
|
2
|
+
|
|
3
|
+
Polls every 30 s to detect:
|
|
4
|
+
- Dev server processes starting/stopping (by process name allowlist)
|
|
5
|
+
- Listening TCP ports opening/closing in the dev-server range
|
|
6
|
+
|
|
7
|
+
Emits PORT_OPEN / PORT_CLOSE events when a listening port appears or
|
|
8
|
+
disappears for a tracked process.
|
|
9
|
+
|
|
10
|
+
Gracefully becomes a no-op when psutil is not installed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
from recall.models import Event, EventType, Source
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import psutil # type: ignore
|
|
27
|
+
|
|
28
|
+
_PSUTIL_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_PSUTIL_AVAILABLE = False
|
|
31
|
+
|
|
32
|
+
# Process names that indicate a dev server or interesting background service.
|
|
33
|
+
# Matched as a substring of the process name or first cmdline argument.
|
|
34
|
+
_DEV_PROCESS_NAMES = {
|
|
35
|
+
"node",
|
|
36
|
+
"npm",
|
|
37
|
+
"npx",
|
|
38
|
+
"yarn",
|
|
39
|
+
"pnpm",
|
|
40
|
+
"bun",
|
|
41
|
+
"deno",
|
|
42
|
+
"next",
|
|
43
|
+
"vite",
|
|
44
|
+
"webpack",
|
|
45
|
+
"parcel",
|
|
46
|
+
"rollup",
|
|
47
|
+
"esbuild",
|
|
48
|
+
"turbopack",
|
|
49
|
+
"python",
|
|
50
|
+
"python3",
|
|
51
|
+
"uvicorn",
|
|
52
|
+
"gunicorn",
|
|
53
|
+
"hypercorn",
|
|
54
|
+
"fastapi",
|
|
55
|
+
"flask",
|
|
56
|
+
"django",
|
|
57
|
+
"starlette",
|
|
58
|
+
"tornado",
|
|
59
|
+
"aiohttp",
|
|
60
|
+
"ruby",
|
|
61
|
+
"rails",
|
|
62
|
+
"puma",
|
|
63
|
+
"unicorn",
|
|
64
|
+
"thin",
|
|
65
|
+
"java",
|
|
66
|
+
"mvn",
|
|
67
|
+
"gradle",
|
|
68
|
+
"go",
|
|
69
|
+
"air",
|
|
70
|
+
"gin",
|
|
71
|
+
"fiber",
|
|
72
|
+
"php",
|
|
73
|
+
"artisan",
|
|
74
|
+
"symfony",
|
|
75
|
+
"cargo",
|
|
76
|
+
"rust-analyzer",
|
|
77
|
+
"dotnet",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Only track ports in this range (typical dev / ephemeral service ports).
|
|
81
|
+
_PORT_MIN = 1024
|
|
82
|
+
_PORT_MAX = 65535
|
|
83
|
+
|
|
84
|
+
# Ports to ignore (databases, system services, etc.)
|
|
85
|
+
_PORT_IGNORE = {
|
|
86
|
+
3306, # MySQL
|
|
87
|
+
5432, # PostgreSQL
|
|
88
|
+
6379, # Redis
|
|
89
|
+
27017, # MongoDB
|
|
90
|
+
11211, # Memcached
|
|
91
|
+
2181, # ZooKeeper
|
|
92
|
+
2375, # Docker daemon (unencrypted)
|
|
93
|
+
2376, # Docker daemon (TLS)
|
|
94
|
+
22, # SSH
|
|
95
|
+
25, # SMTP
|
|
96
|
+
53, # DNS
|
|
97
|
+
80, # HTTP (production)
|
|
98
|
+
443, # HTTPS (production)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_POLL_INTERVAL = 30 # seconds
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _now_ts() -> tuple[str, str]:
|
|
105
|
+
now = datetime.now(timezone.utc)
|
|
106
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_dev_process(proc: "psutil.Process") -> bool: # type: ignore[name-defined]
|
|
110
|
+
"""Return True if the process looks like a dev server."""
|
|
111
|
+
try:
|
|
112
|
+
name = proc.name().lower()
|
|
113
|
+
if name in _DEV_PROCESS_NAMES:
|
|
114
|
+
return True
|
|
115
|
+
# Also check the first cmdline argument (e.g. "python manage.py runserver")
|
|
116
|
+
cmdline = proc.cmdline()
|
|
117
|
+
if cmdline:
|
|
118
|
+
exe_base = cmdline[0].rsplit("/", 1)[-1].lower()
|
|
119
|
+
if exe_base in _DEV_PROCESS_NAMES:
|
|
120
|
+
return True
|
|
121
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
122
|
+
pass
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _snapshot_ports() -> dict[int, str]:
|
|
127
|
+
"""Return {port: process_name} for all listening TCP sockets owned by dev processes."""
|
|
128
|
+
result: dict[int, str] = {}
|
|
129
|
+
try:
|
|
130
|
+
connections = psutil.net_connections(kind="tcp")
|
|
131
|
+
except psutil.AccessDenied:
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
for conn in connections:
|
|
135
|
+
if conn.status != psutil.CONN_LISTEN:
|
|
136
|
+
continue
|
|
137
|
+
port = conn.laddr.port
|
|
138
|
+
if port < _PORT_MIN or port > _PORT_MAX or port in _PORT_IGNORE:
|
|
139
|
+
continue
|
|
140
|
+
pid = conn.pid
|
|
141
|
+
if pid is None:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
proc = psutil.Process(pid)
|
|
145
|
+
if _is_dev_process(proc):
|
|
146
|
+
result[port] = proc.name()
|
|
147
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
148
|
+
continue
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ProcessCollector:
|
|
153
|
+
"""Poll psutil every 30 s and emit PORT_OPEN / PORT_CLOSE events."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, event_callback: Callable[[Event], None]) -> None:
|
|
156
|
+
self._cb = event_callback
|
|
157
|
+
self._thread: Optional[threading.Thread] = None
|
|
158
|
+
self._stop_event = threading.Event()
|
|
159
|
+
|
|
160
|
+
def start(self) -> None:
|
|
161
|
+
if not _PSUTIL_AVAILABLE:
|
|
162
|
+
logger.debug("psutil unavailable — process/port tracking disabled")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
self._thread = threading.Thread(
|
|
166
|
+
target=self._run,
|
|
167
|
+
daemon=True,
|
|
168
|
+
name="dev-recall-process",
|
|
169
|
+
)
|
|
170
|
+
self._thread.start()
|
|
171
|
+
logger.info("ProcessCollector started (poll interval %ds)", _POLL_INTERVAL)
|
|
172
|
+
|
|
173
|
+
def stop(self) -> None:
|
|
174
|
+
self._stop_event.set()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Internal
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _run(self) -> None:
|
|
181
|
+
prev: dict[int, str] = {}
|
|
182
|
+
try:
|
|
183
|
+
prev = _snapshot_ports()
|
|
184
|
+
except Exception:
|
|
185
|
+
logger.exception("ProcessCollector initial snapshot failed")
|
|
186
|
+
|
|
187
|
+
while not self._stop_event.wait(_POLL_INTERVAL):
|
|
188
|
+
try:
|
|
189
|
+
curr = _snapshot_ports()
|
|
190
|
+
self._diff(prev, curr)
|
|
191
|
+
prev = curr
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.exception("ProcessCollector poll failed")
|
|
194
|
+
|
|
195
|
+
def _diff(self, prev: dict[int, str], curr: dict[int, str]) -> None:
|
|
196
|
+
ts, date = _now_ts()
|
|
197
|
+
|
|
198
|
+
# Newly opened ports
|
|
199
|
+
for port, proc_name in curr.items():
|
|
200
|
+
if port not in prev:
|
|
201
|
+
raw = {"port": port, "process_name": proc_name}
|
|
202
|
+
event = Event(
|
|
203
|
+
timestamp=ts,
|
|
204
|
+
date=date,
|
|
205
|
+
event_type=EventType.PORT_OPEN,
|
|
206
|
+
source=Source.PROCESS_TRACKER,
|
|
207
|
+
content="",
|
|
208
|
+
raw_data=raw,
|
|
209
|
+
)
|
|
210
|
+
from recall.models import build_content
|
|
211
|
+
|
|
212
|
+
event.content = build_content(EventType.PORT_OPEN, raw)
|
|
213
|
+
self._cb(event)
|
|
214
|
+
|
|
215
|
+
# Closed ports
|
|
216
|
+
for port, proc_name in prev.items():
|
|
217
|
+
if port not in curr:
|
|
218
|
+
raw = {"port": port, "process_name": proc_name}
|
|
219
|
+
event = Event(
|
|
220
|
+
timestamp=ts,
|
|
221
|
+
date=date,
|
|
222
|
+
event_type=EventType.PORT_CLOSE,
|
|
223
|
+
source=Source.PROCESS_TRACKER,
|
|
224
|
+
content="",
|
|
225
|
+
raw_data=raw,
|
|
226
|
+
)
|
|
227
|
+
from recall.models import build_content
|
|
228
|
+
|
|
229
|
+
event.content = build_content(EventType.PORT_CLOSE, raw)
|
|
230
|
+
self._cb(event)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Linux session event collector via D-Bus logind.
|
|
2
|
+
|
|
3
|
+
Subscribes to org.freedesktop.login1.Manager signals:
|
|
4
|
+
- PrepareForSleep(before: bool) → SYSTEM_SUSPEND / SYSTEM_RESUME
|
|
5
|
+
- org.freedesktop.login1.Session Lock / Unlock → SESSION_LOCK / SESSION_UNLOCK
|
|
6
|
+
|
|
7
|
+
Uses dbus-next with an asyncio event loop running in a daemon thread.
|
|
8
|
+
|
|
9
|
+
Gracefully becomes a no-op when dbus-next is not installed or the D-Bus
|
|
10
|
+
session bus is not available.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import threading
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Callable, Optional
|
|
22
|
+
|
|
23
|
+
from recall.models import Event, EventType, Source
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from dbus_next.aio import MessageBus # type: ignore
|
|
29
|
+
from dbus_next import BusType, Message, MessageType # type: ignore
|
|
30
|
+
|
|
31
|
+
_DBUS_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_DBUS_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _now_ts() -> tuple[str, str]:
|
|
37
|
+
now = datetime.now(timezone.utc)
|
|
38
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LinuxSessionCollector:
|
|
42
|
+
"""Collect screen lock/unlock and system sleep/resume events from D-Bus."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, event_callback: Callable[[Event], None]) -> None:
|
|
45
|
+
self._cb = event_callback
|
|
46
|
+
self._thread: Optional[threading.Thread] = None
|
|
47
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
48
|
+
self._lock_time: Optional[float] = None
|
|
49
|
+
self._suspend_time: Optional[float] = None
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
if not _DBUS_AVAILABLE:
|
|
53
|
+
logger.debug("dbus-next unavailable — session event tracking disabled")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Only works with a session D-Bus socket
|
|
57
|
+
if not os.environ.get("DBUS_SESSION_BUS_ADDRESS") and not os.path.exists(
|
|
58
|
+
"/run/user/%d/bus" % os.getuid()
|
|
59
|
+
):
|
|
60
|
+
logger.debug("No D-Bus session bus — session event tracking disabled")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
self._thread = threading.Thread(
|
|
64
|
+
target=self._run,
|
|
65
|
+
daemon=True,
|
|
66
|
+
name="devmem-session",
|
|
67
|
+
)
|
|
68
|
+
self._thread.start()
|
|
69
|
+
|
|
70
|
+
def stop(self) -> None:
|
|
71
|
+
if self._loop is not None:
|
|
72
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Internal
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _run(self) -> None:
|
|
79
|
+
self._loop = asyncio.new_event_loop()
|
|
80
|
+
try:
|
|
81
|
+
self._loop.run_until_complete(self._subscribe())
|
|
82
|
+
except Exception:
|
|
83
|
+
logger.exception("LinuxSessionCollector crashed")
|
|
84
|
+
|
|
85
|
+
async def _subscribe(self) -> None:
|
|
86
|
+
try:
|
|
87
|
+
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.debug("Could not connect to system D-Bus — session tracking disabled")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# ----------------------------------------------------------------
|
|
93
|
+
# Subscribe to PrepareForSleep (system suspend/resume)
|
|
94
|
+
# ----------------------------------------------------------------
|
|
95
|
+
await bus.call(
|
|
96
|
+
Message(
|
|
97
|
+
destination="org.freedesktop.DBus",
|
|
98
|
+
path="/org/freedesktop/DBus",
|
|
99
|
+
interface="org.freedesktop.DBus",
|
|
100
|
+
member="AddMatch",
|
|
101
|
+
signature="s",
|
|
102
|
+
body=[
|
|
103
|
+
"type='signal',"
|
|
104
|
+
"sender='org.freedesktop.login1',"
|
|
105
|
+
"interface='org.freedesktop.login1.Manager',"
|
|
106
|
+
"member='PrepareForSleep'"
|
|
107
|
+
],
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ----------------------------------------------------------------
|
|
112
|
+
# Subscribe to session Lock/Unlock
|
|
113
|
+
# ----------------------------------------------------------------
|
|
114
|
+
await bus.call(
|
|
115
|
+
Message(
|
|
116
|
+
destination="org.freedesktop.DBus",
|
|
117
|
+
path="/org/freedesktop/DBus",
|
|
118
|
+
interface="org.freedesktop.DBus",
|
|
119
|
+
member="AddMatch",
|
|
120
|
+
signature="s",
|
|
121
|
+
body=[
|
|
122
|
+
"type='signal',"
|
|
123
|
+
"sender='org.freedesktop.login1',"
|
|
124
|
+
"interface='org.freedesktop.login1.Session',"
|
|
125
|
+
"member='Lock'"
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
await bus.call(
|
|
130
|
+
Message(
|
|
131
|
+
destination="org.freedesktop.DBus",
|
|
132
|
+
path="/org/freedesktop/DBus",
|
|
133
|
+
interface="org.freedesktop.DBus",
|
|
134
|
+
member="AddMatch",
|
|
135
|
+
signature="s",
|
|
136
|
+
body=[
|
|
137
|
+
"type='signal',"
|
|
138
|
+
"sender='org.freedesktop.login1',"
|
|
139
|
+
"interface='org.freedesktop.login1.Session',"
|
|
140
|
+
"member='Unlock'"
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
logger.info("LinuxSessionCollector subscribed to D-Bus logind signals")
|
|
146
|
+
|
|
147
|
+
bus.add_message_handler(self._handle_message)
|
|
148
|
+
|
|
149
|
+
# Keep running until stop() is called
|
|
150
|
+
await asyncio.get_event_loop().run_in_executor(None, self._thread.join if self._thread else lambda: None)
|
|
151
|
+
|
|
152
|
+
def _handle_message(self, message: "Message") -> None: # type: ignore[name-defined]
|
|
153
|
+
if message.message_type != MessageType.SIGNAL:
|
|
154
|
+
return
|
|
155
|
+
member = message.member
|
|
156
|
+
if member == "PrepareForSleep":
|
|
157
|
+
before = message.body[0] if message.body else True
|
|
158
|
+
self._on_prepare_for_sleep(before)
|
|
159
|
+
elif member == "Lock":
|
|
160
|
+
self._on_session_lock()
|
|
161
|
+
elif member == "Unlock":
|
|
162
|
+
self._on_session_unlock()
|
|
163
|
+
|
|
164
|
+
def _on_prepare_for_sleep(self, before: bool) -> None:
|
|
165
|
+
ts, date = _now_ts()
|
|
166
|
+
if before:
|
|
167
|
+
# Going to sleep
|
|
168
|
+
self._suspend_time = time.monotonic()
|
|
169
|
+
event = Event(
|
|
170
|
+
timestamp=ts,
|
|
171
|
+
date=date,
|
|
172
|
+
event_type=EventType.SYSTEM_SUSPEND,
|
|
173
|
+
source=Source.LINUX_SESSION,
|
|
174
|
+
content="",
|
|
175
|
+
raw_data={},
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
# Waking up
|
|
179
|
+
raw: dict = {}
|
|
180
|
+
if self._suspend_time is not None:
|
|
181
|
+
raw["suspended_seconds"] = round(time.monotonic() - self._suspend_time)
|
|
182
|
+
self._suspend_time = None
|
|
183
|
+
event = Event(
|
|
184
|
+
timestamp=ts,
|
|
185
|
+
date=date,
|
|
186
|
+
event_type=EventType.SYSTEM_RESUME,
|
|
187
|
+
source=Source.LINUX_SESSION,
|
|
188
|
+
content="",
|
|
189
|
+
raw_data=raw,
|
|
190
|
+
)
|
|
191
|
+
from recall.models import build_content
|
|
192
|
+
|
|
193
|
+
event.content = build_content(event.event_type, event.raw_data)
|
|
194
|
+
self._cb(event)
|
|
195
|
+
|
|
196
|
+
def _on_session_lock(self) -> None:
|
|
197
|
+
self._lock_time = time.monotonic()
|
|
198
|
+
ts, date = _now_ts()
|
|
199
|
+
event = Event(
|
|
200
|
+
timestamp=ts,
|
|
201
|
+
date=date,
|
|
202
|
+
event_type=EventType.SESSION_LOCK,
|
|
203
|
+
source=Source.LINUX_SESSION,
|
|
204
|
+
content="",
|
|
205
|
+
raw_data={},
|
|
206
|
+
)
|
|
207
|
+
from recall.models import build_content
|
|
208
|
+
|
|
209
|
+
event.content = build_content(EventType.SESSION_LOCK, event.raw_data)
|
|
210
|
+
self._cb(event)
|
|
211
|
+
|
|
212
|
+
def _on_session_unlock(self) -> None:
|
|
213
|
+
ts, date = _now_ts()
|
|
214
|
+
raw: dict = {}
|
|
215
|
+
if self._lock_time is not None:
|
|
216
|
+
raw["locked_seconds"] = round(time.monotonic() - self._lock_time)
|
|
217
|
+
self._lock_time = None
|
|
218
|
+
event = Event(
|
|
219
|
+
timestamp=ts,
|
|
220
|
+
date=date,
|
|
221
|
+
event_type=EventType.SESSION_UNLOCK,
|
|
222
|
+
source=Source.LINUX_SESSION,
|
|
223
|
+
content="",
|
|
224
|
+
raw_data=raw,
|
|
225
|
+
)
|
|
226
|
+
from recall.models import build_content
|
|
227
|
+
|
|
228
|
+
event.content = build_content(EventType.SESSION_UNLOCK, event.raw_data)
|
|
229
|
+
self._cb(event)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Linux window/app tracker using libwnck (X11/XWayland).
|
|
2
|
+
|
|
3
|
+
Tracks application open/close, window focus changes, and virtual workspace
|
|
4
|
+
switches via Wnck signals on a GLib main loop in a daemon thread.
|
|
5
|
+
|
|
6
|
+
Gracefully becomes a no-op when:
|
|
7
|
+
- PyGObject / libwnck is not installed
|
|
8
|
+
- No X display is available (pure Wayland without XWayland)
|
|
9
|
+
- Wnck.Screen.get_default() returns None
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
from recall.models import Event, EventType, Source
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import gi
|
|
27
|
+
|
|
28
|
+
gi.require_version("Wnck", "3.0")
|
|
29
|
+
gi.require_version("GLib", "2.0")
|
|
30
|
+
from gi.repository import GLib, Wnck # type: ignore
|
|
31
|
+
|
|
32
|
+
_WNCK_AVAILABLE = True
|
|
33
|
+
except Exception:
|
|
34
|
+
_WNCK_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now_ts() -> tuple[str, str]:
|
|
38
|
+
"""Return (ISO timestamp, YYYY-MM-DD)."""
|
|
39
|
+
now = datetime.now(timezone.utc)
|
|
40
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LinuxWindowCollector:
|
|
44
|
+
"""Collect application and window events from the X11/XWayland session."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, event_callback: Callable[[Event], None]) -> None:
|
|
47
|
+
self._cb = event_callback
|
|
48
|
+
self._thread: Optional[threading.Thread] = None
|
|
49
|
+
self._loop: Optional["GLib.MainLoop"] = None # type: ignore[name-defined]
|
|
50
|
+
# Track app open times for duration calculation
|
|
51
|
+
self._app_open_times: dict[int, float] = {}
|
|
52
|
+
# Track active workspace name for switch events
|
|
53
|
+
self._current_workspace: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
if not _WNCK_AVAILABLE:
|
|
57
|
+
logger.debug("libwnck unavailable — window tracking disabled")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
display = os.environ.get("DISPLAY", "")
|
|
61
|
+
if not display:
|
|
62
|
+
logger.debug("No DISPLAY — window tracking disabled")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
self._thread = threading.Thread(
|
|
66
|
+
target=self._run,
|
|
67
|
+
daemon=True,
|
|
68
|
+
name="devmem-window",
|
|
69
|
+
)
|
|
70
|
+
self._thread.start()
|
|
71
|
+
|
|
72
|
+
def stop(self) -> None:
|
|
73
|
+
if self._loop is not None:
|
|
74
|
+
self._loop.quit()
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Internal
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _run(self) -> None:
|
|
81
|
+
try:
|
|
82
|
+
self._loop = GLib.MainLoop()
|
|
83
|
+
|
|
84
|
+
# Wnck.Screen.get_default() must be called inside the GLib loop thread
|
|
85
|
+
GLib.idle_add(self._setup_wnck)
|
|
86
|
+
self._loop.run()
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.exception("LinuxWindowCollector loop crashed")
|
|
89
|
+
|
|
90
|
+
def _setup_wnck(self) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
screen = Wnck.Screen.get_default()
|
|
93
|
+
if screen is None:
|
|
94
|
+
logger.debug("Wnck.Screen.get_default() returned None — disabling window tracking")
|
|
95
|
+
if self._loop:
|
|
96
|
+
self._loop.quit()
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
screen.force_update()
|
|
100
|
+
|
|
101
|
+
# Seed current workspace
|
|
102
|
+
ws = screen.get_active_workspace()
|
|
103
|
+
self._current_workspace = ws.get_name() if ws else None
|
|
104
|
+
|
|
105
|
+
screen.connect("application-opened", self._on_app_opened)
|
|
106
|
+
screen.connect("application-closed", self._on_app_closed)
|
|
107
|
+
screen.connect("active-window-changed", self._on_window_focus)
|
|
108
|
+
screen.connect("active-workspace-changed", self._on_workspace_changed)
|
|
109
|
+
logger.info("Wnck window tracking active on DISPLAY=%s", os.environ.get("DISPLAY"))
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.exception("Wnck setup failed")
|
|
112
|
+
return False # remove idle callback
|
|
113
|
+
|
|
114
|
+
def _on_app_opened(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined]
|
|
115
|
+
xid = app.get_xid()
|
|
116
|
+
self._app_open_times[xid] = time.monotonic()
|
|
117
|
+
ts, date = _now_ts()
|
|
118
|
+
name = app.get_name() or ""
|
|
119
|
+
event = Event(
|
|
120
|
+
timestamp=ts,
|
|
121
|
+
date=date,
|
|
122
|
+
event_type=EventType.APP_OPEN,
|
|
123
|
+
source=Source.LINUX_WINDOW,
|
|
124
|
+
content="",
|
|
125
|
+
raw_data={"app_name": name, "xid": xid},
|
|
126
|
+
)
|
|
127
|
+
from recall.models import build_content
|
|
128
|
+
|
|
129
|
+
event.content = build_content(EventType.APP_OPEN, event.raw_data)
|
|
130
|
+
self._cb(event)
|
|
131
|
+
|
|
132
|
+
def _on_app_closed(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined]
|
|
133
|
+
xid = app.get_xid()
|
|
134
|
+
ts, date = _now_ts()
|
|
135
|
+
name = app.get_name() or ""
|
|
136
|
+
raw: dict = {"app_name": name, "xid": xid}
|
|
137
|
+
open_t = self._app_open_times.pop(xid, None)
|
|
138
|
+
if open_t is not None:
|
|
139
|
+
raw["duration_seconds"] = round(time.monotonic() - open_t)
|
|
140
|
+
event = Event(
|
|
141
|
+
timestamp=ts,
|
|
142
|
+
date=date,
|
|
143
|
+
event_type=EventType.APP_CLOSE,
|
|
144
|
+
source=Source.LINUX_WINDOW,
|
|
145
|
+
content="",
|
|
146
|
+
raw_data=raw,
|
|
147
|
+
)
|
|
148
|
+
from recall.models import build_content
|
|
149
|
+
|
|
150
|
+
event.content = build_content(EventType.APP_CLOSE, event.raw_data)
|
|
151
|
+
self._cb(event)
|
|
152
|
+
|
|
153
|
+
def _on_window_focus(
|
|
154
|
+
self,
|
|
155
|
+
screen: "Wnck.Screen", # type: ignore[name-defined]
|
|
156
|
+
prev_window: Optional["Wnck.Window"], # type: ignore[name-defined]
|
|
157
|
+
) -> None:
|
|
158
|
+
window = screen.get_active_window()
|
|
159
|
+
if window is None:
|
|
160
|
+
return
|
|
161
|
+
ts, date = _now_ts()
|
|
162
|
+
title = window.get_name() or ""
|
|
163
|
+
app = window.get_application()
|
|
164
|
+
app_name = app.get_name() if app else ""
|
|
165
|
+
event = Event(
|
|
166
|
+
timestamp=ts,
|
|
167
|
+
date=date,
|
|
168
|
+
event_type=EventType.WINDOW_FOCUS,
|
|
169
|
+
source=Source.LINUX_WINDOW,
|
|
170
|
+
content="",
|
|
171
|
+
raw_data={"window_title": title, "app_name": app_name},
|
|
172
|
+
)
|
|
173
|
+
from recall.models import build_content
|
|
174
|
+
|
|
175
|
+
event.content = build_content(EventType.WINDOW_FOCUS, event.raw_data)
|
|
176
|
+
self._cb(event)
|
|
177
|
+
|
|
178
|
+
def _on_workspace_changed(
|
|
179
|
+
self,
|
|
180
|
+
screen: "Wnck.Screen", # type: ignore[name-defined]
|
|
181
|
+
prev_ws: Optional["Wnck.Workspace"], # type: ignore[name-defined]
|
|
182
|
+
) -> None:
|
|
183
|
+
from_name = prev_ws.get_name() if prev_ws else (self._current_workspace or "")
|
|
184
|
+
ws = screen.get_active_workspace()
|
|
185
|
+
to_name = ws.get_name() if ws else ""
|
|
186
|
+
self._current_workspace = to_name
|
|
187
|
+
ts, date = _now_ts()
|
|
188
|
+
event = Event(
|
|
189
|
+
timestamp=ts,
|
|
190
|
+
date=date,
|
|
191
|
+
event_type=EventType.WORKSPACE_SWITCH,
|
|
192
|
+
source=Source.LINUX_WINDOW,
|
|
193
|
+
content="",
|
|
194
|
+
raw_data={"from_workspace": from_name, "to_workspace": to_name},
|
|
195
|
+
)
|
|
196
|
+
from recall.models import build_content
|
|
197
|
+
|
|
198
|
+
event.content = build_content(EventType.WORKSPACE_SWITCH, event.raw_data)
|
|
199
|
+
self._cb(event)
|