anywhere-cli 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.
- anywhere_cli-0.1.0.dist-info/METADATA +110 -0
- anywhere_cli-0.1.0.dist-info/RECORD +36 -0
- anywhere_cli-0.1.0.dist-info/WHEEL +4 -0
- anywhere_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +334 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1377 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +149 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +951 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1223 -0
- connector/codex/rpc.py +260 -0
- connector/launch.py +104 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +122 -0
- connector/local/ops.py +83 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +389 -0
- connector/local_ops.py +5 -0
- connector/protocol.py +26 -0
- connector/runtime.py +1002 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from connector.time import utc_now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CODEX_APPROVAL_METHODS = {
|
|
12
|
+
"item/commandExecution/requestApproval",
|
|
13
|
+
"item/fileChange/requestApproval",
|
|
14
|
+
"item/permissions/requestApproval",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
OUTPUT_PREVIEW_CHARS = 4000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class ReductionResult:
|
|
22
|
+
session_update: dict[str, Any] | None = None
|
|
23
|
+
timeline_items: list[dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
approvals: list[dict[str, Any]] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TimelineReducer:
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._session_by_thread: dict[str, str] = {}
|
|
30
|
+
self._thread_by_session: dict[str, str] = {}
|
|
31
|
+
self._items: dict[str, dict[str, Any]] = {}
|
|
32
|
+
self._order_by_item: dict[str, int] = {}
|
|
33
|
+
self._tool_kind_by_call: dict[str, str] = {}
|
|
34
|
+
self._client_message_by_turn: dict[tuple[str, str | None, str], dict[str, Any]] = {}
|
|
35
|
+
self._pending_client_messages: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
|
|
36
|
+
self._next_order = 1
|
|
37
|
+
|
|
38
|
+
def bind_session(self, session_id: str, thread_id: str) -> None:
|
|
39
|
+
self._session_by_thread[thread_id] = session_id
|
|
40
|
+
self._thread_by_session[session_id] = thread_id
|
|
41
|
+
|
|
42
|
+
def thread_for_session(self, session_id: str) -> str | None:
|
|
43
|
+
return self._thread_by_session.get(session_id)
|
|
44
|
+
|
|
45
|
+
def session_for_thread(self, thread_id: str) -> str | None:
|
|
46
|
+
return self._session_by_thread.get(thread_id)
|
|
47
|
+
|
|
48
|
+
def _session_update(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
session_id: str,
|
|
52
|
+
thread_id: str | None,
|
|
53
|
+
status: str | None = None,
|
|
54
|
+
**values: Any,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
update = {
|
|
57
|
+
"sessionId": session_id,
|
|
58
|
+
"runtime": "codex",
|
|
59
|
+
"sourceObservedAt": utc_now(),
|
|
60
|
+
**values,
|
|
61
|
+
}
|
|
62
|
+
if status is not None:
|
|
63
|
+
update["status"] = status
|
|
64
|
+
if thread_id:
|
|
65
|
+
update["externalSessionId"] = thread_id
|
|
66
|
+
return update
|
|
67
|
+
|
|
68
|
+
def register_client_message(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
session_id: str,
|
|
72
|
+
thread_id: str | None,
|
|
73
|
+
client_message_id: str,
|
|
74
|
+
text: str | None = None,
|
|
75
|
+
turn_id: str | None = None,
|
|
76
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
message = {"clientMessageId": client_message_id, "text": text, "attachments": attachments or []}
|
|
79
|
+
if turn_id:
|
|
80
|
+
self._client_message_by_turn[(session_id, thread_id, turn_id)] = message
|
|
81
|
+
pending_key = (session_id, thread_id)
|
|
82
|
+
pending = self._pending_client_messages.get(pending_key)
|
|
83
|
+
if pending is not None:
|
|
84
|
+
self._pending_client_messages[pending_key] = [
|
|
85
|
+
item for item in pending if item.get("clientMessageId") != client_message_id
|
|
86
|
+
]
|
|
87
|
+
return
|
|
88
|
+
self._pending_client_messages.setdefault((session_id, thread_id), []).append(
|
|
89
|
+
message
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def reduce_thread_snapshot(
|
|
93
|
+
self,
|
|
94
|
+
session_id: str,
|
|
95
|
+
thread: dict[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
fallback_thread_id: str | None = None,
|
|
98
|
+
) -> ReductionResult:
|
|
99
|
+
thread_id = fallback_thread_id or _thread_id(thread)
|
|
100
|
+
if thread_id:
|
|
101
|
+
self.bind_session(session_id, thread_id)
|
|
102
|
+
|
|
103
|
+
items: list[dict[str, Any]] = []
|
|
104
|
+
for turn in _list_value(thread.get("turns")):
|
|
105
|
+
turn_id = _string_value(turn.get("id")) or _string_value(turn.get("turnId"))
|
|
106
|
+
status = _turn_status(turn)
|
|
107
|
+
is_complete = status in {"completed", "failed", "cancelled", "interrupted"}
|
|
108
|
+
turn_items = [
|
|
109
|
+
item for item in _list_value(turn.get("items"))
|
|
110
|
+
if not _is_bootstrap_user_message(item) and not _is_external_import_marker(item)
|
|
111
|
+
]
|
|
112
|
+
message_counts = _message_type_counts(turn_items)
|
|
113
|
+
message_indices: dict[str, int] = {}
|
|
114
|
+
if turn_id:
|
|
115
|
+
items.append(
|
|
116
|
+
self._upsert_turn_start(
|
|
117
|
+
session_id,
|
|
118
|
+
thread_id,
|
|
119
|
+
turn_id,
|
|
120
|
+
turn,
|
|
121
|
+
status=_turn_result_to_status(_turn_result(turn)) if is_complete else "running",
|
|
122
|
+
event="turn/completed" if is_complete else "turn/started",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
for index, item in enumerate(turn_items):
|
|
126
|
+
item = dict(item)
|
|
127
|
+
item.setdefault("_snapshotIndex", index)
|
|
128
|
+
codex_type = _string_value(item.get("type"))
|
|
129
|
+
if codex_type in {"userMessage", "agentMessage"}:
|
|
130
|
+
message_index = message_indices.get(codex_type, 0)
|
|
131
|
+
message_indices[codex_type] = message_index + 1
|
|
132
|
+
if message_counts.get(codex_type, 0) > 1:
|
|
133
|
+
item["_messageKey"] = f"message-{codex_type}-{message_index}"
|
|
134
|
+
reduced = self._upsert_completed_item(session_id, thread_id, turn_id, item)
|
|
135
|
+
if reduced is not None:
|
|
136
|
+
items.append(reduced)
|
|
137
|
+
if turn_id and is_complete:
|
|
138
|
+
items.append(self._upsert_turn_end(session_id, thread_id, turn_id, turn))
|
|
139
|
+
|
|
140
|
+
session_update = {
|
|
141
|
+
"sessionId": session_id,
|
|
142
|
+
"runtime": "codex",
|
|
143
|
+
"status": _session_status_from_thread(thread),
|
|
144
|
+
"externalSessionId": thread_id,
|
|
145
|
+
"title": _string_value(thread.get("name")) or _string_value(thread.get("title")),
|
|
146
|
+
"cwd": _string_value(thread.get("cwd")),
|
|
147
|
+
"lastSyncedAt": utc_now(),
|
|
148
|
+
"sourceObservedAt": utc_now(),
|
|
149
|
+
}
|
|
150
|
+
return ReductionResult(session_update=session_update, timeline_items=items)
|
|
151
|
+
|
|
152
|
+
def reduce_history_items(
|
|
153
|
+
self,
|
|
154
|
+
session_id: str,
|
|
155
|
+
thread_id: str,
|
|
156
|
+
items: list[dict[str, Any]],
|
|
157
|
+
) -> ReductionResult:
|
|
158
|
+
self.bind_session(session_id, thread_id)
|
|
159
|
+
records: list[tuple[str | None, dict[str, Any]]] = []
|
|
160
|
+
completed_turns: dict[str, str] = {}
|
|
161
|
+
message_counts: dict[str, int] = {}
|
|
162
|
+
for item_record in items:
|
|
163
|
+
raw_item = item_record.get("item") if isinstance(item_record.get("item"), dict) else item_record
|
|
164
|
+
if not isinstance(raw_item, dict):
|
|
165
|
+
continue
|
|
166
|
+
if _is_bootstrap_user_message(raw_item) or _is_external_import_marker(raw_item):
|
|
167
|
+
continue
|
|
168
|
+
turn_id = _string_value(item_record.get("turnId")) or _string_value(item_record.get("turn_id"))
|
|
169
|
+
item = dict(raw_item)
|
|
170
|
+
codex_type = _string_value(item.get("type"))
|
|
171
|
+
if codex_type in {"userMessage", "agentMessage"}:
|
|
172
|
+
message_counts[f"{turn_id}:{codex_type}"] = message_counts.get(f"{turn_id}:{codex_type}", 0) + 1
|
|
173
|
+
if codex_type == "turnEnd" and turn_id is not None:
|
|
174
|
+
completed_turns[turn_id] = _turn_result_to_status(_turn_result(item))
|
|
175
|
+
records.append((turn_id, item))
|
|
176
|
+
|
|
177
|
+
reduced_items: list[dict[str, Any]] = []
|
|
178
|
+
message_indices: dict[str, int] = {}
|
|
179
|
+
for turn_id, item in records:
|
|
180
|
+
codex_type = _string_value(item.get("type"))
|
|
181
|
+
if codex_type in {"userMessage", "agentMessage"} and _string_value(item.get("_derivedKey")) is None:
|
|
182
|
+
message_index = message_indices.get(f"{turn_id}:{codex_type}", 0)
|
|
183
|
+
message_indices[f"{turn_id}:{codex_type}"] = message_index + 1
|
|
184
|
+
if message_counts.get(f"{turn_id}:{codex_type}", 0) > 1:
|
|
185
|
+
item["_messageKey"] = f"message-{codex_type}-{message_index}"
|
|
186
|
+
if codex_type == "turnStart" and turn_id in completed_turns:
|
|
187
|
+
item["_historyTurnStartStatus"] = completed_turns[turn_id]
|
|
188
|
+
reduced = self._upsert_completed_item(
|
|
189
|
+
session_id,
|
|
190
|
+
thread_id,
|
|
191
|
+
turn_id,
|
|
192
|
+
item,
|
|
193
|
+
event="history/response_item",
|
|
194
|
+
)
|
|
195
|
+
if reduced is not None:
|
|
196
|
+
reduced_items.append(reduced)
|
|
197
|
+
return ReductionResult(timeline_items=reduced_items)
|
|
198
|
+
|
|
199
|
+
def reduce_notification(self, message: dict[str, Any]) -> ReductionResult:
|
|
200
|
+
method = _string_value(message.get("method"))
|
|
201
|
+
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
202
|
+
thread_id = _extract_thread_id(params)
|
|
203
|
+
turn_id = _extract_turn_id(params)
|
|
204
|
+
session_id = _string_value(params.get("platformSessionId"))
|
|
205
|
+
if session_id is None and thread_id is not None:
|
|
206
|
+
session_id = self._session_by_thread.get(thread_id)
|
|
207
|
+
if session_id is None:
|
|
208
|
+
return ReductionResult()
|
|
209
|
+
if thread_id:
|
|
210
|
+
self.bind_session(session_id, thread_id)
|
|
211
|
+
|
|
212
|
+
if method == "thread/name/updated":
|
|
213
|
+
return ReductionResult(
|
|
214
|
+
session_update=self._session_update(
|
|
215
|
+
session_id=session_id,
|
|
216
|
+
thread_id=thread_id,
|
|
217
|
+
title=_string_value(params.get("threadName")),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if method == "turn/started":
|
|
222
|
+
return ReductionResult(
|
|
223
|
+
session_update=self._session_update(
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
thread_id=thread_id,
|
|
226
|
+
status="running",
|
|
227
|
+
),
|
|
228
|
+
timeline_items=[self._upsert_turn_start(session_id, thread_id, turn_id, params.get("turn") or params)],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if method == "turn/completed":
|
|
232
|
+
turn = params.get("turn") if isinstance(params.get("turn"), dict) else params
|
|
233
|
+
return ReductionResult(
|
|
234
|
+
session_update=self._session_update(
|
|
235
|
+
session_id=session_id,
|
|
236
|
+
thread_id=thread_id,
|
|
237
|
+
status=_session_status_from_turn(turn),
|
|
238
|
+
),
|
|
239
|
+
timeline_items=self._complete_turn(session_id, thread_id, turn_id, turn),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if method == "turn/diff/updated":
|
|
243
|
+
item = self._upsert_item(
|
|
244
|
+
session_id=session_id,
|
|
245
|
+
turn_id=turn_id,
|
|
246
|
+
item_id=None,
|
|
247
|
+
derived_key="turn-diff",
|
|
248
|
+
item_type="artifact",
|
|
249
|
+
status="running",
|
|
250
|
+
role=None,
|
|
251
|
+
content={
|
|
252
|
+
"kind": "diff",
|
|
253
|
+
"unifiedDiff": _string_value(params.get("diff")) or _string_value(params.get("patch")) or "",
|
|
254
|
+
},
|
|
255
|
+
source_session_id=thread_id,
|
|
256
|
+
source_item_type=None,
|
|
257
|
+
event=method,
|
|
258
|
+
)
|
|
259
|
+
return ReductionResult(timeline_items=[item])
|
|
260
|
+
|
|
261
|
+
if method == "turn/plan/updated":
|
|
262
|
+
plan = params.get("plan") if isinstance(params.get("plan"), dict) else params
|
|
263
|
+
item = self._upsert_item(
|
|
264
|
+
session_id=session_id,
|
|
265
|
+
turn_id=turn_id,
|
|
266
|
+
item_id=None,
|
|
267
|
+
derived_key="turn-plan",
|
|
268
|
+
item_type="system",
|
|
269
|
+
status="running",
|
|
270
|
+
role="system",
|
|
271
|
+
content=_plan_content(plan),
|
|
272
|
+
source_session_id=thread_id,
|
|
273
|
+
source_item_type=None,
|
|
274
|
+
event=method,
|
|
275
|
+
)
|
|
276
|
+
return ReductionResult(timeline_items=[item])
|
|
277
|
+
|
|
278
|
+
if method in CODEX_APPROVAL_METHODS:
|
|
279
|
+
approval = self._approval_from_request(method, message, params, session_id, thread_id, turn_id)
|
|
280
|
+
timeline_item = self._approval_target_item(method, params, approval)
|
|
281
|
+
return ReductionResult(
|
|
282
|
+
session_update=self._session_update(
|
|
283
|
+
session_id=session_id,
|
|
284
|
+
thread_id=thread_id,
|
|
285
|
+
status="waiting_approval",
|
|
286
|
+
),
|
|
287
|
+
timeline_items=[timeline_item] if timeline_item else [],
|
|
288
|
+
approvals=[approval],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if method == "item/agentMessage/delta":
|
|
292
|
+
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
293
|
+
item = self._append_text_item(
|
|
294
|
+
session_id=session_id,
|
|
295
|
+
thread_id=thread_id,
|
|
296
|
+
turn_id=turn_id,
|
|
297
|
+
item_id=item_id,
|
|
298
|
+
delta=_string_value(params.get("delta")) or _string_value(params.get("text")) or "",
|
|
299
|
+
)
|
|
300
|
+
return ReductionResult(timeline_items=[item])
|
|
301
|
+
|
|
302
|
+
if method == "item/commandExecution/outputDelta":
|
|
303
|
+
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
304
|
+
item = self._append_command_output(
|
|
305
|
+
session_id=session_id,
|
|
306
|
+
thread_id=thread_id,
|
|
307
|
+
turn_id=turn_id,
|
|
308
|
+
item_id=item_id,
|
|
309
|
+
delta=_string_value(params.get("delta")) or _string_value(params.get("text")) or "",
|
|
310
|
+
)
|
|
311
|
+
return ReductionResult(timeline_items=[item])
|
|
312
|
+
|
|
313
|
+
if method == "item/fileChange/patchUpdated":
|
|
314
|
+
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
315
|
+
item = self._upsert_item(
|
|
316
|
+
session_id=session_id,
|
|
317
|
+
turn_id=turn_id,
|
|
318
|
+
item_id=item_id,
|
|
319
|
+
derived_key=None,
|
|
320
|
+
item_type="tool",
|
|
321
|
+
status="running",
|
|
322
|
+
role="tool",
|
|
323
|
+
content={
|
|
324
|
+
"kind": "file_change",
|
|
325
|
+
"changes": [
|
|
326
|
+
{
|
|
327
|
+
"path": _string_value(params.get("path")) or "",
|
|
328
|
+
"action": _string_value(params.get("action")) or "unknown",
|
|
329
|
+
"diff": _string_value(params.get("patch")) or _string_value(params.get("diff")),
|
|
330
|
+
}
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
source_session_id=thread_id,
|
|
334
|
+
source_item_type="fileChange",
|
|
335
|
+
event=method,
|
|
336
|
+
)
|
|
337
|
+
return ReductionResult(timeline_items=[item])
|
|
338
|
+
|
|
339
|
+
if method == "item/completed":
|
|
340
|
+
item = params.get("item") if isinstance(params.get("item"), dict) else params
|
|
341
|
+
item = dict(item)
|
|
342
|
+
item["_eventItemId"] = _string_value(params.get("itemId"))
|
|
343
|
+
timeline_item = self._upsert_completed_item(session_id, thread_id, turn_id, item, event=method)
|
|
344
|
+
return ReductionResult(timeline_items=[timeline_item] if timeline_item else [])
|
|
345
|
+
|
|
346
|
+
if method == "error":
|
|
347
|
+
item = self._upsert_item(
|
|
348
|
+
session_id=session_id,
|
|
349
|
+
turn_id=turn_id,
|
|
350
|
+
item_id=None,
|
|
351
|
+
derived_key=f"error-{_short_hash(message)}",
|
|
352
|
+
item_type="system",
|
|
353
|
+
status="failed",
|
|
354
|
+
role="system",
|
|
355
|
+
content={
|
|
356
|
+
"kind": "error",
|
|
357
|
+
"code": _string_value(params.get("code")) or "codex_error",
|
|
358
|
+
"message": _string_value(params.get("message")) or json.dumps(params, ensure_ascii=False),
|
|
359
|
+
"details": params,
|
|
360
|
+
"recoverable": True,
|
|
361
|
+
},
|
|
362
|
+
source_session_id=thread_id,
|
|
363
|
+
source_item_type=None,
|
|
364
|
+
event=method,
|
|
365
|
+
)
|
|
366
|
+
return ReductionResult(
|
|
367
|
+
session_update=self._session_update(
|
|
368
|
+
session_id=session_id,
|
|
369
|
+
thread_id=thread_id,
|
|
370
|
+
status="error",
|
|
371
|
+
),
|
|
372
|
+
timeline_items=[item],
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return ReductionResult()
|
|
376
|
+
|
|
377
|
+
def _upsert_completed_item(
|
|
378
|
+
self,
|
|
379
|
+
session_id: str,
|
|
380
|
+
thread_id: str | None,
|
|
381
|
+
turn_id: str | None,
|
|
382
|
+
item: dict[str, Any],
|
|
383
|
+
*,
|
|
384
|
+
event: str | None = None,
|
|
385
|
+
) -> dict[str, Any] | None:
|
|
386
|
+
codex_type = _string_value(item.get("type")) or "unknown"
|
|
387
|
+
item_id = _string_value(item.get("id")) or _string_value(item.get("itemId")) or _string_value(item.get("call_id")) or _short_hash(item)
|
|
388
|
+
derived_key = _stable_item_key(item)
|
|
389
|
+
source_item_id = _string_value(item.get("_eventItemId")) or item_id
|
|
390
|
+
status = _timeline_status(item.get("status")) or "done"
|
|
391
|
+
role: str | None = None
|
|
392
|
+
timeline_type = "system"
|
|
393
|
+
content: dict[str, Any]
|
|
394
|
+
source_extra: dict[str, Any] | None = None
|
|
395
|
+
|
|
396
|
+
if codex_type == "userMessage":
|
|
397
|
+
timeline_type = "message"
|
|
398
|
+
role = "user"
|
|
399
|
+
content = {"text": _message_text(item), "format": "markdown"}
|
|
400
|
+
client_message = self._client_message_for_user_message(
|
|
401
|
+
session_id, thread_id, turn_id, content["text"]
|
|
402
|
+
)
|
|
403
|
+
client_message_id = client_message.get("clientMessageId") if client_message else None
|
|
404
|
+
if client_message_id:
|
|
405
|
+
source_extra = {"clientMessageId": client_message_id}
|
|
406
|
+
attachments = client_message.get("attachments") if client_message else None
|
|
407
|
+
if isinstance(attachments, list) and attachments:
|
|
408
|
+
content["attachments"] = attachments
|
|
409
|
+
elif codex_type == "agentMessage":
|
|
410
|
+
timeline_type = "message"
|
|
411
|
+
role = "assistant"
|
|
412
|
+
content = {"text": _message_text(item), "format": "markdown"}
|
|
413
|
+
elif codex_type == "reasoning":
|
|
414
|
+
role = "system"
|
|
415
|
+
content = _reasoning_content(item)
|
|
416
|
+
elif codex_type == "plan":
|
|
417
|
+
role = "system"
|
|
418
|
+
content = _plan_content(item)
|
|
419
|
+
elif codex_type == "turnStart":
|
|
420
|
+
return self._upsert_turn_start(
|
|
421
|
+
session_id,
|
|
422
|
+
thread_id,
|
|
423
|
+
turn_id,
|
|
424
|
+
item,
|
|
425
|
+
status=_timeline_status(item.get("_historyTurnStartStatus")) or "running",
|
|
426
|
+
event=event or "history/turn_started",
|
|
427
|
+
)
|
|
428
|
+
elif codex_type == "turnEnd":
|
|
429
|
+
return self._upsert_turn_end(session_id, thread_id, turn_id, item)
|
|
430
|
+
elif codex_type == "commandExecution":
|
|
431
|
+
timeline_type = "tool"
|
|
432
|
+
role = "tool"
|
|
433
|
+
content = _command_content(item)
|
|
434
|
+
elif codex_type == "function_call":
|
|
435
|
+
timeline_type = "tool"
|
|
436
|
+
role = "tool"
|
|
437
|
+
content = _function_call_content(item)
|
|
438
|
+
self._tool_kind_by_call[source_item_id] = str(content.get("kind") or "command")
|
|
439
|
+
elif codex_type == "fileChange":
|
|
440
|
+
timeline_type = "tool"
|
|
441
|
+
role = "tool"
|
|
442
|
+
content = _file_change_content(item)
|
|
443
|
+
elif codex_type == "custom_tool_call":
|
|
444
|
+
timeline_type = "tool"
|
|
445
|
+
role = "tool"
|
|
446
|
+
content = _custom_tool_call_content(item)
|
|
447
|
+
self._tool_kind_by_call[source_item_id] = str(content.get("kind") or "tool")
|
|
448
|
+
elif codex_type in {"function_call_output", "custom_tool_call_output"}:
|
|
449
|
+
timeline_type = "tool"
|
|
450
|
+
role = "tool"
|
|
451
|
+
content = self._tool_output_content(session_id, thread_id, turn_id, source_item_id, item)
|
|
452
|
+
elif codex_type == "mcpToolCall":
|
|
453
|
+
timeline_type = "tool"
|
|
454
|
+
role = "tool"
|
|
455
|
+
content = {
|
|
456
|
+
"kind": "mcp",
|
|
457
|
+
"server": _string_value(item.get("server")) or "",
|
|
458
|
+
"tool": _string_value(item.get("tool")) or _string_value(item.get("name")) or "",
|
|
459
|
+
"arguments": item.get("arguments"),
|
|
460
|
+
"result": item.get("result"),
|
|
461
|
+
"error": item.get("error"),
|
|
462
|
+
}
|
|
463
|
+
elif codex_type == "webSearch":
|
|
464
|
+
timeline_type = "tool"
|
|
465
|
+
role = "tool"
|
|
466
|
+
content = {"kind": "web_search", "query": _string_value(item.get("query")), "action": item.get("action")}
|
|
467
|
+
elif codex_type == "imageView":
|
|
468
|
+
timeline_type = "artifact"
|
|
469
|
+
content = {
|
|
470
|
+
"kind": "image",
|
|
471
|
+
"path": _string_value(item.get("path")) or "",
|
|
472
|
+
"url": _string_value(item.get("url")),
|
|
473
|
+
"mediaType": _string_value(item.get("mediaType")),
|
|
474
|
+
}
|
|
475
|
+
else:
|
|
476
|
+
role = "system"
|
|
477
|
+
content = {"kind": "status", "code": f"codex.{codex_type}", "message": codex_type, "details": item}
|
|
478
|
+
|
|
479
|
+
return self._upsert_item(
|
|
480
|
+
session_id=session_id,
|
|
481
|
+
turn_id=turn_id,
|
|
482
|
+
item_id=None if derived_key else source_item_id,
|
|
483
|
+
derived_key=derived_key,
|
|
484
|
+
item_type=timeline_type,
|
|
485
|
+
status=status,
|
|
486
|
+
role=role,
|
|
487
|
+
content=content,
|
|
488
|
+
source_session_id=thread_id,
|
|
489
|
+
source_item_type=codex_type,
|
|
490
|
+
source_item_id=source_item_id,
|
|
491
|
+
event=event,
|
|
492
|
+
source_extra=source_extra,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def _client_message_for_user_message(
|
|
496
|
+
self,
|
|
497
|
+
session_id: str,
|
|
498
|
+
thread_id: str | None,
|
|
499
|
+
turn_id: str | None,
|
|
500
|
+
text: str,
|
|
501
|
+
) -> dict[str, Any] | None:
|
|
502
|
+
if turn_id is not None:
|
|
503
|
+
mapped = self._client_message_by_turn.get((session_id, thread_id, turn_id))
|
|
504
|
+
if mapped:
|
|
505
|
+
return mapped
|
|
506
|
+
pending_key = (session_id, thread_id)
|
|
507
|
+
pending = self._pending_client_messages.get(pending_key)
|
|
508
|
+
if not pending:
|
|
509
|
+
return None
|
|
510
|
+
for index, candidate in enumerate(pending):
|
|
511
|
+
expected = candidate.get("text")
|
|
512
|
+
if expected is None or _client_message_text_matches(text, expected):
|
|
513
|
+
client_message_id = candidate.get("clientMessageId")
|
|
514
|
+
del pending[index]
|
|
515
|
+
if turn_id is not None and client_message_id:
|
|
516
|
+
self._client_message_by_turn[(session_id, thread_id, turn_id)] = candidate
|
|
517
|
+
return candidate
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def _upsert_turn_start(
|
|
521
|
+
self,
|
|
522
|
+
session_id: str,
|
|
523
|
+
thread_id: str | None,
|
|
524
|
+
turn_id: str | None,
|
|
525
|
+
turn: dict[str, Any],
|
|
526
|
+
*,
|
|
527
|
+
status: str = "running",
|
|
528
|
+
event: str = "turn/started",
|
|
529
|
+
) -> dict[str, Any]:
|
|
530
|
+
return self._upsert_item(
|
|
531
|
+
session_id=session_id,
|
|
532
|
+
turn_id=turn_id,
|
|
533
|
+
item_id=None,
|
|
534
|
+
derived_key="turn-start",
|
|
535
|
+
item_type="turn.start",
|
|
536
|
+
status=status,
|
|
537
|
+
role=None,
|
|
538
|
+
content={
|
|
539
|
+
"title": _string_value(turn.get("title")),
|
|
540
|
+
"inputSummary": _turn_input_summary(turn),
|
|
541
|
+
},
|
|
542
|
+
source_session_id=thread_id,
|
|
543
|
+
source_item_type=None,
|
|
544
|
+
event=event,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def _complete_turn(
|
|
548
|
+
self,
|
|
549
|
+
session_id: str,
|
|
550
|
+
thread_id: str | None,
|
|
551
|
+
turn_id: str | None,
|
|
552
|
+
turn: dict[str, Any],
|
|
553
|
+
) -> list[dict[str, Any]]:
|
|
554
|
+
result = _turn_result(turn)
|
|
555
|
+
start = self._upsert_turn_start(
|
|
556
|
+
session_id=session_id,
|
|
557
|
+
thread_id=thread_id,
|
|
558
|
+
turn_id=turn_id,
|
|
559
|
+
turn=turn,
|
|
560
|
+
status=_turn_result_to_status(result),
|
|
561
|
+
event="turn/completed",
|
|
562
|
+
)
|
|
563
|
+
end = self._upsert_turn_end(session_id, thread_id, turn_id, turn)
|
|
564
|
+
return [start, end]
|
|
565
|
+
|
|
566
|
+
def _upsert_turn_end(
|
|
567
|
+
self,
|
|
568
|
+
session_id: str,
|
|
569
|
+
thread_id: str | None,
|
|
570
|
+
turn_id: str | None,
|
|
571
|
+
turn: dict[str, Any],
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
result = _turn_result(turn)
|
|
574
|
+
return self._upsert_item(
|
|
575
|
+
session_id=session_id,
|
|
576
|
+
turn_id=turn_id,
|
|
577
|
+
item_id=None,
|
|
578
|
+
derived_key="turn-end",
|
|
579
|
+
item_type="turn.end",
|
|
580
|
+
status=_turn_result_to_status(result),
|
|
581
|
+
role=None,
|
|
582
|
+
content={
|
|
583
|
+
"result": result,
|
|
584
|
+
"error": _error_content(turn.get("error")),
|
|
585
|
+
"usage": turn.get("usage"),
|
|
586
|
+
},
|
|
587
|
+
source_session_id=thread_id,
|
|
588
|
+
source_item_type=None,
|
|
589
|
+
event="turn/completed",
|
|
590
|
+
completed_at=_turn_completed_at(turn),
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def _append_text_item(
|
|
594
|
+
self,
|
|
595
|
+
*,
|
|
596
|
+
session_id: str,
|
|
597
|
+
thread_id: str | None,
|
|
598
|
+
turn_id: str | None,
|
|
599
|
+
item_id: str | None,
|
|
600
|
+
delta: str,
|
|
601
|
+
) -> dict[str, Any]:
|
|
602
|
+
timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
|
|
603
|
+
existing = self._items.get(timeline_id)
|
|
604
|
+
text = ""
|
|
605
|
+
if existing:
|
|
606
|
+
text = str(existing.get("content", {}).get("text") or "")
|
|
607
|
+
return self._upsert_item(
|
|
608
|
+
session_id=session_id,
|
|
609
|
+
turn_id=turn_id,
|
|
610
|
+
item_id=item_id,
|
|
611
|
+
derived_key=None,
|
|
612
|
+
item_type="message",
|
|
613
|
+
status="running",
|
|
614
|
+
role="assistant",
|
|
615
|
+
content={"text": text + delta, "format": "markdown"},
|
|
616
|
+
source_session_id=thread_id,
|
|
617
|
+
source_item_type="agentMessage",
|
|
618
|
+
source_item_id=item_id,
|
|
619
|
+
event="item/agentMessage/delta",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _append_command_output(
|
|
623
|
+
self,
|
|
624
|
+
*,
|
|
625
|
+
session_id: str,
|
|
626
|
+
thread_id: str | None,
|
|
627
|
+
turn_id: str | None,
|
|
628
|
+
item_id: str | None,
|
|
629
|
+
delta: str,
|
|
630
|
+
) -> dict[str, Any]:
|
|
631
|
+
timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
|
|
632
|
+
existing = self._items.get(timeline_id)
|
|
633
|
+
content = dict(existing.get("content", {})) if existing else {"kind": "command", "command": ""}
|
|
634
|
+
output = str(content.get("outputText") or "") + delta
|
|
635
|
+
output_preview = _preview_text(output)
|
|
636
|
+
content["outputText"] = output_preview
|
|
637
|
+
content["outputPreview"] = output_preview
|
|
638
|
+
content["outputTruncated"] = len(output) > OUTPUT_PREVIEW_CHARS
|
|
639
|
+
content["outputLength"] = len(output)
|
|
640
|
+
return self._upsert_item(
|
|
641
|
+
session_id=session_id,
|
|
642
|
+
turn_id=turn_id,
|
|
643
|
+
item_id=item_id,
|
|
644
|
+
derived_key=None,
|
|
645
|
+
item_type="tool",
|
|
646
|
+
status="running",
|
|
647
|
+
role="tool",
|
|
648
|
+
content=content,
|
|
649
|
+
source_session_id=thread_id,
|
|
650
|
+
source_item_type="commandExecution",
|
|
651
|
+
event="item/commandExecution/outputDelta",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _tool_output_content(
|
|
655
|
+
self,
|
|
656
|
+
session_id: str,
|
|
657
|
+
thread_id: str | None,
|
|
658
|
+
turn_id: str | None,
|
|
659
|
+
item_id: str,
|
|
660
|
+
item: dict[str, Any],
|
|
661
|
+
) -> dict[str, Any]:
|
|
662
|
+
timeline_id = _timeline_id(session_id, thread_id, turn_id, item_id, None)
|
|
663
|
+
existing = self._items.get(timeline_id)
|
|
664
|
+
content = dict(existing.get("content", {})) if existing else {"kind": self._tool_kind_by_call.get(item_id, "tool")}
|
|
665
|
+
content["result"] = _tool_output_value(item)
|
|
666
|
+
output = _tool_output_text(item)
|
|
667
|
+
output_preview = _preview_text(output)
|
|
668
|
+
content["outputText"] = output_preview
|
|
669
|
+
content["outputPreview"] = output_preview
|
|
670
|
+
content["outputTruncated"] = len(output) > OUTPUT_PREVIEW_CHARS
|
|
671
|
+
content["outputLength"] = len(output)
|
|
672
|
+
return content
|
|
673
|
+
|
|
674
|
+
def _approval_from_request(
|
|
675
|
+
self,
|
|
676
|
+
method: str,
|
|
677
|
+
message: dict[str, Any],
|
|
678
|
+
params: dict[str, Any],
|
|
679
|
+
session_id: str,
|
|
680
|
+
thread_id: str | None,
|
|
681
|
+
turn_id: str | None,
|
|
682
|
+
) -> dict[str, Any]:
|
|
683
|
+
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
684
|
+
approval_id = f"appr_{_short_hash([session_id, thread_id, turn_id, item_id, method, message.get('id')])}"
|
|
685
|
+
if "commandExecution" in method:
|
|
686
|
+
kind = "command"
|
|
687
|
+
title = "Codex wants to run a command"
|
|
688
|
+
elif "fileChange" in method:
|
|
689
|
+
kind = "file_change"
|
|
690
|
+
title = "Codex wants to change files"
|
|
691
|
+
elif "permissions" in method:
|
|
692
|
+
kind = "permission"
|
|
693
|
+
title = "Codex requests permission"
|
|
694
|
+
else:
|
|
695
|
+
kind = "unknown"
|
|
696
|
+
title = "Codex requests approval"
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
"id": approval_id,
|
|
700
|
+
"sessionId": session_id,
|
|
701
|
+
"turnId": turn_id,
|
|
702
|
+
"status": "pending",
|
|
703
|
+
"kind": kind,
|
|
704
|
+
"targetItemId": _timeline_id(session_id, thread_id, turn_id, item_id, None) if item_id else None,
|
|
705
|
+
"title": title,
|
|
706
|
+
"description": _approval_description(params),
|
|
707
|
+
"payload": params,
|
|
708
|
+
"choices": ["approve", "approve_for_session", "reject", "cancel"],
|
|
709
|
+
"source": {
|
|
710
|
+
"runtime": "codex",
|
|
711
|
+
"requestId": message.get("id"),
|
|
712
|
+
"sessionId": thread_id,
|
|
713
|
+
"turnId": turn_id,
|
|
714
|
+
"itemId": item_id,
|
|
715
|
+
"method": method,
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
def _approval_target_item(
|
|
720
|
+
self,
|
|
721
|
+
method: str,
|
|
722
|
+
params: dict[str, Any],
|
|
723
|
+
approval: dict[str, Any],
|
|
724
|
+
) -> dict[str, Any] | None:
|
|
725
|
+
target_item_id = approval.get("targetItemId")
|
|
726
|
+
if not isinstance(target_item_id, str):
|
|
727
|
+
return None
|
|
728
|
+
existing = self._items.get(target_item_id)
|
|
729
|
+
content = dict(existing.get("content", {})) if existing else {}
|
|
730
|
+
if not content:
|
|
731
|
+
if approval["kind"] == "command":
|
|
732
|
+
content = {
|
|
733
|
+
"kind": "command",
|
|
734
|
+
"command": params.get("command") or params.get("cmd") or "",
|
|
735
|
+
"cwd": _string_value(params.get("cwd")),
|
|
736
|
+
}
|
|
737
|
+
else:
|
|
738
|
+
content = {
|
|
739
|
+
"kind": "file_change" if approval["kind"] == "file_change" else "unknown",
|
|
740
|
+
"changes": [],
|
|
741
|
+
}
|
|
742
|
+
content["approval"] = {"id": approval["id"], "status": "pending"}
|
|
743
|
+
item_id = _string_value(params.get("itemId")) or _nested_string(params, "item", "id")
|
|
744
|
+
return self._upsert_item(
|
|
745
|
+
session_id=approval["sessionId"],
|
|
746
|
+
turn_id=approval.get("turnId"),
|
|
747
|
+
item_id=item_id,
|
|
748
|
+
derived_key=None,
|
|
749
|
+
item_type="tool",
|
|
750
|
+
status="waiting_approval",
|
|
751
|
+
role="tool",
|
|
752
|
+
content=content,
|
|
753
|
+
source_session_id=approval["source"].get("sessionId"),
|
|
754
|
+
source_item_type="commandExecution" if "commandExecution" in method else "fileChange",
|
|
755
|
+
event=method,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
def _upsert_item(
|
|
759
|
+
self,
|
|
760
|
+
*,
|
|
761
|
+
session_id: str,
|
|
762
|
+
turn_id: str | None,
|
|
763
|
+
item_id: str | None,
|
|
764
|
+
derived_key: str | None,
|
|
765
|
+
item_type: str,
|
|
766
|
+
status: str,
|
|
767
|
+
role: str | None,
|
|
768
|
+
content: dict[str, Any],
|
|
769
|
+
source_session_id: str | None,
|
|
770
|
+
source_item_type: str | None,
|
|
771
|
+
event: str | None,
|
|
772
|
+
source_item_id: str | None = None,
|
|
773
|
+
completed_at: str | None = None,
|
|
774
|
+
source_extra: dict[str, Any] | None = None,
|
|
775
|
+
) -> dict[str, Any]:
|
|
776
|
+
timeline_id = _timeline_id(session_id, source_session_id, turn_id, item_id, derived_key)
|
|
777
|
+
order_seq = self._order_by_item.setdefault(timeline_id, self._allocate_order_seq())
|
|
778
|
+
existing = self._items.get(timeline_id)
|
|
779
|
+
revision = int(existing.get("revision", 0)) + 1 if existing else 1
|
|
780
|
+
now = utc_now()
|
|
781
|
+
source = {
|
|
782
|
+
"runtime": "codex",
|
|
783
|
+
"sessionId": source_session_id,
|
|
784
|
+
"turnId": turn_id,
|
|
785
|
+
"itemId": source_item_id or item_id,
|
|
786
|
+
"itemType": source_item_type,
|
|
787
|
+
"event": event,
|
|
788
|
+
"derivedKey": derived_key,
|
|
789
|
+
}
|
|
790
|
+
if source_extra:
|
|
791
|
+
source.update(source_extra)
|
|
792
|
+
source = {key: value for key, value in source.items() if value is not None}
|
|
793
|
+
content_hash = _content_hash(item_type, status, role, content, source)
|
|
794
|
+
if existing and existing.get("contentHash") == content_hash:
|
|
795
|
+
return existing
|
|
796
|
+
snapshot = {
|
|
797
|
+
"id": timeline_id,
|
|
798
|
+
"sessionId": session_id,
|
|
799
|
+
"turnId": turn_id,
|
|
800
|
+
"type": item_type,
|
|
801
|
+
"status": status,
|
|
802
|
+
"role": role,
|
|
803
|
+
"content": content,
|
|
804
|
+
"source": source,
|
|
805
|
+
"orderSeq": order_seq,
|
|
806
|
+
"revision": revision,
|
|
807
|
+
"contentHash": content_hash,
|
|
808
|
+
"createdAt": existing.get("createdAt") if existing else now,
|
|
809
|
+
"updatedAt": now,
|
|
810
|
+
"completedAt": completed_at,
|
|
811
|
+
}
|
|
812
|
+
if role is None:
|
|
813
|
+
snapshot.pop("role")
|
|
814
|
+
if turn_id is None:
|
|
815
|
+
snapshot.pop("turnId")
|
|
816
|
+
if completed_at is None:
|
|
817
|
+
snapshot.pop("completedAt")
|
|
818
|
+
self._items[timeline_id] = snapshot
|
|
819
|
+
return snapshot
|
|
820
|
+
|
|
821
|
+
def _allocate_order_seq(self) -> int:
|
|
822
|
+
value = self._next_order
|
|
823
|
+
self._next_order += 1
|
|
824
|
+
return value
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _timeline_id(
|
|
828
|
+
session_id: str,
|
|
829
|
+
source_session_id: str | None,
|
|
830
|
+
turn_id: str | None,
|
|
831
|
+
item_id: str | None,
|
|
832
|
+
derived_key: str | None,
|
|
833
|
+
) -> str:
|
|
834
|
+
identity = [session_id, "codex", source_session_id, turn_id, item_id or derived_key]
|
|
835
|
+
return f"tl_{_short_hash(identity)}"
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _content_hash(*values: Any) -> str:
|
|
839
|
+
return f"sha256:{_short_hash(values, length=64)}"
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _client_message_text_matches(actual: str, expected: str) -> bool:
|
|
843
|
+
if actual == expected:
|
|
844
|
+
return True
|
|
845
|
+
return actual.startswith(expected) and actual[len(expected) :].startswith("\n\n[")
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _short_hash(value: Any, *, length: int = 20) -> str:
|
|
849
|
+
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
|
850
|
+
return hashlib.sha256(encoded).hexdigest()[:length]
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _extract_thread_id(params: dict[str, Any]) -> str | None:
|
|
854
|
+
return _string_value(params.get("threadId")) or _nested_string(params, "thread", "id")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _extract_turn_id(params: dict[str, Any]) -> str | None:
|
|
858
|
+
return _string_value(params.get("turnId")) or _nested_string(params, "turn", "id")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _thread_id(thread: dict[str, Any]) -> str | None:
|
|
862
|
+
return _string_value(thread.get("id")) or _string_value(thread.get("threadId")) or _nested_string(thread, "thread", "id")
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _string_value(value: Any) -> str | None:
|
|
866
|
+
return value if isinstance(value, str) else None
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _nested_string(data: dict[str, Any], key: str, nested_key: str) -> str | None:
|
|
870
|
+
nested = data.get(key)
|
|
871
|
+
if not isinstance(nested, dict):
|
|
872
|
+
return None
|
|
873
|
+
return _string_value(nested.get(nested_key))
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _list_value(value: Any) -> list[dict[str, Any]]:
|
|
877
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _message_type_counts(items: list[dict[str, Any]]) -> dict[str, int]:
|
|
881
|
+
counts: dict[str, int] = {}
|
|
882
|
+
for item in items:
|
|
883
|
+
codex_type = _string_value(item.get("type"))
|
|
884
|
+
if codex_type in {"userMessage", "agentMessage"}:
|
|
885
|
+
counts[codex_type] = counts.get(codex_type, 0) + 1
|
|
886
|
+
return counts
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _message_text(item: dict[str, Any]) -> str:
|
|
890
|
+
if isinstance(item.get("text"), str):
|
|
891
|
+
return item["text"]
|
|
892
|
+
parts = item.get("parts")
|
|
893
|
+
if isinstance(parts, list):
|
|
894
|
+
return "".join(str(part.get("text") or "") for part in parts if isinstance(part, dict))
|
|
895
|
+
content = item.get("content")
|
|
896
|
+
if isinstance(content, list):
|
|
897
|
+
return "".join(str(part.get("text") or "") for part in content if isinstance(part, dict))
|
|
898
|
+
return ""
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _is_bootstrap_user_message(item: dict[str, Any]) -> bool:
|
|
902
|
+
if _string_value(item.get("type")) != "userMessage":
|
|
903
|
+
return False
|
|
904
|
+
text = _message_text(item).lstrip()
|
|
905
|
+
return (
|
|
906
|
+
text.startswith("# AGENTS.md instructions for ")
|
|
907
|
+
and "<INSTRUCTIONS>" in text
|
|
908
|
+
and "<environment_context>" in text
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _is_external_import_marker(item: dict[str, Any]) -> bool:
|
|
913
|
+
if _string_value(item.get("type")) not in {"userMessage", "agentMessage"}:
|
|
914
|
+
return False
|
|
915
|
+
return _message_text(item).strip() == "<EXTERNAL SESSION IMPORTED>"
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _stable_item_key(item: dict[str, Any]) -> str | None:
|
|
919
|
+
derived_key = _string_value(item.get("_derivedKey"))
|
|
920
|
+
if derived_key:
|
|
921
|
+
return derived_key
|
|
922
|
+
message_key = _string_value(item.get("_messageKey"))
|
|
923
|
+
if message_key:
|
|
924
|
+
return message_key
|
|
925
|
+
codex_type = _string_value(item.get("type")) or "unknown"
|
|
926
|
+
if codex_type in {"userMessage", "agentMessage"}:
|
|
927
|
+
item_id = _string_value(item.get("id")) or _string_value(item.get("_eventItemId"))
|
|
928
|
+
if item_id and not item_id.startswith("item-"):
|
|
929
|
+
return None
|
|
930
|
+
return _message_item_key(codex_type)
|
|
931
|
+
item_id = _string_value(item.get("id"))
|
|
932
|
+
if not item_id or not item_id.startswith("item-"):
|
|
933
|
+
return None
|
|
934
|
+
index = item.get("_snapshotIndex")
|
|
935
|
+
if isinstance(index, int):
|
|
936
|
+
return f"snapshot-{codex_type}-{index}"
|
|
937
|
+
return f"snapshot-{codex_type}-{item_id}"
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _message_item_key(codex_type: str) -> str:
|
|
941
|
+
return f"message-{codex_type}"
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _reasoning_content(item: dict[str, Any]) -> dict[str, Any]:
|
|
945
|
+
summaries = item.get("summaries")
|
|
946
|
+
if not isinstance(summaries, list):
|
|
947
|
+
summaries = item.get("summary")
|
|
948
|
+
if isinstance(summaries, list):
|
|
949
|
+
normalized = [
|
|
950
|
+
{"index": index, "text": str(summary.get("text") or "") if isinstance(summary, dict) else str(summary)}
|
|
951
|
+
for index, summary in enumerate(summaries)
|
|
952
|
+
]
|
|
953
|
+
else:
|
|
954
|
+
normalized = []
|
|
955
|
+
return {"kind": "reasoning", "summaries": normalized, "rawText": _string_value(item.get("text"))}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _plan_content(plan: dict[str, Any]) -> dict[str, Any]:
|
|
959
|
+
steps = plan.get("steps")
|
|
960
|
+
normalized_steps = []
|
|
961
|
+
if isinstance(steps, list):
|
|
962
|
+
for step in steps:
|
|
963
|
+
if isinstance(step, dict):
|
|
964
|
+
normalized_steps.append(
|
|
965
|
+
{
|
|
966
|
+
"text": str(step.get("text") or step.get("description") or ""),
|
|
967
|
+
"status": _plan_step_status(step.get("status")),
|
|
968
|
+
}
|
|
969
|
+
)
|
|
970
|
+
else:
|
|
971
|
+
normalized_steps.append({"text": str(step), "status": "pending"})
|
|
972
|
+
return {
|
|
973
|
+
"kind": "plan",
|
|
974
|
+
"explanation": _string_value(plan.get("explanation")),
|
|
975
|
+
"steps": normalized_steps,
|
|
976
|
+
"text": _string_value(plan.get("text")),
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _plan_step_status(value: Any) -> str:
|
|
981
|
+
if value in {"pending", "running", "done"}:
|
|
982
|
+
return str(value)
|
|
983
|
+
if value == "completed":
|
|
984
|
+
return "done"
|
|
985
|
+
if value == "in_progress":
|
|
986
|
+
return "running"
|
|
987
|
+
return "pending"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _command_content(item: dict[str, Any]) -> dict[str, Any]:
|
|
991
|
+
output = (
|
|
992
|
+
_string_value(item.get("outputText"))
|
|
993
|
+
or _string_value(item.get("output"))
|
|
994
|
+
or _string_value(item.get("aggregatedOutput"))
|
|
995
|
+
or ""
|
|
996
|
+
)
|
|
997
|
+
output_preview = _preview_text(output)
|
|
998
|
+
return {
|
|
999
|
+
"kind": "command",
|
|
1000
|
+
"command": item.get("command") or item.get("cmd") or "",
|
|
1001
|
+
"cwd": _string_value(item.get("cwd")),
|
|
1002
|
+
"outputText": output_preview,
|
|
1003
|
+
"outputPreview": output_preview,
|
|
1004
|
+
"outputTruncated": len(output) > OUTPUT_PREVIEW_CHARS,
|
|
1005
|
+
"outputLength": len(output),
|
|
1006
|
+
"exitCode": item.get("exitCode"),
|
|
1007
|
+
"durationMs": item.get("durationMs"),
|
|
1008
|
+
"processId": item.get("processId"),
|
|
1009
|
+
"actions": item.get("commandActions"),
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _function_call_content(item: dict[str, Any]) -> dict[str, Any]:
|
|
1014
|
+
name = _string_value(item.get("name")) or "function"
|
|
1015
|
+
arguments = _parse_jsonish(item.get("arguments"))
|
|
1016
|
+
if name == "exec_command":
|
|
1017
|
+
command = arguments.get("cmd") if isinstance(arguments, dict) else None
|
|
1018
|
+
return {
|
|
1019
|
+
"kind": "command",
|
|
1020
|
+
"command": command or "",
|
|
1021
|
+
"cwd": arguments.get("workdir") if isinstance(arguments, dict) else None,
|
|
1022
|
+
"arguments": arguments,
|
|
1023
|
+
"function": name,
|
|
1024
|
+
}
|
|
1025
|
+
if name in {"web", "web.run"} or name.startswith("web."):
|
|
1026
|
+
return {"kind": "web_search", "query": _query_from_arguments(arguments), "action": arguments, "function": name}
|
|
1027
|
+
return {"kind": "mcp", "server": "function", "tool": name, "arguments": arguments, "result": None, "error": None}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _custom_tool_call_content(item: dict[str, Any]) -> dict[str, Any]:
|
|
1031
|
+
name = _string_value(item.get("name")) or "custom_tool"
|
|
1032
|
+
call_input = item.get("input")
|
|
1033
|
+
if name == "apply_patch":
|
|
1034
|
+
return {"kind": "file_change", "tool": name, "changes": _changes_from_patch(_string_value(call_input) or "")}
|
|
1035
|
+
return {"kind": "mcp", "server": "custom", "tool": name, "arguments": call_input, "result": None, "error": None}
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _tool_output_value(item: dict[str, Any]) -> Any:
|
|
1039
|
+
output = item.get("output")
|
|
1040
|
+
if isinstance(output, str):
|
|
1041
|
+
parsed = _parse_jsonish(output)
|
|
1042
|
+
return parsed
|
|
1043
|
+
return output
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _tool_output_text(item: dict[str, Any]) -> str:
|
|
1047
|
+
output = _tool_output_value(item)
|
|
1048
|
+
if isinstance(output, dict):
|
|
1049
|
+
for key in ("output", "text", "message"):
|
|
1050
|
+
if isinstance(output.get(key), str):
|
|
1051
|
+
return output[key]
|
|
1052
|
+
return json.dumps(output, ensure_ascii=False, indent=2)
|
|
1053
|
+
if output is None:
|
|
1054
|
+
return ""
|
|
1055
|
+
return str(output)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _preview_text(value: str) -> str:
|
|
1059
|
+
return value[-OUTPUT_PREVIEW_CHARS:]
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _file_change_content(item: dict[str, Any]) -> dict[str, Any]:
|
|
1063
|
+
changes = item.get("changes")
|
|
1064
|
+
if not isinstance(changes, list):
|
|
1065
|
+
changes = [
|
|
1066
|
+
{
|
|
1067
|
+
"path": _string_value(item.get("path")) or "",
|
|
1068
|
+
"action": _string_value(item.get("action")) or "unknown",
|
|
1069
|
+
"diff": _string_value(item.get("diff")) or _string_value(item.get("patch")),
|
|
1070
|
+
}
|
|
1071
|
+
]
|
|
1072
|
+
return {"kind": "file_change", "changes": changes}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _parse_jsonish(value: Any) -> Any:
|
|
1076
|
+
if not isinstance(value, str):
|
|
1077
|
+
return value
|
|
1078
|
+
try:
|
|
1079
|
+
return json.loads(value)
|
|
1080
|
+
except json.JSONDecodeError:
|
|
1081
|
+
return value
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _query_from_arguments(arguments: Any) -> str | None:
|
|
1085
|
+
if isinstance(arguments, dict):
|
|
1086
|
+
query = arguments.get("query") or arguments.get("q")
|
|
1087
|
+
if isinstance(query, str):
|
|
1088
|
+
return query
|
|
1089
|
+
search_query = arguments.get("search_query")
|
|
1090
|
+
if isinstance(search_query, list) and search_query and isinstance(search_query[0], dict):
|
|
1091
|
+
q = search_query[0].get("q")
|
|
1092
|
+
return q if isinstance(q, str) else None
|
|
1093
|
+
return None
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _changes_from_patch(patch: str) -> list[dict[str, Any]]:
|
|
1097
|
+
changes: list[dict[str, Any]] = []
|
|
1098
|
+
current: dict[str, Any] | None = None
|
|
1099
|
+
diff_lines: list[str] = []
|
|
1100
|
+
for line in patch.splitlines():
|
|
1101
|
+
if line.startswith("*** Add File: ") or line.startswith("*** Update File: ") or line.startswith("*** Delete File: "):
|
|
1102
|
+
if current is not None:
|
|
1103
|
+
current["diff"] = "\n".join(diff_lines)
|
|
1104
|
+
changes.append(current)
|
|
1105
|
+
action, path = _patch_header(line)
|
|
1106
|
+
current = {"path": path, "action": action}
|
|
1107
|
+
diff_lines = []
|
|
1108
|
+
elif current is not None:
|
|
1109
|
+
diff_lines.append(line)
|
|
1110
|
+
if current is not None:
|
|
1111
|
+
current["diff"] = "\n".join(diff_lines)
|
|
1112
|
+
changes.append(current)
|
|
1113
|
+
return changes or [{"path": "", "action": "patch", "diff": patch}]
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _patch_header(line: str) -> tuple[str, str]:
|
|
1117
|
+
if line.startswith("*** Add File: "):
|
|
1118
|
+
return "add", line.removeprefix("*** Add File: ").strip()
|
|
1119
|
+
if line.startswith("*** Delete File: "):
|
|
1120
|
+
return "delete", line.removeprefix("*** Delete File: ").strip()
|
|
1121
|
+
return "update", line.removeprefix("*** Update File: ").strip()
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _timeline_status(value: Any) -> str | None:
|
|
1125
|
+
if value in {"pending", "running", "waiting_approval", "done", "failed", "cancelled", "interrupted"}:
|
|
1126
|
+
return str(value)
|
|
1127
|
+
if value in {"completed", "succeeded"}:
|
|
1128
|
+
return "done"
|
|
1129
|
+
if value in {"inProgress", "in_progress"}:
|
|
1130
|
+
return "running"
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _turn_status(turn: dict[str, Any]) -> str:
|
|
1135
|
+
status = turn.get("status")
|
|
1136
|
+
if isinstance(status, dict):
|
|
1137
|
+
return str(status.get("type") or "")
|
|
1138
|
+
return str(status or "")
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _turn_result(turn: dict[str, Any]) -> str:
|
|
1142
|
+
status = _turn_status(turn)
|
|
1143
|
+
if status in {"completed", "failed", "interrupted", "cancelled"}:
|
|
1144
|
+
return status
|
|
1145
|
+
return "completed"
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _turn_completed_at(turn: dict[str, Any]) -> str | None:
|
|
1149
|
+
for key in (
|
|
1150
|
+
"completedAt",
|
|
1151
|
+
"completed_at",
|
|
1152
|
+
"endedAt",
|
|
1153
|
+
"ended_at",
|
|
1154
|
+
"finishedAt",
|
|
1155
|
+
"finished_at",
|
|
1156
|
+
"updatedAt",
|
|
1157
|
+
"updated_at",
|
|
1158
|
+
):
|
|
1159
|
+
value = turn.get(key)
|
|
1160
|
+
if isinstance(value, str) and value:
|
|
1161
|
+
return value
|
|
1162
|
+
return None
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _turn_result_to_status(result: str) -> str:
|
|
1166
|
+
if result == "completed":
|
|
1167
|
+
return "done"
|
|
1168
|
+
if result in {"failed", "interrupted", "cancelled"}:
|
|
1169
|
+
return result
|
|
1170
|
+
return "done"
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _session_status_from_turn(turn: dict[str, Any]) -> str:
|
|
1174
|
+
result = _turn_result(turn)
|
|
1175
|
+
if result == "completed":
|
|
1176
|
+
return "idle"
|
|
1177
|
+
if result in {"interrupted", "cancelled"}:
|
|
1178
|
+
return "idle"
|
|
1179
|
+
return "error"
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _session_status_from_thread(thread: dict[str, Any]) -> str:
|
|
1183
|
+
status = thread.get("status")
|
|
1184
|
+
status_type = status.get("type") if isinstance(status, dict) else status
|
|
1185
|
+
if status_type in {"running", "inProgress"}:
|
|
1186
|
+
return "running"
|
|
1187
|
+
if status_type == "waiting_approval":
|
|
1188
|
+
return "waiting_approval"
|
|
1189
|
+
if status_type == "error":
|
|
1190
|
+
return "error"
|
|
1191
|
+
return "idle"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _turn_input_summary(turn: dict[str, Any]) -> str | None:
|
|
1195
|
+
input_value = turn.get("input")
|
|
1196
|
+
if isinstance(input_value, str):
|
|
1197
|
+
return input_value[:200]
|
|
1198
|
+
if isinstance(input_value, list):
|
|
1199
|
+
text = "".join(str(item.get("text") or "") for item in input_value if isinstance(item, dict))
|
|
1200
|
+
return text[:200] if text else None
|
|
1201
|
+
return None
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _error_content(value: Any) -> dict[str, Any] | None:
|
|
1205
|
+
if value is None:
|
|
1206
|
+
return None
|
|
1207
|
+
if isinstance(value, dict):
|
|
1208
|
+
return {
|
|
1209
|
+
"code": _string_value(value.get("code")),
|
|
1210
|
+
"message": _string_value(value.get("message")) or json.dumps(value, ensure_ascii=False),
|
|
1211
|
+
"details": value,
|
|
1212
|
+
}
|
|
1213
|
+
return {"message": str(value)}
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _approval_description(params: dict[str, Any]) -> str | None:
|
|
1217
|
+
parts = [
|
|
1218
|
+
_string_value(params.get("command")),
|
|
1219
|
+
_string_value(params.get("reason")),
|
|
1220
|
+
_string_value(params.get("cwd")),
|
|
1221
|
+
_string_value(params.get("grantRoot")),
|
|
1222
|
+
]
|
|
1223
|
+
return "\n".join(part for part in parts if part) or None
|