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.
@@ -0,0 +1,300 @@
1
+ """Shell collector — reads shell.tsv written by the zsh/bash hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import logging
7
+ import os
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Callable, Optional
12
+
13
+ from watchdog.events import FileModifiedEvent, FileSystemEventHandler
14
+ from watchdog.observers import Observer
15
+
16
+ from recall.models import Event, EventType, Source, build_content
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # TSV column indices
21
+ _COL_TS = 0
22
+ _COL_CWD = 1
23
+ _COL_CMD = 2
24
+ _COL_EXIT = 3
25
+ _COL_DUR = 4
26
+
27
+ # KV key to persist read offset across daemon restarts
28
+ _OFFSET_KEY = "shell_tsv_offset"
29
+
30
+ # Command category prefixes — first matching category wins
31
+ # Each entry is (prefix_lowercase, category)
32
+ _CMD_CATEGORY_RULES: list[tuple[str, str]] = [
33
+ # test
34
+ ("pytest", "test"),
35
+ ("python -m pytest", "test"),
36
+ ("python3 -m pytest", "test"),
37
+ ("jest ", "test"),
38
+ ("vitest", "test"),
39
+ ("mocha ", "test"),
40
+ ("go test", "test"),
41
+ ("cargo test", "test"),
42
+ ("npm test", "test"),
43
+ ("yarn test", "test"),
44
+ ("dotnet test", "test"),
45
+ ("phpunit", "test"),
46
+ ("rspec ", "test"),
47
+ ("flutter test", "test"),
48
+ # build
49
+ ("make", "build"),
50
+ ("cmake", "build"),
51
+ ("cargo build", "build"),
52
+ ("cargo b ", "build"),
53
+ ("gradle ", "build"),
54
+ ("mvn compile", "build"),
55
+ ("mvn package", "build"),
56
+ ("mvn install", "build"),
57
+ ("ant ", "build"),
58
+ ("bazel build", "build"),
59
+ ("ninja", "build"),
60
+ ("msbuild", "build"),
61
+ ("dotnet build", "build"),
62
+ ("npm run build", "build"),
63
+ ("yarn build", "build"),
64
+ ("tsc ", "build"),
65
+ ("tsc", "build"),
66
+ ("go build", "build"),
67
+ ("javac ", "build"),
68
+ ("gcc ", "build"),
69
+ ("g++ ", "build"),
70
+ ("clang ", "build"),
71
+ # install
72
+ ("pip install", "install"),
73
+ ("pip3 install", "install"),
74
+ ("npm install", "install"),
75
+ ("npm i ", "install"),
76
+ ("yarn add", "install"),
77
+ ("yarn install", "install"),
78
+ ("brew install", "install"),
79
+ ("apt-get install", "install"),
80
+ ("apt install", "install"),
81
+ ("cargo add", "install"),
82
+ ("go get", "install"),
83
+ ("gem install", "install"),
84
+ ("composer install", "install"),
85
+ ("composer require", "install"),
86
+ ("poetry add", "install"),
87
+ ("uv add", "install"),
88
+ ("uv pip install", "install"),
89
+ # deploy
90
+ ("docker", "deploy"),
91
+ ("kubectl ", "deploy"),
92
+ ("terraform ", "deploy"),
93
+ ("helm ", "deploy"),
94
+ ("ansible", "deploy"),
95
+ ("fly deploy", "deploy"),
96
+ ("vercel ", "deploy"),
97
+ ("netlify deploy", "deploy"),
98
+ # version control
99
+ ("git push", "vcs"),
100
+ ("git pull", "vcs"),
101
+ ("git fetch", "vcs"),
102
+ ("git merge", "vcs"),
103
+ ("git rebase", "vcs"),
104
+ ("git clone", "vcs"),
105
+ ("git stash", "vcs"),
106
+ ("git cherry-pick", "vcs"),
107
+ ]
108
+
109
+
110
+ class ShellCollector:
111
+ """
112
+ Watches shell.tsv for new lines and emits terminal_cmd Events.
113
+
114
+ The collector is started inside the daemon and calls *event_callback*
115
+ for each new event parsed from the TSV file.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ shell_tsv: Path,
121
+ cmd_ignore_patterns: list[str],
122
+ event_callback: Callable[[Event], None],
123
+ get_offset: Callable[[], Optional[str]],
124
+ set_offset: Callable[[str], None],
125
+ ) -> None:
126
+ self._path = shell_tsv
127
+ self._ignore_patterns = [p.lower() for p in cmd_ignore_patterns]
128
+ self._callback = event_callback
129
+ self._get_offset = get_offset
130
+ self._set_offset = set_offset
131
+
132
+ # Restore byte offset from KV store so we don't re-process old lines
133
+ stored = get_offset()
134
+ self._offset: int = int(stored) if stored else 0
135
+
136
+ self._observer: Optional[Observer] = None
137
+ self._lock = threading.Lock()
138
+
139
+ # ------------------------------------------------------------------
140
+ # Public
141
+ # ------------------------------------------------------------------
142
+
143
+ def start(self) -> None:
144
+ """Start the watchdog observer."""
145
+ # Ensure the file exists
146
+ self._path.parent.mkdir(parents=True, exist_ok=True)
147
+ self._path.touch(exist_ok=True)
148
+
149
+ # If the file grew while the daemon was stopped, catch up now
150
+ self._read_new_lines()
151
+
152
+ handler = _ShellTSVHandler(self._path, self._on_file_change)
153
+ self._observer = Observer()
154
+ self._observer.schedule(handler, str(self._path.parent), recursive=False)
155
+ self._observer.start()
156
+ logger.info("ShellCollector watching %s", self._path)
157
+
158
+ def stop(self) -> None:
159
+ if self._observer:
160
+ self._observer.stop()
161
+ self._observer.join(timeout=5)
162
+ logger.info("ShellCollector stopped")
163
+
164
+ # ------------------------------------------------------------------
165
+ # Internal
166
+ # ------------------------------------------------------------------
167
+
168
+ def _on_file_change(self) -> None:
169
+ with self._lock:
170
+ self._read_new_lines()
171
+
172
+ def _read_new_lines(self) -> None:
173
+ """Read any new bytes from shell.tsv since last offset."""
174
+ try:
175
+ file_size = self._path.stat().st_size
176
+ except OSError:
177
+ return
178
+
179
+ if file_size <= self._offset:
180
+ # File was truncated/rotated — reset
181
+ if file_size < self._offset:
182
+ logger.warning("shell.tsv shrank; resetting offset")
183
+ self._offset = 0
184
+ return
185
+
186
+ try:
187
+ with self._path.open("rb") as fh:
188
+ fh.seek(self._offset)
189
+ new_bytes = fh.read()
190
+ new_offset = fh.tell()
191
+ except OSError as exc:
192
+ logger.error("Cannot read shell.tsv: %s", exc)
193
+ return
194
+
195
+ events: list[Event] = []
196
+ for raw_line in new_bytes.decode("utf-8", errors="replace").splitlines():
197
+ event = self._parse_line(raw_line.rstrip("\r\n"))
198
+ if event:
199
+ events.append(event)
200
+
201
+ self._offset = new_offset
202
+ self._set_offset(str(new_offset))
203
+
204
+ for event in events:
205
+ try:
206
+ self._callback(event)
207
+ except Exception:
208
+ logger.exception("Error in shell event callback")
209
+
210
+ def _parse_line(self, line: str) -> Optional[Event]:
211
+ """Parse a single TSV line into an Event, or None if invalid/filtered."""
212
+ if not line.strip():
213
+ return None
214
+
215
+ parts = line.split("\t")
216
+ if len(parts) < 5:
217
+ logger.debug("Skipping malformed shell.tsv line: %r", line[:100])
218
+ return None
219
+
220
+ ts_str = parts[_COL_TS]
221
+ cwd = parts[_COL_CWD]
222
+ cmd = parts[_COL_CMD]
223
+
224
+ try:
225
+ exit_code = int(parts[_COL_EXIT])
226
+ except ValueError:
227
+ exit_code = 0
228
+
229
+ try:
230
+ duration_ms = int(parts[_COL_DUR])
231
+ except ValueError:
232
+ duration_ms = 0
233
+
234
+ # Privacy: skip commands matching ignore patterns
235
+ if self._is_sensitive(cmd):
236
+ logger.debug("Skipping sensitive command (pattern match)")
237
+ return None
238
+
239
+ # Parse timestamp → date
240
+ try:
241
+ dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
242
+ date_str = dt.strftime("%Y-%m-%d")
243
+ except ValueError:
244
+ # Fallback to local now
245
+ dt = datetime.now(timezone.utc)
246
+ date_str = dt.strftime("%Y-%m-%d")
247
+ ts_str = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
248
+
249
+ raw = {
250
+ "cmd": cmd,
251
+ "cwd": cwd,
252
+ "exit_code": exit_code,
253
+ "duration_ms": duration_ms,
254
+ "repo_name": os.path.basename(cwd.rstrip("/")) if cwd else "",
255
+ }
256
+ content = build_content(EventType.TERMINAL_CMD, raw)
257
+
258
+ metadata = {"cmd_category": _categorize_cmd(cmd)}
259
+
260
+ return Event(
261
+ timestamp=ts_str,
262
+ date=date_str,
263
+ event_type=EventType.TERMINAL_CMD,
264
+ source=Source.SHELL_HOOK,
265
+ content=content,
266
+ raw_data=raw,
267
+ metadata=metadata,
268
+ )
269
+
270
+ def _is_sensitive(self, cmd: str) -> bool:
271
+ """Return True if the command matches any privacy ignore pattern."""
272
+ cmd_lower = cmd.lower()
273
+ for pattern in self._ignore_patterns:
274
+ if fnmatch.fnmatch(cmd_lower, pattern):
275
+ return True
276
+ return False
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Watchdog handler
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ class _ShellTSVHandler(FileSystemEventHandler):
285
+ def __init__(self, path: Path, callback: Callable[[], None]) -> None:
286
+ self._path = str(path)
287
+ self._callback = callback
288
+
289
+ def on_modified(self, event: FileModifiedEvent) -> None:
290
+ if not event.is_directory and event.src_path == self._path:
291
+ self._callback()
292
+
293
+
294
+ def _categorize_cmd(cmd: str) -> str:
295
+ """Return a broad category for a shell command string."""
296
+ cmd_lower = cmd.lower().strip()
297
+ for prefix, category in _CMD_CATEGORY_RULES:
298
+ if cmd_lower.startswith(prefix):
299
+ return category
300
+ return "other"
@@ -0,0 +1,175 @@
1
+ """VS Code extension HTTP receiver — /event endpoint for the TypeScript extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from typing import Callable, Optional
9
+
10
+ from recall.models import Event, EventType, Source, build_content
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Map VS Code extension event types to our internal EventType
15
+ _TYPE_MAP: dict[str, EventType] = {
16
+ "workspace_open": EventType.REPO_OPEN,
17
+ "workspace_close": EventType.REPO_CLOSE,
18
+ "file_save": EventType.FILE_SAVE,
19
+ "active_time": EventType.REPO_OPEN, # treated as "still active" keep-alive
20
+ "file_create": EventType.FILE_CREATE,
21
+ "file_delete": EventType.FILE_DELETE,
22
+ "file_rename": EventType.FILE_RENAME,
23
+ "debug_session_start": EventType.DEBUG_SESSION,
24
+ "debug_session_end": EventType.DEBUG_SESSION,
25
+ "test_run_start": EventType.TEST_RUN,
26
+ "test_run_finish": EventType.TEST_RUN,
27
+ }
28
+
29
+
30
+ def parse_vscode_event(
31
+ payload: dict,
32
+ event_callback: Callable[[Event], None],
33
+ ) -> None:
34
+ """
35
+ Parse an incoming JSON payload from the VS Code extension and emit an Event.
36
+
37
+ Payload fields (all optional except `type`):
38
+ type : str — workspace_open | workspace_close | file_save | active_time
39
+ ts : str — ISO 8601 timestamp (default: now)
40
+ workspace : str — workspace folder path
41
+ file : str — file path (for file_save)
42
+ language : str — language id (for file_save)
43
+ seconds_active : int — (for active_time)
44
+ """
45
+ event_type_str = str(payload.get("type", ""))
46
+ event_type = _TYPE_MAP.get(event_type_str)
47
+ if event_type is None:
48
+ logger.debug("Ignoring unknown VS Code event type: %r", event_type_str)
49
+ return
50
+
51
+ ts_str = str(payload.get("ts", ""))
52
+ try:
53
+ dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
54
+ ts_out = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+ date_str = dt.astimezone(timezone.utc).strftime("%Y-%m-%d")
56
+ except ValueError:
57
+ dt = datetime.now(timezone.utc)
58
+ ts_out = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
59
+ date_str = dt.strftime("%Y-%m-%d")
60
+
61
+ workspace = str(payload.get("workspace", ""))
62
+ repo_path: Optional[str] = workspace if workspace else None
63
+ repo_name: Optional[str] = os.path.basename(workspace.rstrip("/")) if workspace else None
64
+
65
+ if event_type == EventType.FILE_SAVE:
66
+ file_path = str(payload.get("file", ""))
67
+ language = str(payload.get("language", ""))
68
+ filename = os.path.basename(file_path)
69
+ raw = {
70
+ "filename": filename,
71
+ "file_path": file_path,
72
+ "language": language,
73
+ "repo_name": repo_name or "",
74
+ "repo_path": workspace,
75
+ }
76
+ content = build_content(EventType.FILE_SAVE, raw)
77
+
78
+ elif event_type == EventType.FILE_CREATE:
79
+ file_path = str(payload.get("file", ""))
80
+ filename = str(payload.get("filename", "")) or os.path.basename(file_path)
81
+ raw = {
82
+ "filename": filename,
83
+ "file_path": file_path,
84
+ "repo_name": repo_name or "",
85
+ "repo_path": workspace,
86
+ }
87
+ content = build_content(EventType.FILE_CREATE, raw)
88
+
89
+ elif event_type == EventType.FILE_DELETE:
90
+ file_path = str(payload.get("file", ""))
91
+ filename = str(payload.get("filename", "")) or os.path.basename(file_path)
92
+ raw = {
93
+ "filename": filename,
94
+ "file_path": file_path,
95
+ "repo_name": repo_name or "",
96
+ "repo_path": workspace,
97
+ }
98
+ content = build_content(EventType.FILE_DELETE, raw)
99
+
100
+ elif event_type == EventType.FILE_RENAME:
101
+ old_file = str(payload.get("old_file", ""))
102
+ new_file = str(payload.get("new_file", ""))
103
+ old_filename = str(payload.get("old_filename", "")) or os.path.basename(old_file)
104
+ new_filename = str(payload.get("new_filename", "")) or os.path.basename(new_file)
105
+ raw = {
106
+ "old_filename": old_filename,
107
+ "new_filename": new_filename,
108
+ "old_file": old_file,
109
+ "new_file": new_file,
110
+ "repo_name": repo_name or "",
111
+ "repo_path": workspace,
112
+ }
113
+ content = build_content(EventType.FILE_RENAME, raw)
114
+
115
+ elif event_type == EventType.DEBUG_SESSION:
116
+ name = str(payload.get("name", ""))
117
+ debug_type = str(payload.get("debug_type", ""))
118
+ action = "started" if event_type_str == "debug_session_start" else "ended"
119
+ raw = {
120
+ "name": name,
121
+ "debug_type": debug_type,
122
+ "action": action,
123
+ "repo_name": repo_name or "",
124
+ "repo_path": workspace,
125
+ }
126
+ content = build_content(EventType.DEBUG_SESSION, raw)
127
+
128
+ elif event_type == EventType.TEST_RUN:
129
+ name = str(payload.get("name", ""))
130
+ action = "started" if event_type_str == "test_run_start" else "finished"
131
+ raw: dict = {
132
+ "name": name,
133
+ "action": action,
134
+ "repo_name": repo_name or "",
135
+ "repo_path": workspace,
136
+ }
137
+ if event_type_str == "test_run_finish":
138
+ raw["exit_code"] = int(payload.get("exit_code", 0))
139
+ content = build_content(EventType.TEST_RUN, raw)
140
+
141
+ elif event_type == EventType.REPO_OPEN:
142
+ raw = {
143
+ "repo_name": repo_name or workspace,
144
+ "repo_path": workspace,
145
+ "event": event_type_str,
146
+ }
147
+ content = build_content(EventType.REPO_OPEN, raw)
148
+
149
+ elif event_type == EventType.REPO_CLOSE:
150
+ raw = {
151
+ "repo_name": repo_name or workspace,
152
+ "repo_path": workspace,
153
+ "duration_minutes": 0,
154
+ }
155
+ content = build_content(EventType.REPO_CLOSE, raw)
156
+
157
+ else:
158
+ raw = dict(payload)
159
+ content = f"[vscode] {event_type_str}"
160
+
161
+ event = Event(
162
+ timestamp=ts_out,
163
+ date=date_str,
164
+ event_type=event_type,
165
+ source=Source.VSCODE_EXT,
166
+ content=content,
167
+ raw_data=raw,
168
+ repo_path=repo_path,
169
+ repo_name=repo_name,
170
+ )
171
+
172
+ try:
173
+ event_callback(event)
174
+ except Exception:
175
+ logger.exception("Error in VS Code event callback")