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,314 @@
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"}
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 {
29
+ "type": entity_type,
30
+ "id": entity_id,
31
+ "name": name,
32
+ "attributes": attributes or {},
33
+ }
34
+
35
+
36
+ def _event(
37
+ *,
38
+ timestamp: str,
39
+ event_type: str,
40
+ relation: str,
41
+ source: dict[str, Any],
42
+ target: dict[str, Any],
43
+ attributes: dict[str, Any] | None = None,
44
+ ) -> dict[str, Any]:
45
+ merged_attributes: dict[str, Any] = {
46
+ "backend": "semantic",
47
+ "attribution": "codex_hook",
48
+ "evidence_source": "provider_hook",
49
+ "provider": "codex",
50
+ "causal": False,
51
+ }
52
+ if attributes:
53
+ merged_attributes.update(attributes)
54
+ return {
55
+ "timestamp": timestamp,
56
+ "event_type": event_type,
57
+ "relation": relation,
58
+ "source": source,
59
+ "target": target,
60
+ "attributes": merged_attributes,
61
+ }
62
+
63
+
64
+ def _clean_text(value: object, *, limit: int) -> tuple[str | None, bool]:
65
+ if not isinstance(value, str):
66
+ return None, False
67
+ text = value.replace("\x00", "")
68
+ if len(text) <= limit:
69
+ return text, False
70
+ return text[:limit], True
71
+
72
+
73
+ def _main_agent() -> dict[str, Any]:
74
+ return _entity("agent", "agent:OpenAI Codex", name="OpenAI Codex")
75
+
76
+
77
+ def _actor(payload: dict[str, Any]) -> dict[str, Any]:
78
+ agent_id = payload.get("agent_id")
79
+ if isinstance(agent_id, str) and agent_id:
80
+ agent_type = payload.get("agent_type")
81
+ name = agent_type if isinstance(agent_type, str) and agent_type else "Codex subagent"
82
+ session_id = payload.get("session_id")
83
+ scope = session_id if isinstance(session_id, str) and session_id else "unknown"
84
+ return _entity(
85
+ "agent",
86
+ f"agent:codex:{scope}:subagent:{agent_id}",
87
+ name=name,
88
+ attributes={"provider": "codex", "agent_id": agent_id, "agent_type": name},
89
+ )
90
+ return _main_agent()
91
+
92
+
93
+ def _common_attributes(payload: dict[str, Any]) -> dict[str, Any]:
94
+ result: dict[str, Any] = {}
95
+ for key in (
96
+ "session_id",
97
+ "turn_id",
98
+ "cwd",
99
+ "permission_mode",
100
+ "agent_id",
101
+ "agent_type",
102
+ "model",
103
+ ):
104
+ value = payload.get(key)
105
+ if isinstance(value, (str, int, float, bool)) and value != "":
106
+ result[f"codex_{key}"] = value
107
+ return result
108
+
109
+
110
+ def _tool_entity(tool_name: str) -> dict[str, Any]:
111
+ return _entity(
112
+ "tool",
113
+ f"tool:codex:{tool_name}",
114
+ name=tool_name,
115
+ attributes={"provider": "codex", "native_name": tool_name},
116
+ )
117
+
118
+
119
+ def _tool_call_entity(payload: dict[str, Any], tool_name: str) -> dict[str, Any]:
120
+ session_id = payload.get("session_id")
121
+ tool_use_id = payload.get("tool_use_id")
122
+ session = session_id if isinstance(session_id, str) and session_id else "unknown"
123
+ use_id = tool_use_id if isinstance(tool_use_id, str) and tool_use_id else "unknown"
124
+ attrs = _common_attributes(payload)
125
+ attrs.update({"provider": "codex", "tool_name": tool_name, "tool_use_id": 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:codex:{session}:{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 _session_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
152
+ model = payload.get("model")
153
+ if not isinstance(model, str) or not model:
154
+ return []
155
+ attrs = _common_attributes(payload)
156
+ source = payload.get("source")
157
+ if isinstance(source, str) and source:
158
+ attrs["codex_session_source"] = source
159
+ return [
160
+ _event(
161
+ timestamp=timestamp,
162
+ event_type="semantic.codex.model.observed",
163
+ relation="USED_MODEL",
164
+ source=_main_agent(),
165
+ target=_entity(
166
+ "model",
167
+ f"model:codex:{model}",
168
+ name=model,
169
+ attributes={"provider": "codex"},
170
+ ),
171
+ attributes=attrs,
172
+ )
173
+ ]
174
+
175
+
176
+ def _tool_pre_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
177
+ tool_name = payload.get("tool_name")
178
+ tool_use_id = payload.get("tool_use_id")
179
+ if not isinstance(tool_name, str) or not tool_name:
180
+ raise ValueError("PreToolUse requires tool_name")
181
+ if not isinstance(tool_use_id, str) or not tool_use_id:
182
+ raise ValueError("PreToolUse requires tool_use_id")
183
+
184
+ call = _tool_call_entity(payload, tool_name)
185
+ tool = _tool_entity(tool_name)
186
+ common = _common_attributes(payload)
187
+ events = [
188
+ _event(
189
+ timestamp=timestamp,
190
+ event_type="semantic.codex.tool.requested",
191
+ relation="REQUESTED_TOOL_CALL",
192
+ source=_actor(payload),
193
+ target=call,
194
+ attributes=common,
195
+ ),
196
+ _event(
197
+ timestamp=timestamp,
198
+ event_type="semantic.codex.tool.selected",
199
+ relation="USES_TOOL",
200
+ source=call,
201
+ target=tool,
202
+ attributes=common,
203
+ ),
204
+ ]
205
+
206
+ tool_input = payload.get("tool_input")
207
+ if tool_name == "Bash" and isinstance(tool_input, dict):
208
+ command = _command_entity(tool_input)
209
+ if command is not None:
210
+ events.append(
211
+ _event(
212
+ timestamp=timestamp,
213
+ event_type="semantic.codex.command.declared",
214
+ relation="DECLARED_COMMAND",
215
+ source=call,
216
+ target=command,
217
+ attributes=common,
218
+ )
219
+ )
220
+ return events
221
+
222
+
223
+ def _tool_post_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
224
+ tool_name = payload.get("tool_name")
225
+ tool_use_id = payload.get("tool_use_id")
226
+ if not isinstance(tool_name, str) or not tool_name:
227
+ raise ValueError("PostToolUse requires tool_name")
228
+ if not isinstance(tool_use_id, str) or not tool_use_id:
229
+ raise ValueError("PostToolUse requires tool_use_id")
230
+
231
+ attrs = _common_attributes(payload)
232
+ response = payload.get("tool_response")
233
+ if isinstance(response, str):
234
+ attrs["tool_response_type"] = "string"
235
+ attrs["tool_response_chars"] = len(response)
236
+ elif response is not None:
237
+ attrs["tool_response_type"] = type(response).__name__
238
+ attrs["outcome_semantics"] = "provider_reported_completion_without_reliable_success_signal"
239
+ return [
240
+ _event(
241
+ timestamp=timestamp,
242
+ event_type="semantic.codex.tool.returned",
243
+ relation="TOOL_CALL_RETURNED",
244
+ source=_tool_call_entity(payload, tool_name),
245
+ target=_tool_entity(tool_name),
246
+ attributes=attrs,
247
+ )
248
+ ]
249
+
250
+
251
+ def codex_hook_to_semantic_events(
252
+ payload: dict[str, Any],
253
+ *,
254
+ timestamp: str | None = None,
255
+ ) -> list[dict[str, Any]]:
256
+ hook_event = payload.get("hook_event_name")
257
+ if not isinstance(hook_event, str) or not hook_event:
258
+ raise ValueError("Codex hook payload requires hook_event_name")
259
+ if hook_event not in _SUPPORTED_EVENTS:
260
+ return []
261
+ observed_at = timestamp or _now()
262
+ if hook_event == "SessionStart":
263
+ return _session_start_events(payload, timestamp=observed_at)
264
+ if hook_event == "PreToolUse":
265
+ return _tool_pre_events(payload, timestamp=observed_at)
266
+ if hook_event == "PostToolUse":
267
+ return _tool_post_events(payload, timestamp=observed_at)
268
+ return []
269
+
270
+
271
+ def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
272
+ output = Path(path).expanduser().resolve()
273
+ output.parent.mkdir(parents=True, exist_ok=True)
274
+ if not records:
275
+ return output
276
+ blob = "".join(
277
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
278
+ for record in records
279
+ )
280
+ lock_dir = output.with_name(output.name + ".lock")
281
+ deadline = time.monotonic() + 5.0
282
+ while True:
283
+ try:
284
+ lock_dir.mkdir()
285
+ break
286
+ except FileExistsError:
287
+ if time.monotonic() >= deadline:
288
+ raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
289
+ time.sleep(0.01)
290
+ try:
291
+ with output.open("a", encoding="utf-8", newline="\n") as handle:
292
+ handle.write(blob)
293
+ handle.flush()
294
+ os.fsync(handle.fileno())
295
+ finally:
296
+ try:
297
+ lock_dir.rmdir()
298
+ except OSError:
299
+ pass
300
+ return output
301
+
302
+
303
+ def read_hook_payload(stream: Any = None) -> dict[str, Any]:
304
+ source = stream if stream is not None else sys.stdin
305
+ raw = source.read()
306
+ if not isinstance(raw, str) or not raw.strip():
307
+ raise ValueError("Codex hook stdin is empty")
308
+ try:
309
+ payload = json.loads(raw)
310
+ except json.JSONDecodeError as exc:
311
+ raise ValueError(f"Codex hook stdin is invalid JSON: {exc.msg}") from exc
312
+ if not isinstance(payload, dict):
313
+ raise ValueError("Codex hook stdin must be one JSON object")
314
+ return payload
@@ -0,0 +1,98 @@
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 .codex_adapter import append_semantic_records, codex_hook_to_semantic_events, read_hook_payload
11
+
12
+
13
+ def _hook_handler(command: str) -> dict[str, str]:
14
+ return {"type": "command", "command": command}
15
+
16
+
17
+ def codex_hook_config(command: str = "execweave-codex-hook") -> dict[str, Any]:
18
+ handler = _hook_handler(command)
19
+ tool_group = {"matcher": ".*", "hooks": [handler]}
20
+ plain_group = {"hooks": [handler]}
21
+ return {
22
+ "hooks": {
23
+ "SessionStart": [plain_group],
24
+ "PreToolUse": [tool_group],
25
+ "PostToolUse": [tool_group],
26
+ }
27
+ }
28
+
29
+
30
+ def _default_sidecar(payload: dict[str, Any]) -> Path:
31
+ cwd = payload.get("cwd")
32
+ session_id = payload.get("session_id")
33
+ if not isinstance(cwd, str) or not cwd:
34
+ raise ValueError("Codex hook payload has no cwd for automatic sidecar placement")
35
+ if not isinstance(session_id, str) or not session_id:
36
+ raise ValueError("Codex hook payload has no session_id for automatic sidecar placement")
37
+ safe_session = "".join(
38
+ character if character.isalnum() or character in {"-", "_", "."} else "_"
39
+ for character in session_id
40
+ )
41
+ return Path(cwd) / ".execweave" / "semantic" / "codex" / f"{safe_session}.jsonl"
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="execweave-codex-hook",
47
+ description="Capture OpenAI Codex lifecycle hook input as local ExecWeave semantic telemetry.",
48
+ )
49
+ parser.add_argument(
50
+ "--sidecar",
51
+ type=Path,
52
+ default=None,
53
+ help=(
54
+ "Semantic JSONL output path. Defaults to EXECWEAVE_SEMANTIC_SIDECAR, then "
55
+ "<cwd>/.execweave/semantic/codex/<Codex-session-id>.jsonl."
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--strict",
60
+ action="store_true",
61
+ help="Return non-zero on telemetry errors. Default is fail-open so tracing cannot block Codex.",
62
+ )
63
+ parser.add_argument(
64
+ "--print-config",
65
+ action="store_true",
66
+ help="Print a Codex hooks.json fragment for the supported ExecWeave lifecycle hooks and exit.",
67
+ )
68
+ parser.add_argument(
69
+ "--command",
70
+ default="execweave-codex-hook",
71
+ help="Hook command embedded by --print-config (default: execweave-codex-hook).",
72
+ )
73
+ return parser
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ parser = build_parser()
78
+ args = parser.parse_args(argv)
79
+ if args.print_config:
80
+ print(json.dumps(codex_hook_config(args.command), indent=2, sort_keys=True))
81
+ return 0
82
+
83
+ try:
84
+ payload = read_hook_payload()
85
+ sidecar = args.sidecar
86
+ if sidecar is None:
87
+ configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
88
+ sidecar = Path(configured) if configured else _default_sidecar(payload)
89
+ records = codex_hook_to_semantic_events(payload)
90
+ append_semantic_records(sidecar, records)
91
+ except (OSError, TimeoutError, ValueError) as exc:
92
+ print(f"ExecWeave Codex hook warning: {exc}", file=sys.stderr)
93
+ return 1 if args.strict else 0
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
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
+ CodexRecordResult = ProviderRecordResult
11
+
12
+
13
+ def record_codex_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
+ ) -> CodexRecordResult:
26
+ """Record one Codex run using the shared provider-record pipeline.
27
+
28
+ Codex must already be configured to invoke ``execweave-codex-hook``. This
29
+ wrapper never edits Codex settings; it only binds the child process to the
30
+ run-specific semantic sidecar managed by the shared core.
31
+ """
32
+ return record_provider_to_viewer(
33
+ command,
34
+ provider_name="Codex",
35
+ watch_root=watch_root,
36
+ output_dir=output_dir,
37
+ backend=backend,
38
+ poll_interval=poll_interval,
39
+ collect_filesystem=collect_filesystem,
40
+ collect_network=collect_network,
41
+ keep_raw_trace=keep_raw_trace,
42
+ correlation_window_ms=correlation_window_ms,
43
+ open_browser=open_browser,
44
+ )
45
+
46
+
47
+ def _clean_command(command: list[str]) -> list[str]:
48
+ result = list(command)
49
+ if result and result[0] == "--":
50
+ result = result[1:]
51
+ return result
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(
56
+ prog="execweave-codex-record",
57
+ description=(
58
+ "Record runtime evidence, OpenAI Codex hook telemetry, and conservative "
59
+ "Tool-to-Process correlation in one local run."
60
+ ),
61
+ )
62
+ parser.add_argument("--watch-root", type=Path, default=None)
63
+ parser.add_argument("--output-dir", type=Path, default=None)
64
+ parser.add_argument("--interval", type=float, default=0.10)
65
+ parser.add_argument(
66
+ "--backend",
67
+ choices=["auto", "portable", "strace"],
68
+ default="auto",
69
+ )
70
+ parser.add_argument(
71
+ "--correlation-window-ms",
72
+ type=int,
73
+ default=3000,
74
+ help="maximum Tool-to-Process correlation window in milliseconds (default: 3000)",
75
+ )
76
+ parser.add_argument("--no-files", action="store_true")
77
+ parser.add_argument("--no-network", action="store_true")
78
+ parser.add_argument("--keep-native-trace", action="store_true")
79
+ parser.add_argument("--open", action="store_true", dest="open_browser")
80
+ parser.add_argument("command", nargs=argparse.REMAINDER)
81
+ return parser
82
+
83
+
84
+ def main(argv: list[str] | None = None) -> int:
85
+ parser = build_parser()
86
+ args = parser.parse_args(argv)
87
+ command = _clean_command(args.command)
88
+ if not command:
89
+ parser.error("a Codex command is required, e.g. execweave-codex-record --open -- codex")
90
+ watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
91
+ try:
92
+ result = record_codex_to_viewer(
93
+ command,
94
+ watch_root=watch_root,
95
+ output_dir=args.output_dir,
96
+ backend=args.backend,
97
+ poll_interval=args.interval,
98
+ collect_filesystem=not args.no_files,
99
+ collect_network=not args.no_network,
100
+ keep_raw_trace=args.keep_native_trace,
101
+ correlation_window_ms=args.correlation_window_ms,
102
+ open_browser=args.open_browser,
103
+ )
104
+ except (FileExistsError, RuntimeError, ValueError, OSError) as exc:
105
+ parser.error(str(exc))
106
+ print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
107
+ return result.runtime.return_code
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())