execweave 0.6.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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ _MAX_COMMAND_CHARS = 4096
13
+ _MAX_LABEL_CHARS = 160
14
+ _SUPPORTED_EVENTS = {"sessionStart", "preToolUse", "postToolUse", "postToolUseFailure"}
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
19
+
20
+
21
+ def _entity(
22
+ entity_type: str,
23
+ entity_id: str,
24
+ *,
25
+ name: str | None = None,
26
+ attributes: dict[str, Any] | None = None,
27
+ ) -> dict[str, Any]:
28
+ return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
29
+
30
+
31
+ def _event(
32
+ *,
33
+ timestamp: str,
34
+ event_type: str,
35
+ relation: str,
36
+ source: dict[str, Any],
37
+ target: dict[str, Any],
38
+ attributes: dict[str, Any] | None = None,
39
+ ) -> dict[str, Any]:
40
+ merged = {
41
+ "backend": "semantic",
42
+ "attribution": "cursor_hook",
43
+ "evidence_source": "provider_hook",
44
+ "provider": "cursor",
45
+ "causal": False,
46
+ }
47
+ if attributes:
48
+ merged.update(attributes)
49
+ return {
50
+ "timestamp": timestamp,
51
+ "event_type": event_type,
52
+ "relation": relation,
53
+ "source": source,
54
+ "target": target,
55
+ "attributes": merged,
56
+ }
57
+
58
+
59
+ def _clean_text(value: object, *, limit: int) -> tuple[str | None, bool]:
60
+ if not isinstance(value, str):
61
+ return None, False
62
+ text = value.replace("\x00", "")
63
+ return (text, False) if len(text) <= limit else (text[:limit], True)
64
+
65
+
66
+ def _agent() -> dict[str, Any]:
67
+ return _entity("agent", "agent:Cursor", name="Cursor")
68
+
69
+
70
+ def _scope(payload: dict[str, Any]) -> str:
71
+ for key in ("conversation_id", "session_id", "generation_id"):
72
+ value = payload.get(key)
73
+ if isinstance(value, str) and value:
74
+ return value
75
+ return "unknown"
76
+
77
+
78
+ def _common_attributes(payload: dict[str, Any]) -> dict[str, Any]:
79
+ result: dict[str, Any] = {}
80
+ for key in (
81
+ "conversation_id",
82
+ "generation_id",
83
+ "session_id",
84
+ "cursor_version",
85
+ "cwd",
86
+ "model",
87
+ "model_id",
88
+ ):
89
+ value = payload.get(key)
90
+ if isinstance(value, (str, int, float, bool)) and value != "":
91
+ result[f"cursor_{key}"] = value
92
+ roots = payload.get("workspace_roots")
93
+ if isinstance(roots, list):
94
+ result["cursor_workspace_root_count"] = len(roots)
95
+ return result
96
+
97
+
98
+ def _tool_entity(tool_name: str) -> dict[str, Any]:
99
+ if tool_name.startswith("MCP:"):
100
+ native = tool_name[4:] or tool_name
101
+ return _entity(
102
+ "tool",
103
+ f"tool:cursor:mcp:{native}",
104
+ name=native,
105
+ attributes={
106
+ "provider": "cursor",
107
+ "native_name": tool_name,
108
+ "mcp_tool": True,
109
+ "mcp_server_identity_available": False,
110
+ },
111
+ )
112
+ return _entity(
113
+ "tool",
114
+ f"tool:cursor:{tool_name}",
115
+ name=tool_name,
116
+ attributes={"provider": "cursor", "native_name": tool_name},
117
+ )
118
+
119
+
120
+ def _tool_call_entity(payload: dict[str, Any], tool_name: str) -> dict[str, Any]:
121
+ tool_use_id = payload.get("tool_use_id")
122
+ if not isinstance(tool_use_id, str) or not tool_use_id:
123
+ raise ValueError("Cursor tool hook requires tool_use_id")
124
+ attrs = _common_attributes(payload)
125
+ attrs.update({"provider": "cursor", "tool_name": tool_name, "tool_use_id": tool_use_id})
126
+ tool_input = payload.get("tool_input")
127
+ if isinstance(tool_input, dict):
128
+ attrs["input_keys"] = sorted(str(key) for key in tool_input)
129
+ return _entity(
130
+ "tool_call",
131
+ f"tool-call:cursor:{_scope(payload)}:{tool_use_id}",
132
+ name=tool_name,
133
+ attributes=attrs,
134
+ )
135
+
136
+
137
+ def _command_entity(tool_input: dict[str, Any]) -> dict[str, Any] | None:
138
+ command, truncated = _clean_text(tool_input.get("command"), limit=_MAX_COMMAND_CHARS)
139
+ if not command:
140
+ return None
141
+ digest = hashlib.sha256(command.encode("utf-8", errors="replace")).hexdigest()
142
+ label, _ = _clean_text(command.replace("\n", " "), limit=_MAX_LABEL_CHARS)
143
+ return _entity(
144
+ "command",
145
+ f"command:sha256:{digest}",
146
+ name=label,
147
+ attributes={"command": command, "truncated": truncated},
148
+ )
149
+
150
+
151
+ def _declared_file_entity(payload: dict[str, Any], tool_input: dict[str, Any]) -> dict[str, Any] | None:
152
+ raw = tool_input.get("file_path")
153
+ if not isinstance(raw, str) or not raw:
154
+ raw = tool_input.get("path")
155
+ if not isinstance(raw, str) or not raw:
156
+ return None
157
+ candidate = Path(raw).expanduser()
158
+ if not candidate.is_absolute():
159
+ cwd = payload.get("cwd")
160
+ if not isinstance(cwd, str) or not cwd:
161
+ cwd = tool_input.get("working_directory")
162
+ if isinstance(cwd, str) and cwd:
163
+ candidate = Path(cwd) / candidate
164
+ try:
165
+ normalized = candidate.resolve(strict=False)
166
+ except OSError:
167
+ normalized = candidate.absolute()
168
+ return _entity(
169
+ "file",
170
+ f"file:{normalized}",
171
+ name=normalized.name or str(normalized),
172
+ attributes={"declared_by_provider_hook": True, "provider": "cursor"},
173
+ )
174
+
175
+
176
+ def _session_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
177
+ model = payload.get("model_id")
178
+ if not isinstance(model, str) or not model:
179
+ model = payload.get("model")
180
+ if not isinstance(model, str) or not model:
181
+ return []
182
+ return [
183
+ _event(
184
+ timestamp=timestamp,
185
+ event_type="semantic.cursor.model.observed",
186
+ relation="USED_MODEL",
187
+ source=_agent(),
188
+ target=_entity(
189
+ "model",
190
+ f"model:cursor:{model}",
191
+ name=model,
192
+ attributes={"provider": "cursor"},
193
+ ),
194
+ attributes=_common_attributes(payload),
195
+ )
196
+ ]
197
+
198
+
199
+ def _pre_tool_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
200
+ tool_name = payload.get("tool_name")
201
+ if not isinstance(tool_name, str) or not tool_name:
202
+ raise ValueError("Cursor preToolUse requires tool_name")
203
+ call = _tool_call_entity(payload, tool_name)
204
+ tool = _tool_entity(tool_name)
205
+ common = _common_attributes(payload)
206
+ events = [
207
+ _event(
208
+ timestamp=timestamp,
209
+ event_type="semantic.cursor.tool.requested",
210
+ relation="REQUESTED_TOOL_CALL",
211
+ source=_agent(),
212
+ target=call,
213
+ attributes=common,
214
+ ),
215
+ _event(
216
+ timestamp=timestamp,
217
+ event_type="semantic.cursor.tool.selected",
218
+ relation="USES_TOOL",
219
+ source=call,
220
+ target=tool,
221
+ attributes=common,
222
+ ),
223
+ ]
224
+ tool_input = payload.get("tool_input")
225
+ if isinstance(tool_input, dict):
226
+ if tool_name == "Shell":
227
+ command = _command_entity(tool_input)
228
+ if command is not None:
229
+ events.append(
230
+ _event(
231
+ timestamp=timestamp,
232
+ event_type="semantic.cursor.command.declared",
233
+ relation="DECLARED_COMMAND",
234
+ source=call,
235
+ target=command,
236
+ attributes=common,
237
+ )
238
+ )
239
+ if tool_name in {"Read", "Write", "Delete"}:
240
+ target = _declared_file_entity(payload, tool_input)
241
+ if target is not None:
242
+ events.append(
243
+ _event(
244
+ timestamp=timestamp,
245
+ event_type="semantic.cursor.file.declared",
246
+ relation="DECLARED_TARGET",
247
+ source=call,
248
+ target=target,
249
+ attributes=common,
250
+ )
251
+ )
252
+ return events
253
+
254
+
255
+ def _tool_result_events(
256
+ payload: dict[str, Any],
257
+ *,
258
+ timestamp: str,
259
+ success: bool,
260
+ ) -> list[dict[str, Any]]:
261
+ tool_name = payload.get("tool_name")
262
+ if not isinstance(tool_name, str) or not tool_name:
263
+ raise ValueError("Cursor tool result hook requires tool_name")
264
+ call = _tool_call_entity(payload, tool_name)
265
+ attrs = _common_attributes(payload)
266
+ if not success:
267
+ attrs["provider_reported_failure"] = True
268
+ return [
269
+ _event(
270
+ timestamp=timestamp,
271
+ event_type=(
272
+ "semantic.cursor.tool.returned" if success else "semantic.cursor.tool.failed"
273
+ ),
274
+ relation="TOOL_CALL_RETURNED" if success else "TOOL_CALL_FAILED",
275
+ source=call,
276
+ target=_tool_entity(tool_name),
277
+ attributes=attrs,
278
+ )
279
+ ]
280
+
281
+
282
+ def cursor_hook_to_semantic_events(
283
+ payload: dict[str, Any],
284
+ *,
285
+ timestamp: str | None = None,
286
+ ) -> list[dict[str, Any]]:
287
+ hook_event = payload.get("hook_event_name")
288
+ if not isinstance(hook_event, str) or not hook_event:
289
+ raise ValueError("Cursor hook payload requires hook_event_name")
290
+ if hook_event not in _SUPPORTED_EVENTS:
291
+ return []
292
+ observed_at = timestamp or _now()
293
+ if hook_event == "sessionStart":
294
+ return _session_start_events(payload, timestamp=observed_at)
295
+ if hook_event == "preToolUse":
296
+ return _pre_tool_events(payload, timestamp=observed_at)
297
+ if hook_event == "postToolUse":
298
+ return _tool_result_events(payload, timestamp=observed_at, success=True)
299
+ if hook_event == "postToolUseFailure":
300
+ return _tool_result_events(payload, timestamp=observed_at, success=False)
301
+ return []
302
+
303
+
304
+ def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
305
+ output = Path(path).expanduser().resolve()
306
+ output.parent.mkdir(parents=True, exist_ok=True)
307
+ if not records:
308
+ return output
309
+ blob = "".join(
310
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
311
+ for record in records
312
+ )
313
+ lock_dir = output.with_name(output.name + ".lock")
314
+ deadline = time.monotonic() + 5.0
315
+ while True:
316
+ try:
317
+ lock_dir.mkdir()
318
+ break
319
+ except FileExistsError:
320
+ if time.monotonic() >= deadline:
321
+ raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
322
+ time.sleep(0.01)
323
+ try:
324
+ with output.open("a", encoding="utf-8", newline="\n") as handle:
325
+ handle.write(blob)
326
+ handle.flush()
327
+ os.fsync(handle.fileno())
328
+ finally:
329
+ try:
330
+ lock_dir.rmdir()
331
+ except OSError:
332
+ pass
333
+ return output
334
+
335
+
336
+ def read_hook_payload(stream: Any = None) -> dict[str, Any]:
337
+ source = stream if stream is not None else sys.stdin
338
+ raw = source.read()
339
+ if not isinstance(raw, str) or not raw.strip():
340
+ raise ValueError("Cursor hook stdin is empty")
341
+ try:
342
+ payload = json.loads(raw)
343
+ except json.JSONDecodeError as exc:
344
+ raise ValueError(f"Cursor hook stdin is invalid JSON: {exc.msg}") from exc
345
+ if not isinstance(payload, dict):
346
+ raise ValueError("Cursor hook stdin must be one JSON object")
347
+ return payload
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .cursor_adapter import append_semantic_records, cursor_hook_to_semantic_events, read_hook_payload
11
+
12
+
13
+ def cursor_hook_config(command: str = "execweave-cursor-hook") -> dict[str, Any]:
14
+ handler = {"command": command}
15
+ return {
16
+ "version": 1,
17
+ "hooks": {
18
+ "sessionStart": [handler],
19
+ "preToolUse": [handler],
20
+ "postToolUse": [handler],
21
+ "postToolUseFailure": [handler],
22
+ },
23
+ }
24
+
25
+
26
+ def _default_sidecar(payload: dict[str, Any]) -> Path:
27
+ cwd = payload.get("cwd")
28
+ if not isinstance(cwd, str) or not cwd:
29
+ roots = payload.get("workspace_roots")
30
+ if isinstance(roots, list) and roots and isinstance(roots[0], str):
31
+ cwd = roots[0]
32
+ if not isinstance(cwd, str) or not cwd:
33
+ raise ValueError("Cursor hook payload has no cwd/workspace root for sidecar placement")
34
+ scope = None
35
+ for key in ("conversation_id", "session_id", "generation_id"):
36
+ value = payload.get(key)
37
+ if isinstance(value, str) and value:
38
+ scope = value
39
+ break
40
+ if scope is None:
41
+ raise ValueError("Cursor hook payload has no conversation/session identifier")
42
+ safe_scope = "".join(
43
+ character if character.isalnum() or character in {"-", "_", "."} else "_"
44
+ for character in scope
45
+ )
46
+ return Path(cwd) / ".execweave" / "semantic" / "cursor" / f"{safe_scope}.jsonl"
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(
51
+ prog="execweave-cursor-hook",
52
+ description="Capture Cursor hook input as local ExecWeave semantic telemetry.",
53
+ )
54
+ parser.add_argument("--sidecar", type=Path, default=None)
55
+ parser.add_argument("--strict", action="store_true")
56
+ parser.add_argument("--print-config", action="store_true")
57
+ parser.add_argument("--command", default="execweave-cursor-hook")
58
+ return parser
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+ if args.print_config:
64
+ print(json.dumps(cursor_hook_config(args.command), indent=2, sort_keys=True))
65
+ return 0
66
+ try:
67
+ payload = read_hook_payload()
68
+ sidecar = args.sidecar
69
+ if sidecar is None:
70
+ configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
71
+ sidecar = Path(configured) if configured else _default_sidecar(payload)
72
+ append_semantic_records(sidecar, cursor_hook_to_semantic_events(payload))
73
+ except (OSError, TimeoutError, ValueError) as exc:
74
+ print(f"ExecWeave Cursor hook warning: {exc}", file=sys.stderr)
75
+ if args.strict:
76
+ return 1
77
+ print("{}")
78
+ return 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .backends import BackendName
8
+ from .provider_record import ProviderRecordResult, record_provider_to_viewer
9
+
10
+ CursorRecordResult = ProviderRecordResult
11
+
12
+
13
+ def record_cursor_to_viewer(
14
+ command: list[str],
15
+ *,
16
+ watch_root: str | Path,
17
+ output_dir: str | Path | None = None,
18
+ backend: BackendName = "auto",
19
+ poll_interval: float = 0.10,
20
+ collect_filesystem: bool = True,
21
+ collect_network: bool = True,
22
+ keep_raw_trace: bool = False,
23
+ correlation_window_ms: int = 3000,
24
+ open_browser: bool = False,
25
+ ) -> CursorRecordResult:
26
+ return record_provider_to_viewer(
27
+ command,
28
+ provider_name="Cursor",
29
+ watch_root=watch_root,
30
+ output_dir=output_dir,
31
+ backend=backend,
32
+ poll_interval=poll_interval,
33
+ collect_filesystem=collect_filesystem,
34
+ collect_network=collect_network,
35
+ keep_raw_trace=keep_raw_trace,
36
+ correlation_window_ms=correlation_window_ms,
37
+ open_browser=open_browser,
38
+ )
39
+
40
+
41
+ def _clean_command(command: list[str]) -> list[str]:
42
+ result = list(command)
43
+ if result and result[0] == "--":
44
+ result = result[1:]
45
+ return result
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="execweave-cursor-record",
51
+ description=(
52
+ "Record runtime evidence, Cursor hook telemetry, and conservative "
53
+ "Tool-to-Process correlation in one local run."
54
+ ),
55
+ )
56
+ parser.add_argument("--watch-root", type=Path, default=None)
57
+ parser.add_argument("--output-dir", type=Path, default=None)
58
+ parser.add_argument("--interval", type=float, default=0.10)
59
+ parser.add_argument("--backend", choices=["auto", "portable", "strace"], default="auto")
60
+ parser.add_argument("--correlation-window-ms", type=int, default=3000)
61
+ parser.add_argument("--no-files", action="store_true")
62
+ parser.add_argument("--no-network", action="store_true")
63
+ parser.add_argument("--keep-native-trace", action="store_true")
64
+ parser.add_argument("--open", action="store_true", dest="open_browser")
65
+ parser.add_argument("command", nargs=argparse.REMAINDER)
66
+ return parser
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ parser = build_parser()
71
+ args = parser.parse_args(argv)
72
+ command = _clean_command(args.command)
73
+ if not command:
74
+ parser.error("a Cursor command is required, e.g. execweave-cursor-record --open -- cursor")
75
+ watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
76
+ try:
77
+ result = record_cursor_to_viewer(
78
+ command,
79
+ watch_root=watch_root,
80
+ output_dir=args.output_dir,
81
+ backend=args.backend,
82
+ poll_interval=args.interval,
83
+ collect_filesystem=not args.no_files,
84
+ collect_network=not args.no_network,
85
+ keep_raw_trace=args.keep_native_trace,
86
+ correlation_window_ms=args.correlation_window_ms,
87
+ open_browser=args.open_browser,
88
+ )
89
+ except (FileExistsError, RuntimeError, ValueError, OSError) as exc:
90
+ parser.error(str(exc))
91
+ print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
92
+ return result.runtime.return_code
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+
6
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
7
+ from watchdog.observers import Observer
8
+
9
+ from .schema import Entity, RuntimeEvent
10
+ from .sink import JsonlSink
11
+
12
+
13
+ class SessionFileEventHandler(FileSystemEventHandler):
14
+ """Record filesystem changes observed inside a watched session directory.
15
+
16
+ Phase 1 intentionally records these as session-level observations. A filesystem
17
+ change is not attributed to a specific process until a lower-level collector
18
+ (for example eBPF/ETW) can prove that relationship.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ session_id: str,
25
+ session_entity: Entity,
26
+ sink: JsonlSink,
27
+ excluded_roots: Iterable[Path] = (),
28
+ ) -> None:
29
+ super().__init__()
30
+ self.session_id = session_id
31
+ self.session_entity = session_entity
32
+ self.sink = sink
33
+ self.excluded_roots = tuple(path.resolve() for path in excluded_roots)
34
+
35
+ def _excluded(self, path: str) -> bool:
36
+ candidate = Path(path).expanduser().resolve()
37
+ return any(candidate == root or root in candidate.parents for root in self.excluded_roots)
38
+
39
+ def _emit(self, event: FileSystemEvent) -> None:
40
+ if self._excluded(event.src_path):
41
+ return
42
+
43
+ src = Path(event.src_path).expanduser().resolve()
44
+ target_path = src
45
+ attributes: dict[str, object] = {
46
+ "filesystem_event": event.event_type,
47
+ "is_directory": event.is_directory,
48
+ "attribution": "session_observation",
49
+ "causal": False,
50
+ }
51
+
52
+ destination = getattr(event, "dest_path", None)
53
+ if destination:
54
+ dest = Path(destination).expanduser().resolve()
55
+ if self._excluded(str(dest)):
56
+ return
57
+ attributes["source_path"] = str(src)
58
+ attributes["destination_path"] = str(dest)
59
+ target_path = dest
60
+
61
+ entity_type = "directory" if event.is_directory else "file"
62
+ target = Entity(type=entity_type, id=f"{entity_type}:{target_path}", name=target_path.name)
63
+ self.sink.emit(
64
+ RuntimeEvent.create(
65
+ session_id=self.session_id,
66
+ event_type=f"filesystem.{event.event_type}",
67
+ relation="OBSERVED_FILE_CHANGE",
68
+ source=self.session_entity,
69
+ target=target,
70
+ attributes=attributes,
71
+ )
72
+ )
73
+
74
+ def on_any_event(self, event: FileSystemEvent) -> None:
75
+ self._emit(event)
76
+
77
+
78
+ class FileWatcher:
79
+ def __init__(
80
+ self,
81
+ *,
82
+ root: Path,
83
+ session_id: str,
84
+ session_entity: Entity,
85
+ sink: JsonlSink,
86
+ excluded_roots: Iterable[Path] = (),
87
+ ) -> None:
88
+ self.root = root.expanduser().resolve()
89
+ self.handler = SessionFileEventHandler(
90
+ session_id=session_id,
91
+ session_entity=session_entity,
92
+ sink=sink,
93
+ excluded_roots=excluded_roots,
94
+ )
95
+ self.observer = Observer()
96
+
97
+ def start(self) -> None:
98
+ self.observer.schedule(self.handler, str(self.root), recursive=True)
99
+ self.observer.start()
100
+
101
+ def stop(self) -> None:
102
+ self.observer.stop()
103
+ self.observer.join(timeout=5)