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,316 @@
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 = {"chat.message", "tool.execute.before", "tool.execute.after"}
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": "opencode_plugin",
43
+ "evidence_source": "provider_plugin",
44
+ "provider": "opencode",
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:OpenCode", name="OpenCode")
68
+
69
+
70
+ def _common(payload: dict[str, Any]) -> dict[str, Any]:
71
+ attrs: dict[str, Any] = {}
72
+ for source, target in (
73
+ ("sessionID", "opencode_session_id"),
74
+ ("callID", "opencode_call_id"),
75
+ ("messageID", "opencode_message_id"),
76
+ ("agent", "opencode_agent"),
77
+ ("cwd", "opencode_cwd"),
78
+ ):
79
+ value = payload.get(source)
80
+ if isinstance(value, (str, int, float, bool)) and value != "":
81
+ attrs[target] = value
82
+ return attrs
83
+
84
+
85
+ def _tool_entity(tool: str) -> dict[str, Any]:
86
+ return _entity(
87
+ "tool",
88
+ f"tool:opencode:{tool}",
89
+ name=tool,
90
+ attributes={"provider": "opencode", "native_name": tool},
91
+ )
92
+
93
+
94
+ def _tool_call(payload: dict[str, Any], tool: str) -> dict[str, Any]:
95
+ session_id = payload.get("sessionID")
96
+ call_id = payload.get("callID")
97
+ if not isinstance(session_id, str) or not session_id:
98
+ raise ValueError("OpenCode tool hook requires sessionID")
99
+ if not isinstance(call_id, str) or not call_id:
100
+ raise ValueError("OpenCode tool hook requires callID")
101
+ attrs = _common(payload)
102
+ attrs.update({"provider": "opencode", "tool_name": tool, "call_id": call_id})
103
+ safe_args = payload.get("args")
104
+ if isinstance(safe_args, dict):
105
+ attrs["input_keys"] = sorted(str(key) for key in safe_args)
106
+ return _entity(
107
+ "tool_call",
108
+ f"tool-call:opencode:{session_id}:{call_id}",
109
+ name=tool,
110
+ attributes=attrs,
111
+ )
112
+
113
+
114
+ def _command_entity(args: dict[str, Any]) -> dict[str, Any] | None:
115
+ command, truncated = _clean_text(args.get("command"), limit=_MAX_COMMAND_CHARS)
116
+ if not command:
117
+ return None
118
+ digest = hashlib.sha256(command.encode("utf-8", errors="replace")).hexdigest()
119
+ label, _ = _clean_text(command.replace("\n", " "), limit=_MAX_LABEL_CHARS)
120
+ return _entity(
121
+ "command",
122
+ f"command:sha256:{digest}",
123
+ name=label,
124
+ attributes={"command": command, "truncated": truncated},
125
+ )
126
+
127
+
128
+ def _declared_file(payload: dict[str, Any], args: dict[str, Any]) -> dict[str, Any] | None:
129
+ raw = None
130
+ for key in ("filePath", "file_path", "path"):
131
+ value = args.get(key)
132
+ if isinstance(value, str) and value:
133
+ raw = value
134
+ break
135
+ if raw is None:
136
+ return None
137
+ candidate = Path(raw).expanduser()
138
+ if not candidate.is_absolute():
139
+ cwd = payload.get("cwd")
140
+ if isinstance(cwd, str) and cwd:
141
+ candidate = Path(cwd) / candidate
142
+ try:
143
+ normalized = candidate.resolve(strict=False)
144
+ except OSError:
145
+ normalized = candidate.absolute()
146
+ return _entity(
147
+ "file",
148
+ f"file:{normalized}",
149
+ name=normalized.name or str(normalized),
150
+ attributes={"declared_by_provider_plugin": True, "provider": "opencode"},
151
+ )
152
+
153
+
154
+ def _chat_message(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
155
+ model = payload.get("model")
156
+ if not isinstance(model, dict):
157
+ return []
158
+ provider_id = model.get("providerID")
159
+ model_id = model.get("modelID")
160
+ if not isinstance(model_id, str) or not model_id:
161
+ return []
162
+ name = f"{provider_id}/{model_id}" if isinstance(provider_id, str) and provider_id else model_id
163
+ return [
164
+ _event(
165
+ timestamp=timestamp,
166
+ event_type="semantic.opencode.model.observed",
167
+ relation="USED_MODEL",
168
+ source=_agent(),
169
+ target=_entity(
170
+ "model",
171
+ f"model:opencode:{name}",
172
+ name=name,
173
+ attributes={
174
+ "provider": "opencode",
175
+ "model_provider_id": provider_id,
176
+ "model_id": model_id,
177
+ },
178
+ ),
179
+ attributes=_common(payload),
180
+ )
181
+ ]
182
+
183
+
184
+ def _before(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
185
+ tool = payload.get("tool")
186
+ if not isinstance(tool, str) or not tool:
187
+ raise ValueError("OpenCode tool.execute.before requires tool")
188
+ call = _tool_call(payload, tool)
189
+ common = _common(payload)
190
+ events = [
191
+ _event(
192
+ timestamp=timestamp,
193
+ event_type="semantic.opencode.tool.requested",
194
+ relation="REQUESTED_TOOL_CALL",
195
+ source=_agent(),
196
+ target=call,
197
+ attributes=common,
198
+ ),
199
+ _event(
200
+ timestamp=timestamp,
201
+ event_type="semantic.opencode.tool.selected",
202
+ relation="USES_TOOL",
203
+ source=call,
204
+ target=_tool_entity(tool),
205
+ attributes=common,
206
+ ),
207
+ ]
208
+ args = payload.get("args")
209
+ if isinstance(args, dict):
210
+ if tool == "bash":
211
+ command = _command_entity(args)
212
+ if command is not None:
213
+ events.append(
214
+ _event(
215
+ timestamp=timestamp,
216
+ event_type="semantic.opencode.command.declared",
217
+ relation="DECLARED_COMMAND",
218
+ source=call,
219
+ target=command,
220
+ attributes=common,
221
+ )
222
+ )
223
+ target = _declared_file(payload, args)
224
+ if target is not None:
225
+ events.append(
226
+ _event(
227
+ timestamp=timestamp,
228
+ event_type="semantic.opencode.file.declared",
229
+ relation="DECLARED_TARGET",
230
+ source=call,
231
+ target=target,
232
+ attributes=common,
233
+ )
234
+ )
235
+ return events
236
+
237
+
238
+ def _after(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
239
+ tool = payload.get("tool")
240
+ if not isinstance(tool, str) or not tool:
241
+ raise ValueError("OpenCode tool.execute.after requires tool")
242
+ call = _tool_call(payload, tool)
243
+ return [
244
+ _event(
245
+ timestamp=timestamp,
246
+ event_type="semantic.opencode.tool.returned",
247
+ relation="TOOL_CALL_RETURNED",
248
+ source=call,
249
+ target=_tool_entity(tool),
250
+ attributes=_common(payload),
251
+ )
252
+ ]
253
+
254
+
255
+ def opencode_plugin_to_semantic_events(
256
+ payload: dict[str, Any],
257
+ *,
258
+ timestamp: str | None = None,
259
+ ) -> list[dict[str, Any]]:
260
+ hook_event = payload.get("hook_event_name")
261
+ if not isinstance(hook_event, str) or not hook_event:
262
+ raise ValueError("OpenCode payload requires hook_event_name")
263
+ if hook_event not in _SUPPORTED_EVENTS:
264
+ return []
265
+ observed_at = timestamp or _now()
266
+ if hook_event == "chat.message":
267
+ return _chat_message(payload, timestamp=observed_at)
268
+ if hook_event == "tool.execute.before":
269
+ return _before(payload, timestamp=observed_at)
270
+ return _after(payload, timestamp=observed_at)
271
+
272
+
273
+ def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
274
+ output = Path(path).expanduser().resolve()
275
+ output.parent.mkdir(parents=True, exist_ok=True)
276
+ if not records:
277
+ return output
278
+ blob = "".join(
279
+ json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
280
+ for record in records
281
+ )
282
+ lock_dir = output.with_name(output.name + ".lock")
283
+ deadline = time.monotonic() + 5.0
284
+ while True:
285
+ try:
286
+ lock_dir.mkdir()
287
+ break
288
+ except FileExistsError:
289
+ if time.monotonic() >= deadline:
290
+ raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
291
+ time.sleep(0.01)
292
+ try:
293
+ with output.open("a", encoding="utf-8", newline="\n") as handle:
294
+ handle.write(blob)
295
+ handle.flush()
296
+ os.fsync(handle.fileno())
297
+ finally:
298
+ try:
299
+ lock_dir.rmdir()
300
+ except OSError:
301
+ pass
302
+ return output
303
+
304
+
305
+ def read_plugin_payload(stream: Any = None) -> dict[str, Any]:
306
+ source = stream if stream is not None else sys.stdin
307
+ raw = source.read()
308
+ if not isinstance(raw, str) or not raw.strip():
309
+ raise ValueError("OpenCode plugin stdin is empty")
310
+ try:
311
+ payload = json.loads(raw)
312
+ except json.JSONDecodeError as exc:
313
+ raise ValueError(f"OpenCode plugin stdin is invalid JSON: {exc.msg}") from exc
314
+ if not isinstance(payload, dict):
315
+ raise ValueError("OpenCode plugin stdin must be one JSON object")
316
+ return payload
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .opencode_adapter import (
9
+ append_semantic_records,
10
+ opencode_plugin_to_semantic_events,
11
+ read_plugin_payload,
12
+ )
13
+
14
+
15
+ def _default_sidecar(payload: dict) -> Path:
16
+ cwd = payload.get("cwd")
17
+ if not isinstance(cwd, str) or not cwd:
18
+ cwd = str(Path.cwd())
19
+ session_id = payload.get("sessionID")
20
+ if not isinstance(session_id, str) or not session_id:
21
+ raise ValueError("OpenCode payload has no sessionID for sidecar placement")
22
+ safe = "".join(
23
+ character if character.isalnum() or character in {"-", "_", "."} else "_"
24
+ for character in session_id
25
+ )
26
+ return Path(cwd) / ".execweave" / "semantic" / "opencode" / f"{safe}.jsonl"
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="execweave-opencode-hook",
32
+ description="Capture OpenCode plugin telemetry as local ExecWeave semantic events.",
33
+ )
34
+ parser.add_argument("--sidecar", type=Path, default=None)
35
+ parser.add_argument("--strict", action="store_true")
36
+ return parser
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ args = build_parser().parse_args(argv)
41
+ try:
42
+ payload = read_plugin_payload()
43
+ sidecar = args.sidecar
44
+ if sidecar is None:
45
+ configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
46
+ sidecar = Path(configured) if configured else _default_sidecar(payload)
47
+ append_semantic_records(sidecar, opencode_plugin_to_semantic_events(payload))
48
+ except (OSError, TimeoutError, ValueError) as exc:
49
+ print(f"ExecWeave OpenCode hook warning: {exc}", file=sys.stderr)
50
+ if args.strict:
51
+ return 1
52
+ print("{}")
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ _PLUGIN = r'''const safeArgs = (tool, args) => {
7
+ if (!args || typeof args !== "object") return {}
8
+ const safe = {}
9
+ if (tool === "bash" && typeof args.command === "string") {
10
+ safe.command = args.command
11
+ }
12
+ for (const key of ["filePath", "file_path", "path", "cwd", "workdir"]) {
13
+ if (typeof args[key] === "string") safe[key] = args[key]
14
+ }
15
+ return safe
16
+ }
17
+
18
+ export const ExecWeavePlugin = async ({ directory }) => {
19
+ const emit = async (payload) => {
20
+ try {
21
+ const proc = Bun.spawn(["execweave-opencode-hook"], {
22
+ stdin: "pipe",
23
+ stdout: "ignore",
24
+ stderr: "inherit",
25
+ env: process.env,
26
+ })
27
+ proc.stdin.write(JSON.stringify({ ...payload, cwd: directory }))
28
+ proc.stdin.end()
29
+ await proc.exited
30
+ } catch (error) {
31
+ console.error("ExecWeave OpenCode plugin warning:", error)
32
+ }
33
+ }
34
+
35
+ return {
36
+ "chat.message": async (input) => {
37
+ await emit({
38
+ hook_event_name: "chat.message",
39
+ sessionID: input.sessionID,
40
+ agent: input.agent,
41
+ model: input.model,
42
+ messageID: input.messageID,
43
+ })
44
+ },
45
+ "tool.execute.before": async (input, output) => {
46
+ await emit({
47
+ hook_event_name: "tool.execute.before",
48
+ sessionID: input.sessionID,
49
+ callID: input.callID,
50
+ tool: input.tool,
51
+ args: safeArgs(input.tool, output.args),
52
+ })
53
+ },
54
+ "tool.execute.after": async (input) => {
55
+ await emit({
56
+ hook_event_name: "tool.execute.after",
57
+ sessionID: input.sessionID,
58
+ callID: input.callID,
59
+ tool: input.tool,
60
+ args: safeArgs(input.tool, input.args),
61
+ })
62
+ },
63
+ }
64
+ }
65
+ '''
66
+
67
+
68
+ def plugin_text() -> str:
69
+ return _PLUGIN
70
+
71
+
72
+ def install_plugin(root: str | Path, *, force: bool = False) -> Path:
73
+ project = Path(root).expanduser().resolve()
74
+ target = project / ".opencode" / "plugins" / "execweave.ts"
75
+ if target.exists() and target.stat().st_size > 0 and not force:
76
+ raise FileExistsError(f"OpenCode ExecWeave plugin already exists: {target}")
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ target.write_text(plugin_text(), encoding="utf-8", newline="\n")
79
+ return target
80
+
81
+
82
+ def build_parser() -> argparse.ArgumentParser:
83
+ parser = argparse.ArgumentParser(
84
+ prog="execweave-opencode-plugin",
85
+ description="Install or print the local ExecWeave OpenCode telemetry plugin.",
86
+ )
87
+ action = parser.add_mutually_exclusive_group(required=True)
88
+ action.add_argument("--install", action="store_true")
89
+ action.add_argument("--print-plugin", action="store_true")
90
+ parser.add_argument("--root", type=Path, default=Path.cwd())
91
+ parser.add_argument("--force", action="store_true")
92
+ return parser
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ parser = build_parser()
97
+ args = parser.parse_args(argv)
98
+ if args.print_plugin:
99
+ print(plugin_text(), end="")
100
+ return 0
101
+ try:
102
+ target = install_plugin(args.root, force=args.force)
103
+ except (OSError, FileExistsError) as exc:
104
+ parser.error(str(exc))
105
+ print(target)
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ 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
+ OpenCodeRecordResult = ProviderRecordResult
11
+
12
+
13
+ def record_opencode_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
+ ) -> OpenCodeRecordResult:
26
+ return record_provider_to_viewer(
27
+ command,
28
+ provider_name="OpenCode",
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-opencode-record",
51
+ description=(
52
+ "Record runtime evidence, OpenCode plugin 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("an OpenCode command is required, e.g. execweave-opencode-record --open -- opencode")
75
+ watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
76
+ try:
77
+ result = record_opencode_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())