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
execweave/focus.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict, deque
4
+ from copy import deepcopy
5
+ from typing import Any, Iterable, Literal
6
+
7
+ FocusDirection = Literal["both", "in", "out"]
8
+
9
+
10
+ def focus_graph(
11
+ graph: dict[str, Any],
12
+ *,
13
+ anchors: Iterable[str],
14
+ hops: int = 1,
15
+ direction: FocusDirection = "both",
16
+ relations: Iterable[str] = (),
17
+ causal_only: bool = False,
18
+ ) -> dict[str, Any]:
19
+ """Return an evidence-preserving neighborhood around one or more graph nodes.
20
+
21
+ Traversal only follows edges already present in the input graph. The operation
22
+ never creates inferred edges. ``direction`` controls traversal relative to edge
23
+ direction, while the returned payload contains the induced set of eligible edges
24
+ between all selected nodes.
25
+ """
26
+ if hops < 0:
27
+ raise ValueError("hops must be >= 0")
28
+ if direction not in {"both", "in", "out"}:
29
+ raise ValueError("direction must be one of: both, in, out")
30
+
31
+ requested_anchors = list(dict.fromkeys(str(anchor) for anchor in anchors if str(anchor)))
32
+ if not requested_anchors:
33
+ raise ValueError("at least one anchor node ID is required")
34
+
35
+ nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
36
+ edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
37
+ node_by_id = {
38
+ node["id"]: node for node in nodes if isinstance(node.get("id"), str)
39
+ }
40
+ missing = [anchor for anchor in requested_anchors if anchor not in node_by_id]
41
+ if missing:
42
+ raise ValueError(f"anchor node not found: {missing[0]}")
43
+
44
+ requested_relations = set(relations)
45
+ eligible_edges: list[dict[str, Any]] = []
46
+ outgoing: dict[str, list[dict[str, Any]]] = defaultdict(list)
47
+ incoming: dict[str, list[dict[str, Any]]] = defaultdict(list)
48
+
49
+ for edge in edges:
50
+ source = edge.get("source")
51
+ target = edge.get("target")
52
+ relation = edge.get("relation")
53
+ if not isinstance(source, str) or not isinstance(target, str):
54
+ continue
55
+ if source not in node_by_id or target not in node_by_id:
56
+ continue
57
+ if requested_relations and relation not in requested_relations:
58
+ continue
59
+ if causal_only and edge.get("causal") is not True:
60
+ continue
61
+ eligible_edges.append(edge)
62
+ outgoing[source].append(edge)
63
+ incoming[target].append(edge)
64
+
65
+ selected: set[str] = set(requested_anchors)
66
+ queue: deque[tuple[str, int]] = deque((anchor, 0) for anchor in requested_anchors)
67
+ best_depth = {anchor: 0 for anchor in requested_anchors}
68
+
69
+ while queue:
70
+ current, depth = queue.popleft()
71
+ if depth >= hops:
72
+ continue
73
+
74
+ candidates: list[tuple[str, dict[str, Any]]] = []
75
+ if direction in {"both", "out"}:
76
+ candidates.extend((edge["target"], edge) for edge in outgoing.get(current, []))
77
+ if direction in {"both", "in"}:
78
+ candidates.extend((edge["source"], edge) for edge in incoming.get(current, []))
79
+
80
+ for neighbor, _edge in candidates:
81
+ next_depth = depth + 1
82
+ selected.add(neighbor)
83
+ previous = best_depth.get(neighbor)
84
+ if previous is None or next_depth < previous:
85
+ best_depth[neighbor] = next_depth
86
+ queue.append((neighbor, next_depth))
87
+
88
+ focused_nodes = [deepcopy(node) for node in nodes if node.get("id") in selected]
89
+ focused_edges = [
90
+ deepcopy(edge)
91
+ for edge in eligible_edges
92
+ if edge.get("source") in selected and edge.get("target") in selected
93
+ ]
94
+
95
+ payload = deepcopy(graph)
96
+ payload["nodes"] = focused_nodes
97
+ payload["edges"] = focused_edges
98
+ payload["node_count"] = len(focused_nodes)
99
+ payload["edge_count"] = len(focused_edges)
100
+ payload["focus"] = {
101
+ "anchors": requested_anchors,
102
+ "hops": hops,
103
+ "direction": direction,
104
+ "relations": sorted(requested_relations),
105
+ "causal_only": causal_only,
106
+ "source_node_count": len(nodes),
107
+ "source_edge_count": len(edges),
108
+ }
109
+
110
+ expansion = payload.get("expansion")
111
+ if isinstance(expansion, dict) and isinstance(expansion.get("clusters"), dict):
112
+ expansion["clusters"] = {
113
+ cluster_id: value
114
+ for cluster_id, value in expansion["clusters"].items()
115
+ if cluster_id in selected
116
+ }
117
+
118
+ return payload
@@ -0,0 +1,265 @@
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", "BeforeTool", "AfterTool"}
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
19
+
20
+
21
+ def _entity(entity_type: str, entity_id: str, *, name: str | None = None, attributes: dict[str, Any] | None = None) -> dict[str, Any]:
22
+ return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
23
+
24
+
25
+ def _event(*, timestamp: str, event_type: str, relation: str, source: dict[str, Any], target: dict[str, Any], attributes: dict[str, Any] | None = None) -> dict[str, Any]:
26
+ merged = {
27
+ "backend": "semantic",
28
+ "attribution": "gemini_hook",
29
+ "evidence_source": "provider_hook",
30
+ "provider": "gemini",
31
+ "causal": False,
32
+ }
33
+ if attributes:
34
+ merged.update(attributes)
35
+ return {"timestamp": timestamp, "event_type": event_type, "relation": relation, "source": source, "target": target, "attributes": merged}
36
+
37
+
38
+ def _clean_text(value: object, *, limit: int) -> tuple[str | None, bool]:
39
+ if not isinstance(value, str):
40
+ return None, False
41
+ text = value.replace("\x00", "")
42
+ return (text, False) if len(text) <= limit else (text[:limit], True)
43
+
44
+
45
+ def _main_agent() -> dict[str, Any]:
46
+ return _entity("agent", "agent:Gemini CLI", name="Gemini CLI")
47
+
48
+
49
+ def _common_attributes(payload: dict[str, Any]) -> dict[str, Any]:
50
+ result: dict[str, Any] = {}
51
+ for key in ("session_id", "cwd", "original_request_name"):
52
+ value = payload.get(key)
53
+ if isinstance(value, (str, int, float, bool)) and value != "":
54
+ result[f"gemini_{key}"] = value
55
+ return result
56
+
57
+
58
+ def _canonical_tool_input(value: object) -> str:
59
+ if not isinstance(value, dict):
60
+ return "{}"
61
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
62
+
63
+
64
+ def _tool_fingerprint(tool_name: str, tool_input: object) -> str:
65
+ raw = tool_name + "\0" + _canonical_tool_input(tool_input)
66
+ return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()
67
+
68
+
69
+ def _tool_call_entity(payload: dict[str, Any], tool_name: str, *, timestamp: str) -> dict[str, Any]:
70
+ session_id = payload.get("session_id")
71
+ session = session_id if isinstance(session_id, str) and session_id else "unknown"
72
+ fingerprint = _tool_fingerprint(tool_name, payload.get("tool_input"))
73
+ raw = session + "\0" + timestamp + "\0" + fingerprint
74
+ identity = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:24]
75
+ attrs = _common_attributes(payload)
76
+ attrs.update({
77
+ "provider": "gemini",
78
+ "tool_name": tool_name,
79
+ "tool_fingerprint": fingerprint,
80
+ "identity_semantics": "provider_hook_without_unique_tool_call_id",
81
+ })
82
+ tool_input = payload.get("tool_input")
83
+ if isinstance(tool_input, dict):
84
+ attrs["input_keys"] = sorted(str(key) for key in tool_input)
85
+ return _entity("tool_call", f"tool-call:gemini:{session}:{identity}", name=tool_name, attributes=attrs)
86
+
87
+
88
+ def _mcp_identity(payload: dict[str, Any]) -> tuple[str, str] | None:
89
+ context = payload.get("mcp_context")
90
+ if not isinstance(context, dict):
91
+ return None
92
+ server = context.get("server_name")
93
+ tool = context.get("tool_name")
94
+ if not isinstance(server, str) or not server or not isinstance(tool, str) or not tool:
95
+ return None
96
+ return server, tool
97
+
98
+
99
+ def _tool_entity(payload: dict[str, Any], tool_name: str) -> dict[str, Any]:
100
+ mcp = _mcp_identity(payload)
101
+ if mcp is None:
102
+ return _entity("tool", f"tool:gemini:{tool_name}", name=tool_name, attributes={"provider": "gemini", "native_name": tool_name})
103
+ server, tool = mcp
104
+ return _entity("tool", f"tool:mcp:{server}:{tool}", name=tool, attributes={"provider": "gemini", "native_name": tool_name, "mcp_server": server})
105
+
106
+
107
+ def _command_entity(tool_input: dict[str, Any]) -> dict[str, Any] | None:
108
+ command, truncated = _clean_text(tool_input.get("command"), limit=_MAX_COMMAND_CHARS)
109
+ if not command:
110
+ return None
111
+ digest = hashlib.sha256(command.encode("utf-8", errors="replace")).hexdigest()
112
+ label, _ = _clean_text(command.replace("\n", " "), limit=_MAX_LABEL_CHARS)
113
+ return _entity("command", f"command:sha256:{digest}", name=label, attributes={"command": command, "truncated": truncated})
114
+
115
+
116
+ def _declared_file_entity(payload: dict[str, Any], tool_input: dict[str, Any]) -> dict[str, Any] | None:
117
+ raw = tool_input.get("file_path")
118
+ if not isinstance(raw, str) or not raw:
119
+ raw = tool_input.get("path")
120
+ if not isinstance(raw, str) or not raw:
121
+ return None
122
+ candidate = Path(raw).expanduser()
123
+ if not candidate.is_absolute():
124
+ cwd = payload.get("cwd")
125
+ if isinstance(cwd, str) and cwd:
126
+ candidate = Path(cwd) / candidate
127
+ try:
128
+ normalized = candidate.resolve(strict=False)
129
+ except OSError:
130
+ normalized = candidate.absolute()
131
+ return _entity("file", f"file:{normalized}", name=normalized.name or str(normalized), attributes={"declared_by_provider_hook": True, "provider": "gemini"})
132
+
133
+
134
+ def _session_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
135
+ session_id = payload.get("session_id")
136
+ if not isinstance(session_id, str) or not session_id:
137
+ raise ValueError("SessionStart requires session_id")
138
+ attrs = _common_attributes(payload)
139
+ source = payload.get("source")
140
+ if isinstance(source, str) and source:
141
+ attrs["gemini_session_source"] = source
142
+ session = _entity("provider_session", f"provider-session:gemini:{session_id}", name=session_id, attributes={"provider": "gemini"})
143
+ return [_event(timestamp=timestamp, event_type="semantic.gemini.session.started", relation="STARTED_PROVIDER_SESSION", source=_main_agent(), target=session, attributes=attrs)]
144
+
145
+
146
+ def _before_tool_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
147
+ tool_name = payload.get("tool_name")
148
+ if not isinstance(tool_name, str) or not tool_name:
149
+ raise ValueError("BeforeTool requires tool_name")
150
+ call = _tool_call_entity(payload, tool_name, timestamp=timestamp)
151
+ tool = _tool_entity(payload, tool_name)
152
+ common = _common_attributes(payload)
153
+ common["tool_identity_semantics"] = "provider_hook_without_unique_tool_call_id"
154
+ events = [
155
+ _event(timestamp=timestamp, event_type="semantic.gemini.tool.requested", relation="REQUESTED_TOOL_CALL", source=_main_agent(), target=call, attributes=common),
156
+ _event(timestamp=timestamp, event_type="semantic.gemini.tool.selected", relation="USES_TOOL", source=call, target=tool, attributes=common),
157
+ ]
158
+ mcp = _mcp_identity(payload)
159
+ if mcp is not None:
160
+ server, _ = mcp
161
+ mcp_entity = _entity("mcp_server", f"mcp-server:gemini:{server}", name=server, attributes={"provider": "gemini"})
162
+ events.extend([
163
+ _event(timestamp=timestamp, event_type="semantic.gemini.mcp.call", relation="VIA_MCP", source=call, target=mcp_entity, attributes=common),
164
+ _event(timestamp=timestamp, event_type="semantic.gemini.mcp.tool", relation="EXPOSES_TOOL", source=mcp_entity, target=tool, attributes=common),
165
+ ])
166
+ tool_input = payload.get("tool_input")
167
+ if isinstance(tool_input, dict):
168
+ if tool_name == "run_shell_command":
169
+ command = _command_entity(tool_input)
170
+ if command is not None:
171
+ events.append(_event(timestamp=timestamp, event_type="semantic.gemini.command.declared", relation="DECLARED_COMMAND", source=call, target=command, attributes=common))
172
+ if tool_name in {"read_file", "write_file", "replace"}:
173
+ target_file = _declared_file_entity(payload, tool_input)
174
+ if target_file is not None:
175
+ events.append(_event(timestamp=timestamp, event_type="semantic.gemini.file.declared", relation="DECLARED_TARGET", source=call, target=target_file, attributes=common))
176
+ return events
177
+
178
+
179
+ def _after_tool_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
180
+ tool_name = payload.get("tool_name")
181
+ if not isinstance(tool_name, str) or not tool_name:
182
+ raise ValueError("AfterTool requires tool_name")
183
+ fingerprint = _tool_fingerprint(tool_name, payload.get("tool_input"))
184
+ session_id = payload.get("session_id")
185
+ session = session_id if isinstance(session_id, str) and session_id else "unknown"
186
+ raw = session + "\0" + timestamp + "\0" + fingerprint
187
+ result_id = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:24]
188
+ attrs = _common_attributes(payload)
189
+ attrs.update({
190
+ "tool_fingerprint": fingerprint,
191
+ "result_identity_semantics": "provider_hook_without_unique_tool_call_id; no direct BeforeTool linkage asserted",
192
+ })
193
+ response = payload.get("tool_response")
194
+ has_error = False
195
+ if isinstance(response, dict):
196
+ attrs["tool_response_keys"] = sorted(str(key) for key in response)
197
+ error = response.get("error")
198
+ has_error = error not in (None, "", False)
199
+ attrs["provider_reported_error"] = has_error
200
+ if has_error:
201
+ attrs["provider_error_type"] = type(error).__name__
202
+ elif response is not None:
203
+ attrs["tool_response_type"] = type(response).__name__
204
+ result = _entity("tool_result", f"tool-result:gemini:{session}:{result_id}", name=f"{tool_name} result", attributes={"provider": "gemini", "tool_name": tool_name, "tool_fingerprint": fingerprint})
205
+ return [_event(timestamp=timestamp, event_type="semantic.gemini.tool.reported_error" if has_error else "semantic.gemini.tool.returned", relation="TOOL_RESULT_REPORTED_ERROR" if has_error else "TOOL_RESULT_RETURNED", source=_tool_entity(payload, tool_name), target=result, attributes=attrs)]
206
+
207
+
208
+ def gemini_hook_to_semantic_events(payload: dict[str, Any], *, timestamp: str | None = None) -> list[dict[str, Any]]:
209
+ hook_event = payload.get("hook_event_name")
210
+ if not isinstance(hook_event, str) or not hook_event:
211
+ raise ValueError("Gemini hook payload requires hook_event_name")
212
+ if hook_event not in _SUPPORTED_EVENTS:
213
+ return []
214
+ provider_timestamp = payload.get("timestamp")
215
+ observed_at = timestamp or (provider_timestamp if isinstance(provider_timestamp, str) and provider_timestamp else None) or _now()
216
+ if hook_event == "SessionStart":
217
+ return _session_start_events(payload, timestamp=observed_at)
218
+ if hook_event == "BeforeTool":
219
+ return _before_tool_events(payload, timestamp=observed_at)
220
+ if hook_event == "AfterTool":
221
+ return _after_tool_events(payload, timestamp=observed_at)
222
+ return []
223
+
224
+
225
+ def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
226
+ output = Path(path).expanduser().resolve()
227
+ output.parent.mkdir(parents=True, exist_ok=True)
228
+ if not records:
229
+ return output
230
+ blob = "".join(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" for record in records)
231
+ lock_dir = output.with_name(output.name + ".lock")
232
+ deadline = time.monotonic() + 5.0
233
+ while True:
234
+ try:
235
+ lock_dir.mkdir()
236
+ break
237
+ except FileExistsError:
238
+ if time.monotonic() >= deadline:
239
+ raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
240
+ time.sleep(0.01)
241
+ try:
242
+ with output.open("a", encoding="utf-8", newline="\n") as handle:
243
+ handle.write(blob)
244
+ handle.flush()
245
+ os.fsync(handle.fileno())
246
+ finally:
247
+ try:
248
+ lock_dir.rmdir()
249
+ except OSError:
250
+ pass
251
+ return output
252
+
253
+
254
+ def read_hook_payload(stream: Any = None) -> dict[str, Any]:
255
+ source = stream if stream is not None else sys.stdin
256
+ raw = source.read()
257
+ if not isinstance(raw, str) or not raw.strip():
258
+ raise ValueError("Gemini hook stdin is empty")
259
+ try:
260
+ payload = json.loads(raw)
261
+ except json.JSONDecodeError as exc:
262
+ raise ValueError(f"Gemini hook stdin is invalid JSON: {exc.msg}") from exc
263
+ if not isinstance(payload, dict):
264
+ raise ValueError("Gemini hook stdin must be one JSON object")
265
+ return payload
@@ -0,0 +1,77 @@
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 .gemini_adapter import append_semantic_records, gemini_hook_to_semantic_events, read_hook_payload
11
+
12
+
13
+ def _handler(command: str) -> dict[str, Any]:
14
+ return {
15
+ "name": "execweave-gemini-telemetry",
16
+ "type": "command",
17
+ "command": command,
18
+ "timeout": 5000,
19
+ }
20
+
21
+
22
+ def gemini_hook_config(command: str = "execweave-gemini-hook") -> dict[str, Any]:
23
+ handler = _handler(command)
24
+ return {
25
+ "hooks": {
26
+ "SessionStart": [{"hooks": [handler]}],
27
+ "BeforeTool": [{"matcher": ".*", "hooks": [handler]}],
28
+ "AfterTool": [{"matcher": ".*", "hooks": [handler]}],
29
+ }
30
+ }
31
+
32
+
33
+ def _default_sidecar(payload: dict[str, Any]) -> Path:
34
+ cwd = payload.get("cwd")
35
+ session_id = payload.get("session_id")
36
+ if not isinstance(cwd, str) or not cwd:
37
+ raise ValueError("Gemini hook payload has no cwd for automatic sidecar placement")
38
+ if not isinstance(session_id, str) or not session_id:
39
+ raise ValueError("Gemini hook payload has no session_id for automatic sidecar placement")
40
+ safe_session = "".join(character if character.isalnum() or character in {"-", "_", "."} else "_" for character in session_id)
41
+ return Path(cwd) / ".execweave" / "semantic" / "gemini" / f"{safe_session}.jsonl"
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="execweave-gemini-hook",
47
+ description="Capture Gemini CLI hook input as local ExecWeave semantic telemetry.",
48
+ )
49
+ parser.add_argument("--sidecar", type=Path, default=None)
50
+ parser.add_argument("--strict", action="store_true", help="Return non-zero on telemetry errors. Default is fail-open.")
51
+ parser.add_argument("--print-config", action="store_true", help="Print a Gemini CLI settings.json hooks fragment and exit.")
52
+ parser.add_argument("--command", default="execweave-gemini-hook")
53
+ return parser
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ args = build_parser().parse_args(argv)
58
+ if args.print_config:
59
+ print(json.dumps(gemini_hook_config(args.command), indent=2, sort_keys=True))
60
+ return 0
61
+ try:
62
+ payload = read_hook_payload()
63
+ sidecar = args.sidecar
64
+ if sidecar is None:
65
+ configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
66
+ sidecar = Path(configured) if configured else _default_sidecar(payload)
67
+ append_semantic_records(sidecar, gemini_hook_to_semantic_events(payload))
68
+ except (OSError, TimeoutError, ValueError) as exc:
69
+ print(f"ExecWeave Gemini hook warning: {exc}", file=sys.stderr)
70
+ if args.strict:
71
+ return 1
72
+ print("{}")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
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
+ GeminiRecordResult = ProviderRecordResult
11
+
12
+
13
+ def record_gemini_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
+ ) -> GeminiRecordResult:
26
+ """Record one Gemini CLI run using the shared provider-record pipeline."""
27
+ return record_provider_to_viewer(
28
+ command,
29
+ provider_name="Gemini CLI",
30
+ watch_root=watch_root,
31
+ output_dir=output_dir,
32
+ backend=backend,
33
+ poll_interval=poll_interval,
34
+ collect_filesystem=collect_filesystem,
35
+ collect_network=collect_network,
36
+ keep_raw_trace=keep_raw_trace,
37
+ correlation_window_ms=correlation_window_ms,
38
+ open_browser=open_browser,
39
+ )
40
+
41
+
42
+ def _clean_command(command: list[str]) -> list[str]:
43
+ result = list(command)
44
+ if result and result[0] == "--":
45
+ result = result[1:]
46
+ return result
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(
51
+ prog="execweave-gemini-record",
52
+ description="Record runtime evidence, Gemini CLI hook telemetry, and conservative Tool-to-Process correlation in one local run.",
53
+ )
54
+ parser.add_argument("--watch-root", type=Path, default=None)
55
+ parser.add_argument("--output-dir", type=Path, default=None)
56
+ parser.add_argument("--interval", type=float, default=0.10)
57
+ parser.add_argument("--backend", choices=["auto", "portable", "strace"], default="auto")
58
+ parser.add_argument("--correlation-window-ms", type=int, default=3000)
59
+ parser.add_argument("--no-files", action="store_true")
60
+ parser.add_argument("--no-network", action="store_true")
61
+ parser.add_argument("--keep-native-trace", action="store_true")
62
+ parser.add_argument("--open", action="store_true", dest="open_browser")
63
+ parser.add_argument("command", nargs=argparse.REMAINDER)
64
+ return parser
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ parser = build_parser()
69
+ args = parser.parse_args(argv)
70
+ command = _clean_command(args.command)
71
+ if not command:
72
+ parser.error("a Gemini CLI command is required, e.g. execweave-gemini-record --open -- gemini")
73
+ watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
74
+ try:
75
+ result = record_gemini_to_viewer(
76
+ command,
77
+ watch_root=watch_root,
78
+ output_dir=args.output_dir,
79
+ backend=args.backend,
80
+ poll_interval=args.interval,
81
+ collect_filesystem=not args.no_files,
82
+ collect_network=not args.no_network,
83
+ keep_raw_trace=args.keep_native_trace,
84
+ correlation_window_ms=args.correlation_window_ms,
85
+ open_browser=args.open_browser,
86
+ )
87
+ except (FileExistsError, RuntimeError, ValueError, OSError) as exc:
88
+ parser.error(str(exc))
89
+ print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
90
+ return result.runtime.return_code
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())