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,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
|