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,642 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+ from connector.claude.normalizers import ClaudeTranscriptNormalizer
12
+ from connector.claude.path_utils import stable_claude_session_id
13
+ from connector.claude.timeline_identity import content_hash
14
+ from connector.claude.timeline_reducer import ClaudeTimelineReducer
15
+ from connector.sync_state import SyncStateStore
16
+ from connector.time import utc_now
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class PendingClientMessage:
21
+ client_message_id: str
22
+ text: str | None = None
23
+ attachments: list[dict[str, Any]] | None = None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class _HistoryCursor:
28
+ last_modified: int | None
29
+ file_size: int | None
30
+ message_count: int
31
+ last_message_uuid: str | None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class _HistoryTurn:
36
+ turn_id: str
37
+ raw_messages: list[dict[str, Any]] = field(default_factory=list)
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class ClaudeHistoryAdapter:
42
+ """Claude history scanner backed by Claude Agent SDK session APIs."""
43
+
44
+ sdk_module: Any | None = None
45
+ sync_state_store: SyncStateStore | None = None
46
+ _cursors: dict[str, _HistoryCursor] = field(default_factory=dict)
47
+
48
+ def forget_sync_state(self) -> None:
49
+ self._cursors.clear()
50
+
51
+ def forget_persisted_sync_state(self, connector_id: str) -> None:
52
+ self.forget_sync_state()
53
+ if self.sync_state_store is not None:
54
+ self.sync_state_store.delete_runtime("claude", connector_id)
55
+
56
+ def apply_history_sync_state(self, _state: list[dict[str, Any]]) -> None:
57
+ # Reserved for future persisted SDK history state. For now, the
58
+ # connector rebuilds this lightweight cache as it scans.
59
+ return
60
+
61
+ async def sync_existing_sessions(
62
+ self,
63
+ connector_id: str,
64
+ *,
65
+ limit: int = 100,
66
+ force: bool = False,
67
+ skip_external_session_ids: set[str] | None = None,
68
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
69
+ ) -> dict[str, Any]:
70
+ sdk = self._load_sdk()
71
+ sessions = _list_sessions(sdk, limit=limit)
72
+ notifications: list[dict[str, Any]] = []
73
+ synced: list[str] = []
74
+ skipped: list[str] = []
75
+ skipped_active = skip_external_session_ids or set()
76
+
77
+ started = time.perf_counter()
78
+ for session in sessions:
79
+ external_session_id = _string_attr(session, "session_id")
80
+ if external_session_id is None:
81
+ continue
82
+ if external_session_id in skipped_active:
83
+ skipped.append(external_session_id)
84
+ continue
85
+ session_id = stable_claude_session_id(connector_id, external_session_id)
86
+
87
+ messages = _get_session_messages(
88
+ sdk,
89
+ external_session_id,
90
+ directory=_string_attr(session, "cwd"),
91
+ )
92
+ cursor = _cursor_for(session, messages)
93
+ previous_cursor = self._previous_cursor(connector_id, external_session_id)
94
+ if not force and previous_cursor == cursor:
95
+ skipped.append(external_session_id)
96
+ continue
97
+ sync_messages = messages if previous_cursor is None else _messages_after_cursor(messages, previous_cursor)
98
+ if not sync_messages:
99
+ self._store_cursor(connector_id, external_session_id, cursor)
100
+ skipped.append(external_session_id)
101
+ continue
102
+
103
+ thread_notifications = _backend_notifications_from_sdk_history(
104
+ session_id=session_id,
105
+ external_session_id=external_session_id,
106
+ session_info=session,
107
+ messages=sync_messages,
108
+ timeline_method="timeline.sync" if previous_cursor is None else "timeline.itemUpsert",
109
+ )
110
+ self._store_cursor(connector_id, external_session_id, cursor)
111
+ if notification_sink is not None:
112
+ await notification_sink(thread_notifications)
113
+ else:
114
+ notifications.extend(thread_notifications)
115
+ synced.append(session_id)
116
+
117
+ elapsed_ms = (time.perf_counter() - started) * 1000
118
+ logger.info(
119
+ "claude sdk history sync connector_id={} synced={} skipped={} elapsed_ms={:.1f}",
120
+ connector_id,
121
+ len(synced),
122
+ len(skipped),
123
+ elapsed_ms,
124
+ )
125
+ return {
126
+ "threads": synced,
127
+ "skippedThreads": skipped,
128
+ "backendNotifications": notifications,
129
+ }
130
+
131
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
132
+ session_id = _required(params, "sessionId")
133
+ external_session_id = _required(params, "externalSessionId")
134
+ pending_client_messages = _pending_client_messages(params.get("pendingClientMessages"))
135
+
136
+ sdk = self._load_sdk()
137
+ cwd = params.get("cwd") if isinstance(params.get("cwd"), str) else None
138
+ session_info = _get_session_info(sdk, external_session_id, directory=cwd)
139
+ messages = _get_session_messages(sdk, external_session_id, directory=cwd)
140
+ self._cursors[external_session_id] = _cursor_for(session_info, messages)
141
+ return {
142
+ "backendNotifications": _backend_notifications_from_sdk_history(
143
+ session_id=session_id,
144
+ external_session_id=external_session_id,
145
+ session_info=session_info,
146
+ messages=messages,
147
+ fallback_cwd=cwd,
148
+ pending_client_messages=pending_client_messages,
149
+ )
150
+ }
151
+
152
+ async def mark_session_consumed(
153
+ self,
154
+ *,
155
+ connector_id: str | None = None,
156
+ external_session_id: str | None,
157
+ cwd: str | None = None,
158
+ ) -> None:
159
+ if external_session_id is None:
160
+ return
161
+ sdk = self._load_sdk()
162
+ session_info = _get_session_info(sdk, external_session_id, directory=cwd)
163
+ messages = _get_session_messages(sdk, external_session_id, directory=cwd)
164
+ cursor = _cursor_for(session_info, messages)
165
+ if connector_id is None:
166
+ self._cursors[external_session_id] = cursor
167
+ else:
168
+ self._store_cursor(connector_id, external_session_id, cursor)
169
+
170
+ def _previous_cursor(self, connector_id: str, external_session_id: str) -> _HistoryCursor | None:
171
+ cursor = self._cursors.get(external_session_id)
172
+ if cursor is not None:
173
+ return cursor
174
+ if self.sync_state_store is None:
175
+ return None
176
+ state = self.sync_state_store.get("claude", connector_id, external_session_id)
177
+ if state is None:
178
+ return None
179
+ cursor = _cursor_from_state(state.fingerprint, state.cursor)
180
+ if cursor is not None:
181
+ self._cursors[external_session_id] = cursor
182
+ return cursor
183
+
184
+ def _store_cursor(self, connector_id: str, external_session_id: str, cursor: _HistoryCursor) -> None:
185
+ self._cursors[external_session_id] = cursor
186
+ if self.sync_state_store is not None:
187
+ self.sync_state_store.set(
188
+ "claude",
189
+ connector_id,
190
+ external_session_id,
191
+ fingerprint=_cursor_fingerprint_json(cursor),
192
+ cursor=_cursor_position_json(cursor),
193
+ )
194
+
195
+ def _load_sdk(self) -> Any:
196
+ if self.sdk_module is not None:
197
+ return self.sdk_module
198
+ try:
199
+ import claude_agent_sdk # type: ignore[import-not-found]
200
+ except ModuleNotFoundError as exc:
201
+ raise RuntimeError("claude-agent-sdk is not installed") from exc
202
+ return claude_agent_sdk
203
+
204
+ def _backend_notifications_from_sdk_history(
205
+ *,
206
+ session_id: str,
207
+ external_session_id: str,
208
+ session_info: Any,
209
+ messages: list[Any],
210
+ fallback_cwd: str | None = None,
211
+ pending_client_messages: list[PendingClientMessage] | None = None,
212
+ timeline_method: str = "timeline.sync",
213
+ ) -> list[dict[str, Any]]:
214
+ source_observed_at = _timestamp_from_ms(_int_attr(session_info, "last_modified")) or utc_now()
215
+ session_update: dict[str, Any] = {
216
+ "sessionId": session_id,
217
+ "runtime": "claude",
218
+ "externalSessionId": external_session_id,
219
+ "status": "idle",
220
+ "lastSyncedAt": utc_now(),
221
+ "sourceObservedAt": source_observed_at,
222
+ "lastActivityAt": source_observed_at,
223
+ }
224
+ title = _string_attr(session_info, "custom_title") or _string_attr(session_info, "summary")
225
+ cwd = _string_attr(session_info, "cwd") or fallback_cwd
226
+ if title:
227
+ session_update["title"] = title
228
+ if cwd:
229
+ session_update["cwd"] = cwd
230
+
231
+ notifications = [{"method": "session.updated", "params": session_update}]
232
+ timeline_items = _timeline_items_from_messages(
233
+ session_id=session_id,
234
+ external_session_id=external_session_id,
235
+ session_info=session_info,
236
+ messages=messages,
237
+ pending_client_messages=pending_client_messages,
238
+ )
239
+ if timeline_items:
240
+ if timeline_method == "timeline.itemUpsert":
241
+ for item in timeline_items:
242
+ notifications.append(
243
+ {
244
+ "method": "timeline.itemUpsert",
245
+ "params": {
246
+ "sessionId": session_id,
247
+ "sourceObservedAt": source_observed_at,
248
+ "item": item,
249
+ },
250
+ }
251
+ )
252
+ else:
253
+ notifications.append(
254
+ {
255
+ "method": "timeline.sync",
256
+ "params": {
257
+ "sessionId": session_id,
258
+ "sourceObservedAt": source_observed_at,
259
+ "items": timeline_items,
260
+ },
261
+ }
262
+ )
263
+ return notifications
264
+
265
+
266
+ def _timeline_items_from_messages(
267
+ *,
268
+ session_id: str,
269
+ external_session_id: str,
270
+ session_info: Any,
271
+ messages: list[Any],
272
+ pending_client_messages: list[PendingClientMessage] | None = None,
273
+ ) -> list[dict[str, Any]]:
274
+ turns = _partition_history_turns(messages, session_info=session_info)
275
+ out: list[dict[str, Any]] = []
276
+ next_order = 1
277
+ matcher = _PendingClientMessageMatcher(pending_client_messages or [])
278
+ for turn in turns:
279
+ if not turn.raw_messages:
280
+ continue
281
+ timestamp = _raw_timestamp(turn.raw_messages[0])
282
+ turn_start = _turn_boundary_item(
283
+ session_id=session_id,
284
+ external_session_id=external_session_id,
285
+ turn_id=turn.turn_id,
286
+ item_type="turn.start",
287
+ status="running",
288
+ result=None,
289
+ timestamp=timestamp,
290
+ order_seq=next_order,
291
+ )
292
+ next_order += 1
293
+ out.append(turn_start)
294
+
295
+ events = ClaudeTranscriptNormalizer().normalize(turn.raw_messages)
296
+ _attach_pending_client_messages(events, matcher)
297
+ reduced = ClaudeTimelineReducer().reduce(
298
+ session_id=session_id,
299
+ turn_id=turn.turn_id,
300
+ events=events,
301
+ )
302
+ for item in _visible_history_items(reduced):
303
+ adjusted = dict(item)
304
+ adjusted["orderSeq"] = next_order
305
+ next_order += 1
306
+ out.append(adjusted)
307
+
308
+ turn_end = _turn_boundary_item(
309
+ session_id=session_id,
310
+ external_session_id=external_session_id,
311
+ turn_id=turn.turn_id,
312
+ item_type="turn.end",
313
+ status="done",
314
+ result="completed",
315
+ timestamp=_raw_timestamp(turn.raw_messages[-1]),
316
+ order_seq=next_order,
317
+ )
318
+ next_order += 1
319
+ out.append(turn_end)
320
+ return out
321
+
322
+
323
+ def _partition_history_turns(messages: list[Any], *, session_info: Any) -> list[_HistoryTurn]:
324
+ turns: list[_HistoryTurn] = []
325
+ current: _HistoryTurn | None = None
326
+ for index, message in enumerate(messages):
327
+ raw = _raw_from_session_message(message, session_info=session_info, index=index)
328
+ if raw is None:
329
+ continue
330
+ if _is_user_prompt_raw(raw):
331
+ if current is not None and current.raw_messages:
332
+ turns.append(current)
333
+ current = _HistoryTurn(turn_id=_raw_uuid(raw))
334
+ elif current is None:
335
+ current = _HistoryTurn(turn_id=_raw_uuid(raw))
336
+ current.raw_messages.append(raw)
337
+ if current is not None and current.raw_messages:
338
+ turns.append(current)
339
+ return turns
340
+
341
+
342
+ def _raw_from_session_message(message: Any, *, session_info: Any, index: int) -> dict[str, Any] | None:
343
+ raw_message = getattr(message, "message", None)
344
+ if not isinstance(raw_message, dict):
345
+ return None
346
+ message_uuid = getattr(message, "uuid", None)
347
+ if not isinstance(message_uuid, str) or not message_uuid:
348
+ return None
349
+ sdk_session_id = getattr(message, "session_id", None)
350
+ if not isinstance(sdk_session_id, str) or not sdk_session_id:
351
+ sdk_session_id = _string_attr(session_info, "session_id") or "unknown"
352
+
353
+ normalized_message = dict(raw_message)
354
+ role = normalized_message.get("role")
355
+ message_type = getattr(message, "type", None)
356
+ if not isinstance(role, str) and message_type in {"user", "assistant"}:
357
+ normalized_message["role"] = message_type
358
+
359
+ return {
360
+ "uuid": message_uuid,
361
+ "session_id": sdk_session_id,
362
+ "timestamp": _stable_message_timestamp(session_info, index),
363
+ "message": normalized_message,
364
+ }
365
+
366
+
367
+ def _visible_history_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
368
+ return [item for item in items if _is_visible_history_item(item)]
369
+
370
+
371
+ def _is_visible_history_item(item: dict[str, Any]) -> bool:
372
+ if item.get("type") != "tool":
373
+ return True
374
+ content = item.get("content")
375
+ if not isinstance(content, dict):
376
+ return False
377
+ if content.get("kind") != "file_change":
378
+ return False
379
+ status = item.get("status")
380
+ has_call = isinstance(content.get("toolUseId"), str) and isinstance(content.get("toolName"), str)
381
+ has_result = status in {"done", "failed"} and (
382
+ "result" in content or "outputText" in content or "error" in content
383
+ )
384
+ return has_call and has_result
385
+
386
+
387
+ class _PendingClientMessageMatcher:
388
+ def __init__(self, messages: list[PendingClientMessage]) -> None:
389
+ self._messages = list(messages)
390
+
391
+ def pop_match(self, text: str) -> PendingClientMessage | None:
392
+ for index, message in enumerate(self._messages):
393
+ expected = message.text
394
+ if expected is None or _client_message_text_matches(text, expected):
395
+ return self._messages.pop(index)
396
+ return None
397
+
398
+
399
+ def _attach_pending_client_messages(
400
+ events: list[Any],
401
+ matcher: _PendingClientMessageMatcher,
402
+ ) -> None:
403
+ for event in events:
404
+ if event.role != "user" or event.text is None or event.toolUseId:
405
+ continue
406
+ pending = matcher.pop_match(event.text)
407
+ if pending is None:
408
+ continue
409
+ event.clientMessageId = pending.client_message_id
410
+ if pending.attachments:
411
+ event.attachments = pending.attachments
412
+
413
+
414
+ def _pending_client_messages(value: Any) -> list[PendingClientMessage]:
415
+ if not isinstance(value, list):
416
+ return []
417
+ out: list[PendingClientMessage] = []
418
+ for item in value:
419
+ if not isinstance(item, dict):
420
+ continue
421
+ client_message_id = item.get("clientMessageId")
422
+ if not isinstance(client_message_id, str) or not client_message_id:
423
+ continue
424
+ text = item.get("text") if isinstance(item.get("text"), str) else None
425
+ attachments = item.get("attachments")
426
+ if not isinstance(attachments, list):
427
+ attachments = None
428
+ out.append(
429
+ PendingClientMessage(
430
+ client_message_id=client_message_id,
431
+ text=text,
432
+ attachments=attachments,
433
+ )
434
+ )
435
+ return out
436
+
437
+
438
+ def _client_message_text_matches(actual: str, expected: str) -> bool:
439
+ if actual == expected:
440
+ return True
441
+ return actual.startswith(expected) and actual[len(expected) :].startswith("\n\n[")
442
+
443
+
444
+ def _is_user_prompt_raw(raw: dict[str, Any]) -> bool:
445
+ message = raw.get("message") if isinstance(raw.get("message"), dict) else {}
446
+ if message.get("role") != "user":
447
+ return False
448
+ content = message.get("content")
449
+ if isinstance(content, str):
450
+ return bool(content.strip())
451
+ if not isinstance(content, list):
452
+ return False
453
+ for block in content:
454
+ if not isinstance(block, dict):
455
+ continue
456
+ if block.get("type") == "text" and isinstance(block.get("text"), str) and block["text"].strip():
457
+ return True
458
+ return False
459
+
460
+
461
+ def _turn_boundary_item(
462
+ *,
463
+ session_id: str,
464
+ external_session_id: str,
465
+ turn_id: str,
466
+ item_type: str,
467
+ status: str,
468
+ result: str | None,
469
+ timestamp: str | None,
470
+ order_seq: int,
471
+ ) -> dict[str, Any]:
472
+ is_end = item_type == "turn.end"
473
+ derived_key = "turn-end" if is_end else "turn-start"
474
+ content = {"stopReason": result, "result": result} if is_end else {}
475
+ item_id = f"{turn_id}:{derived_key}"
476
+ now = timestamp or utc_now()
477
+ return {
478
+ "id": item_id,
479
+ "sessionId": session_id,
480
+ "turnId": turn_id,
481
+ "type": item_type,
482
+ "status": status,
483
+ "role": None,
484
+ "content": content,
485
+ "source": {
486
+ "runtime": "claude",
487
+ "sessionId": external_session_id,
488
+ "turnId": turn_id,
489
+ "itemId": item_id,
490
+ "itemType": item_type,
491
+ "event": item_type,
492
+ "derivedKey": derived_key,
493
+ },
494
+ "orderSeq": order_seq,
495
+ "revision": 1,
496
+ "contentHash": content_hash(content),
497
+ "createdAt": now,
498
+ "updatedAt": now,
499
+ "completedAt": now if is_end else None,
500
+ }
501
+
502
+
503
+ def _list_sessions(sdk: Any, *, limit: int) -> list[Any]:
504
+ list_sessions = getattr(sdk, "list_sessions", None)
505
+ if not callable(list_sessions):
506
+ raise RuntimeError("claude-agent-sdk does not expose list_sessions()")
507
+ return list(list_sessions(limit=limit))
508
+
509
+
510
+ def _get_session_info(sdk: Any, session_id: str, *, directory: str | None) -> Any:
511
+ get_session_info = getattr(sdk, "get_session_info", None)
512
+ if not callable(get_session_info):
513
+ return None
514
+ try:
515
+ return get_session_info(session_id, directory=directory)
516
+ except TypeError:
517
+ return get_session_info(session_id)
518
+
519
+
520
+ def _get_session_messages(sdk: Any, session_id: str, *, directory: str | None) -> list[Any]:
521
+ get_session_messages = getattr(sdk, "get_session_messages", None)
522
+ if not callable(get_session_messages):
523
+ raise RuntimeError("claude-agent-sdk does not expose get_session_messages()")
524
+ try:
525
+ return list(get_session_messages(session_id, directory=directory))
526
+ except TypeError:
527
+ return list(get_session_messages(session_id))
528
+
529
+
530
+ def _cursor_for(session_info: Any, messages: list[Any]) -> _HistoryCursor:
531
+ last_message_uuid = None
532
+ if messages:
533
+ candidate = getattr(messages[-1], "uuid", None)
534
+ last_message_uuid = candidate if isinstance(candidate, str) and candidate else None
535
+ return _HistoryCursor(
536
+ last_modified=_int_attr(session_info, "last_modified"),
537
+ file_size=_int_attr(session_info, "file_size"),
538
+ message_count=len(messages),
539
+ last_message_uuid=last_message_uuid,
540
+ )
541
+
542
+
543
+ def _cursor_fingerprint_json(cursor: _HistoryCursor) -> dict[str, Any]:
544
+ return {
545
+ "lastModified": cursor.last_modified,
546
+ "fileSize": cursor.file_size,
547
+ }
548
+
549
+
550
+ def _cursor_position_json(cursor: _HistoryCursor) -> dict[str, Any]:
551
+ return {
552
+ "messageCount": cursor.message_count,
553
+ "lastMessageUuid": cursor.last_message_uuid,
554
+ }
555
+
556
+
557
+ def _cursor_from_state(
558
+ fingerprint: dict[str, Any] | None,
559
+ cursor: dict[str, Any] | None,
560
+ ) -> _HistoryCursor | None:
561
+ if fingerprint is None and cursor is None:
562
+ return None
563
+ fingerprint = fingerprint or {}
564
+ cursor = cursor or {}
565
+ return _HistoryCursor(
566
+ last_modified=_optional_int(fingerprint.get("lastModified")),
567
+ file_size=_optional_int(fingerprint.get("fileSize")),
568
+ message_count=_optional_int(cursor.get("messageCount")) or 0,
569
+ last_message_uuid=_optional_json_string(cursor.get("lastMessageUuid")),
570
+ )
571
+
572
+
573
+ def _messages_after_cursor(messages: list[Any], cursor: _HistoryCursor) -> list[Any]:
574
+ if cursor.last_message_uuid:
575
+ for index, message in enumerate(messages):
576
+ if getattr(message, "uuid", None) == cursor.last_message_uuid:
577
+ return messages[index + 1 :]
578
+ if cursor.message_count > 0:
579
+ return messages[cursor.message_count :]
580
+ return messages
581
+
582
+
583
+ def _optional_int(value: Any) -> int | None:
584
+ if isinstance(value, bool):
585
+ return None
586
+ if isinstance(value, int):
587
+ return value
588
+ if isinstance(value, str):
589
+ try:
590
+ return int(value)
591
+ except ValueError:
592
+ return None
593
+ return None
594
+
595
+
596
+ def _optional_json_string(value: Any) -> str | None:
597
+ return value if isinstance(value, str) and value else None
598
+
599
+
600
+ def _stable_message_timestamp(session_info: Any, index: int) -> str:
601
+ base_ms = (
602
+ _int_attr(session_info, "created_at")
603
+ or _int_attr(session_info, "last_modified")
604
+ or int(time.time() * 1000)
605
+ )
606
+ return _timestamp_from_ms(base_ms + index) or utc_now()
607
+
608
+
609
+ def _timestamp_from_ms(value: int | None) -> str | None:
610
+ if value is None:
611
+ return None
612
+ return datetime.fromtimestamp(value / 1000, tz=UTC).isoformat().replace("+00:00", "Z")
613
+
614
+
615
+ def _raw_timestamp(raw: dict[str, Any]) -> str | None:
616
+ timestamp = raw.get("timestamp")
617
+ return timestamp if isinstance(timestamp, str) and timestamp else None
618
+
619
+
620
+ def _raw_uuid(raw: dict[str, Any]) -> str:
621
+ value = raw.get("uuid")
622
+ return value if isinstance(value, str) and value else "history-unknown"
623
+
624
+
625
+ def _string_attr(value: Any, attr: str) -> str | None:
626
+ candidate = getattr(value, attr, None)
627
+ return candidate if isinstance(candidate, str) and candidate else None
628
+
629
+
630
+ def _int_attr(value: Any, attr: str) -> int | None:
631
+ candidate = getattr(value, attr, None)
632
+ return candidate if isinstance(candidate, int) else None
633
+
634
+
635
+ def _required(params: dict[str, Any], key: str) -> str:
636
+ value = params.get(key)
637
+ if not isinstance(value, str) or not value:
638
+ raise ValueError(f"{key} is required")
639
+ return value
640
+
641
+
642
+ __all__ = ["ClaudeHistoryAdapter"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class NormalizedClaudeEvent:
9
+ claudeSessionId: str
10
+ sourceEventId: str
11
+ messageId: str | None = None
12
+ role: Literal["user", "assistant", "tool", "system"] | None = None
13
+ blockIndex: int | None = None
14
+ blockType: str | None = None
15
+ text: str | None = None
16
+ toolUseId: str | None = None
17
+ toolName: str | None = None
18
+ toolInput: Any = None
19
+ toolResult: Any = None
20
+ toolResultIsError: bool | None = None
21
+ timestamp: str | None = None
22
+ clientMessageId: str | None = None
23
+ attachments: list[dict[str, Any]] | None = None