agentlink-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.
Files changed (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. connector/version.py +13 -0
@@ -0,0 +1,615 @@
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
+ @dataclass(slots=True)
12
+ class AcpReductionResult:
13
+ timeline_items: list[dict[str, Any]] = field(default_factory=list)
14
+ session_update: dict[str, Any] | None = None
15
+ approval: dict[str, Any] | None = None
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class AcpTimelineReducer:
20
+ """Map ACP session/update notifications to AgentLink timeline items."""
21
+
22
+ runtime: str
23
+ _next_order: int = 1
24
+ _order_by_item: dict[str, int] = field(default_factory=dict)
25
+ _items: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+ _message_text: dict[str, str] = field(default_factory=dict)
27
+ _tool_state: dict[str, dict[str, Any]] = field(default_factory=dict)
28
+
29
+ def reset_turn(self) -> None:
30
+ # Keep order counters for session stability; clear only streaming buffers.
31
+ self._message_text.clear()
32
+
33
+ def turn_start(
34
+ self,
35
+ *,
36
+ session_id: str,
37
+ turn_id: str,
38
+ external_session_id: str | None,
39
+ content: str | None = None,
40
+ client_message_id: str | None = None,
41
+ attachments: list[dict[str, Any]] | None = None,
42
+ ) -> AcpReductionResult:
43
+ items: list[dict[str, Any]] = []
44
+ items.append(
45
+ self._upsert(
46
+ session_id=session_id,
47
+ turn_id=turn_id,
48
+ item_id=f"{turn_id}:start",
49
+ item_type="turn.start",
50
+ status="running",
51
+ role=None,
52
+ content={},
53
+ external_session_id=external_session_id,
54
+ event="turn.start",
55
+ )
56
+ )
57
+ if content:
58
+ user_content: dict[str, Any] = {"text": content, "format": "markdown"}
59
+ if attachments:
60
+ user_content["attachments"] = attachments
61
+ source_extra = {"clientMessageId": client_message_id} if client_message_id else None
62
+ items.append(
63
+ self._upsert(
64
+ session_id=session_id,
65
+ turn_id=turn_id,
66
+ item_id=f"{turn_id}:user",
67
+ item_type="message",
68
+ status="done",
69
+ role="user",
70
+ content=user_content,
71
+ external_session_id=external_session_id,
72
+ event="user_message",
73
+ source_extra=source_extra,
74
+ )
75
+ )
76
+ return AcpReductionResult(
77
+ timeline_items=items,
78
+ session_update={
79
+ "sessionId": session_id,
80
+ "runtime": self.runtime,
81
+ "externalSessionId": external_session_id,
82
+ "status": "running",
83
+ "sourceObservedAt": utc_now(),
84
+ },
85
+ )
86
+
87
+ def turn_end(
88
+ self,
89
+ *,
90
+ session_id: str,
91
+ turn_id: str,
92
+ external_session_id: str | None,
93
+ stop_reason: str | None,
94
+ interrupted: bool = False,
95
+ ) -> AcpReductionResult:
96
+ status, result = _stop_reason_to_status(stop_reason, interrupted=interrupted)
97
+ item = self._upsert(
98
+ session_id=session_id,
99
+ turn_id=turn_id,
100
+ item_id=f"{turn_id}:end",
101
+ item_type="turn.end",
102
+ status=status,
103
+ role=None,
104
+ content={"result": result, "stopReason": stop_reason or result},
105
+ external_session_id=external_session_id,
106
+ event="turn.end",
107
+ completed_at=utc_now(),
108
+ )
109
+ # Mark in-flight assistant messages done.
110
+ finalized: list[dict[str, Any]] = [item]
111
+ for existing in list(self._items.values()):
112
+ if (
113
+ existing.get("turnId") == turn_id
114
+ and existing.get("type") == "message"
115
+ and existing.get("role") == "assistant"
116
+ and existing.get("status") == "running"
117
+ ):
118
+ finalized.append(
119
+ self._upsert(
120
+ session_id=session_id,
121
+ turn_id=turn_id,
122
+ item_id=str(existing["source"].get("itemId") or existing["id"]),
123
+ item_type="message",
124
+ status="done",
125
+ role="assistant",
126
+ content=existing.get("content") or {},
127
+ external_session_id=external_session_id,
128
+ event="agent_message_chunk",
129
+ completed_at=utc_now(),
130
+ )
131
+ )
132
+ return AcpReductionResult(
133
+ timeline_items=finalized,
134
+ session_update={
135
+ "sessionId": session_id,
136
+ "runtime": self.runtime,
137
+ "externalSessionId": external_session_id,
138
+ "status": "idle" if status != "waiting_approval" else "waiting_approval",
139
+ "sourceObservedAt": utc_now(),
140
+ },
141
+ )
142
+
143
+ def reduce_session_update(
144
+ self,
145
+ *,
146
+ session_id: str,
147
+ turn_id: str,
148
+ external_session_id: str | None,
149
+ update: dict[str, Any],
150
+ ) -> AcpReductionResult:
151
+ kind = update.get("sessionUpdate") or update.get("session_update")
152
+ if not isinstance(kind, str):
153
+ return AcpReductionResult()
154
+
155
+ if kind in {"agent_message_chunk", "user_message_chunk", "agent_thought_chunk"}:
156
+ return self._reduce_message_chunk(
157
+ session_id=session_id,
158
+ turn_id=turn_id,
159
+ external_session_id=external_session_id,
160
+ kind=kind,
161
+ update=update,
162
+ )
163
+ if kind in {"tool_call", "tool_call_update"}:
164
+ return self._reduce_tool_call(
165
+ session_id=session_id,
166
+ turn_id=turn_id,
167
+ external_session_id=external_session_id,
168
+ update=update,
169
+ is_update=kind == "tool_call_update",
170
+ )
171
+ if kind == "plan":
172
+ entries = update.get("entries") if isinstance(update.get("entries"), list) else []
173
+ text = "\n".join(
174
+ str(entry.get("content") or "")
175
+ for entry in entries
176
+ if isinstance(entry, dict) and entry.get("content")
177
+ )
178
+ if not text:
179
+ return AcpReductionResult()
180
+ item = self._upsert(
181
+ session_id=session_id,
182
+ turn_id=turn_id,
183
+ item_id=f"{turn_id}:plan",
184
+ item_type="system",
185
+ status="done",
186
+ role="system",
187
+ content={"text": text, "format": "markdown", "kind": "plan"},
188
+ external_session_id=external_session_id,
189
+ event="plan",
190
+ )
191
+ return AcpReductionResult(timeline_items=[item])
192
+ if kind == "session_info_update":
193
+ session_update: dict[str, Any] = {
194
+ "sessionId": session_id,
195
+ "runtime": self.runtime,
196
+ "externalSessionId": external_session_id,
197
+ "sourceObservedAt": utc_now(),
198
+ }
199
+ if "title" in update:
200
+ session_update["title"] = update.get("title")
201
+ return AcpReductionResult(session_update=session_update)
202
+ return AcpReductionResult()
203
+
204
+ def reduce_permission_request(
205
+ self,
206
+ *,
207
+ session_id: str,
208
+ turn_id: str,
209
+ external_session_id: str | None,
210
+ request_id: str | int,
211
+ params: dict[str, Any],
212
+ ) -> AcpReductionResult:
213
+ tool_call = params.get("toolCall") if isinstance(params.get("toolCall"), dict) else {}
214
+ tool_call_id = str(tool_call.get("toolCallId") or tool_call.get("tool_call_id") or request_id)
215
+ title = str(
216
+ tool_call.get("title")
217
+ or params.get("title")
218
+ or f"{self.runtime} requests permission"
219
+ )
220
+ options = params.get("options") if isinstance(params.get("options"), list) else []
221
+ choices = _permission_choices(options)
222
+ kind = _tool_kind_to_approval_kind(tool_call.get("kind"))
223
+ tool_item = self._upsert(
224
+ session_id=session_id,
225
+ turn_id=turn_id,
226
+ item_id=tool_call_id,
227
+ item_type="tool",
228
+ status="waiting_approval",
229
+ role="tool",
230
+ content={
231
+ "toolCallId": tool_call_id,
232
+ "title": title,
233
+ "kind": _map_tool_kind(tool_call.get("kind")),
234
+ "rawInput": tool_call.get("rawInput") or tool_call.get("raw_input"),
235
+ },
236
+ external_session_id=external_session_id,
237
+ event="request_permission",
238
+ )
239
+ approval = {
240
+ "id": f"appr_{self.runtime}_{_short_hash([session_id, str(request_id)])}",
241
+ "sessionId": session_id,
242
+ "turnId": turn_id,
243
+ "status": "pending",
244
+ "kind": kind,
245
+ "targetItemId": tool_item["id"],
246
+ "title": title,
247
+ "description": _permission_description(options),
248
+ "payload": {
249
+ "toolCall": tool_call,
250
+ "options": options,
251
+ "requestId": request_id,
252
+ },
253
+ "choices": choices,
254
+ "source": {
255
+ "runtime": self.runtime,
256
+ "requestId": request_id,
257
+ "sessionId": external_session_id,
258
+ "turnId": turn_id,
259
+ "itemId": tool_call_id,
260
+ "method": "session/request_permission",
261
+ },
262
+ "createdAt": utc_now(),
263
+ }
264
+ return AcpReductionResult(
265
+ timeline_items=[tool_item],
266
+ approval=approval,
267
+ session_update={
268
+ "sessionId": session_id,
269
+ "runtime": self.runtime,
270
+ "externalSessionId": external_session_id,
271
+ "status": "waiting_approval",
272
+ "sourceObservedAt": utc_now(),
273
+ },
274
+ )
275
+
276
+ def _reduce_message_chunk(
277
+ self,
278
+ *,
279
+ session_id: str,
280
+ turn_id: str,
281
+ external_session_id: str | None,
282
+ kind: str,
283
+ update: dict[str, Any],
284
+ ) -> AcpReductionResult:
285
+ content_block = update.get("content") if isinstance(update.get("content"), dict) else {}
286
+ delta = ""
287
+ if isinstance(content_block.get("text"), str):
288
+ delta = content_block["text"]
289
+ elif isinstance(update.get("text"), str):
290
+ delta = update["text"]
291
+ if not delta and kind != "agent_thought_chunk":
292
+ return AcpReductionResult()
293
+
294
+ message_id = (
295
+ str(update.get("messageId") or update.get("message_id") or "")
296
+ or f"{turn_id}:{kind}"
297
+ )
298
+ previous = self._message_text.get(message_id, "")
299
+ text = previous + delta
300
+ self._message_text[message_id] = text
301
+
302
+ if kind == "user_message_chunk":
303
+ role = "user"
304
+ item_type = "message"
305
+ status = "done"
306
+ elif kind == "agent_thought_chunk":
307
+ role = "assistant"
308
+ item_type = "message"
309
+ status = "running"
310
+ content = {"text": text, "format": "markdown", "kind": "thinking"}
311
+ item = self._upsert(
312
+ session_id=session_id,
313
+ turn_id=turn_id,
314
+ item_id=message_id,
315
+ item_type=item_type,
316
+ status=status,
317
+ role=role,
318
+ content=content,
319
+ external_session_id=external_session_id,
320
+ event=kind,
321
+ )
322
+ return AcpReductionResult(timeline_items=[item])
323
+ else:
324
+ role = "assistant"
325
+ item_type = "message"
326
+ status = "running"
327
+
328
+ item = self._upsert(
329
+ session_id=session_id,
330
+ turn_id=turn_id,
331
+ item_id=message_id,
332
+ item_type=item_type,
333
+ status=status,
334
+ role=role,
335
+ content={"text": text, "format": "markdown"},
336
+ external_session_id=external_session_id,
337
+ event=kind,
338
+ )
339
+ return AcpReductionResult(timeline_items=[item])
340
+
341
+ def _reduce_tool_call(
342
+ self,
343
+ *,
344
+ session_id: str,
345
+ turn_id: str,
346
+ external_session_id: str | None,
347
+ update: dict[str, Any],
348
+ is_update: bool,
349
+ ) -> AcpReductionResult:
350
+ tool_call_id = str(update.get("toolCallId") or update.get("tool_call_id") or "tool")
351
+ prev = self._tool_state.get(tool_call_id, {})
352
+ status = _tool_status(update.get("status") or prev.get("status") or "pending")
353
+ title = update.get("title") if update.get("title") is not None else prev.get("title")
354
+ kind = update.get("kind") if update.get("kind") is not None else prev.get("kind")
355
+ raw_input = update.get("rawInput") if "rawInput" in update else update.get("raw_input", prev.get("rawInput"))
356
+ raw_output = update.get("rawOutput") if "rawOutput" in update else update.get("raw_output", prev.get("rawOutput"))
357
+ content_blocks = update.get("content") if isinstance(update.get("content"), list) else prev.get("contentBlocks")
358
+ preview = _tool_content_preview(content_blocks)
359
+ state = {
360
+ "title": title,
361
+ "kind": kind,
362
+ "status": status,
363
+ "rawInput": raw_input,
364
+ "rawOutput": raw_output,
365
+ "contentBlocks": content_blocks,
366
+ }
367
+ self._tool_state[tool_call_id] = state
368
+ content: dict[str, Any] = {
369
+ "toolCallId": tool_call_id,
370
+ "toolName": title or kind or "tool",
371
+ "title": title,
372
+ "kind": _map_tool_kind(kind),
373
+ "status": status,
374
+ }
375
+ if isinstance(raw_input, dict):
376
+ content["rawInput"] = raw_input
377
+ if isinstance(raw_input.get("command"), str):
378
+ content["command"] = raw_input["command"]
379
+ content["kind"] = "command"
380
+ if raw_output is not None:
381
+ content["rawOutput"] = raw_output
382
+ content["result"] = _stringify_output(raw_output)
383
+ content["outputPreview"] = content["result"][:2000]
384
+ if preview:
385
+ content["outputPreview"] = preview[:2000]
386
+ content.setdefault("result", preview)
387
+ item = self._upsert(
388
+ session_id=session_id,
389
+ turn_id=turn_id,
390
+ item_id=tool_call_id,
391
+ item_type="tool",
392
+ status=status,
393
+ role="tool",
394
+ content=content,
395
+ external_session_id=external_session_id,
396
+ event="tool_call_update" if is_update else "tool_call",
397
+ completed_at=utc_now() if status in {"done", "failed", "cancelled"} else None,
398
+ )
399
+ return AcpReductionResult(timeline_items=[item])
400
+
401
+ def _upsert(
402
+ self,
403
+ *,
404
+ session_id: str,
405
+ turn_id: str | None,
406
+ item_id: str,
407
+ item_type: str,
408
+ status: str,
409
+ role: str | None,
410
+ content: dict[str, Any],
411
+ external_session_id: str | None,
412
+ event: str | None,
413
+ source_extra: dict[str, Any] | None = None,
414
+ completed_at: str | None = None,
415
+ ) -> dict[str, Any]:
416
+ timeline_id = _timeline_id(session_id, self.runtime, external_session_id, turn_id, item_id)
417
+ order_seq = self._order_by_item.setdefault(timeline_id, self._allocate_order_seq())
418
+ existing = self._items.get(timeline_id)
419
+ revision = int(existing.get("revision", 0)) + 1 if existing else 1
420
+ now = utc_now()
421
+ source: dict[str, Any] = {
422
+ "runtime": self.runtime,
423
+ "sessionId": external_session_id,
424
+ "turnId": turn_id,
425
+ "itemId": item_id,
426
+ "itemType": item_type,
427
+ "event": event,
428
+ }
429
+ if source_extra:
430
+ source.update(source_extra)
431
+ source = {key: value for key, value in source.items() if value is not None}
432
+ content_hash = _content_hash(item_type, status, role, content, source)
433
+ if existing and existing.get("contentHash") == content_hash:
434
+ return existing
435
+ snapshot: dict[str, Any] = {
436
+ "id": timeline_id,
437
+ "sessionId": session_id,
438
+ "turnId": turn_id,
439
+ "type": item_type,
440
+ "status": status,
441
+ "role": role,
442
+ "content": content,
443
+ "source": source,
444
+ "orderSeq": order_seq,
445
+ "revision": revision,
446
+ "contentHash": content_hash,
447
+ "createdAt": existing.get("createdAt") if existing else now,
448
+ "updatedAt": now,
449
+ "completedAt": completed_at,
450
+ }
451
+ if role is None:
452
+ snapshot.pop("role", None)
453
+ if turn_id is None:
454
+ snapshot.pop("turnId", None)
455
+ if completed_at is None:
456
+ snapshot.pop("completedAt", None)
457
+ self._items[timeline_id] = snapshot
458
+ return snapshot
459
+
460
+ def _allocate_order_seq(self) -> int:
461
+ value = self._next_order
462
+ self._next_order += 1
463
+ return value
464
+
465
+
466
+ def map_approval_status_to_option(
467
+ status: str,
468
+ options: list[dict[str, Any]] | None,
469
+ ) -> str | None:
470
+ """Map AA approval status to an ACP permission optionId."""
471
+ options = options or []
472
+ if status == "approved":
473
+ preferred_ids = ("allow-once", "allow_once", "allow-always", "allow_always")
474
+ preferred_kinds = {"allow_once", "allow_always"}
475
+ elif status == "approved_for_session":
476
+ preferred_ids = ("allow-always", "allow_always", "allow-once", "allow_once")
477
+ preferred_kinds = {"allow_always", "allow_once"}
478
+ elif status == "rejected":
479
+ preferred_ids = ("reject-once", "reject_once", "reject-always", "reject_always")
480
+ preferred_kinds = {"reject_once", "reject_always"}
481
+ else:
482
+ return None
483
+
484
+ by_id = {
485
+ str(opt.get("optionId") or opt.get("option_id") or ""): opt
486
+ for opt in options
487
+ if isinstance(opt, dict)
488
+ }
489
+ for candidate in preferred_ids:
490
+ if candidate in by_id and candidate:
491
+ return candidate
492
+ for opt in options:
493
+ if not isinstance(opt, dict):
494
+ continue
495
+ kind = str(opt.get("kind") or "").replace("-", "_")
496
+ option_id = str(opt.get("optionId") or opt.get("option_id") or "")
497
+ if kind in preferred_kinds and option_id:
498
+ return option_id
499
+ return None
500
+
501
+
502
+ def _stop_reason_to_status(stop_reason: str | None, *, interrupted: bool) -> tuple[str, str]:
503
+ if interrupted or stop_reason == "cancelled":
504
+ return "interrupted", "interrupted"
505
+ if stop_reason in {"refusal", "max_tokens", "max_turn_requests"}:
506
+ return "failed", stop_reason or "failed"
507
+ if stop_reason in {"error", "failed"}:
508
+ return "failed", "failed"
509
+ return "done", "completed"
510
+
511
+
512
+ def _tool_status(raw: Any) -> str:
513
+ text = str(raw or "pending")
514
+ mapping = {
515
+ "pending": "pending",
516
+ "in_progress": "running",
517
+ "in-progress": "running",
518
+ "completed": "done",
519
+ "failed": "failed",
520
+ "cancelled": "cancelled",
521
+ "canceled": "cancelled",
522
+ }
523
+ return mapping.get(text, "running" if text else "pending")
524
+
525
+
526
+ def _map_tool_kind(kind: Any) -> str:
527
+ text = str(kind or "other")
528
+ if text in {"read", "search", "fetch", "think"}:
529
+ return "read"
530
+ if text in {"edit", "delete", "move"}:
531
+ return "edit"
532
+ if text in {"execute"}:
533
+ return "command"
534
+ return "other"
535
+
536
+
537
+ def _tool_kind_to_approval_kind(kind: Any) -> str:
538
+ text = str(kind or "")
539
+ if text == "execute":
540
+ return "command"
541
+ if text in {"edit", "delete", "move"}:
542
+ return "file_change"
543
+ return "tool_call"
544
+
545
+
546
+ def _permission_choices(options: list[Any]) -> list[str]:
547
+ choices: list[str] = []
548
+ for opt in options:
549
+ if not isinstance(opt, dict):
550
+ continue
551
+ kind = str(opt.get("kind") or "").replace("-", "_")
552
+ if kind in {"allow_once"} and "approve" not in choices:
553
+ choices.append("approve")
554
+ elif kind in {"allow_always"} and "approve_for_session" not in choices:
555
+ choices.append("approve_for_session")
556
+ elif kind in {"reject_once", "reject_always"} and "reject" not in choices:
557
+ choices.append("reject")
558
+ if not choices:
559
+ choices = ["approve", "reject"]
560
+ if "cancel" not in choices:
561
+ choices.append("cancel")
562
+ return choices
563
+
564
+
565
+ def _permission_description(options: list[Any]) -> str | None:
566
+ labels = []
567
+ for opt in options:
568
+ if isinstance(opt, dict) and opt.get("name"):
569
+ labels.append(str(opt["name"]))
570
+ return ", ".join(labels) if labels else None
571
+
572
+
573
+ def _tool_content_preview(blocks: Any) -> str:
574
+ if not isinstance(blocks, list):
575
+ return ""
576
+ parts: list[str] = []
577
+ for block in blocks:
578
+ if not isinstance(block, dict):
579
+ continue
580
+ if block.get("type") == "content" and isinstance(block.get("content"), dict):
581
+ text = block["content"].get("text")
582
+ if isinstance(text, str):
583
+ parts.append(text)
584
+ elif isinstance(block.get("text"), str):
585
+ parts.append(block["text"])
586
+ return "\n".join(parts)
587
+
588
+
589
+ def _stringify_output(value: Any) -> str:
590
+ if isinstance(value, str):
591
+ return value
592
+ try:
593
+ return json.dumps(value, ensure_ascii=False)
594
+ except TypeError:
595
+ return str(value)
596
+
597
+
598
+ def _timeline_id(
599
+ session_id: str,
600
+ runtime: str,
601
+ external_session_id: str | None,
602
+ turn_id: str | None,
603
+ item_id: str | None,
604
+ ) -> str:
605
+ identity = [session_id, runtime, external_session_id, turn_id, item_id]
606
+ return f"tl_{_short_hash(identity)}"
607
+
608
+
609
+ def _content_hash(*values: Any) -> str:
610
+ return f"sha256:{_short_hash(values, length=64)}"
611
+
612
+
613
+ def _short_hash(value: Any, *, length: int = 20) -> str:
614
+ raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
615
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:length]