aicortexpy 0.1.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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/sandbox.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Sandbox / isolation strategy for agent subprocesses.
|
|
2
|
+
|
|
3
|
+
Layers (defense in depth):
|
|
4
|
+
1. Provider-native — Codex config.toml / Claude permission+sandbox flags
|
|
5
|
+
2. Optional OS wrapper — Linux bubblewrap, macOS sandbox-exec
|
|
6
|
+
3. Windows — no bwrap/Seatbelt; rely on (1) + workdir confinement for MVP
|
|
7
|
+
|
|
8
|
+
This mirrors Go execenv/codex_sandbox.go policy, including the macOS
|
|
9
|
+
Seatbelt+network_access caveat (openai/codex#10390).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .platform_info import (
|
|
22
|
+
env_truthy,
|
|
23
|
+
has_bubblewrap,
|
|
24
|
+
has_sandbox_exec,
|
|
25
|
+
host_os,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("aicortexpy.sandbox")
|
|
29
|
+
|
|
30
|
+
# Empty = treat macOS Codex network_access as broken (same as Go constant).
|
|
31
|
+
CODEX_DARWIN_NETWORK_ACCESS_FIXED_VERSION = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CodexSandboxPolicy:
|
|
36
|
+
mode: str # workspace-write | danger-full-access | read-only
|
|
37
|
+
network_access: bool
|
|
38
|
+
reason: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SandboxPlan:
|
|
43
|
+
"""How to launch one agent command."""
|
|
44
|
+
|
|
45
|
+
provider: str
|
|
46
|
+
work_dir: Path
|
|
47
|
+
policy_label: str
|
|
48
|
+
env_updates: dict[str, str]
|
|
49
|
+
# If set, replace argv with wrapper + original argv
|
|
50
|
+
wrap_argv: list[str] | None = None
|
|
51
|
+
notes: list[str] | None = None
|
|
52
|
+
# Temp files to delete after run
|
|
53
|
+
cleanup_paths: list[Path] | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def codex_sandbox_policy(detected_version: str = "") -> CodexSandboxPolicy:
|
|
57
|
+
os_name = host_os()
|
|
58
|
+
if os_name != "macos":
|
|
59
|
+
return CodexSandboxPolicy(
|
|
60
|
+
mode="workspace-write",
|
|
61
|
+
network_access=True,
|
|
62
|
+
reason="non-darwin — Landlock/workspace-write OK",
|
|
63
|
+
)
|
|
64
|
+
# darwin: network_access ignored under Seatbelt until upstream fix
|
|
65
|
+
if CODEX_DARWIN_NETWORK_ACCESS_FIXED_VERSION and detected_version:
|
|
66
|
+
# semver compare omitted in MVP — constant empty means always broken
|
|
67
|
+
pass
|
|
68
|
+
return CodexSandboxPolicy(
|
|
69
|
+
mode="danger-full-access",
|
|
70
|
+
network_access=False,
|
|
71
|
+
reason="macos Seatbelt ignores network_access (openai/codex#10390); "
|
|
72
|
+
"use danger-full-access so API calls work",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_codex_sandbox_config(codex_home: Path, policy: CodexSandboxPolicy) -> Path:
|
|
77
|
+
"""Write minimal config.toml sandbox block into per-task CODEX_HOME."""
|
|
78
|
+
codex_home.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
cfg = codex_home / "config.toml"
|
|
80
|
+
block = (
|
|
81
|
+
"# managed-by: aicortex-python-daemon\n"
|
|
82
|
+
f'sandbox_mode = "{policy.mode}"\n'
|
|
83
|
+
)
|
|
84
|
+
if policy.mode == "workspace-write":
|
|
85
|
+
net = "true" if policy.network_access else "false"
|
|
86
|
+
block += (
|
|
87
|
+
"\n[sandbox_workspace_write]\n"
|
|
88
|
+
f"network_access = {net}\n"
|
|
89
|
+
)
|
|
90
|
+
# Preserve any existing user content below a marker if file already exists.
|
|
91
|
+
existing = ""
|
|
92
|
+
if cfg.is_file():
|
|
93
|
+
text = cfg.read_text(encoding="utf-8")
|
|
94
|
+
marker = "# managed-by: aicortex-python-daemon"
|
|
95
|
+
if marker in text:
|
|
96
|
+
# rewrite managed header only
|
|
97
|
+
rest = text.split(marker, 1)[1]
|
|
98
|
+
# drop until blank line after our block — keep simple: replace whole managed prefix
|
|
99
|
+
existing = ""
|
|
100
|
+
# Keep lines after first blank following old managed block is complex; overwrite.
|
|
101
|
+
_ = rest
|
|
102
|
+
else:
|
|
103
|
+
existing = "\n" + text
|
|
104
|
+
cfg.write_text(block + existing, encoding="utf-8")
|
|
105
|
+
log.info("codex sandbox config written mode=%s reason=%s", policy.mode, policy.reason)
|
|
106
|
+
return cfg
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_provider_sandbox(
|
|
110
|
+
provider: str,
|
|
111
|
+
work_dir: Path,
|
|
112
|
+
*,
|
|
113
|
+
codex_version: str = "",
|
|
114
|
+
) -> SandboxPlan:
|
|
115
|
+
"""Prepare env / config for a provider before spawn."""
|
|
116
|
+
notes: list[str] = []
|
|
117
|
+
env_updates: dict[str, str] = {}
|
|
118
|
+
cleanup: list[Path] = []
|
|
119
|
+
policy_label = "none"
|
|
120
|
+
wrap: list[str] | None = None
|
|
121
|
+
|
|
122
|
+
if provider == "codex":
|
|
123
|
+
policy = codex_sandbox_policy(codex_version)
|
|
124
|
+
policy_label = f"codex:{policy.mode}"
|
|
125
|
+
notes.append(policy.reason)
|
|
126
|
+
codex_home = work_dir.parent / "codex-home"
|
|
127
|
+
write_codex_sandbox_config(codex_home, policy)
|
|
128
|
+
env_updates["CODEX_HOME"] = str(codex_home)
|
|
129
|
+
# Seed auth/config from user home when present (read-only link prefer)
|
|
130
|
+
user_codex = Path.home() / ".codex"
|
|
131
|
+
for name in ("auth.json",):
|
|
132
|
+
src = user_codex / name
|
|
133
|
+
dst = codex_home / name
|
|
134
|
+
if src.is_file() and not dst.exists():
|
|
135
|
+
try:
|
|
136
|
+
shutil.copy2(src, dst)
|
|
137
|
+
except OSError as e:
|
|
138
|
+
notes.append(f"copy {name} failed: {e}")
|
|
139
|
+
|
|
140
|
+
elif provider == "claude":
|
|
141
|
+
policy_label = "claude:permission-mode"
|
|
142
|
+
notes.append(
|
|
143
|
+
"Claude OS sandbox available on macOS/Linux/WSL2 via Claude settings; "
|
|
144
|
+
"Windows native sandbox not available — permission mode only."
|
|
145
|
+
)
|
|
146
|
+
# Encourage workspace-scoped behavior; actual Seatbelt/bwrap is inside Claude.
|
|
147
|
+
env_updates.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "0")
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
policy_label = f"{provider}:workdir-only"
|
|
151
|
+
notes.append("No provider-native sandbox wired; confine cwd to work_dir.")
|
|
152
|
+
|
|
153
|
+
# Optional OS wrapper (off by default — enable aicortex_OS_SANDBOX=1)
|
|
154
|
+
if env_truthy("aicortex_OS_SANDBOX", False):
|
|
155
|
+
wrap, wrap_notes, wrap_cleanup = build_os_wrapper(work_dir)
|
|
156
|
+
notes.extend(wrap_notes)
|
|
157
|
+
cleanup.extend(wrap_cleanup)
|
|
158
|
+
if wrap:
|
|
159
|
+
policy_label = f"{policy_label}+os-wrapper"
|
|
160
|
+
|
|
161
|
+
return SandboxPlan(
|
|
162
|
+
provider=provider,
|
|
163
|
+
work_dir=work_dir,
|
|
164
|
+
policy_label=policy_label,
|
|
165
|
+
env_updates=env_updates,
|
|
166
|
+
wrap_argv=wrap,
|
|
167
|
+
notes=notes,
|
|
168
|
+
cleanup_paths=cleanup,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_os_wrapper(work_dir: Path) -> tuple[list[str] | None, list[str], list[Path]]:
|
|
173
|
+
"""Return argv prefix to wrap the agent, or None if unavailable."""
|
|
174
|
+
notes: list[str] = []
|
|
175
|
+
cleanup: list[Path] = []
|
|
176
|
+
os_name = host_os()
|
|
177
|
+
root = str(work_dir.resolve())
|
|
178
|
+
|
|
179
|
+
if os_name == "linux" and has_bubblewrap():
|
|
180
|
+
# Minimal bwrap: allow read host, write only work_dir (+ /tmp)
|
|
181
|
+
wrap = [
|
|
182
|
+
"bwrap",
|
|
183
|
+
"--die-with-parent",
|
|
184
|
+
"--unshare-pid",
|
|
185
|
+
"--ro-bind", "/", "/",
|
|
186
|
+
"--dev", "/dev",
|
|
187
|
+
"--proc", "/proc",
|
|
188
|
+
"--tmpfs", "/tmp",
|
|
189
|
+
"--bind", root, root,
|
|
190
|
+
"--chdir", root,
|
|
191
|
+
"--",
|
|
192
|
+
]
|
|
193
|
+
notes.append("linux bubblewrap wrapper enabled (aicortex_OS_SANDBOX=1)")
|
|
194
|
+
return wrap, notes, cleanup
|
|
195
|
+
|
|
196
|
+
if os_name == "macos" and has_sandbox_exec():
|
|
197
|
+
profile = _macos_seatbelt_profile(root)
|
|
198
|
+
fd, path = tempfile.mkstemp(prefix="aicortex-sb-", suffix=".sb")
|
|
199
|
+
os.close(fd)
|
|
200
|
+
Path(path).write_text(profile, encoding="utf-8")
|
|
201
|
+
cleanup.append(Path(path))
|
|
202
|
+
wrap = ["sandbox-exec", "-f", path]
|
|
203
|
+
notes.append("macos sandbox-exec wrapper enabled")
|
|
204
|
+
return wrap, notes, cleanup
|
|
205
|
+
|
|
206
|
+
if os_name == "windows":
|
|
207
|
+
notes.append(
|
|
208
|
+
"Windows OS wrapper not implemented (no bwrap/Seatbelt). "
|
|
209
|
+
"Use provider sandbox + workdir confinement; consider WSL2 for stronger isolation."
|
|
210
|
+
)
|
|
211
|
+
return None, notes, cleanup
|
|
212
|
+
|
|
213
|
+
notes.append(f"OS sandbox requested but no backend on {os_name}")
|
|
214
|
+
return None, notes, cleanup
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _macos_seatbelt_profile(work_dir: str) -> str:
|
|
218
|
+
# Very small profile: allow most reads, write only work_dir + tmp + user caches.
|
|
219
|
+
wd = work_dir.replace("\\", "/")
|
|
220
|
+
return f"""
|
|
221
|
+
(version 1)
|
|
222
|
+
(deny default)
|
|
223
|
+
(allow process*)
|
|
224
|
+
(allow sysctl-read)
|
|
225
|
+
(allow mach*)
|
|
226
|
+
(allow file-read*)
|
|
227
|
+
(allow file-write* (subpath "{wd}"))
|
|
228
|
+
(allow file-write* (subpath "/private/tmp"))
|
|
229
|
+
(allow file-write* (subpath "/tmp"))
|
|
230
|
+
(allow file-write* (subpath "/private/var/folders"))
|
|
231
|
+
(allow network*)
|
|
232
|
+
""".strip()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def apply_plan_to_command(plan: SandboxPlan, cmd: list[str]) -> list[str]:
|
|
236
|
+
if plan.wrap_argv:
|
|
237
|
+
return list(plan.wrap_argv) + cmd
|
|
238
|
+
return cmd
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cleanup_plan(plan: SandboxPlan) -> None:
|
|
242
|
+
for p in plan.cleanup_paths or []:
|
|
243
|
+
try:
|
|
244
|
+
p.unlink(missing_ok=True)
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def describe_default_strategy() -> str:
|
|
250
|
+
os_name = host_os()
|
|
251
|
+
lines = [
|
|
252
|
+
f"host_os={os_name}",
|
|
253
|
+
f"bubblewrap={has_bubblewrap()}",
|
|
254
|
+
f"sandbox-exec={has_sandbox_exec()}",
|
|
255
|
+
"layers:",
|
|
256
|
+
" 1) provider-native (Codex config / Claude flags) — default ON",
|
|
257
|
+
" 2) OS wrapper (bwrap / sandbox-exec) — aicortex_OS_SANDBOX=1",
|
|
258
|
+
" 3) Windows: provider + workdir only; prefer WSL2 for OS sandbox",
|
|
259
|
+
]
|
|
260
|
+
if os_name == "macos":
|
|
261
|
+
lines.append(
|
|
262
|
+
"note: Codex on macOS uses danger-full-access until network_access Seatbelt fix"
|
|
263
|
+
)
|
|
264
|
+
return "\n".join(lines)
|
aicortexpy/stream.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Parse agent CLI stdout into daemon task messages + usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("aicortexpy.stream")
|
|
11
|
+
|
|
12
|
+
STREAM_JSON_PROVIDERS = frozenset({"claude", "cursor", "gemini", "opencode", "copilot"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class UsageAcc:
|
|
17
|
+
provider: str
|
|
18
|
+
model: str = ""
|
|
19
|
+
input_tokens: int = 0
|
|
20
|
+
output_tokens: int = 0
|
|
21
|
+
cache_read_tokens: int = 0
|
|
22
|
+
cache_write_tokens: int = 0
|
|
23
|
+
|
|
24
|
+
def as_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"provider": self.provider,
|
|
27
|
+
"model": self.model or "unknown",
|
|
28
|
+
"input_tokens": self.input_tokens,
|
|
29
|
+
"output_tokens": self.output_tokens,
|
|
30
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
31
|
+
"cache_write_tokens": self.cache_write_tokens,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def any(self) -> bool:
|
|
36
|
+
return any(
|
|
37
|
+
(
|
|
38
|
+
self.input_tokens,
|
|
39
|
+
self.output_tokens,
|
|
40
|
+
self.cache_read_tokens,
|
|
41
|
+
self.cache_write_tokens,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class StreamCollector:
|
|
48
|
+
provider: str
|
|
49
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
50
|
+
usage: UsageAcc = field(default_factory=lambda: UsageAcc(provider=""))
|
|
51
|
+
text_parts: list[str] = field(default_factory=list)
|
|
52
|
+
session_id: str = ""
|
|
53
|
+
_seq: int = 0
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
self.usage = UsageAcc(provider=self.provider)
|
|
57
|
+
|
|
58
|
+
def _next_seq(self) -> int:
|
|
59
|
+
self._seq += 1
|
|
60
|
+
return self._seq
|
|
61
|
+
|
|
62
|
+
def add_text(self, content: str) -> None:
|
|
63
|
+
content = (content or "").strip()
|
|
64
|
+
if not content:
|
|
65
|
+
return
|
|
66
|
+
self.text_parts.append(content)
|
|
67
|
+
self.messages.append(
|
|
68
|
+
{
|
|
69
|
+
"seq": self._next_seq(),
|
|
70
|
+
"type": "text",
|
|
71
|
+
"content": content[:50_000],
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def add_tool_use(self, tool: str, input_obj: dict[str, Any] | None = None) -> None:
|
|
76
|
+
msg: dict[str, Any] = {
|
|
77
|
+
"seq": self._next_seq(),
|
|
78
|
+
"type": "tool_use",
|
|
79
|
+
"tool": tool or "tool",
|
|
80
|
+
}
|
|
81
|
+
if input_obj:
|
|
82
|
+
msg["input"] = input_obj
|
|
83
|
+
self.messages.append(msg)
|
|
84
|
+
|
|
85
|
+
def add_tool_result(self, tool: str, output: str) -> None:
|
|
86
|
+
self.messages.append(
|
|
87
|
+
{
|
|
88
|
+
"seq": self._next_seq(),
|
|
89
|
+
"type": "tool_result",
|
|
90
|
+
"tool": tool or "tool",
|
|
91
|
+
"output": (output or "")[:50_000],
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def feed_line(self, line: str) -> None:
|
|
96
|
+
line = line.strip()
|
|
97
|
+
if not line:
|
|
98
|
+
return
|
|
99
|
+
if self.provider == "claude":
|
|
100
|
+
self._feed_claude_json(line)
|
|
101
|
+
elif self.provider == "cursor":
|
|
102
|
+
self._feed_cursor_json(line)
|
|
103
|
+
elif self.provider == "gemini":
|
|
104
|
+
self._feed_gemini_json(line)
|
|
105
|
+
elif self.provider == "opencode":
|
|
106
|
+
self._feed_opencode_json(line)
|
|
107
|
+
elif self.provider == "copilot":
|
|
108
|
+
self._feed_copilot_json(line)
|
|
109
|
+
|
|
110
|
+
def _feed_claude_json(self, line: str) -> None:
|
|
111
|
+
try:
|
|
112
|
+
obj = json.loads(line)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
return
|
|
115
|
+
if not isinstance(obj, dict):
|
|
116
|
+
return
|
|
117
|
+
typ = str(obj.get("type") or "")
|
|
118
|
+
|
|
119
|
+
if typ == "assistant":
|
|
120
|
+
message = obj.get("message") or {}
|
|
121
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
122
|
+
if isinstance(content, list):
|
|
123
|
+
for block in content:
|
|
124
|
+
if not isinstance(block, dict):
|
|
125
|
+
continue
|
|
126
|
+
btype = block.get("type")
|
|
127
|
+
if btype == "text":
|
|
128
|
+
self.add_text(str(block.get("text") or ""))
|
|
129
|
+
elif btype == "tool_use":
|
|
130
|
+
self.add_tool_use(
|
|
131
|
+
str(block.get("name") or "tool"),
|
|
132
|
+
block.get("input")
|
|
133
|
+
if isinstance(block.get("input"), dict)
|
|
134
|
+
else None,
|
|
135
|
+
)
|
|
136
|
+
usage = message.get("usage") if isinstance(message, dict) else None
|
|
137
|
+
self._merge_usage(usage if isinstance(usage, dict) else None, message)
|
|
138
|
+
|
|
139
|
+
elif typ == "result":
|
|
140
|
+
# Final result event often carries usage + result text
|
|
141
|
+
result = obj.get("result")
|
|
142
|
+
if isinstance(result, str) and result.strip():
|
|
143
|
+
self.add_text(result)
|
|
144
|
+
self._merge_usage(obj.get("usage") if isinstance(obj.get("usage"), dict) else None, obj)
|
|
145
|
+
model = obj.get("model")
|
|
146
|
+
if isinstance(model, str) and model:
|
|
147
|
+
self.usage.model = model
|
|
148
|
+
sid = obj.get("session_id")
|
|
149
|
+
if isinstance(sid, str) and sid.strip():
|
|
150
|
+
self.session_id = sid.strip()
|
|
151
|
+
elif typ == "system":
|
|
152
|
+
sid = obj.get("session_id")
|
|
153
|
+
if isinstance(sid, str) and sid.strip():
|
|
154
|
+
self.session_id = sid.strip()
|
|
155
|
+
|
|
156
|
+
elif typ == "user":
|
|
157
|
+
message = obj.get("message") or {}
|
|
158
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
159
|
+
if isinstance(content, list):
|
|
160
|
+
for block in content:
|
|
161
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
162
|
+
self.add_tool_result(
|
|
163
|
+
str(block.get("tool_use_id") or "tool"),
|
|
164
|
+
str(block.get("content") or ""),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _merge_usage(self, usage: dict[str, Any] | None, parent: dict[str, Any] | None = None) -> None:
|
|
168
|
+
if parent and isinstance(parent.get("model"), str):
|
|
169
|
+
self.usage.model = parent["model"] or self.usage.model
|
|
170
|
+
if not usage:
|
|
171
|
+
return
|
|
172
|
+
self.usage.input_tokens += int(usage.get("input_tokens") or 0)
|
|
173
|
+
self.usage.output_tokens += int(usage.get("output_tokens") or 0)
|
|
174
|
+
self.usage.cache_read_tokens += int(
|
|
175
|
+
usage.get("cache_read_input_tokens")
|
|
176
|
+
or usage.get("cache_read_tokens")
|
|
177
|
+
or 0
|
|
178
|
+
)
|
|
179
|
+
self.usage.cache_write_tokens += int(
|
|
180
|
+
usage.get("cache_creation_input_tokens")
|
|
181
|
+
or usage.get("cache_write_tokens")
|
|
182
|
+
or 0
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _feed_cursor_json(self, line: str) -> None:
|
|
186
|
+
try:
|
|
187
|
+
obj = json.loads(line)
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
return
|
|
190
|
+
if not isinstance(obj, dict):
|
|
191
|
+
return
|
|
192
|
+
sid = obj.get("session_id") or obj.get("sessionId")
|
|
193
|
+
if isinstance(sid, str) and sid.strip():
|
|
194
|
+
self.session_id = sid.strip()
|
|
195
|
+
typ = str(obj.get("type") or "")
|
|
196
|
+
|
|
197
|
+
if typ == "assistant":
|
|
198
|
+
message = obj.get("message")
|
|
199
|
+
if isinstance(message, str):
|
|
200
|
+
try:
|
|
201
|
+
message = json.loads(message)
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
message = None
|
|
204
|
+
if isinstance(message, dict):
|
|
205
|
+
content = message.get("content")
|
|
206
|
+
if isinstance(content, list):
|
|
207
|
+
for block in content:
|
|
208
|
+
if not isinstance(block, dict):
|
|
209
|
+
continue
|
|
210
|
+
btype = str(block.get("type") or "")
|
|
211
|
+
if btype in ("text", "output_text"):
|
|
212
|
+
self.add_text(str(block.get("text") or ""))
|
|
213
|
+
elif btype == "tool_use":
|
|
214
|
+
self.add_tool_use(
|
|
215
|
+
str(block.get("name") or "tool"),
|
|
216
|
+
block.get("input")
|
|
217
|
+
if isinstance(block.get("input"), dict)
|
|
218
|
+
else None,
|
|
219
|
+
)
|
|
220
|
+
elif typ == "tool_use":
|
|
221
|
+
params = obj.get("parameters") or obj.get("input")
|
|
222
|
+
if isinstance(params, str):
|
|
223
|
+
try:
|
|
224
|
+
params = json.loads(params)
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
params = {"raw": params}
|
|
227
|
+
self.add_tool_use(
|
|
228
|
+
str(obj.get("tool_name") or obj.get("name") or "tool"),
|
|
229
|
+
params if isinstance(params, dict) else None,
|
|
230
|
+
)
|
|
231
|
+
elif typ == "tool_call":
|
|
232
|
+
title = str(obj.get("title") or obj.get("name") or obj.get("tool") or "tool")
|
|
233
|
+
raw_input = obj.get("rawInput") or obj.get("input") or obj.get("parameters")
|
|
234
|
+
self.add_tool_use(
|
|
235
|
+
title, raw_input if isinstance(raw_input, dict) else None
|
|
236
|
+
)
|
|
237
|
+
elif typ == "tool_result":
|
|
238
|
+
self.add_tool_result(
|
|
239
|
+
str(obj.get("tool_use_id") or obj.get("toolCallId") or "tool"),
|
|
240
|
+
str(obj.get("output") or obj.get("content") or ""),
|
|
241
|
+
)
|
|
242
|
+
elif typ == "text":
|
|
243
|
+
part = obj.get("part")
|
|
244
|
+
if isinstance(part, dict) and part.get("text"):
|
|
245
|
+
self.add_text(str(part["text"]))
|
|
246
|
+
elif obj.get("text"):
|
|
247
|
+
self.add_text(str(obj["text"]))
|
|
248
|
+
elif typ == "result":
|
|
249
|
+
result = obj.get("result") or obj.get("result_text")
|
|
250
|
+
if isinstance(result, str) and result.strip():
|
|
251
|
+
self.add_text(result)
|
|
252
|
+
usage = obj.get("usage")
|
|
253
|
+
if isinstance(usage, dict):
|
|
254
|
+
self._merge_usage(usage, obj)
|
|
255
|
+
model = obj.get("model")
|
|
256
|
+
if isinstance(model, str) and model:
|
|
257
|
+
self.usage.model = model
|
|
258
|
+
|
|
259
|
+
def _feed_gemini_json(self, line: str) -> None:
|
|
260
|
+
try:
|
|
261
|
+
obj = json.loads(line)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
return
|
|
264
|
+
if not isinstance(obj, dict):
|
|
265
|
+
return
|
|
266
|
+
typ = str(obj.get("type") or "")
|
|
267
|
+
if typ == "init":
|
|
268
|
+
sid = obj.get("session_id") or obj.get("sessionId")
|
|
269
|
+
if isinstance(sid, str) and sid.strip():
|
|
270
|
+
self.session_id = sid.strip()
|
|
271
|
+
elif typ == "message":
|
|
272
|
+
if str(obj.get("role") or "") == "assistant" and obj.get("content"):
|
|
273
|
+
self.add_text(str(obj["content"]))
|
|
274
|
+
elif typ == "tool_use":
|
|
275
|
+
params = obj.get("parameters") or obj.get("input")
|
|
276
|
+
if isinstance(params, str):
|
|
277
|
+
try:
|
|
278
|
+
params = json.loads(params)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
params = None
|
|
281
|
+
self.add_tool_use(
|
|
282
|
+
str(obj.get("tool_name") or obj.get("name") or "tool"),
|
|
283
|
+
params if isinstance(params, dict) else None,
|
|
284
|
+
)
|
|
285
|
+
elif typ == "tool_result":
|
|
286
|
+
self.add_tool_result(
|
|
287
|
+
str(obj.get("tool_id") or obj.get("tool_use_id") or "tool"),
|
|
288
|
+
str(obj.get("output") or ""),
|
|
289
|
+
)
|
|
290
|
+
elif typ == "result":
|
|
291
|
+
stats = obj.get("stats")
|
|
292
|
+
if isinstance(stats, dict):
|
|
293
|
+
models = stats.get("models")
|
|
294
|
+
if isinstance(models, dict):
|
|
295
|
+
for model, m in models.items():
|
|
296
|
+
if not isinstance(m, dict):
|
|
297
|
+
continue
|
|
298
|
+
if isinstance(model, str) and model:
|
|
299
|
+
self.usage.model = model
|
|
300
|
+
self.usage.input_tokens += int(m.get("input_tokens") or m.get("inputTokens") or 0)
|
|
301
|
+
self.usage.output_tokens += int(
|
|
302
|
+
m.get("output_tokens") or m.get("outputTokens") or 0
|
|
303
|
+
)
|
|
304
|
+
self.usage.cache_read_tokens += int(m.get("cached") or 0)
|
|
305
|
+
|
|
306
|
+
def _feed_opencode_json(self, line: str) -> None:
|
|
307
|
+
try:
|
|
308
|
+
obj = json.loads(line)
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
return
|
|
311
|
+
if not isinstance(obj, dict):
|
|
312
|
+
return
|
|
313
|
+
sid = obj.get("sessionID") or obj.get("sessionId") or obj.get("session_id")
|
|
314
|
+
if isinstance(sid, str) and sid.strip():
|
|
315
|
+
self.session_id = sid.strip()
|
|
316
|
+
typ = str(obj.get("type") or "")
|
|
317
|
+
part = obj.get("part") if isinstance(obj.get("part"), dict) else {}
|
|
318
|
+
|
|
319
|
+
if typ == "text":
|
|
320
|
+
text = str(part.get("text") or obj.get("text") or "")
|
|
321
|
+
if text:
|
|
322
|
+
self.add_text(text)
|
|
323
|
+
elif typ == "tool_use":
|
|
324
|
+
tool = str(part.get("tool") or obj.get("tool") or "tool")
|
|
325
|
+
call_id = str(part.get("callID") or part.get("call_id") or "")
|
|
326
|
+
state = part.get("state") if isinstance(part.get("state"), dict) else {}
|
|
327
|
+
raw_in = state.get("input")
|
|
328
|
+
inp: dict[str, Any] | None = None
|
|
329
|
+
if isinstance(raw_in, dict):
|
|
330
|
+
inp = raw_in
|
|
331
|
+
elif isinstance(raw_in, str):
|
|
332
|
+
try:
|
|
333
|
+
parsed = json.loads(raw_in)
|
|
334
|
+
inp = parsed if isinstance(parsed, dict) else {"text": raw_in}
|
|
335
|
+
except json.JSONDecodeError:
|
|
336
|
+
inp = {"text": raw_in}
|
|
337
|
+
self.add_tool_use(tool, inp)
|
|
338
|
+
if str(state.get("status") or "") == "completed":
|
|
339
|
+
out = state.get("output")
|
|
340
|
+
self.add_tool_result(call_id or tool, str(out or ""))
|
|
341
|
+
elif typ == "step_finish":
|
|
342
|
+
tokens = part.get("tokens") if isinstance(part.get("tokens"), dict) else None
|
|
343
|
+
if tokens:
|
|
344
|
+
self.usage.input_tokens += int(tokens.get("input") or 0)
|
|
345
|
+
self.usage.output_tokens += int(tokens.get("output") or 0)
|
|
346
|
+
cache = tokens.get("cache") if isinstance(tokens.get("cache"), dict) else {}
|
|
347
|
+
self.usage.cache_read_tokens += int(cache.get("read") or 0)
|
|
348
|
+
self.usage.cache_write_tokens += int(cache.get("write") or 0)
|
|
349
|
+
|
|
350
|
+
def _feed_copilot_json(self, line: str) -> None:
|
|
351
|
+
try:
|
|
352
|
+
obj = json.loads(line)
|
|
353
|
+
except json.JSONDecodeError:
|
|
354
|
+
return
|
|
355
|
+
if not isinstance(obj, dict):
|
|
356
|
+
return
|
|
357
|
+
typ = str(obj.get("type") or "")
|
|
358
|
+
data = obj.get("data") if isinstance(obj.get("data"), dict) else {}
|
|
359
|
+
|
|
360
|
+
if typ == "session.start":
|
|
361
|
+
sid = data.get("sessionId") or data.get("session_id")
|
|
362
|
+
if isinstance(sid, str) and sid.strip():
|
|
363
|
+
self.session_id = sid.strip()
|
|
364
|
+
model = data.get("selectedModel") or data.get("model")
|
|
365
|
+
if isinstance(model, str) and model:
|
|
366
|
+
self.usage.model = model
|
|
367
|
+
elif typ == "assistant.message_delta":
|
|
368
|
+
delta = str(data.get("deltaContent") or data.get("content") or "")
|
|
369
|
+
if delta:
|
|
370
|
+
self.add_text(delta)
|
|
371
|
+
elif typ == "assistant.message":
|
|
372
|
+
content = str(data.get("content") or "")
|
|
373
|
+
if content and not self.text_parts:
|
|
374
|
+
self.add_text(content)
|
|
375
|
+
reasoning = str(data.get("reasoningText") or "")
|
|
376
|
+
if reasoning:
|
|
377
|
+
self.messages.append(
|
|
378
|
+
{"seq": self._next_seq(), "type": "thinking", "content": reasoning[:50_000]}
|
|
379
|
+
)
|
|
380
|
+
reqs = data.get("toolRequests")
|
|
381
|
+
if isinstance(reqs, list):
|
|
382
|
+
for tr in reqs:
|
|
383
|
+
if not isinstance(tr, dict):
|
|
384
|
+
continue
|
|
385
|
+
self.add_tool_use(
|
|
386
|
+
str(tr.get("name") or tr.get("tool") or "tool"),
|
|
387
|
+
tr.get("arguments") if isinstance(tr.get("arguments"), dict) else None,
|
|
388
|
+
)
|
|
389
|
+
out_tok = data.get("outputTokens")
|
|
390
|
+
if out_tok is not None:
|
|
391
|
+
self.usage.output_tokens += int(out_tok or 0)
|
|
392
|
+
elif typ in ("assistant.reasoning", "assistant.reasoning_delta"):
|
|
393
|
+
text = str(data.get("content") or data.get("deltaContent") or "")
|
|
394
|
+
if text:
|
|
395
|
+
self.messages.append(
|
|
396
|
+
{"seq": self._next_seq(), "type": "thinking", "content": text[:50_000]}
|
|
397
|
+
)
|
|
398
|
+
elif typ == "tool.execution_complete":
|
|
399
|
+
call_id = str(data.get("toolCallId") or data.get("tool_call_id") or "tool")
|
|
400
|
+
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
|
401
|
+
out = str(result.get("content") or data.get("error", {}).get("message") or "")
|
|
402
|
+
if not out and isinstance(data.get("error"), dict):
|
|
403
|
+
out = str(data["error"].get("message") or "")
|
|
404
|
+
self.add_tool_result(call_id, out)
|
|
405
|
+
elif typ == "result":
|
|
406
|
+
sid = obj.get("sessionId") or data.get("sessionId")
|
|
407
|
+
if isinstance(sid, str) and sid.strip():
|
|
408
|
+
self.session_id = sid.strip()
|
|
409
|
+
|
|
410
|
+
def finalize_plain_output(self, text: str) -> None:
|
|
411
|
+
"""For non-JSON providers: one text message from full stdout."""
|
|
412
|
+
if self.messages:
|
|
413
|
+
return
|
|
414
|
+
self.add_text(text)
|
|
415
|
+
|
|
416
|
+
def output_text(self) -> str:
|
|
417
|
+
if self.text_parts:
|
|
418
|
+
return "\n".join(self.text_parts)
|
|
419
|
+
return ""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
MessageSink = Callable[[list[dict[str, Any]]], None]
|