agent-session-bridge 0.2.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 (37) hide show
  1. agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
  2. agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
  3. agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
  4. agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
  7. session_bridge/__init__.py +0 -0
  8. session_bridge/_ids.py +43 -0
  9. session_bridge/cli.py +473 -0
  10. session_bridge/convert.py +130 -0
  11. session_bridge/handshake.py +175 -0
  12. session_bridge/ir.py +246 -0
  13. session_bridge/place.py +98 -0
  14. session_bridge/readers/__init__.py +0 -0
  15. session_bridge/readers/_content.py +42 -0
  16. session_bridge/readers/_jsonl.py +50 -0
  17. session_bridge/readers/_pending.py +63 -0
  18. session_bridge/readers/claude_code.py +226 -0
  19. session_bridge/readers/codex.py +203 -0
  20. session_bridge/readers/hermes.py +167 -0
  21. session_bridge/skill_install.py +134 -0
  22. session_bridge/skills/session-handoff/SKILL.md +119 -0
  23. session_bridge/tui/__init__.py +0 -0
  24. session_bridge/tui/actions.py +123 -0
  25. session_bridge/tui/app.py +65 -0
  26. session_bridge/tui/discovery.py +208 -0
  27. session_bridge/tui/options.py +86 -0
  28. session_bridge/tui/register.py +475 -0
  29. session_bridge/tui/screens.py +710 -0
  30. session_bridge/tui/summary.py +41 -0
  31. session_bridge/writers/__init__.py +0 -0
  32. session_bridge/writers/_common.py +288 -0
  33. session_bridge/writers/claude_code.py +152 -0
  34. session_bridge/writers/codex.py +154 -0
  35. session_bridge/writers/codex_db.py +302 -0
  36. session_bridge/writers/hermes.py +142 -0
  37. session_bridge/writers/hermes_db.py +294 -0
@@ -0,0 +1,226 @@
1
+ """Reader: Claude Code session JSONL -> IR.
2
+
3
+ Claude Code interleaves control events with message entries:
4
+ - ``queue-operation`` {operation:enqueue|dequeue, content, sessionId}:
5
+ user input queued while a turn is running. An enqueue with no matching
6
+ dequeue is an undelivered (pending) user message.
7
+ - ``user`` / ``assistant`` {parentUuid, uuid, message:{...}, cwd, gitBranch,
8
+ version, permissionMode, sessionId}: the actual turns. ``message`` is the
9
+ Anthropic API message; ``content`` is a str (user) or a list of typed blocks
10
+ (``text`` / ``thinking`` / ``tool_use`` / ``tool_result``). Note tool_result
11
+ blocks arrive inside ``user`` records.
12
+ - ``ai-title`` / ``last-prompt`` / ``attachment``: metadata, not turns.
13
+
14
+ Thread order is an explicit parentUuid linked list; we preserve both uid and
15
+ parent_uid on each IR message and emit them in file order (which matches the
16
+ chain in practice).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ from ..ir import (
26
+ PLACEHOLDER_MODELS,
27
+ ContentBlock,
28
+ Message,
29
+ PendingState,
30
+ Role,
31
+ Session,
32
+ SessionMeta,
33
+ )
34
+ from ._jsonl import load_records
35
+ from ._pending import open_tool_calls
36
+
37
+ _MESSAGE_TYPES = {"user", "assistant"}
38
+
39
+
40
+ def _blocks_from_content(content: Any) -> tuple[ContentBlock, ...]:
41
+ """Normalize an Anthropic message ``content`` (str or block list) to IR blocks."""
42
+ if isinstance(content, str):
43
+ return (ContentBlock.text_block(content),) if content else ()
44
+ if not isinstance(content, list):
45
+ return ()
46
+
47
+ blocks: list[ContentBlock] = []
48
+ for b in content:
49
+ if not isinstance(b, dict):
50
+ continue
51
+ bt = b.get("type")
52
+ if bt == "text":
53
+ blocks.append(ContentBlock.text_block(b.get("text", "")))
54
+ elif bt in ("thinking", "redacted_thinking"):
55
+ blocks.append(ContentBlock.reasoning(b.get("thinking", "")))
56
+ elif bt == "tool_use":
57
+ inp = b.get("input")
58
+ blocks.append(
59
+ ContentBlock.tool_call(
60
+ call_id=b.get("id", ""),
61
+ tool_name=b.get("name", ""),
62
+ tool_input=inp if isinstance(inp, dict) else {"_value": inp},
63
+ )
64
+ )
65
+ elif bt == "tool_result":
66
+ result = b.get("content", "")
67
+ nontext_parts: list[dict[str, Any]] = []
68
+ if isinstance(result, list):
69
+ # Anthropic tool_result content can itself be a block list mixing
70
+ # text with non-text parts (e.g. an image from a Read of a PNG).
71
+ # Join the text parts for the result payload, and keep each
72
+ # non-text part as a RAW passthrough sibling so it is preserved
73
+ # (same-harness) and reported (cross-harness) rather than silently
74
+ # dropped into an empty result.
75
+ text_bits = []
76
+ for part in result:
77
+ if isinstance(part, dict) and part.get("type") in (None, "text"):
78
+ text_bits.append(part.get("text", ""))
79
+ elif isinstance(part, dict) and part.get("type"):
80
+ nontext_parts.append(part)
81
+ else:
82
+ text_bits.append(str(part))
83
+ result = "\n".join(text_bits)
84
+ blocks.append(
85
+ ContentBlock.tool_result(
86
+ call_id=b.get("tool_use_id", ""),
87
+ text=result if isinstance(result, str) else json.dumps(result),
88
+ is_error=bool(b.get("is_error", False)),
89
+ # Carry non-text parts ON the result (not as sibling blocks),
90
+ # so writers never mistake them for a separate turn.
91
+ result_parts=tuple(nontext_parts),
92
+ )
93
+ )
94
+ elif bt:
95
+ # Unknown block type (e.g. image, document, server_tool_use). Keep the
96
+ # original block verbatim as a RAW passthrough so a same-harness writer
97
+ # re-emits it losslessly; cross-harness writers degrade it to a
98
+ # reported placeholder rather than dropping it silently.
99
+ blocks.append(ContentBlock.raw(b, bt))
100
+ return tuple(blocks)
101
+
102
+
103
+ def _real_model(value: Any) -> Optional[str]:
104
+ """Return the model id only if it is a real (routable) one, else None.
105
+
106
+ Filters harness placeholders (e.g. Claude Code's "<synthetic>") so they don't
107
+ become the session model."""
108
+ if isinstance(value, str) and value and value not in PLACEHOLDER_MODELS:
109
+ return value
110
+ return None
111
+
112
+
113
+ def _extract_meta(record: dict[str, Any], base: SessionMeta) -> SessionMeta:
114
+ """Fill session meta from the first message record that carries it."""
115
+ msg = record.get("message", {})
116
+ rec_model = _real_model(msg.get("model")) if isinstance(msg, dict) else None
117
+ return SessionMeta(
118
+ source_harness="claude-code",
119
+ session_id=base.session_id or record.get("sessionId"),
120
+ cwd=base.cwd or record.get("cwd"),
121
+ # Prefer the first REAL model; ignore synthetic placeholders even if they
122
+ # appear first in file order.
123
+ model=base.model or rec_model,
124
+ model_provider="anthropic",
125
+ permission_mode=base.permission_mode or record.get("permissionMode"),
126
+ version=base.version or record.get("version"),
127
+ # Keep the first branch seen; don't let a later record without gitBranch
128
+ # clobber it (first-non-null-wins, matching the other meta fields).
129
+ extra=(
130
+ base.extra
131
+ if base.extra.get("gitBranch")
132
+ else ({"gitBranch": record.get("gitBranch")} if record.get("gitBranch") else base.extra)
133
+ ),
134
+ )
135
+
136
+
137
+ def _queued_messages(records: list[dict[str, Any]]) -> tuple[str, ...]:
138
+ """Enqueued user inputs with no matching later dequeue are undelivered.
139
+
140
+ Matching is scoped per ``sessionId`` so a dequeue from one session cannot
141
+ consume another session's queued item when a file mixes sessions. Emission
142
+ order follows first-enqueue order across sessions.
143
+ """
144
+ # Each queued item carries an enqueue sequence number so the still-pending
145
+ # items can be emitted in global first-enqueue order. Duplicates are kept
146
+ # (two identical undelivered messages are two pending items, not one) — a
147
+ # set-based dedup here would undercount genuinely-pending input.
148
+ per_session: dict[str, list[tuple[int, str]]] = {}
149
+ seq = 0
150
+ for rec in records:
151
+ if rec.get("type") != "queue-operation":
152
+ continue
153
+ sid = rec.get("sessionId", "")
154
+ op = rec.get("operation")
155
+ content = rec.get("content", "")
156
+ queue = per_session.setdefault(sid, [])
157
+ if op == "enqueue" and content:
158
+ queue.append((seq, content))
159
+ seq += 1
160
+ elif op == "dequeue" and queue:
161
+ # A dequeue (content-less) consumes this session's oldest queued item.
162
+ queue.pop(0)
163
+ elif op == "remove" and queue:
164
+ # Claude Code auto-withdraws an undelivered queued item (e.g. a
165
+ # background-task notification). Real `remove` records are mixed:
166
+ # some carry the withdrawn content, some carry none. When content is
167
+ # present, honor an exact match (newest-first). When absent, a remove
168
+ # in real traces immediately follows its own enqueue, so it withdraws
169
+ # the MOST-RECENTLY-enqueued pending item (LIFO).
170
+ if content:
171
+ for i in range(len(queue) - 1, -1, -1):
172
+ if queue[i][1] == content:
173
+ queue.pop(i)
174
+ break
175
+ else:
176
+ queue.pop()
177
+ else:
178
+ queue.pop()
179
+ elif op == "popAll":
180
+ # Claude Code flushes the ENTIRE pending queue in one op (e.g. all
181
+ # queued input delivered/cleared at once). Ignoring it would leave
182
+ # stale items that then mis-consume later dequeues; clear the queue.
183
+ queue.clear()
184
+ remaining = [item for q in per_session.values() for item in q]
185
+ remaining.sort(key=lambda item: item[0])
186
+ return tuple(content for _, content in remaining)
187
+
188
+
189
+ def read_claude_code(path: str | Path) -> Session:
190
+ path = Path(path)
191
+ records = load_records(path)
192
+
193
+ meta = SessionMeta(source_harness="claude-code")
194
+ messages: list[Message] = []
195
+
196
+ for rec in records:
197
+ rtype = rec.get("type")
198
+ if rtype not in _MESSAGE_TYPES:
199
+ continue
200
+ # Skip Claude Code's own API-error notices (auth failure, rate limit,
201
+ # connection dropped). They are stamped type:assistant but are harness
202
+ # status messages, not model output — ingesting their text ("API Error:
203
+ # ...") would fabricate an assistant turn in the converted transcript.
204
+ if rec.get("isApiErrorMessage"):
205
+ continue
206
+ meta = _extract_meta(rec, meta)
207
+ msg = rec.get("message", {})
208
+ content = msg.get("content") if isinstance(msg, dict) else None
209
+ role = Role.USER if rtype == "user" else Role.ASSISTANT
210
+ messages.append(
211
+ Message(
212
+ role=role,
213
+ content=_blocks_from_content(content),
214
+ uid=rec.get("uuid"),
215
+ parent_uid=rec.get("parentUuid"),
216
+ timestamp=rec.get("timestamp"),
217
+ raw=rec,
218
+ )
219
+ )
220
+
221
+ msgs = tuple(messages)
222
+ pending = PendingState(
223
+ open_tool_calls=open_tool_calls(msgs),
224
+ queued_user_messages=_queued_messages(records),
225
+ )
226
+ return Session(meta=meta, messages=msgs, tools=(), pending=pending)
@@ -0,0 +1,203 @@
1
+ """Reader: Codex (rollout-*.jsonl) session -> IR.
2
+
3
+ Codex wraps everything in ``{timestamp, type, payload}``:
4
+ - ``session_meta``: payload has {id, cwd, model_provider, base_instructions:{text}, cli_version}.
5
+ - ``turn_context``: per-turn {turn_id, model, cwd, approval_policy, sandbox_policy}.
6
+ Model lives here, not in session_meta.
7
+ - ``event_msg``: UI-facing turn events (task_started/task_complete/user_message/
8
+ agent_message/token_count). These DUPLICATE content that also appears as
9
+ ``response_item`` records, so we ignore event_msg for content to avoid doubling.
10
+ - ``response_item``: the canonical conversation, OpenAI Responses shape:
11
+ - {type:message, role:user|assistant, content:[{type:input_text|output_text, text}]}
12
+ - {type:reasoning, summary:[{type:summary_text, text}]}
13
+ - {type:function_call, name, arguments:<json-string>, call_id}
14
+ - {type:function_call_output, call_id, output}
15
+
16
+ Purely append-ordered; no parent linkage (grouped by turn_id).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ..ir import (
26
+ ContentBlock,
27
+ Message,
28
+ PendingState,
29
+ Role,
30
+ Session,
31
+ SessionMeta,
32
+ recover_tool_error,
33
+ )
34
+ from ._content import content_blocks
35
+ from ._jsonl import load_records
36
+ from ._pending import open_tool_calls
37
+
38
+ _ROLE_FROM_CODEX = {
39
+ "user": Role.USER,
40
+ "assistant": Role.ASSISTANT,
41
+ "system": Role.SYSTEM,
42
+ "developer": Role.SYSTEM,
43
+ }
44
+
45
+
46
+ def _reasoning_text(payload: dict[str, Any]) -> str:
47
+ """Codex reasoning appears in either ``summary`` or ``content``, and each may
48
+ be a list of ``{type, text}`` blocks (real sessions) or a plain string.
49
+ Prefer non-empty ``content`` (the full reasoning) over ``summary``."""
50
+
51
+ def _join(value: Any) -> str:
52
+ if isinstance(value, str):
53
+ return value
54
+ if isinstance(value, list):
55
+ return "\n".join(
56
+ b.get("text", "") for b in value if isinstance(b, dict) and b.get("text")
57
+ )
58
+ return ""
59
+
60
+ content = _join(payload.get("content"))
61
+ if content.strip():
62
+ return content
63
+ return _join(payload.get("summary"))
64
+
65
+
66
+ def _parse_arguments(raw_args: Any) -> dict[str, Any]:
67
+ if isinstance(raw_args, dict):
68
+ return raw_args
69
+ if isinstance(raw_args, str) and raw_args.strip():
70
+ try:
71
+ parsed = json.loads(raw_args)
72
+ return parsed if isinstance(parsed, dict) else {"_value": parsed}
73
+ except json.JSONDecodeError:
74
+ return {"_raw": raw_args}
75
+ return {}
76
+
77
+
78
+ def read_codex(path: str | Path) -> Session:
79
+ path = Path(path)
80
+ records = load_records(path)
81
+
82
+ meta = SessionMeta(source_harness="codex", model_provider="openai")
83
+ messages: list[Message] = []
84
+ turn_models: set[str] = set()
85
+
86
+ for rec in records:
87
+ rtype = rec.get("type")
88
+ payload = rec.get("payload", {})
89
+ if not isinstance(payload, dict):
90
+ continue
91
+ ts = rec.get("timestamp")
92
+
93
+ if rtype == "session_meta":
94
+ base = payload.get("base_instructions")
95
+ instr = base.get("text") if isinstance(base, dict) else base
96
+ meta = SessionMeta(
97
+ source_harness="codex",
98
+ session_id=payload.get("id") or payload.get("session_id"),
99
+ cwd=payload.get("cwd"),
100
+ model_provider=payload.get("model_provider", "openai"),
101
+ system_instructions=instr,
102
+ version=payload.get("cli_version"),
103
+ )
104
+ elif rtype == "turn_context":
105
+ # Model and approval policy live here; keep the first seen. Also record
106
+ # every distinct per-turn model so a mid-session model switch (Codex's
107
+ # per-turn mechanism) can be reported as lossy — the IR keeps only one
108
+ # session model.
109
+ from dataclasses import replace
110
+
111
+ if payload.get("model"):
112
+ turn_models.add(payload["model"])
113
+ updates: dict[str, Any] = {}
114
+ if not meta.model and payload.get("model"):
115
+ updates["model"] = payload["model"]
116
+ if not meta.permission_mode and payload.get("approval_policy"):
117
+ updates["permission_mode"] = payload["approval_policy"]
118
+ if not meta.cwd and payload.get("cwd"):
119
+ updates["cwd"] = payload["cwd"]
120
+ if updates:
121
+ meta = replace(meta, **updates)
122
+ elif rtype == "response_item":
123
+ ptype = payload.get("type")
124
+ if ptype == "message":
125
+ role = _ROLE_FROM_CODEX.get(payload.get("role"), Role.ASSISTANT)
126
+ # Text parts -> TEXT blocks, non-text parts -> RAW passthrough
127
+ # (not silently dropped). Empty content is preserved as () so the
128
+ # turn/message count round-trips.
129
+ content = content_blocks(payload.get("content"))
130
+ messages.append(
131
+ Message(role=role, content=content, timestamp=ts, raw=rec)
132
+ )
133
+ elif ptype == "reasoning":
134
+ text = _reasoning_text(payload)
135
+ # A reasoning record means a reasoning block EXISTED, so always
136
+ # emit one even when its summary text is empty. Real extended
137
+ # thinking stores content in an (unreadable) signature with empty
138
+ # visible text, so dropping empty-text reasoning would lose ~all
139
+ # real reasoning blocks and leave a contentless assistant turn.
140
+ messages.append(
141
+ Message(role=Role.ASSISTANT,
142
+ content=(ContentBlock.reasoning(text),),
143
+ timestamp=ts, raw=rec)
144
+ )
145
+ elif ptype == "function_call":
146
+ messages.append(
147
+ Message(
148
+ role=Role.ASSISTANT,
149
+ content=(
150
+ ContentBlock.tool_call(
151
+ call_id=payload.get("call_id", ""),
152
+ tool_name=payload.get("name", ""),
153
+ tool_input=_parse_arguments(payload.get("arguments")),
154
+ ),
155
+ ),
156
+ timestamp=ts,
157
+ raw=rec,
158
+ )
159
+ )
160
+ elif ptype == "function_call_output":
161
+ out = payload.get("output", "")
162
+ is_error = False
163
+ if isinstance(out, dict):
164
+ # A dict output can carry an explicit failure flag; preserve it
165
+ # so a failed tool call is not reported as successful. Accept
166
+ # both the JSON bool False and a string "false".
167
+ success = out.get("success")
168
+ failed = (
169
+ success is False
170
+ or (isinstance(success, str) and success.strip().lower() == "false")
171
+ or bool(out.get("error"))
172
+ )
173
+ if failed:
174
+ is_error = True
175
+ out = out.get("content", json.dumps(out))
176
+ text = out if isinstance(out, str) else json.dumps(out)
177
+ # Recover an error baked into text by a prior hop (a writer with no
178
+ # native error flag prefixes ERROR_MARKER), so failure survives.
179
+ text, marked = recover_tool_error(text)
180
+ messages.append(
181
+ Message(
182
+ role=Role.TOOL,
183
+ content=(
184
+ ContentBlock.tool_result(
185
+ call_id=payload.get("call_id", ""),
186
+ text=text,
187
+ is_error=is_error or marked,
188
+ ),
189
+ ),
190
+ timestamp=ts,
191
+ raw=rec,
192
+ )
193
+ )
194
+ # event_msg intentionally ignored (duplicates response_item content)
195
+
196
+ if len(turn_models) > 1:
197
+ from dataclasses import replace
198
+
199
+ meta = replace(meta, extra={**meta.extra, "turn_models": sorted(turn_models)})
200
+
201
+ msgs = tuple(messages)
202
+ pending = PendingState(open_tool_calls=open_tool_calls(msgs))
203
+ return Session(meta=meta, messages=msgs, tools=(), pending=pending)
@@ -0,0 +1,167 @@
1
+ """Reader: Hermes session JSONL -> IR.
2
+
3
+ Hermes stores an OpenAI-chat-completion-style log:
4
+ - ``session_meta``: {model, platform, tools:[{type:function, function:{name,description,parameters}}], timestamp}
5
+ - ``user``: {content, timestamp}
6
+ - ``assistant``: {content, reasoning, tool_calls:[{id/call_id, function:{name, arguments:<json-string>}}], finish_reason, timestamp}
7
+ - ``tool``: {content, tool_call_id, timestamp} # a tool result, linked by tool_call_id
8
+
9
+ Records are append-ordered; there is no explicit parent linkage, so IR
10
+ ``parent_uid`` is left None and order is list order.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from ..ir import (
20
+ ContentBlock,
21
+ Message,
22
+ PendingState,
23
+ Role,
24
+ Session,
25
+ SessionMeta,
26
+ ToolSchema,
27
+ recover_tool_error,
28
+ )
29
+ from ._content import content_blocks
30
+ from ._jsonl import load_records
31
+ from ._pending import open_tool_calls
32
+
33
+
34
+ def _parse_tool_schemas(raw_tools: Any) -> tuple[ToolSchema, ...]:
35
+ schemas: list[ToolSchema] = []
36
+ if not isinstance(raw_tools, list):
37
+ return ()
38
+ for entry in raw_tools:
39
+ fn = entry.get("function", entry) if isinstance(entry, dict) else {}
40
+ name = fn.get("name")
41
+ if not name:
42
+ continue
43
+ schemas.append(
44
+ ToolSchema(
45
+ name=name,
46
+ description=fn.get("description"),
47
+ parameters=fn.get("parameters"),
48
+ )
49
+ )
50
+ return tuple(schemas)
51
+
52
+
53
+ def _parse_arguments(raw_args: Any) -> dict[str, Any]:
54
+ """Hermes stores tool arguments as a JSON string (OpenAI style)."""
55
+ if isinstance(raw_args, dict):
56
+ return raw_args
57
+ if isinstance(raw_args, str) and raw_args.strip():
58
+ try:
59
+ parsed = json.loads(raw_args)
60
+ return parsed if isinstance(parsed, dict) else {"_value": parsed}
61
+ except json.JSONDecodeError:
62
+ return {"_raw": raw_args}
63
+ return {}
64
+
65
+
66
+ def _assistant_blocks(record: dict[str, Any]) -> tuple[ContentBlock, ...]:
67
+ blocks: list[ContentBlock] = []
68
+ reasoning = record.get("reasoning")
69
+ # Reasoning presence has two signals in real Hermes data: the flat visible
70
+ # `reasoning` string, and a sibling `codex_reasoning_items` list (opaque
71
+ # encrypted extended-thinking, mirroring Codex). A real record often has
72
+ # reasoning=null WITH codex_reasoning_items present — emit a reasoning block
73
+ # from whichever signal exists so a real reasoning turn isn't silently lost.
74
+ codex_items = record.get("codex_reasoning_items")
75
+ if isinstance(reasoning, str) and reasoning.strip():
76
+ blocks.append(ContentBlock.reasoning(reasoning))
77
+ elif isinstance(codex_items, list) and codex_items:
78
+ # Content is opaque/encrypted; preserve presence with empty visible text.
79
+ blocks.append(ContentBlock.reasoning(""))
80
+ # Text parts -> TEXT, non-text parts -> RAW passthrough (not dropped).
81
+ blocks.extend(content_blocks(record.get("content")))
82
+ for call in record.get("tool_calls") or []:
83
+ if not isinstance(call, dict):
84
+ continue
85
+ call_id = call.get("call_id") or call.get("id") or ""
86
+ fn = call.get("function", {})
87
+ blocks.append(
88
+ ContentBlock.tool_call(
89
+ call_id=call_id,
90
+ tool_name=fn.get("name", ""),
91
+ tool_input=_parse_arguments(fn.get("arguments")),
92
+ )
93
+ )
94
+ return tuple(blocks)
95
+
96
+
97
+ def read_hermes(path: str | Path) -> Session:
98
+ path = Path(path)
99
+ records = load_records(path)
100
+
101
+ meta = SessionMeta(source_harness="hermes")
102
+ tools: tuple[ToolSchema, ...] = ()
103
+ messages: list[Message] = []
104
+
105
+ for rec in records:
106
+ role = rec.get("role")
107
+ if role == "session_meta":
108
+ meta = SessionMeta(
109
+ source_harness="hermes",
110
+ model=rec.get("model"),
111
+ version=rec.get("platform"),
112
+ extra={"platform": rec.get("platform")},
113
+ )
114
+ tools = _parse_tool_schemas(rec.get("tools"))
115
+ elif role == "user":
116
+ # Text parts -> TEXT, non-text parts (image_url, ...) -> RAW passthrough.
117
+ blocks = content_blocks(rec.get("content"))
118
+ messages.append(
119
+ Message(
120
+ role=Role.USER,
121
+ content=blocks,
122
+ timestamp=rec.get("timestamp"),
123
+ raw=rec,
124
+ )
125
+ )
126
+ elif role == "assistant":
127
+ messages.append(
128
+ Message(
129
+ role=Role.ASSISTANT,
130
+ content=_assistant_blocks(rec),
131
+ timestamp=rec.get("timestamp"),
132
+ raw=rec,
133
+ )
134
+ )
135
+ elif role == "system":
136
+ # write_hermes emits SYSTEM messages (e.g. the injected handshake) as
137
+ # role:system; read them back rather than dropping the whole class.
138
+ blocks = content_blocks(rec.get("content"))
139
+ messages.append(
140
+ Message(
141
+ role=Role.SYSTEM,
142
+ content=blocks,
143
+ timestamp=rec.get("timestamp"),
144
+ raw=rec,
145
+ )
146
+ )
147
+ elif role == "tool":
148
+ text, marked = recover_tool_error(rec.get("content", "") or "")
149
+ messages.append(
150
+ Message(
151
+ role=Role.TOOL,
152
+ content=(
153
+ ContentBlock.tool_result(
154
+ call_id=rec.get("tool_call_id", ""),
155
+ text=text,
156
+ is_error=marked,
157
+ ),
158
+ ),
159
+ timestamp=rec.get("timestamp"),
160
+ raw=rec,
161
+ )
162
+ )
163
+ # unknown roles are ignored but preserved via raw if needed later
164
+
165
+ msgs = tuple(messages)
166
+ pending = PendingState(open_tool_calls=open_tool_calls(msgs))
167
+ return Session(meta=meta, messages=msgs, tools=tools, pending=pending)