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.
@@ -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