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
|
@@ -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())
|