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/graph_ops.py ADDED
@@ -0,0 +1,446 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections import Counter, defaultdict, deque
6
+ from copy import deepcopy
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Any, Iterable
9
+
10
+
11
+ def load_graph(path: str | Path) -> dict[str, Any]:
12
+ graph_path = Path(path).expanduser().resolve()
13
+ try:
14
+ payload = json.loads(graph_path.read_text(encoding="utf-8"))
15
+ except FileNotFoundError as exc:
16
+ raise ValueError(f"graph does not exist: {graph_path}") from exc
17
+ except json.JSONDecodeError as exc:
18
+ raise ValueError(f"graph is not valid JSON: {exc.msg}") from exc
19
+ if not isinstance(payload, dict):
20
+ raise ValueError("graph root must be a JSON object")
21
+ if not isinstance(payload.get("nodes"), list) or not isinstance(payload.get("edges"), list):
22
+ raise ValueError("graph must contain nodes and edges arrays")
23
+ return payload
24
+
25
+
26
+ def write_graph_payload(graph: dict[str, Any], path: str | Path) -> Path:
27
+ output = Path(path).expanduser().resolve()
28
+ output.parent.mkdir(parents=True, exist_ok=True)
29
+ if output.exists() and output.stat().st_size > 0:
30
+ raise FileExistsError(f"ExecWeave graph output already exists: {output}")
31
+ output.write_text(
32
+ json.dumps(graph, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
33
+ encoding="utf-8",
34
+ )
35
+ return output
36
+
37
+
38
+ def graph_summary(graph: dict[str, Any]) -> dict[str, Any]:
39
+ nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
40
+ edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
41
+ node_types = Counter(
42
+ node.get("type") for node in nodes if isinstance(node.get("type"), str)
43
+ )
44
+ relations = Counter(
45
+ edge.get("relation") for edge in edges if isinstance(edge.get("relation"), str)
46
+ )
47
+ causal_edges = sum(1 for edge in edges if edge.get("causal") is True)
48
+ noncausal_edges = sum(1 for edge in edges if edge.get("causal") is False)
49
+ mixed_edges = len(edges) - causal_edges - noncausal_edges
50
+ expansion = graph.get("expansion")
51
+ expansion_clusters = (
52
+ expansion.get("clusters", {})
53
+ if isinstance(expansion, dict) and isinstance(expansion.get("clusters"), dict)
54
+ else {}
55
+ )
56
+ return {
57
+ "session_id": graph.get("session_id"),
58
+ "event_count": graph.get("event_count"),
59
+ "node_count": len(nodes),
60
+ "edge_count": len(edges),
61
+ "node_types": dict(sorted(node_types.items())),
62
+ "relations": dict(sorted(relations.items())),
63
+ "causal_edges": causal_edges,
64
+ "noncausal_edges": noncausal_edges,
65
+ "mixed_or_unknown_causal_edges": mixed_edges,
66
+ "condensed": bool(graph.get("condensed")),
67
+ "expandable_cluster_count": len(expansion_clusters),
68
+ }
69
+
70
+
71
+ def filter_graph(
72
+ graph: dict[str, Any],
73
+ *,
74
+ node_types: Iterable[str] = (),
75
+ relations: Iterable[str] = (),
76
+ causal_only: bool = False,
77
+ backends: Iterable[str] = (),
78
+ ) -> dict[str, Any]:
79
+ requested_node_types = set(node_types)
80
+ requested_relations = set(relations)
81
+ requested_backends = set(backends)
82
+
83
+ nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
84
+ edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
85
+
86
+ if requested_node_types:
87
+ selected_nodes = {
88
+ node.get("id")
89
+ for node in nodes
90
+ if node.get("type") in requested_node_types and isinstance(node.get("id"), str)
91
+ }
92
+ else:
93
+ selected_nodes = {
94
+ node.get("id") for node in nodes if isinstance(node.get("id"), str)
95
+ }
96
+
97
+ filtered_edges: list[dict[str, Any]] = []
98
+ for edge in edges:
99
+ source = edge.get("source")
100
+ target = edge.get("target")
101
+ if source not in selected_nodes or target not in selected_nodes:
102
+ continue
103
+ if requested_relations and edge.get("relation") not in requested_relations:
104
+ continue
105
+ if causal_only and edge.get("causal") is not True:
106
+ continue
107
+ edge_backends = set(edge.get("backends") or [])
108
+ if requested_backends and not requested_backends.intersection(edge_backends):
109
+ continue
110
+ filtered_edges.append(deepcopy(edge))
111
+
112
+ connected_nodes: set[str] = set()
113
+ for edge in filtered_edges:
114
+ source = edge.get("source")
115
+ target = edge.get("target")
116
+ if isinstance(source, str):
117
+ connected_nodes.add(source)
118
+ if isinstance(target, str):
119
+ connected_nodes.add(target)
120
+
121
+ keep_nodes = selected_nodes if requested_node_types else connected_nodes
122
+ filtered_nodes = [
123
+ deepcopy(node)
124
+ for node in nodes
125
+ if isinstance(node.get("id"), str) and node.get("id") in keep_nodes
126
+ ]
127
+
128
+ payload = deepcopy(graph)
129
+ payload["nodes"] = filtered_nodes
130
+ payload["edges"] = filtered_edges
131
+ payload["node_count"] = len(filtered_nodes)
132
+ payload["edge_count"] = len(filtered_edges)
133
+ payload["filter"] = {
134
+ "node_types": sorted(requested_node_types),
135
+ "relations": sorted(requested_relations),
136
+ "causal_only": causal_only,
137
+ "backends": sorted(requested_backends),
138
+ }
139
+ return payload
140
+
141
+
142
+ def _node_bucket(node: dict[str, Any]) -> str:
143
+ node_id = str(node.get("id") or "")
144
+ prefix, _, value = node_id.partition(":")
145
+ if prefix in {"file", "directory", "executable"} and value:
146
+ canonical = value.replace("\\", "/")
147
+ parent = str(PurePosixPath(canonical).parent)
148
+ return parent if parent != "." else "<relative>"
149
+ return "<unknown>"
150
+
151
+
152
+ def _cluster_id(parts: tuple[object, ...]) -> str:
153
+ raw = json.dumps(parts, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
154
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
155
+ return f"cluster:{digest}"
156
+
157
+
158
+ def condense_graph(
159
+ graph: dict[str, Any],
160
+ *,
161
+ threshold: int = 8,
162
+ sample_size: int = 8,
163
+ collapsible_types: Iterable[str] = ("file", "directory", "executable"),
164
+ include_expansion: bool = False,
165
+ ) -> dict[str, Any]:
166
+ """Collapse repetitive leaf resources while preserving runtime topology.
167
+
168
+ By default the result stays compact. When ``include_expansion`` is true, the
169
+ original member nodes and their incoming evidence edges are preserved under the
170
+ top-level ``expansion.clusters`` map so a viewer can materialize them on demand.
171
+ The expansion payload copies observed evidence; it does not infer new relations.
172
+ """
173
+ if threshold < 2:
174
+ raise ValueError("threshold must be >= 2")
175
+ if sample_size < 1:
176
+ raise ValueError("sample_size must be >= 1")
177
+
178
+ nodes = [deepcopy(node) for node in graph.get("nodes", []) if isinstance(node, dict)]
179
+ edges = [deepcopy(edge) for edge in graph.get("edges", []) if isinstance(edge, dict)]
180
+ node_by_id = {
181
+ node["id"]: node for node in nodes if isinstance(node.get("id"), str)
182
+ }
183
+ incoming: dict[str, list[dict[str, Any]]] = defaultdict(list)
184
+ outgoing: dict[str, list[dict[str, Any]]] = defaultdict(list)
185
+ for edge in edges:
186
+ source = edge.get("source")
187
+ target = edge.get("target")
188
+ if isinstance(source, str):
189
+ outgoing[source].append(edge)
190
+ if isinstance(target, str):
191
+ incoming[target].append(edge)
192
+
193
+ allowed_types = set(collapsible_types)
194
+ groups: dict[tuple[object, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
195
+ for node_id, node in node_by_id.items():
196
+ node_type = node.get("type")
197
+ if node_type not in allowed_types:
198
+ continue
199
+ if outgoing.get(node_id) or len(incoming.get(node_id, [])) != 1:
200
+ continue
201
+ edge = incoming[node_id][0]
202
+ source = edge.get("source")
203
+ relation = edge.get("relation")
204
+ if not isinstance(source, str) or not isinstance(relation, str):
205
+ continue
206
+ key = (
207
+ source,
208
+ relation,
209
+ node_type,
210
+ _node_bucket(node),
211
+ edge.get("causal"),
212
+ tuple(sorted(str(item) for item in (edge.get("backends") or []))),
213
+ )
214
+ groups[key].append((node, edge))
215
+
216
+ collapsed_node_ids: set[str] = set()
217
+ collapsed_edge_ids: set[str] = set()
218
+ cluster_nodes: list[dict[str, Any]] = []
219
+ cluster_edges: list[dict[str, Any]] = []
220
+ expansion_clusters: dict[str, dict[str, Any]] = {}
221
+ collapsed_groups = 0
222
+
223
+ for key, members in groups.items():
224
+ if len(members) < threshold:
225
+ continue
226
+ collapsed_groups += 1
227
+ source, relation, node_type, bucket, causal, backends = key
228
+ cluster_id = _cluster_id(key)
229
+ member_nodes = [node for node, _ in members]
230
+ member_edges = [edge for _, edge in members]
231
+ collapsed_node_ids.update(
232
+ node["id"] for node in member_nodes if isinstance(node.get("id"), str)
233
+ )
234
+ collapsed_edge_ids.update(
235
+ edge["id"] for edge in member_edges if isinstance(edge.get("id"), str)
236
+ )
237
+
238
+ names = [
239
+ str(node.get("name") or node.get("id"))
240
+ for node in member_nodes
241
+ if node.get("name") or node.get("id")
242
+ ]
243
+ first_seen_values = [
244
+ node.get("first_seen") for node in member_nodes if isinstance(node.get("first_seen"), str)
245
+ ]
246
+ last_seen_values = [
247
+ node.get("last_seen") for node in member_nodes if isinstance(node.get("last_seen"), str)
248
+ ]
249
+ first_seq_values = [
250
+ edge.get("first_sequence")
251
+ for edge in member_edges
252
+ if isinstance(edge.get("first_sequence"), int)
253
+ ]
254
+ last_seq_values = [
255
+ edge.get("last_sequence")
256
+ for edge in member_edges
257
+ if isinstance(edge.get("last_sequence"), int)
258
+ ]
259
+ event_ids = [
260
+ str(event_id)
261
+ for edge in member_edges
262
+ for event_id in (edge.get("event_ids") or [])
263
+ if isinstance(event_id, str)
264
+ ]
265
+ event_types = sorted(
266
+ {
267
+ str(event_type)
268
+ for edge in member_edges
269
+ for event_type in (edge.get("event_types") or [])
270
+ if isinstance(event_type, str)
271
+ }
272
+ )
273
+ attributions = sorted(
274
+ {
275
+ str(value)
276
+ for edge in member_edges
277
+ for value in (edge.get("attributions") or [])
278
+ if isinstance(value, str)
279
+ }
280
+ )
281
+
282
+ cluster_nodes.append(
283
+ {
284
+ "id": cluster_id,
285
+ "type": f"{node_type}_cluster",
286
+ "name": f"{len(member_nodes)} {node_type}s in {bucket}",
287
+ "attributes": {
288
+ "collapsed": True,
289
+ "member_count": len(member_nodes),
290
+ "member_type": node_type,
291
+ "directory_bucket": bucket,
292
+ "sample_members": names[:sample_size],
293
+ "sample_truncated": len(names) > sample_size,
294
+ "expandable": include_expansion,
295
+ },
296
+ "first_seen": min(first_seen_values) if first_seen_values else None,
297
+ "last_seen": max(last_seen_values) if last_seen_values else None,
298
+ "event_count": sum(
299
+ int(node.get("event_count") or 0) for node in member_nodes
300
+ ),
301
+ "event_types": event_types,
302
+ }
303
+ )
304
+ cluster_edge_id = f"{source}--{relation}-->{cluster_id}"
305
+ cluster_edges.append(
306
+ {
307
+ "id": cluster_edge_id,
308
+ "source": source,
309
+ "target": cluster_id,
310
+ "relation": relation,
311
+ "count": sum(int(edge.get("count") or 0) for edge in member_edges),
312
+ "first_seen": min(first_seen_values) if first_seen_values else None,
313
+ "last_seen": max(last_seen_values) if last_seen_values else None,
314
+ "first_sequence": min(first_seq_values) if first_seq_values else None,
315
+ "last_sequence": max(last_seq_values) if last_seq_values else None,
316
+ "event_ids": event_ids[:32],
317
+ "event_ids_truncated": len(event_ids) > 32,
318
+ "evidence_event_count": len(event_ids),
319
+ "event_types": event_types,
320
+ "backends": list(backends),
321
+ "attributions": attributions,
322
+ "causal": causal,
323
+ "collapsed_member_count": len(member_nodes),
324
+ }
325
+ )
326
+ if include_expansion:
327
+ expansion_clusters[cluster_id] = {
328
+ "cluster_node_id": cluster_id,
329
+ "cluster_edge_id": cluster_edge_id,
330
+ "nodes": deepcopy(member_nodes),
331
+ "edges": deepcopy(member_edges),
332
+ }
333
+
334
+ condensed_nodes = [
335
+ node for node in nodes if node.get("id") not in collapsed_node_ids
336
+ ] + cluster_nodes
337
+ condensed_edges = [
338
+ edge for edge in edges if edge.get("id") not in collapsed_edge_ids
339
+ ] + cluster_edges
340
+
341
+ payload = deepcopy(graph)
342
+ payload["nodes"] = sorted(
343
+ condensed_nodes,
344
+ key=lambda node: (str(node.get("type") or ""), str(node.get("id") or "")),
345
+ )
346
+ payload["edges"] = sorted(
347
+ condensed_edges,
348
+ key=lambda edge: (
349
+ str(edge.get("source") or ""),
350
+ str(edge.get("relation") or ""),
351
+ str(edge.get("target") or ""),
352
+ ),
353
+ )
354
+ payload["node_count"] = len(payload["nodes"])
355
+ payload["edge_count"] = len(payload["edges"])
356
+ payload["condensed"] = True
357
+ payload["condensation"] = {
358
+ "threshold": threshold,
359
+ "sample_size": sample_size,
360
+ "collapsible_types": sorted(allowed_types),
361
+ "original_node_count": len(nodes),
362
+ "original_edge_count": len(edges),
363
+ "collapsed_group_count": collapsed_groups,
364
+ "collapsed_node_count": len(collapsed_node_ids),
365
+ "result_node_count": len(payload["nodes"]),
366
+ "result_edge_count": len(payload["edges"]),
367
+ "expansion_embedded": include_expansion,
368
+ }
369
+ if include_expansion:
370
+ payload["expansion"] = {
371
+ "schema_version": "0.1",
372
+ "clusters": expansion_clusters,
373
+ }
374
+ else:
375
+ payload.pop("expansion", None)
376
+ return payload
377
+
378
+
379
+ def find_paths(
380
+ graph: dict[str, Any],
381
+ *,
382
+ source: str,
383
+ target: str,
384
+ max_depth: int = 6,
385
+ max_paths: int = 20,
386
+ relations: Iterable[str] = (),
387
+ causal_only: bool = False,
388
+ ) -> list[dict[str, Any]]:
389
+ if max_depth < 1:
390
+ raise ValueError("max_depth must be >= 1")
391
+ if max_paths < 1:
392
+ raise ValueError("max_paths must be >= 1")
393
+
394
+ nodes = {
395
+ node.get("id"): node
396
+ for node in graph.get("nodes", [])
397
+ if isinstance(node, dict) and isinstance(node.get("id"), str)
398
+ }
399
+ if source not in nodes:
400
+ raise ValueError(f"source node not found: {source}")
401
+ if target not in nodes:
402
+ raise ValueError(f"target node not found: {target}")
403
+
404
+ requested_relations = set(relations)
405
+ adjacency: dict[str, list[dict[str, Any]]] = {}
406
+ for edge in graph.get("edges", []):
407
+ if not isinstance(edge, dict):
408
+ continue
409
+ if requested_relations and edge.get("relation") not in requested_relations:
410
+ continue
411
+ if causal_only and edge.get("causal") is not True:
412
+ continue
413
+ edge_source = edge.get("source")
414
+ edge_target = edge.get("target")
415
+ if not isinstance(edge_source, str) or not isinstance(edge_target, str):
416
+ continue
417
+ adjacency.setdefault(edge_source, []).append(edge)
418
+
419
+ queue: deque[tuple[str, list[str], list[dict[str, Any]]]] = deque()
420
+ queue.append((source, [source], []))
421
+ results: list[dict[str, Any]] = []
422
+
423
+ while queue and len(results) < max_paths:
424
+ current, node_path, edge_path = queue.popleft()
425
+ if len(edge_path) >= max_depth:
426
+ continue
427
+ for edge in adjacency.get(current, []):
428
+ next_node = edge["target"]
429
+ if next_node in node_path:
430
+ continue
431
+ next_nodes = [*node_path, next_node]
432
+ next_edges = [*edge_path, edge]
433
+ if next_node == target:
434
+ results.append(
435
+ {
436
+ "nodes": next_nodes,
437
+ "relations": [item.get("relation") for item in next_edges],
438
+ "edges": deepcopy(next_edges),
439
+ }
440
+ )
441
+ if len(results) >= max_paths:
442
+ break
443
+ continue
444
+ queue.append((next_node, next_nodes, next_edges))
445
+
446
+ return results