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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- 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())
|