ccx-native 0.0.1 → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
"""Read and search worker histories as text: ``ccx history``.
|
|
2
|
+
|
|
3
|
+
A projection of the normalized conversation document
|
|
4
|
+
(:mod:`ccx.ops.conversation`), never a second parser: every message keeps the
|
|
5
|
+
document's turn ``sequence`` and item ``ordinal``, so an address ``#TURN.N``
|
|
6
|
+
means the same item in ``ccx history``, ``ccx logs`` and ``peek --since-turn``.
|
|
7
|
+
|
|
8
|
+
The projection maps normalized items to a small message model shared by both
|
|
9
|
+
engines: prompts (``U``), steers (``U+``), notifications and events
|
|
10
|
+
(``N[kind]``), assistant text (``A``, final ``A*``, a running turn's latest
|
|
11
|
+
``A…``), tool calls (``T``), slash commands (``/``), interrupts (``!``) and
|
|
12
|
+
compactions (``══``). Reasoning is never shown. Everything here is read-only
|
|
13
|
+
text rendering over documents the caller already read.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Callable, TextIO
|
|
25
|
+
|
|
26
|
+
from ccx.claude.transcript import _parse_task_notification
|
|
27
|
+
from ccx.errors import InvalidRequest
|
|
28
|
+
|
|
29
|
+
# --- message model -------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
ROLES = ("user", "notify", "assistant", "final", "tool", "output", "compact")
|
|
32
|
+
VIEW_ROLES = {"chat": {"user", "notify", "final"},
|
|
33
|
+
"trace": {"user", "notify", "assistant", "tool"}}
|
|
34
|
+
SEARCH_ROLES = {"user", "assistant", "notify", "tool"}
|
|
35
|
+
VIEWS = ("outline", "chat", "trace", "map")
|
|
36
|
+
|
|
37
|
+
DEFAULT_WINDOW = 30 # turns shown with no selector (one worker)
|
|
38
|
+
DEFAULT_MANY_WINDOW = 10 # per worker when several are named
|
|
39
|
+
DEFAULT_LIMIT = 20 # hit lines per worker
|
|
40
|
+
DEFAULT_MAX_CHARS = 3000
|
|
41
|
+
MAP_GAP_HOURS = 6.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Message:
|
|
46
|
+
turn: int
|
|
47
|
+
n: int
|
|
48
|
+
role: str # user steer notify assistant tool compact command interrupt
|
|
49
|
+
text: str = ""
|
|
50
|
+
ts: float | None = None # epoch seconds, when the source records it
|
|
51
|
+
kind: str | None = None # notify kind: bg monitor agent cron system error <namespace>
|
|
52
|
+
final: str | None = None # "final" (A*) or "latest" (A…, turn still running)
|
|
53
|
+
tool: str | None = None
|
|
54
|
+
cmd: str | None = None
|
|
55
|
+
desc: str | None = None
|
|
56
|
+
out: str = "" # tool output, notification body, compaction summary
|
|
57
|
+
out_cut: tuple[int, int] | None = None # (returned, original) bytes when the source bound cut it
|
|
58
|
+
err: bool = False
|
|
59
|
+
dur: float | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Turn:
|
|
64
|
+
seq: int
|
|
65
|
+
start: float | None
|
|
66
|
+
end: float | None
|
|
67
|
+
status: str | None
|
|
68
|
+
messages: list[Message] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class History:
|
|
73
|
+
engine: str
|
|
74
|
+
short: str
|
|
75
|
+
name: str | None
|
|
76
|
+
cwd: str | None
|
|
77
|
+
turns: list[Turn]
|
|
78
|
+
notes: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def messages(self) -> list[Message]:
|
|
81
|
+
return [m for t in self.turns for m in t.messages]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _epoch_iso(value) -> float | None:
|
|
85
|
+
if not isinstance(value, str) or not value:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
89
|
+
tzinfo=dt.timezone.utc).timestamp()
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _epoch_ms(value) -> float | None:
|
|
95
|
+
return value / 1000.0 if isinstance(value, int) and not isinstance(value, bool) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_COMPACT_HEAD = re.compile(r"\AThis session is being continued from a previous conversation.*?\n\n"
|
|
99
|
+
r"(?:<system-reminder>.*?</system-reminder>\s*)?", re.S)
|
|
100
|
+
_COMPACT_TAIL = re.compile(r"\n+If you need specific details from before compaction.*\Z", re.S)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def strip_compact_wrapper(text: str) -> str:
|
|
104
|
+
"""Drop Claude Code's resume boilerplate around a compaction summary."""
|
|
105
|
+
return _COMPACT_TAIL.sub("", _COMPACT_HEAD.sub("", text or "")).strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_XML_TAG = re.compile(r"<(result|event|note)>(.*?)</\1>", re.S)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _notification(text: str) -> tuple[str, str, str] | None:
|
|
112
|
+
"""``(kind, one-line summary, body)`` for a Claude ``<task-notification>``."""
|
|
113
|
+
parsed = _parse_task_notification(text)
|
|
114
|
+
if parsed is None:
|
|
115
|
+
return None
|
|
116
|
+
summary = (parsed.get("summary") or "").strip()
|
|
117
|
+
kind = ("monitor" if summary.startswith("Monitor") or parsed.get("event")
|
|
118
|
+
else "agent" if summary.startswith("Agent") else "bg")
|
|
119
|
+
body = "\n".join(v.strip() for _k, v in _XML_TAG.findall(text) if v.strip())
|
|
120
|
+
return kind, summary or (parsed.get("status") or "") or text[:200], body
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_JSON_FIELD = re.compile(r'"(\w+)"\s*:\s*("(?:[^"\\]|\\.)*("|$)|-?\d+(?:\.\d+)?|true|false|null)')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _json_fields(raw: str) -> dict:
|
|
127
|
+
"""The fields of a JSON object, also from one cut short by a result bound.
|
|
128
|
+
|
|
129
|
+
A cut payload keeps its leading string and number fields; an unterminated
|
|
130
|
+
last string keeps what arrived."""
|
|
131
|
+
try:
|
|
132
|
+
body = json.loads(raw)
|
|
133
|
+
return body if isinstance(body, dict) else {}
|
|
134
|
+
except ValueError:
|
|
135
|
+
pass
|
|
136
|
+
fields: dict = {}
|
|
137
|
+
for key, value, closed in _JSON_FIELD.findall(raw or ""):
|
|
138
|
+
if key in fields:
|
|
139
|
+
continue
|
|
140
|
+
if value.startswith('"') and not closed:
|
|
141
|
+
value = value.rstrip("\\") + '"'
|
|
142
|
+
try:
|
|
143
|
+
fields[key] = json.loads(value)
|
|
144
|
+
except ValueError:
|
|
145
|
+
continue
|
|
146
|
+
return fields
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _injected_output(item: dict) -> str:
|
|
150
|
+
"""The text of a Codex namespaced wake-up output: who, phase, status, report."""
|
|
151
|
+
raw = (item.get("result") or {}).get("content") or ""
|
|
152
|
+
if not isinstance(raw, str):
|
|
153
|
+
raw = json.dumps(raw)
|
|
154
|
+
body = _json_fields(raw)
|
|
155
|
+
if not body:
|
|
156
|
+
return raw
|
|
157
|
+
report = next((str(body[k]) for k in ("report", "message", "summary", "activity")
|
|
158
|
+
if isinstance(body.get(k), str) and body[k]), "")
|
|
159
|
+
who = next((str(body[k]) for k in ("worker", "sessionId", "work_id", "workId")
|
|
160
|
+
if isinstance(body.get(k), str) and body[k]), "")
|
|
161
|
+
status = body.get("status") or body.get("state")
|
|
162
|
+
head = " ".join(str(x) for x in (who, body.get("phase"), status) if x)
|
|
163
|
+
return f"{head}: {report}" if head else report
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _shell_command(item: dict) -> str:
|
|
167
|
+
"""The command a shell call ran; Codex already unwraps ``bash -lc``."""
|
|
168
|
+
actions = ((item.get("data") or {}).get("commandActions")) or []
|
|
169
|
+
commands = [a.get("command") for a in actions
|
|
170
|
+
if isinstance(a, dict) and isinstance(a.get("command"), str) and a["command"]]
|
|
171
|
+
if commands:
|
|
172
|
+
return "\n".join(commands)
|
|
173
|
+
command = (item.get("arguments") or {}).get("command")
|
|
174
|
+
if isinstance(command, list):
|
|
175
|
+
command = command[-1] if len(command) == 3 else " ".join(map(str, command))
|
|
176
|
+
command = command if isinstance(command, str) else ""
|
|
177
|
+
m = re.match(r"^\S*(?:bash|sh|zsh) -l?c '(.*)'$", command, re.S)
|
|
178
|
+
return m.group(1).replace("'\\''", "'") if m else command
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _tool_message(item: dict, base: dict) -> Message:
|
|
182
|
+
name = item.get("name") or item.get("nativeType") or "tool"
|
|
183
|
+
summary = item.get("summary") if isinstance(item.get("summary"), dict) else {}
|
|
184
|
+
if name in ("Bash", "shell"):
|
|
185
|
+
cmd = _shell_command(item)
|
|
186
|
+
elif summary.get("target"):
|
|
187
|
+
cmd = summary["target"]
|
|
188
|
+
else:
|
|
189
|
+
args = item.get("arguments")
|
|
190
|
+
cmd = json.dumps(args, ensure_ascii=False)[:300] if args else ""
|
|
191
|
+
result = item.get("result") if isinstance(item.get("result"), dict) else {}
|
|
192
|
+
started, completed = item.get("startedAtMs"), item.get("completedAtMs")
|
|
193
|
+
dur = ((completed - started) / 1000.0 if isinstance(started, int)
|
|
194
|
+
and isinstance(completed, int) and completed >= started else None)
|
|
195
|
+
out = result.get("content")
|
|
196
|
+
out_cut = ((result.get("returnedByteCount") or 0, result.get("originalByteCount") or 0)
|
|
197
|
+
if result.get("truncated") else None)
|
|
198
|
+
return Message(**base, role="tool", tool=name, cmd=cmd, desc=summary.get("description"),
|
|
199
|
+
out=out if isinstance(out, str) else ("" if out is None else json.dumps(out)),
|
|
200
|
+
out_cut=out_cut,
|
|
201
|
+
err=bool(result.get("isError")) or item.get("status") in ("failed", "declined"),
|
|
202
|
+
dur=dur)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _project_item(item: dict, base: dict, cron: dict | None) -> Message | None:
|
|
206
|
+
kind, native_type = item.get("kind"), item.get("nativeType")
|
|
207
|
+
text = item.get("text") if isinstance(item.get("text"), str) else ""
|
|
208
|
+
if kind == "user-text":
|
|
209
|
+
if cron is not None:
|
|
210
|
+
return Message(**base, role="notify", kind="cron", text=text)
|
|
211
|
+
if item.get("meta"):
|
|
212
|
+
if text.lstrip().startswith("<local-command-caveat>"):
|
|
213
|
+
return None
|
|
214
|
+
return Message(**base, role="notify", kind="system", text=text)
|
|
215
|
+
note = _notification(text)
|
|
216
|
+
if note is not None:
|
|
217
|
+
k, summary, body = note
|
|
218
|
+
return Message(**base, role="notify", kind=k, text=summary, out=body)
|
|
219
|
+
if text.startswith("[Request interrupted by user"):
|
|
220
|
+
return Message(**base, role="interrupt", text=text)
|
|
221
|
+
if text.lstrip().startswith(("<local-command-stdout>", "<local-command-stderr>")):
|
|
222
|
+
return None
|
|
223
|
+
return Message(**base, role="user", text=text)
|
|
224
|
+
if kind == "command":
|
|
225
|
+
args = (item.get("arguments") or {}).get("args")
|
|
226
|
+
return Message(**base, role="command",
|
|
227
|
+
text=f"{item.get('name') or ''}{' ' + args if args else ''}")
|
|
228
|
+
if kind in ("assistant-text", "assistant-plan"):
|
|
229
|
+
return Message(**base, role="assistant", text=text, kind=item.get("phase"))
|
|
230
|
+
if kind == "tool-result" and item.get("namespace"):
|
|
231
|
+
return Message(**base, role="notify", kind=item["namespace"], text=_injected_output(item))
|
|
232
|
+
if kind in ("tool-call", "tool-result", "file-change", "subagent-action",
|
|
233
|
+
"subagent-activity", "action"):
|
|
234
|
+
return _tool_message(item, base)
|
|
235
|
+
if kind == "system-text" and native_type in ("compact_boundary", "contextCompaction"):
|
|
236
|
+
data = item.get("data") if isinstance(item.get("data"), dict) else {}
|
|
237
|
+
bits = [b for b in (data.get("trigger"),
|
|
238
|
+
f"{data['preTokens'] // 1000}k tokens"
|
|
239
|
+
if isinstance(data.get("preTokens"), int) else None) if b]
|
|
240
|
+
summary = strip_compact_wrapper(data.get("summary") or "")
|
|
241
|
+
return Message(**base, role="compact", text=" · ".join(bits), out=summary)
|
|
242
|
+
if kind == "system-text":
|
|
243
|
+
return Message(**base, role="notify", kind="system", text=text)
|
|
244
|
+
if kind == "error":
|
|
245
|
+
return Message(**base, role="notify", kind="error", text=str(item.get("message") or ""))
|
|
246
|
+
if kind == "other" and native_type == "system":
|
|
247
|
+
native = item.get("native") if isinstance(item.get("native"), dict) else {}
|
|
248
|
+
if native.get("subtype") == "informational":
|
|
249
|
+
return Message(**base, role="notify", kind="system",
|
|
250
|
+
text=str(native.get("content") or ""))
|
|
251
|
+
return None # attachments, reasoning summaries, bookkeeping: not shown
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def project(doc: dict, *, name: str | None = None) -> History:
|
|
255
|
+
"""Map one normalized document to the history message model."""
|
|
256
|
+
worker = doc.get("worker") or {}
|
|
257
|
+
busy = doc.get("state") == "busy"
|
|
258
|
+
turns: list[Turn] = []
|
|
259
|
+
cron: dict | None = None
|
|
260
|
+
for t in doc.get("turns") or []:
|
|
261
|
+
seq = t.get("sequence")
|
|
262
|
+
turn = Turn(seq, _epoch_iso(t.get("startedAt")), _epoch_iso(t.get("completedAt")),
|
|
263
|
+
t.get("status"))
|
|
264
|
+
for item in sorted(t.get("input", []) + t.get("output", []),
|
|
265
|
+
key=lambda i: i.get("ordinal") or 0):
|
|
266
|
+
native = item.get("native") if isinstance(item.get("native"), dict) else {}
|
|
267
|
+
if item.get("kind") == "other" and native.get("subtype") == "scheduled_task_fire":
|
|
268
|
+
cron = native # its prompt is the next user row
|
|
269
|
+
continue
|
|
270
|
+
base = {"turn": seq, "n": item.get("ordinal"), "ts": _epoch_ms(item.get("startedAtMs"))}
|
|
271
|
+
message = _project_item(item, base, cron)
|
|
272
|
+
if item.get("kind") == "user-text":
|
|
273
|
+
cron = None
|
|
274
|
+
if message is not None:
|
|
275
|
+
turn.messages.append(message)
|
|
276
|
+
_label_turn(turn, worker.get("engine"))
|
|
277
|
+
turns.append(turn)
|
|
278
|
+
if turns and (busy or turns[-1].status == "in-progress"):
|
|
279
|
+
for m in reversed(turns[-1].messages):
|
|
280
|
+
if m.role == "assistant":
|
|
281
|
+
for other in turns[-1].messages:
|
|
282
|
+
if other.final:
|
|
283
|
+
other.final = None
|
|
284
|
+
m.final = "latest"
|
|
285
|
+
break
|
|
286
|
+
completeness = doc.get("completeness") or {}
|
|
287
|
+
notes = list(completeness.get("warnings") or []) if completeness.get("status") in (
|
|
288
|
+
"partial", "source-limited") else []
|
|
289
|
+
return History(engine=worker.get("engine") or "?", short=worker.get("short") or "?",
|
|
290
|
+
name=name, cwd=worker.get("cwd"), turns=turns, notes=notes)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _label_turn(turn: Turn, engine: str | None) -> None:
|
|
294
|
+
"""Prompt vs steer, and the final answer, from each turn's own messages."""
|
|
295
|
+
prompted = False
|
|
296
|
+
for m in turn.messages:
|
|
297
|
+
if m.role in ("user", "notify", "command"):
|
|
298
|
+
if prompted and m.role == "user":
|
|
299
|
+
m.role = "steer"
|
|
300
|
+
prompted = True
|
|
301
|
+
texts = [m for m in turn.messages if m.role == "assistant"]
|
|
302
|
+
if not texts:
|
|
303
|
+
return
|
|
304
|
+
if engine == "codex" and any(m.kind for m in texts):
|
|
305
|
+
finals = [m for m in texts if m.kind == "final_answer"]
|
|
306
|
+
if finals:
|
|
307
|
+
finals[-1].final = "final"
|
|
308
|
+
return
|
|
309
|
+
texts[-1].final = "final"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --- shell command words -------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2\s*(?=\n|$)", re.S)
|
|
315
|
+
# wrapper -> its options that take a separate argument
|
|
316
|
+
_WRAPPERS = {
|
|
317
|
+
"sudo": {"-u", "-g", "-C", "-D"}, "env": {"-u", "-C", "-S", "--unset", "--chdir"},
|
|
318
|
+
"timeout": {"-s", "-k", "--signal", "--kill-after"}, "nice": {"-n"},
|
|
319
|
+
"xargs": {"-I", "-n", "-P", "-L", "-d", "-a", "-E", "-s"},
|
|
320
|
+
"uv": {"--with", "--python", "-p", "--project", "--directory", "--group", "--extra"},
|
|
321
|
+
"run": {"--with", "--python", "-p", "--project", "--directory", "--group", "--extra"},
|
|
322
|
+
"uvx": {"--from", "--with"}, "npx": {"-p", "--package"}, "ionice": {"-c", "-n"},
|
|
323
|
+
"time": set(), "nohup": set(), "exec": set(), "command": set(), "stdbuf": set(),
|
|
324
|
+
"caffeinate": set(),
|
|
325
|
+
# shell keywords that precede a command
|
|
326
|
+
"if": set(), "then": set(), "elif": set(), "else": set(), "while": set(),
|
|
327
|
+
"until": set(), "do": set(), "!": set(), "{": set(),
|
|
328
|
+
}
|
|
329
|
+
_KEYWORDS = {"fi", "done", "for", "in", "esac", "case", "function", "}", "]]", "[["}
|
|
330
|
+
_SHELLS = {"bash", "sh", "zsh"}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _segments(cmd: str) -> list[str]:
|
|
334
|
+
"""Split on unquoted ``; & | && || newline $( ` ( )``; heredoc bodies and
|
|
335
|
+
``$(( ))`` arithmetic dropped, ``\\``-newline continuations joined, and
|
|
336
|
+
redirections such as ``2>&1`` kept whole."""
|
|
337
|
+
cmd = _HEREDOC.sub("<<HEREDOC", (cmd or "").replace("\\\n", " "))
|
|
338
|
+
segs, cur, quote, i = [], [], None, 0
|
|
339
|
+
while i < len(cmd):
|
|
340
|
+
c = cmd[i]
|
|
341
|
+
if quote:
|
|
342
|
+
if c == "\\" and quote == '"':
|
|
343
|
+
cur.append(cmd[i:i + 2])
|
|
344
|
+
i += 2
|
|
345
|
+
continue
|
|
346
|
+
if c == quote:
|
|
347
|
+
quote = None
|
|
348
|
+
cur.append(c)
|
|
349
|
+
elif c in "'\"":
|
|
350
|
+
quote = c
|
|
351
|
+
cur.append(c)
|
|
352
|
+
elif c == "\\":
|
|
353
|
+
cur.append(cmd[i:i + 2])
|
|
354
|
+
i += 2
|
|
355
|
+
continue
|
|
356
|
+
elif cmd.startswith("$((", i):
|
|
357
|
+
end = cmd.find("))", i + 3)
|
|
358
|
+
i = len(cmd) if end < 0 else end + 2
|
|
359
|
+
cur.append("0") # arithmetic is a value, not a command
|
|
360
|
+
continue
|
|
361
|
+
elif c == "&" and ((cur and cur[-1] in "<>") or cmd[i + 1:i + 2] == ">"):
|
|
362
|
+
cur.append(c) # 2>&1, >&2, &>file, &>>file
|
|
363
|
+
elif c == "|" and cur and cur[-1] == ">":
|
|
364
|
+
cur.append(c) # >| (noclobber override)
|
|
365
|
+
elif c in ";&|\n`()":
|
|
366
|
+
segs.append("".join(cur))
|
|
367
|
+
cur = []
|
|
368
|
+
elif c == "$" and cmd[i + 1:i + 2] == "(":
|
|
369
|
+
segs.append("".join(cur))
|
|
370
|
+
cur = []
|
|
371
|
+
i += 2
|
|
372
|
+
continue
|
|
373
|
+
elif c == "#" and (not cur or cur[-1] in " \t"):
|
|
374
|
+
while i < len(cmd) and cmd[i] != "\n":
|
|
375
|
+
i += 1
|
|
376
|
+
continue
|
|
377
|
+
else:
|
|
378
|
+
cur.append(c)
|
|
379
|
+
i += 1
|
|
380
|
+
segs.append("".join(cur))
|
|
381
|
+
return segs
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def command_words(cmd: str, _depth: int = 0) -> list[str]:
|
|
385
|
+
"""The command name of each pipeline/list segment, after env assignments,
|
|
386
|
+
wrappers (with their option arguments) and leading shell keywords;
|
|
387
|
+
``bash -c '…'`` is unwrapped and ``python3.x`` reads as ``python``."""
|
|
388
|
+
words: list[str] = []
|
|
389
|
+
for seg in _segments(cmd):
|
|
390
|
+
toks = seg.strip().split()
|
|
391
|
+
wrapper = None
|
|
392
|
+
while toks:
|
|
393
|
+
t = toks[0]
|
|
394
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", t):
|
|
395
|
+
toks.pop(0)
|
|
396
|
+
elif t in _WRAPPERS:
|
|
397
|
+
wrapper = t
|
|
398
|
+
toks.pop(0)
|
|
399
|
+
elif wrapper is not None and t.startswith("-"):
|
|
400
|
+
toks.pop(0)
|
|
401
|
+
if t in _WRAPPERS[wrapper] and toks:
|
|
402
|
+
toks.pop(0) # the option's argument
|
|
403
|
+
elif wrapper == "timeout" and re.match(r"^\d+(\.\d+)?[smhd]?$", t):
|
|
404
|
+
toks.pop(0) # the duration
|
|
405
|
+
else:
|
|
406
|
+
break
|
|
407
|
+
if not toks:
|
|
408
|
+
continue
|
|
409
|
+
word = os.path.basename(toks[0].strip("'\"{}"))
|
|
410
|
+
word = re.sub(r"^python3(\.\d+)?$", "python", word)
|
|
411
|
+
if not word or not re.match(r"^[\w.+-]+$", word) or word in _KEYWORDS:
|
|
412
|
+
continue
|
|
413
|
+
if word in _SHELLS and _depth < 2:
|
|
414
|
+
inner = _shell_c_argument(seg)
|
|
415
|
+
if inner is not None:
|
|
416
|
+
words.extend(command_words(inner, _depth + 1))
|
|
417
|
+
continue
|
|
418
|
+
words.append(word)
|
|
419
|
+
if word == "python":
|
|
420
|
+
module = _python_module(toks[1:])
|
|
421
|
+
if module:
|
|
422
|
+
words.append(module)
|
|
423
|
+
return words
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _python_module(args: list[str]) -> str | None:
|
|
427
|
+
"""MODULE of ``python [options] -m MODULE``: ``python -m pytest`` runs pytest."""
|
|
428
|
+
i = 0
|
|
429
|
+
while i < len(args):
|
|
430
|
+
arg = args[i]
|
|
431
|
+
if arg == "-m":
|
|
432
|
+
module = args[i + 1].strip("'\"") if i + 1 < len(args) else ""
|
|
433
|
+
return module if re.fullmatch(r"[\w.]+", module) else None
|
|
434
|
+
if arg in ("-X", "-W"):
|
|
435
|
+
i += 2
|
|
436
|
+
continue
|
|
437
|
+
if not arg.startswith("-") or arg == "-c":
|
|
438
|
+
return None # a script, `-` (stdin) or -c code: no module
|
|
439
|
+
i += 1
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _shell_c_argument(segment: str) -> str | None:
|
|
444
|
+
"""The script of ``bash -c '…'`` / ``sh -lc "…"``, or None."""
|
|
445
|
+
try:
|
|
446
|
+
toks = shlex.split(segment)
|
|
447
|
+
except ValueError:
|
|
448
|
+
return None
|
|
449
|
+
for i, tok in enumerate(toks[1:], 1):
|
|
450
|
+
if re.fullmatch(r"-[a-z]*c[a-z]*", tok):
|
|
451
|
+
return toks[i + 1] if i + 1 < len(toks) else None
|
|
452
|
+
if not tok.startswith("-"):
|
|
453
|
+
return None
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --- time ---------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
_TIME_FORMS = "2026-11-02, 2026-11-02T14:00, '11-02 14:00', 14:00 (today), or 90m/6h/2d (ago)"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def parse_time(value: str | None, *, end: bool = False, now: float | None = None) -> float | None:
|
|
463
|
+
"""Epoch seconds for ``--since``/``--until``; a bare ``--until`` date includes that day."""
|
|
464
|
+
if value is None:
|
|
465
|
+
return None
|
|
466
|
+
v = value.strip()
|
|
467
|
+
now_s = time.time() if now is None else now
|
|
468
|
+
m = re.fullmatch(r"(\d+)([mhd])", v)
|
|
469
|
+
if m:
|
|
470
|
+
return now_s - int(m.group(1)) * {"m": 60, "h": 3600, "d": 86400}[m.group(2)]
|
|
471
|
+
today = dt.datetime.fromtimestamp(now_s)
|
|
472
|
+
if re.fullmatch(r"\d{1,2}:\d{2}", v):
|
|
473
|
+
v = f"{today:%Y-%m-%d} {v}"
|
|
474
|
+
elif re.fullmatch(r"\d{2}-\d{2}( .*)?", v):
|
|
475
|
+
v = f"{today.year}-{v}"
|
|
476
|
+
try:
|
|
477
|
+
moment = dt.datetime.fromisoformat(v.replace(" ", "T"))
|
|
478
|
+
except ValueError:
|
|
479
|
+
raise InvalidRequest(f"cannot read time {value!r}; use {_TIME_FORMS}") from None
|
|
480
|
+
if moment.tzinfo is not None:
|
|
481
|
+
return moment.timestamp()
|
|
482
|
+
if end and re.fullmatch(r"\d{4}-\d{2}-\d{2}", v):
|
|
483
|
+
moment += dt.timedelta(days=1)
|
|
484
|
+
return moment.timestamp()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
_YEAR = dt.datetime.now().year
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _clock(ts: float | None, full: bool = False) -> str:
|
|
491
|
+
if not ts:
|
|
492
|
+
return "--"
|
|
493
|
+
d = dt.datetime.fromtimestamp(ts)
|
|
494
|
+
if not full:
|
|
495
|
+
return d.strftime("%H:%M")
|
|
496
|
+
return d.strftime("%m-%d %H:%M") if d.year == _YEAR else d.strftime("%Y-%m-%d %H:%M")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _msg_clock(ts: float | None, turn_ts: float | None) -> str:
|
|
500
|
+
"""HH:MM inside a turn; the date too when the turn crossed midnight."""
|
|
501
|
+
if not ts:
|
|
502
|
+
return ""
|
|
503
|
+
if turn_ts and dt.date.fromtimestamp(ts) != dt.date.fromtimestamp(turn_ts):
|
|
504
|
+
return " " + _clock(ts, True)
|
|
505
|
+
return " " + _clock(ts)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _dur(s: float | None) -> str:
|
|
509
|
+
if s is None:
|
|
510
|
+
return "?"
|
|
511
|
+
s = int(s)
|
|
512
|
+
if s < 60:
|
|
513
|
+
return f"{s}s"
|
|
514
|
+
if s < 3600:
|
|
515
|
+
return f"{s // 60}m{s % 60:02d}"
|
|
516
|
+
return f"{s // 3600}h{(s % 3600) // 60:02d}"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def zone_name() -> str:
|
|
520
|
+
tz = os.environ.get("TZ")
|
|
521
|
+
if tz:
|
|
522
|
+
return tz.lstrip(":")
|
|
523
|
+
try:
|
|
524
|
+
link = os.path.realpath("/etc/localtime")
|
|
525
|
+
except OSError:
|
|
526
|
+
link = ""
|
|
527
|
+
return link.split("zoneinfo/")[-1] if "zoneinfo/" in link else (time.tzname[0] or "local")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# --- selection ----------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
@dataclass
|
|
533
|
+
class Selection:
|
|
534
|
+
turns: set[int] | None = None # None: no --turns
|
|
535
|
+
messages: set[tuple[int, int]] = field(default_factory=set)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
_TURN_PART = re.compile(r"\d+(\.\d+)?|\d+-\d*|-\d+")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def check_turns(spec: str | None) -> None:
|
|
542
|
+
for part in (spec or "").replace("#", "").split(","):
|
|
543
|
+
if spec is not None and not _TURN_PART.fullmatch(part.strip()):
|
|
544
|
+
raise InvalidRequest(
|
|
545
|
+
f"cannot read --turns part {part!r}; use 12, 10-14, 120-, -5 (the last five) "
|
|
546
|
+
"or 184.0 (one message), comma-separated")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def parse_turns(spec: str | None, last: int) -> Selection:
|
|
550
|
+
if spec is None:
|
|
551
|
+
return Selection()
|
|
552
|
+
check_turns(spec)
|
|
553
|
+
sel = Selection(turns=set())
|
|
554
|
+
for part in spec.replace("#", "").split(","):
|
|
555
|
+
part = part.strip()
|
|
556
|
+
if "." in part:
|
|
557
|
+
t, k = map(int, part.split("."))
|
|
558
|
+
sel.messages.add((t, k))
|
|
559
|
+
elif part.startswith("-"):
|
|
560
|
+
sel.turns |= set(range(max(1, last - int(part[1:]) + 1), last + 1))
|
|
561
|
+
elif "-" in part:
|
|
562
|
+
a, b = part.split("-")
|
|
563
|
+
sel.turns |= set(range(int(a), (int(b) if b else last) + 1))
|
|
564
|
+
else:
|
|
565
|
+
sel.turns.add(int(part))
|
|
566
|
+
return sel
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def smart_regex(pattern: str | None, what: str = "--grep") -> re.Pattern | None:
|
|
570
|
+
"""ripgrep-style smart case: an all-lowercase pattern is case-insensitive."""
|
|
571
|
+
if pattern is None:
|
|
572
|
+
return None
|
|
573
|
+
flags = 0 if re.search(r"[A-Z]", re.sub(r"\\.", "", pattern)) else re.I
|
|
574
|
+
try:
|
|
575
|
+
return re.compile(pattern, flags)
|
|
576
|
+
except re.error as error:
|
|
577
|
+
raise InvalidRequest(f"{what}: invalid regular expression: {error}") from None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def roles_of(m: Message) -> set[str]:
|
|
581
|
+
if m.role in ("user", "steer", "command", "interrupt"):
|
|
582
|
+
return {"user"}
|
|
583
|
+
if m.role == "assistant":
|
|
584
|
+
return {"assistant", "final"} if m.final else {"assistant"}
|
|
585
|
+
if m.role == "tool":
|
|
586
|
+
return {"tool", "output"}
|
|
587
|
+
return {m.role}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@dataclass
|
|
591
|
+
class Options:
|
|
592
|
+
view: str | None = None
|
|
593
|
+
turns: str | None = None
|
|
594
|
+
grep: str | None = None
|
|
595
|
+
cmd: str | None = None
|
|
596
|
+
errors: bool = False
|
|
597
|
+
roles: set[str] | None = None
|
|
598
|
+
since: float | None = None
|
|
599
|
+
until: float | None = None
|
|
600
|
+
context: int | None = None
|
|
601
|
+
limit: int = DEFAULT_LIMIT
|
|
602
|
+
max_chars: int | None = None
|
|
603
|
+
full: bool = False
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def search(self) -> bool:
|
|
607
|
+
return bool(self.grep or self.cmd or self.errors)
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def hits_mode(self) -> bool:
|
|
611
|
+
return self.search and self.view is None and self.context is None
|
|
612
|
+
|
|
613
|
+
def needs_full_results(self) -> bool:
|
|
614
|
+
"""Read tool results unbounded when output is searched or shown, or a
|
|
615
|
+
message is read by address: a cut result would hide its real end."""
|
|
616
|
+
addressed = self.turns is not None and any(
|
|
617
|
+
"." in p for p in self.turns.replace("#", "").split(","))
|
|
618
|
+
return addressed or bool(self.roles and "output" in self.roles)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def options(*, view=None, turns=None, grep=None, cmd=None, errors=False, role=None,
|
|
622
|
+
since=None, until=None, context=None, limit=DEFAULT_LIMIT, max_chars=None,
|
|
623
|
+
full=False, now: float | None = None) -> Options:
|
|
624
|
+
"""Validate CLI arguments before any worker is read."""
|
|
625
|
+
if view is not None and view not in VIEWS:
|
|
626
|
+
raise InvalidRequest(f"--view must be one of {', '.join(VIEWS)}")
|
|
627
|
+
roles = None
|
|
628
|
+
if role is not None:
|
|
629
|
+
roles = {r.strip() for r in role.split(",") if r.strip()}
|
|
630
|
+
bad = roles - set(ROLES)
|
|
631
|
+
if bad or not roles:
|
|
632
|
+
raise InvalidRequest(f"--role: unknown role {','.join(sorted(bad)) or '(none given)'} "
|
|
633
|
+
f"(use {','.join(ROLES)})")
|
|
634
|
+
check_turns(turns)
|
|
635
|
+
smart_regex(grep)
|
|
636
|
+
smart_regex(cmd, "--cmd")
|
|
637
|
+
for name, value in (("--limit", limit), ("--max-chars", max_chars)):
|
|
638
|
+
if value is not None and value < 1:
|
|
639
|
+
raise InvalidRequest(f"{name} must be at least 1")
|
|
640
|
+
if context is not None and context < 0:
|
|
641
|
+
raise InvalidRequest("-C must be zero or more")
|
|
642
|
+
return Options(view=view, turns=turns, grep=grep, cmd=cmd, errors=errors, roles=roles,
|
|
643
|
+
since=parse_time(since, now=now), until=parse_time(until, end=True, now=now),
|
|
644
|
+
context=context, limit=limit, max_chars=max_chars, full=full)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def matcher(opts: Options, turn_span: Callable[[Message], tuple]) -> Callable[[Message], bool] | None:
|
|
648
|
+
"""Message predicate for the search and time filters, or None when unfiltered."""
|
|
649
|
+
if not (opts.search or opts.since or opts.until):
|
|
650
|
+
return None
|
|
651
|
+
rx = smart_regex(opts.grep)
|
|
652
|
+
cw = re.compile(opts.cmd, re.I) if opts.cmd else None
|
|
653
|
+
scopes = opts.roles or SEARCH_ROLES
|
|
654
|
+
|
|
655
|
+
def in_time(m: Message) -> bool:
|
|
656
|
+
if m.ts is not None:
|
|
657
|
+
return not ((opts.since and m.ts < opts.since) or (opts.until and m.ts >= opts.until))
|
|
658
|
+
start, end = turn_span(m) # no per-message time: the turn's span
|
|
659
|
+
if opts.since and (end or start or 0) < opts.since:
|
|
660
|
+
return False
|
|
661
|
+
return not (opts.until and (start or 0) >= opts.until)
|
|
662
|
+
|
|
663
|
+
def keep(m: Message) -> bool:
|
|
664
|
+
if opts.errors and not (m.role == "tool" and m.err):
|
|
665
|
+
return False
|
|
666
|
+
if (opts.since or opts.until) and not in_time(m):
|
|
667
|
+
return False
|
|
668
|
+
if cw is not None and (m.role != "tool" or not any(
|
|
669
|
+
cw.fullmatch(w) for w in command_words(m.cmd or ""))):
|
|
670
|
+
return False
|
|
671
|
+
if rx is None:
|
|
672
|
+
return True
|
|
673
|
+
fields: list[str] = []
|
|
674
|
+
if m.role in ("user", "steer", "command", "interrupt") and "user" in scopes:
|
|
675
|
+
fields.append(m.text)
|
|
676
|
+
if m.role == "notify" and "notify" in scopes:
|
|
677
|
+
fields += [m.text, m.out]
|
|
678
|
+
if m.role == "assistant" and ("assistant" in scopes or ("final" in scopes and m.final)):
|
|
679
|
+
fields.append(m.text)
|
|
680
|
+
if m.role == "tool":
|
|
681
|
+
if "tool" in scopes:
|
|
682
|
+
fields += [m.cmd or "", m.desc or ""]
|
|
683
|
+
if "output" in scopes:
|
|
684
|
+
fields.append(m.out)
|
|
685
|
+
if m.role == "compact" and "compact" in scopes:
|
|
686
|
+
fields.append(m.out)
|
|
687
|
+
return any(rx.search(f) for f in fields if f)
|
|
688
|
+
|
|
689
|
+
return keep
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# --- rendering ----------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
_TAGS = {"user": "U", "steer": "U+", "notify": "N", "assistant": "A", "tool": "T",
|
|
695
|
+
"compact": "══", "command": "/", "interrupt": "!"}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def tag(m: Message) -> str:
|
|
699
|
+
if m.final == "final":
|
|
700
|
+
return "A*"
|
|
701
|
+
if m.final == "latest":
|
|
702
|
+
return "A…"
|
|
703
|
+
if m.role == "notify" and m.kind:
|
|
704
|
+
return f"N[{m.kind}]"
|
|
705
|
+
return _TAGS[m.role]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _one(text: str, n: int) -> str:
|
|
709
|
+
text = " ".join((text or "").split())
|
|
710
|
+
return text if len(text) <= n else text[: n - 1] + "…"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def cut(text: str, n: int | None, *, keep_end: bool = False) -> str:
|
|
714
|
+
# Don't cut for a remainder shorter than the marker itself.
|
|
715
|
+
if n is None or len(text) <= n + 40:
|
|
716
|
+
return text
|
|
717
|
+
if keep_end: # compaction summaries end with current work / next step
|
|
718
|
+
return f"[… {len(text) - n} earlier chars]\n" + text[-n:]
|
|
719
|
+
return text[:n] + f"\n[… {len(text) - n} more chars]"
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def tool_label(m: Message, commands: bool, limit: int | None = 160) -> str:
|
|
723
|
+
"""One line; with ``limit=None`` the whole command, newlines kept."""
|
|
724
|
+
body = (m.cmd or "") if commands or not m.desc else m.desc
|
|
725
|
+
mark = " ✗" if m.err else ""
|
|
726
|
+
d = f" {_dur(m.dur)}" if m.dur and m.dur >= 5 else ""
|
|
727
|
+
flat = " ".join(body.split())
|
|
728
|
+
if limit is None:
|
|
729
|
+
text = body
|
|
730
|
+
elif len(flat) <= limit:
|
|
731
|
+
text = flat
|
|
732
|
+
else:
|
|
733
|
+
text = flat[:limit] + f" [+{len(flat) - limit} chars]"
|
|
734
|
+
return f"{m.tool}{mark}{d}: {text}"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def cut_note(m: Message) -> str:
|
|
738
|
+
"""Say that a tool output is only the start of the real one."""
|
|
739
|
+
if not m.out_cut:
|
|
740
|
+
return ""
|
|
741
|
+
returned, original = m.out_cut
|
|
742
|
+
kb = lambda n: f"{max(1, round(n / 1024))} KB" # noqa: E731
|
|
743
|
+
return (f"[output cut at {kb(returned)} of {kb(original)}; "
|
|
744
|
+
f"--full or --turns {m.turn}.{m.n} for all]")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def compact_line(m: Message) -> str:
|
|
748
|
+
extra = f" · {len(m.out) // 1000}k-char summary" if m.out else ""
|
|
749
|
+
text = f" · {m.text}" if m.text else ""
|
|
750
|
+
return f"══ #{m.turn}.{m.n} compaction{text}{extra}"
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
_NOISE_WORDS = {"cd", "echo", "head", "tail", "sed", "wc", "sort", "ls", "cat", "true",
|
|
754
|
+
"printf", "sleep"}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def main_word(m: Message) -> str:
|
|
758
|
+
if m.tool not in ("Bash", "shell"):
|
|
759
|
+
return m.tool or "?"
|
|
760
|
+
words = command_words(m.cmd or "")
|
|
761
|
+
useful = [w for w in words if w not in _NOISE_WORDS] or words
|
|
762
|
+
return useful[0] if useful else "sh"
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def tool_digest(turn: Turn) -> str:
|
|
766
|
+
tools = [m for m in turn.messages if m.role == "tool"]
|
|
767
|
+
if not tools:
|
|
768
|
+
return ""
|
|
769
|
+
counts: dict[str, int] = {}
|
|
770
|
+
for m in tools:
|
|
771
|
+
counts[main_word(m)] = counts.get(main_word(m), 0) + 1
|
|
772
|
+
top = sorted(counts.items(), key=lambda kv: -kv[1])[:5]
|
|
773
|
+
stamps = [t for m in tools for t in (m.ts, (m.ts or 0) + (m.dur or 0) if m.ts else None) if t]
|
|
774
|
+
span = f" · {_dur(max(stamps) - min(stamps))}" if len(stamps) > 1 else ""
|
|
775
|
+
errs = sum(1 for m in tools if m.err)
|
|
776
|
+
return (f"[{len(tools)} tools{span}" + (f" · {errs}✗" if errs else "") + " · "
|
|
777
|
+
+ ", ".join(f"{k}×{v}" if v > 1 else k for k, v in top) + "]")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def idle_gaps(turns: list[Turn]) -> dict[int, float]:
|
|
781
|
+
"""Idle time before each turn, on the whole history (not a selection)."""
|
|
782
|
+
gaps, last = {}, None
|
|
783
|
+
for t in turns:
|
|
784
|
+
if last and t.start:
|
|
785
|
+
gaps[t.seq] = t.start - last
|
|
786
|
+
last = t.end or t.start or last
|
|
787
|
+
return gaps
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def render_turns(turns: list[Turn], view: str, out: TextIO, *, max_chars: int | None,
|
|
791
|
+
gaps: dict[int, float], roles: set[str] | None = None,
|
|
792
|
+
keep: Callable[[Message], bool] | None = None, commands: bool = False) -> None:
|
|
793
|
+
tool_limit = None if max_chars is None else 160
|
|
794
|
+
shown = roles or VIEW_ROLES.get(view, set())
|
|
795
|
+
for t in turns:
|
|
796
|
+
idle = gaps.get(t.seq, 0)
|
|
797
|
+
gap = f" (+{_dur(idle)} idle)" if idle > 3600 else ""
|
|
798
|
+
if view == "outline":
|
|
799
|
+
_render_outline_turn(t, gap, out)
|
|
800
|
+
continue
|
|
801
|
+
print(f"\n━━ #{t.seq} {_clock(t.start, True)}{gap}", file=out)
|
|
802
|
+
for m in t.messages:
|
|
803
|
+
if m.role == "compact":
|
|
804
|
+
print(f"[.{m.n}] {compact_line(m)}", file=out)
|
|
805
|
+
continue
|
|
806
|
+
if not roles_of(m) & shown:
|
|
807
|
+
continue
|
|
808
|
+
if m.role == "tool":
|
|
809
|
+
if keep is None or keep(m):
|
|
810
|
+
print(f" .{m.n}{_msg_clock(m.ts, t.start)} "
|
|
811
|
+
f"{tool_label(m, commands, tool_limit)}", file=out)
|
|
812
|
+
if "output" in shown and m.out:
|
|
813
|
+
text = cut(m.out, max_chars) + (f"\n{cut_note(m)}" if m.out_cut else "")
|
|
814
|
+
print(" " + text.replace("\n", "\n "), file=out)
|
|
815
|
+
continue
|
|
816
|
+
body = m.text + (f"\n{m.out}" if m.role == "notify" and m.out else "")
|
|
817
|
+
print(f"[.{m.n} {tag(m)}{_msg_clock(m.ts, t.start)}] {cut(body, max_chars)}", file=out)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _render_outline_turn(t: Turn, gap: str, out: TextIO) -> None:
|
|
821
|
+
if not t.messages:
|
|
822
|
+
return # e.g. a turn of command output only: its number stays a gap
|
|
823
|
+
compacts = [m for m in t.messages if m.role == "compact"]
|
|
824
|
+
heads = [m for m in t.messages if m.role in ("user", "notify", "steer", "interrupt")
|
|
825
|
+
or (m.role == "command" and not (compacts and m.text.startswith("/compact")))]
|
|
826
|
+
final = next((m for m in reversed(t.messages) if m.final), None)
|
|
827
|
+
if heads:
|
|
828
|
+
h = heads[0]
|
|
829
|
+
print(f"#{t.seq}.{h.n} {_clock(t.start, True)}{gap} {tag(h)}: {_one(h.text, 150)}", file=out)
|
|
830
|
+
for m in heads[1:3]:
|
|
831
|
+
print(f" .{m.n} {tag(m)}: {_one(m.text, 120)}", file=out)
|
|
832
|
+
if len(heads) > 3:
|
|
833
|
+
print(f" (+{len(heads) - 3} more prompts)", file=out)
|
|
834
|
+
elif not compacts:
|
|
835
|
+
print(f"#{t.seq} {_clock(t.start, True)}{gap} (no prompt)", file=out)
|
|
836
|
+
if final:
|
|
837
|
+
print(f" .{final.n} {tag(final)}: {_one(final.text, 200)}", file=out)
|
|
838
|
+
digest = tool_digest(t)
|
|
839
|
+
if digest:
|
|
840
|
+
print(f" {digest}", file=out)
|
|
841
|
+
for c in compacts:
|
|
842
|
+
print(f" {compact_line(c)} · {_clock(c.ts or t.end, True)}", file=out)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _section(summary: str, name: str, n: int) -> str:
|
|
846
|
+
m = re.search(rf"^\s*\d+\. {name}[^\n]*:\s*\n(.*?)(?=^\s*\d+\. [A-Z][^\n]*:\s*$|\Z)",
|
|
847
|
+
summary, re.S | re.M)
|
|
848
|
+
return _one(m.group(1), n) if m else ""
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def render_map(turns: list[Turn], out: TextIO) -> None:
|
|
852
|
+
"""Segments between compactions and long idle gaps; one title line each."""
|
|
853
|
+
segs, cur, last_ts, why = [], None, None, "start"
|
|
854
|
+
for t in turns:
|
|
855
|
+
if cur and t.start and last_ts and t.start - last_ts > MAP_GAP_HOURS * 3600:
|
|
856
|
+
segs.append((cur, None))
|
|
857
|
+
cur, why = None, f"idle {_dur(t.start - last_ts)}"
|
|
858
|
+
if cur is None:
|
|
859
|
+
cur = {"turns": [], "why": why}
|
|
860
|
+
cur["turns"].append(t)
|
|
861
|
+
last_ts = t.end or t.start or last_ts
|
|
862
|
+
compact = next((m for m in reversed(t.messages) if m.role == "compact"), None)
|
|
863
|
+
if compact is not None: # the compacting turn closes its segment
|
|
864
|
+
segs.append((cur, compact))
|
|
865
|
+
cur, why = None, f"after compaction #{compact.turn}.{compact.n}"
|
|
866
|
+
if cur:
|
|
867
|
+
segs.append((cur, None))
|
|
868
|
+
msgs = [m for t in turns for m in t.messages]
|
|
869
|
+
tools = [m for m in msgs if m.role == "tool"]
|
|
870
|
+
words: dict[str, int] = {}
|
|
871
|
+
for m in tools:
|
|
872
|
+
words[main_word(m)] = words.get(main_word(m), 0) + 1
|
|
873
|
+
starts = [t.start for t in turns if t.start]
|
|
874
|
+
ends = [t.end or t.start for t in turns if t.end or t.start]
|
|
875
|
+
print(f"{len(turns)} turns · {sum(1 for m in msgs if m.role in ('user', 'steer'))} user msgs · "
|
|
876
|
+
f"{sum(1 for m in msgs if m.role == 'notify')} notifications · "
|
|
877
|
+
f"{sum(1 for m in msgs if m.role == 'compact')} compactions · {len(tools)} tool calls "
|
|
878
|
+
f"({sum(1 for m in tools if m.err)}✗) · {_clock(min(starts) if starts else None, True)} → "
|
|
879
|
+
f"{_clock(max(ends) if ends else None, True)}", file=out)
|
|
880
|
+
if words:
|
|
881
|
+
print(" top: " + ", ".join(f"{k}×{v}" for k, v in
|
|
882
|
+
sorted(words.items(), key=lambda kv: -kv[1])[:12]), file=out)
|
|
883
|
+
for seg, closer in segs:
|
|
884
|
+
ts_ = seg["turns"]
|
|
885
|
+
sm = [m for t in ts_ for m in t.messages]
|
|
886
|
+
users = [m for m in sm if m.role in ("user", "steer")]
|
|
887
|
+
notes = sum(1 for t in ts_ if t.messages and t.messages[0].role == "notify")
|
|
888
|
+
st = [t.start for t in ts_ if t.start]
|
|
889
|
+
en = [t.end or t.start for t in ts_ if t.end or t.start]
|
|
890
|
+
print(f"── #{ts_[0].seq}-{ts_[-1].seq} {_clock(min(st) if st else None, True)} → "
|
|
891
|
+
f"{_clock(max(en) if en else None, True)} {seg['why']} · {len(users)} user · "
|
|
892
|
+
f"{notes} notify-led turns", file=out)
|
|
893
|
+
title = ""
|
|
894
|
+
if closer is not None and closer.out:
|
|
895
|
+
work = _section(closer.out, "Current Work", 170)
|
|
896
|
+
title = f"#{closer.turn}.{closer.n} compaction, current work: {work}" if work else ""
|
|
897
|
+
if not title and users:
|
|
898
|
+
title = f"#{users[0].turn}.{users[0].n} {tag(users[0])}: {_one(users[0].text, 160)}"
|
|
899
|
+
if not title:
|
|
900
|
+
fin = next((m for m in sm if m.final), None)
|
|
901
|
+
title = f"#{fin.turn}.{fin.n} {tag(fin)}: {_one(fin.text, 160)}" if fin else ""
|
|
902
|
+
if title:
|
|
903
|
+
print(f" ⤷ {title}", file=out)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def snippet(m: Message, rx: re.Pattern | None, width: int = 220) -> str:
|
|
907
|
+
if m.role == "tool" and rx is None:
|
|
908
|
+
return tool_label(m, True)
|
|
909
|
+
for text in (m.text, m.cmd or "", m.desc or "", m.out):
|
|
910
|
+
found = rx.search(text) if (rx and text) else None
|
|
911
|
+
if found:
|
|
912
|
+
a = max(0, found.start() - width // 2)
|
|
913
|
+
return ("…" if a else "") + _one(text[a: a + width], width)
|
|
914
|
+
return tool_label(m, True) if m.role == "tool" else _one(m.text, width)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def header(h: History, zone: str) -> str:
|
|
918
|
+
starts = [t.start for t in h.turns if t.start]
|
|
919
|
+
ends = [t.end or t.start for t in h.turns if t.end or t.start]
|
|
920
|
+
span = f"{_clock(min(starts), True)} → {_clock(max(ends), True)}" if starts else "no times"
|
|
921
|
+
compacts = [m for m in h.messages() if m.role == "compact"]
|
|
922
|
+
latest = f" · latest compaction #{compacts[-1].turn}.{compacts[-1].n}" if compacts else ""
|
|
923
|
+
cwd = (h.cwd or "").replace(os.path.expanduser("~"), "~", 1)
|
|
924
|
+
name = f" {h.name!r}" if h.name else ""
|
|
925
|
+
return (f"{h.engine}:{h.short}{name} {cwd} · {len(h.turns)} turns · {span}{latest} · "
|
|
926
|
+
f"local times ({zone})")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@dataclass
|
|
930
|
+
class Hint:
|
|
931
|
+
short: str
|
|
932
|
+
turn: int
|
|
933
|
+
n: int
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def render_history(h: History, opts: Options, out: TextIO, *, many: bool,
|
|
937
|
+
zone: str) -> Hint | None:
|
|
938
|
+
"""Print one worker's part of ``ccx history``; return a next-step hint."""
|
|
939
|
+
if many:
|
|
940
|
+
print(file=out)
|
|
941
|
+
print(header(h, zone), file=out)
|
|
942
|
+
for note in h.notes:
|
|
943
|
+
print(f" note: {note}", file=out)
|
|
944
|
+
last = h.turns[-1].seq if h.turns else 0
|
|
945
|
+
sel = parse_turns(opts.turns, last)
|
|
946
|
+
if sel.messages:
|
|
947
|
+
_render_addressed(h, sel, opts, out)
|
|
948
|
+
if not sel.turns:
|
|
949
|
+
return None
|
|
950
|
+
span = {t.seq: (t.start, t.end) for t in h.turns}
|
|
951
|
+
keep = matcher(opts, lambda m: span.get(m.turn, (None, None)))
|
|
952
|
+
view = opts.view or ("chat" if (opts.turns or opts.roles or opts.context is not None)
|
|
953
|
+
else "outline")
|
|
954
|
+
gaps = idle_gaps(h.turns)
|
|
955
|
+
turns = [t for t in h.turns if sel.turns is None or t.seq in sel.turns]
|
|
956
|
+
if view == "map":
|
|
957
|
+
if keep is not None:
|
|
958
|
+
turns = [t for t in turns if any(keep(m) for m in t.messages)]
|
|
959
|
+
render_map(turns, out)
|
|
960
|
+
return None
|
|
961
|
+
if opts.hits_mode:
|
|
962
|
+
return _render_hits(h, turns, keep, opts, out)
|
|
963
|
+
if keep is not None:
|
|
964
|
+
index = [i for i, t in enumerate(turns) if any(keep(m) for m in t.messages)
|
|
965
|
+
or (not opts.search and not t.messages and _turn_in_range(t, opts))]
|
|
966
|
+
ctx = opts.context or 0
|
|
967
|
+
show = {j for i in index for j in range(i - ctx, i + ctx + 1)}
|
|
968
|
+
turns = [t for i, t in enumerate(turns) if i in show]
|
|
969
|
+
elif sel.turns is None:
|
|
970
|
+
window = DEFAULT_MANY_WINDOW if many else DEFAULT_WINDOW
|
|
971
|
+
if len(turns) > window:
|
|
972
|
+
print(f"(last {window} of {len(turns)} turns; --turns 1- for all, "
|
|
973
|
+
"--view map for an overview)", file=out)
|
|
974
|
+
turns = turns[-window:]
|
|
975
|
+
if not turns:
|
|
976
|
+
print(" (no matching turns)" if opts.search else " (no turns in range)", file=out)
|
|
977
|
+
max_chars = None if opts.full else (opts.max_chars or DEFAULT_MAX_CHARS)
|
|
978
|
+
render_turns(turns, view, out, max_chars=max_chars, gaps=gaps, roles=opts.roles,
|
|
979
|
+
keep=keep if opts.search else None,
|
|
980
|
+
commands=bool(opts.cmd or (opts.roles and "tool" in opts.roles)))
|
|
981
|
+
return None
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _turn_in_range(t: Turn, opts: Options) -> bool:
|
|
985
|
+
start, end = t.start, t.end or t.start
|
|
986
|
+
return not ((opts.since and (end or 0) < opts.since) or (opts.until and (start or 0) >= opts.until))
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _render_addressed(h: History, sel: Selection, opts: Options, out: TextIO) -> None:
|
|
990
|
+
found = {(m.turn, m.n): m for m in h.messages()}
|
|
991
|
+
for address in sorted(sel.messages):
|
|
992
|
+
m = found.get(address)
|
|
993
|
+
if m is None:
|
|
994
|
+
print(f"(no message #{address[0]}.{address[1]}: it doesn't exist or isn't shown "
|
|
995
|
+
"by history, e.g. reasoning or bookkeeping)", file=out)
|
|
996
|
+
continue
|
|
997
|
+
if m.role == "tool":
|
|
998
|
+
body = (m.cmd or "") + ("\n--- output ---\n" + m.out if m.out else "")
|
|
999
|
+
body += f"\n{cut_note(m)}" if m.out_cut else ""
|
|
1000
|
+
elif m.role == "compact":
|
|
1001
|
+
body = m.out or ("(no summary stored locally: Codex encrypts compaction summaries)"
|
|
1002
|
+
if h.engine == "codex" else "(no summary text)")
|
|
1003
|
+
else:
|
|
1004
|
+
body = m.text + (f"\n{m.out}" if m.role == "notify" and m.out else "")
|
|
1005
|
+
turn_start = next((t.start for t in h.turns if t.seq == m.turn), None)
|
|
1006
|
+
print(f"#{m.turn}.{m.n} {tag(m)} {_clock(m.ts or turn_start, True)}"
|
|
1007
|
+
+ (f" {m.tool}" if m.tool else ""), file=out)
|
|
1008
|
+
print(cut(body, None if opts.full else opts.max_chars, keep_end=m.role == "compact"),
|
|
1009
|
+
file=out)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _render_hits(h: History, turns: list[Turn], keep, opts: Options, out: TextIO) -> Hint | None:
|
|
1013
|
+
rx = smart_regex(opts.grep)
|
|
1014
|
+
starts = {t.seq: t.start for t in turns}
|
|
1015
|
+
hits = [m for t in turns for m in t.messages if keep(m)]
|
|
1016
|
+
shown = hits[-opts.limit:]
|
|
1017
|
+
if len(hits) > len(shown):
|
|
1018
|
+
older = f"--turns 1-{shown[0].turn - 1} or " if shown[0].turn > 1 else ""
|
|
1019
|
+
print(f" {len(hits)} hits, newest {len(shown)} shown (older: {older}--limit {len(hits)})",
|
|
1020
|
+
file=out)
|
|
1021
|
+
else:
|
|
1022
|
+
print(f" {len(hits)} hit{'s' * (len(hits) != 1)}", file=out)
|
|
1023
|
+
with_output = bool(opts.roles and "output" in opts.roles)
|
|
1024
|
+
for m in shown:
|
|
1025
|
+
print(f" #{m.turn}.{m.n} {_clock(m.ts or starts.get(m.turn), True)} {tag(m)}: "
|
|
1026
|
+
f"{snippet(m, rx)}", file=out)
|
|
1027
|
+
if with_output and m.role == "tool" and m.out:
|
|
1028
|
+
ending = cut_note(m) or _one(m.out[-200:], 200) # how it ended
|
|
1029
|
+
print(f" ⤷ {ending}", file=out)
|
|
1030
|
+
return Hint(h.short, shown[-1].turn, shown[-1].n) if shown else None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def hint_line(hint: Hint) -> str:
|
|
1034
|
+
return (f"next: ccx history {hint.short} --turns {hint.turn}.{hint.n} prints that message "
|
|
1035
|
+
f"whole; --turns {max(1, hint.turn - 1)}-{hint.turn + 1} reads the exchange around it")
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
__all__ = [
|
|
1039
|
+
"History", "Message", "Options", "Turn", "command_words", "hint_line", "options",
|
|
1040
|
+
"parse_time", "parse_turns", "project", "render_history", "zone_name",
|
|
1041
|
+
]
|