thread-archive 0.0.2__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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,908 @@
1
+ """thread_read: reconstruct a readable conversation transcript from events.
2
+
3
+ The event log is the source of truth; a conversation is rebuilt by walking a
4
+ thread's events in order. The importer's ``DefaultEventBuilder`` emits granular
5
+ per-block events (text_complete / thinking_complete / tool_use_complete /
6
+ tool_execution_*) plus lifecycle/summary events (api_request_*, stream_completed)
7
+ — we render from the granular events and skip the lifecycle ones.
8
+
9
+ :func:`read_thread` is the string transcript surface (CLI / MCP). It mirrors the
10
+ monorepo ``thread_read`` contract: a ``mode`` view knob (user / chat / full),
11
+ turn-based pagination (``limit`` / ``offset`` / ``after_event``), a per-chunk
12
+ ``max_chars`` budget with a CHUNKED footer, and ``summary`` for the summary views
13
+ (true/'toc' = compact TOC; 'short' / 'indexed' = the stored thread summaries). The
14
+ default view is ``user`` — only the user turns, the cheap signal — exactly as the
15
+ monorepo defaults. Tool *results* are never rendered (the transcript shows tool
16
+ calls, not their output), also matching the monorepo. :func:`read_thread_structured`
17
+ is the render-friendly sibling for the web viewer (typed blocks, results included).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from datetime import datetime
25
+ from typing import Container, Optional
26
+
27
+ from sqlalchemy import func, select
28
+ from sqlalchemy.orm import Session
29
+
30
+ from .._store import Event, Thread, TopicMessage, use_session
31
+ from ._codex import codex_kind, render_codex_block
32
+ from ._extract import _block_search_text
33
+
34
+ # Lifecycle / duplicate-summary events that carry no standalone transcript text.
35
+ _SKIP_TYPES = frozenset({
36
+ "api_request_started",
37
+ "api_request_completed", # its content is already in text_complete/thinking_complete
38
+ "stream_completed",
39
+ "tool_loaded",
40
+ # Pure session bookkeeping the source parser marks visually-hidden — not content.
41
+ # Rendering them (as "QUEUE_OPERATION"/"FILE_SNAPSHOT" boxes) is just noise.
42
+ "queue_operation",
43
+ "file_snapshot",
44
+ "progress",
45
+ })
46
+
47
+
48
+ def _payload(ev: Event) -> dict:
49
+ return ev.payload if isinstance(ev.payload, dict) else json.loads(ev.payload)
50
+
51
+
52
+ def _unknown_payload_text(payload: dict) -> str:
53
+ """Best-effort short text for an event type the reader doesn't model — so an
54
+ unrecognized event is *surfaced*, never silently dropped. Prefers obvious text
55
+ keys, else a compact JSON dump (provider_data omitted; capped)."""
56
+ for k in ("content", "text", "output", "error"):
57
+ v = payload.get(k)
58
+ if isinstance(v, str) and v.strip():
59
+ return v
60
+ try:
61
+ return json.dumps(
62
+ {k: v for k, v in payload.items() if k != "provider_data"}, default=str
63
+ )[:1000]
64
+ except Exception: # noqa: BLE001 — rendering must never raise
65
+ return ""
66
+
67
+
68
+ def _rendered_text(events: list[Event]) -> set[str]:
69
+ """The text of every turn the modeled path already renders in this thread.
70
+
71
+ Codex records its transcript twice — once as the ``event_msg`` stream the importer
72
+ models, once as ``response_item.message`` API history preserved verbatim — so the
73
+ reader needs to know a block's content is already on screen. Matching on text rather
74
+ than kind means a duplicate can't slip through under a kind we haven't met."""
75
+ seen: set[str] = set()
76
+ for ev in events:
77
+ if ev.event_type in _USER_TYPES:
78
+ key = "content"
79
+ elif ev.event_type == "text_complete":
80
+ key = "text"
81
+ else:
82
+ continue
83
+ value = _payload(ev).get(key)
84
+ if isinstance(value, str) and value.strip():
85
+ seen.add(value.strip())
86
+ return seen
87
+
88
+
89
+ def _content_block_view(p: dict, rendered_text: Container[str]) -> Optional[tuple[str, str]]:
90
+ """``(label, text)`` for a preserved ``content_block`` event, or None to hide it.
91
+
92
+ Only codex blocks are ever hidden — see :mod:`._codex`. Every other provider's
93
+ preserved block renders under its own block type, flattened to its readable text."""
94
+ block_type = p.get("block_type") or "block"
95
+ kind = codex_kind(block_type)
96
+ if kind is not None:
97
+ return render_codex_block(kind, p.get("data"), rendered_text)
98
+ return block_type, _block_search_text(p.get("data"))
99
+
100
+
101
+ def resolve_thread_ref(s: Session, ref: int | str) -> Optional[int]:
102
+ """Resolve a thread reference to the archive's integer thread id.
103
+
104
+ ``ref`` is either the archive's own integer thread id (the primary key) or a
105
+ provider **session id** — the uuid/source_id a tool like claude-code knows a
106
+ conversation by. An integer (or all-digit) ref resolves as a primary-key lookup
107
+ first, preserving the original ``thread_id`` contract exactly; anything that
108
+ isn't an existing PK falls back to a ``source_id`` match — exact, **or** the
109
+ separator-suffix forms the watcher stores: ``{project}:{uuid}`` (claude-code, ``:``)
110
+ and ``rollout-{ts}-{uuid}`` (codex, ``-``). Mirrors the web's
111
+ ``resolve_archive_link``. Newest thread wins; None when nothing matches."""
112
+ if isinstance(ref, int) or (isinstance(ref, str) and ref.isdigit()):
113
+ tid = int(ref)
114
+ if s.get(Thread, tid) is not None:
115
+ return tid
116
+ # A digit ref that isn't a PK may still be a numeric provider session id
117
+ # (e.g. grok), so fall through to source_id resolution.
118
+ ref = str(ref)
119
+ return s.execute(
120
+ select(Thread.id)
121
+ .where(
122
+ (Thread.source_id == ref)
123
+ | (Thread.source_id.like(f"%:{ref}"))
124
+ | (Thread.source_id.like(f"%-{ref}"))
125
+ )
126
+ .order_by(Thread.updated_at.desc())
127
+ ).scalars().first()
128
+
129
+
130
+ # Default per-read character budget for the budgeted "view" read (the thread_read
131
+ # tool + CLI). ~48k chars ≈ 12-15k tokens — under the MCP output cap with headroom,
132
+ # big enough that most threads read in one chunk; larger threads come back chunked
133
+ # with a footer naming the next offset. Mirrors the monorepo's DEFAULT_READ_CHAR_BUDGET.
134
+ DEFAULT_READ_CHAR_BUDGET = 48000
135
+
136
+ # A context-compaction continuation opens with this sentinel as its first user
137
+ # message; it becomes a chunk boundary (a short placeholder, not the huge summary).
138
+ _COMPACTION_PREFIX = "This session is being continued from a previous conversation"
139
+
140
+ _USER_TYPES = frozenset({"user_message_sent", "thread_message_sent"})
141
+
142
+
143
+ def _fmt_ts(dt) -> str:
144
+ """ISO-ish ``YYYY-MM-DDTHH:MM:SS`` for a step/thread timestamp (seconds, no µs)."""
145
+ if isinstance(dt, datetime):
146
+ return dt.strftime("%Y-%m-%dT%H:%M:%S")
147
+ return str(dt).replace(" ", "T", 1)[:19]
148
+
149
+
150
+ def _fmt_hm(dt) -> str:
151
+ """``HH:MM`` for the summary TOC time column."""
152
+ if isinstance(dt, datetime):
153
+ return dt.strftime("%H:%M")
154
+ s = str(dt).replace(" ", "T", 1)
155
+ return s[11:16] if len(s) >= 16 else s
156
+
157
+
158
+ def resolve_read_view(mode: Optional[str], user_only: Optional[bool]) -> tuple[bool, bool, bool]:
159
+ """Map the requested view → ``(user_only, strip_tools, strip_thinking)``.
160
+
161
+ mode=user → user turns only (True, False, False)
162
+ mode=chat → user + assistant text (False, True, True)
163
+ mode=full → full transcript incl. tools (False, False, False)
164
+
165
+ ``mode`` is the primary knob; ``user_only`` is a back-compat alias (True→user,
166
+ False→full) and ``mode`` wins when both are set. Default (neither set, or an
167
+ unrecognised mode) is ``user`` — the cheap default, matching the monorepo.
168
+ """
169
+ if mode is not None:
170
+ m = mode.strip().lower()
171
+ if m == "user":
172
+ return True, False, False
173
+ if m == "chat":
174
+ return False, True, True
175
+ if m == "full":
176
+ return False, False, False
177
+ # Unrecognised mode → fall through to user_only / default rather than error.
178
+ if user_only is not None:
179
+ return user_only, False, False
180
+ return True, False, False
181
+
182
+
183
+ def _assistant_block(et: str, p: dict, rendered_text: Container[str]) -> Optional[dict]:
184
+ """One assistant render block from a granular event, or None to skip.
185
+
186
+ Tool *result* blocks (tool_execution_*) are built here but only rendered when
187
+ the caller opts in (``tool_results=True``); they're off by default, the way the
188
+ monorepo never shows them — but reachable, unlike the monorepo, which can't."""
189
+ if et == "thinking_complete":
190
+ t = p.get("text", "")
191
+ return {"type": "thinking", "content": t} if t.strip() else None
192
+ if et in ("tool_use_complete", "tool_use_started"):
193
+ return {"type": "tool", "name": p.get("tool_name", "?"), "input": p.get("input") or {}}
194
+ if et == "tool_execution_completed":
195
+ return {"type": "tool_result", "output": str(p.get("output", ""))}
196
+ if et == "tool_execution_error":
197
+ return {"type": "tool_error", "error": str(p.get("error", ""))}
198
+ if et == "text_complete":
199
+ t = p.get("text", "")
200
+ return {"type": "text", "content": t} if t.strip() else None
201
+ if et == "context_summary":
202
+ c = p.get("content", "")
203
+ return {"type": "text", "content": f"[context summary] {c}"} if c.strip() else None
204
+ if et == "content_block":
205
+ view = _content_block_view(p, rendered_text)
206
+ if view is None:
207
+ return None
208
+ block_type, text = view
209
+ return {"type": "content_block", "block_type": block_type, "content": text}
210
+ if et == "ide_context":
211
+ return {"type": "ide_context", "context_type": p.get("context_type") or "context",
212
+ "file_path": p.get("file_path"), "content": p.get("content", "")}
213
+ if et in _SKIP_TYPES:
214
+ return None # lifecycle / duplicate-summary noise — deliberately hidden
215
+ # Any other unrecognized type: surface it rather than silently dropping it.
216
+ return {"type": "unknown", "event_type": et, "content": _unknown_payload_text(p)}
217
+
218
+
219
+ def _slot_queued_events(events: list[Event]) -> list[Event]:
220
+ """Relocate backfilled events into their chronological slot.
221
+
222
+ Two kinds of event can arrive with a tail-end ``Event.id`` that the id-ordered
223
+ walk would render at the end of the thread instead of where it belongs: a
224
+ steering message (typed mid-turn, queued, consumed as an attachment injection —
225
+ ``payload.queued``), and a ``model_change`` marker added by re-importing an older
226
+ thread once the ``/model`` switch capture existed. Move each to sit after the last
227
+ other event whose ``occurred_at`` is at or before its own (max-index semantics: one
228
+ deep event with a garbage inferred timestamp can't drag it to the top). Freshly
229
+ captured events arrive in file order, so at most this shifts one within its own
230
+ turn; everything else is untouched — this deliberately does NOT sort the stream,
231
+ because bookkeeping events (``file_snapshot`` etc.) carry inferred timestamps that
232
+ are wrong by days."""
233
+ queued = [
234
+ ev for ev in events
235
+ if (ev.event_type in _USER_TYPES and _payload(ev).get("queued"))
236
+ or ev.event_type == "model_change"
237
+ ]
238
+ if not queued:
239
+ return events
240
+ rest = [ev for ev in events if ev not in queued]
241
+ for q in sorted(queued, key=lambda e: (e.occurred_at, e.id)):
242
+ pos = 0
243
+ for i, ev in enumerate(rest):
244
+ if ev.occurred_at is not None and q.occurred_at is not None \
245
+ and ev.occurred_at <= q.occurred_at:
246
+ pos = i + 1
247
+ rest.insert(pos, q)
248
+ return rest
249
+
250
+
251
+ def _build_steps(events: list[Event]) -> list[dict]:
252
+ """Fold the granular event stream into steps (the monorepo's regroup_by_steps
253
+ analogue): a USER message is its own step; assistant block events accumulate
254
+ into one step that closes at each text output. Tool calls + thinking thus group
255
+ under the step whose text they precede; trailing tools form a final step."""
256
+ steps: list[dict] = []
257
+ rendered_text = _rendered_text(events)
258
+ cur: Optional[dict] = None # open assistant step
259
+ for ev in events:
260
+ et = ev.event_type
261
+ p = _payload(ev)
262
+ if et in _USER_TYPES:
263
+ if cur is not None:
264
+ steps.append(cur)
265
+ cur = None
266
+ content = p.get("content", "")
267
+ if not content.strip():
268
+ continue
269
+ steps.append({
270
+ "role": "user",
271
+ "id": ev.id,
272
+ "ts": ev.occurred_at,
273
+ "content": content,
274
+ "is_compaction": content.startswith(_COMPACTION_PREFIX),
275
+ })
276
+ continue
277
+ if et == "message":
278
+ # A preserved turn whose role isn't user/assistant/system (tool/developer/
279
+ # …). Render it as its own labeled step rather than dropping it.
280
+ if cur is not None:
281
+ steps.append(cur)
282
+ cur = None
283
+ content = p.get("content", "")
284
+ if content.strip():
285
+ steps.append({
286
+ "role": p.get("role") or "message",
287
+ "id": ev.id,
288
+ "ts": ev.occurred_at,
289
+ "content": content,
290
+ "is_compaction": False,
291
+ })
292
+ continue
293
+ block = _assistant_block(et, p, rendered_text)
294
+ if block is not None:
295
+ is_result = block["type"] in ("tool_result", "tool_error")
296
+ # A result event can land after the text that closed its step; glue it
297
+ # back onto that step rather than spawning an orphan result-only step.
298
+ if cur is None and is_result and steps and steps[-1]["role"] == "assistant":
299
+ steps[-1]["blocks"].append(block)
300
+ else:
301
+ if cur is None:
302
+ cur = {"role": "assistant", "id": ev.id, "ts": ev.occurred_at, "blocks": []}
303
+ cur["blocks"].append(block)
304
+ if et == "text_complete" and cur is not None:
305
+ steps.append(cur)
306
+ cur = None
307
+ if cur is not None:
308
+ steps.append(cur)
309
+ return steps
310
+
311
+
312
+ # Per-result transcript cap — tool output can be a megabyte; keep the head and
313
+ # flag the truncation so one result can't blow the whole char budget.
314
+ _TOOL_RESULT_CAP = 2000
315
+
316
+
317
+ def _format_tool_block(block: dict) -> str:
318
+ """Render a tool block as ``[tool: name k=v ...]`` (each value truncated at 80)."""
319
+ name = block.get("name", "?")
320
+ tool_input = block.get("input") or {}
321
+ if not tool_input:
322
+ return f"[tool: {name}]"
323
+ arg_strs = []
324
+ for k, v in tool_input.items():
325
+ v_str = str(v)
326
+ if len(v_str) > 80:
327
+ v_str = v_str[:80] + "..."
328
+ arg_strs.append(f"{k}={v_str}")
329
+ return f"[tool: {name} {' '.join(arg_strs)}]"
330
+
331
+
332
+ def _format_result_block(block: dict) -> str:
333
+ """Render a tool result/error block (only when ``tool_results`` is on)."""
334
+ if block.get("type") == "tool_error":
335
+ err = (block.get("error", "") or "").strip()
336
+ if len(err) > _TOOL_RESULT_CAP:
337
+ err = err[:_TOOL_RESULT_CAP] + "… (truncated)"
338
+ return f"[tool error] {err}"
339
+ out = (block.get("output", "") or "").strip()
340
+ if len(out) > _TOOL_RESULT_CAP:
341
+ out = out[:_TOOL_RESULT_CAP] + "… (truncated)"
342
+ return f"[result] {out}"
343
+
344
+
345
+ def _format_step(step: dict, *, strip_tools: bool, strip_thinking: bool, include_results: bool) -> str:
346
+ """Format one step as ``[USER ...]`` / ``[ASSISTANT ...]`` text, honoring strips.
347
+
348
+ Tool result/error blocks render only when ``include_results`` is set *and* tools
349
+ aren't stripped (a result with its call hidden would be context-free)."""
350
+ ts = _fmt_ts(step["ts"])
351
+ if "content" in step: # user, or a preserved non-standard-role turn
352
+ label = "USER" if step["role"] == "user" else step["role"].upper()
353
+ return f"[{label} {ts} event:{step['id']}] {step['content']}"
354
+ parts = []
355
+ for b in step.get("blocks", []):
356
+ bt = b.get("type")
357
+ if bt == "tool":
358
+ if not strip_tools:
359
+ parts.append(_format_tool_block(b))
360
+ elif bt in ("tool_result", "tool_error"):
361
+ if not strip_tools and include_results:
362
+ parts.append(_format_result_block(b))
363
+ elif bt == "thinking":
364
+ if not strip_thinking:
365
+ c = b.get("content", "").strip()
366
+ if c:
367
+ parts.append(f"[thinking]\n{c}\n[/thinking]")
368
+ elif bt == "text":
369
+ c = b.get("content", "").strip()
370
+ if c:
371
+ parts.append(c)
372
+ elif bt == "ide_context":
373
+ if not strip_tools:
374
+ fp = b.get("file_path")
375
+ head = "ide " + (b.get("context_type") or "context") + (f" {fp}" if fp else "")
376
+ c = b.get("content", "").strip()
377
+ parts.append(f"[{head}]" + (f" {c}" if c else ""))
378
+ elif bt == "content_block":
379
+ if not strip_tools:
380
+ c = b.get("content", "").strip()
381
+ parts.append(f"[block: {b.get('block_type')}]" + (f" {c}" if c else ""))
382
+ elif bt == "unknown":
383
+ if not strip_tools:
384
+ c = b.get("content", "").strip()
385
+ parts.append(f"[{b.get('event_type')}]" + (f" {c}" if c else ""))
386
+ content = "\n" + "\n".join(parts) if parts else ""
387
+ return f"[ASSISTANT {ts} event:{step['id']}]{content}"
388
+
389
+
390
+ def _group_steps_into_turns(steps: list[dict]) -> list[list[dict]]:
391
+ """A turn = one USER step + all following ASSISTANT steps until the next USER."""
392
+ turns: list[list[dict]] = []
393
+ cur: list[dict] = []
394
+ for step in steps:
395
+ if step["role"] == "user" and cur:
396
+ turns.append(cur)
397
+ cur = []
398
+ cur.append(step)
399
+ if cur:
400
+ turns.append(cur)
401
+ return turns
402
+
403
+
404
+ def _accumulate_turns(remaining, limit, max_chars, *, strip_tools, strip_thinking,
405
+ include_results, user_only):
406
+ """Accumulate turns into one chunk until the char budget (or turn limit) is hit.
407
+ Returns ``(formatted_steps, turns_consumed, total_chars)`` — ``turns_consumed``
408
+ counts every turn advanced past (incl. compaction placeholders), so
409
+ ``offset + turns_consumed`` is the exact resume point."""
410
+ page: list[str] = []
411
+ consumed = 0
412
+ total_chars = 0
413
+ for turn in remaining:
414
+ user_step = turn[0] if turn and turn[0]["role"] == "user" else None
415
+ if user_step is not None and user_step.get("is_compaction"):
416
+ placeholder = f"[COMPACTION event:{user_step['id']}] (context was compacted here)"
417
+ page.append(placeholder)
418
+ total_chars += len(placeholder)
419
+ consumed += 1
420
+ if consumed >= limit:
421
+ break
422
+ continue
423
+ turn_formatted = [
424
+ _format_step(s, strip_tools=strip_tools, strip_thinking=strip_thinking,
425
+ include_results=include_results)
426
+ for s in turn
427
+ if not (user_only and s["role"] != "user")
428
+ ]
429
+ turn_text = "\n\n".join(turn_formatted)
430
+ turn_chars = len(turn_text)
431
+ # Size gate: stop *before* a turn that would blow the budget (unless the page
432
+ # is empty — a single oversized turn can't split, so it's emitted whole).
433
+ if max_chars > 0 and total_chars + turn_chars > max_chars and page:
434
+ break
435
+ consumed += 1
436
+ if turn_formatted:
437
+ page.extend(turn_formatted)
438
+ total_chars += turn_chars
439
+ if consumed >= limit:
440
+ break
441
+ return page, consumed, total_chars
442
+
443
+
444
+ def _topic_read_message(thread: Thread, *, session: Optional[Session] = None) -> str:
445
+ """The message returned when a topic thread is read as a conversation."""
446
+ with use_session(session) as s:
447
+ link_count = s.execute(
448
+ select(func.count()).select_from(TopicMessage)
449
+ .where(TopicMessage.topic_id == thread.id, TopicMessage.archived_at.is_(None))
450
+ ).scalar_one()
451
+ return (
452
+ f"Thread {thread.id} is a **topic** thread, not a conversation. "
453
+ f"Topic threads don't have messages — they collect references to messages in "
454
+ f"other threads.\n"
455
+ f"Title: {thread.title or '(untitled)'}\n"
456
+ f"Description: {(thread.description or 'none')[:300]}\n"
457
+ f"Linked messages: {link_count}"
458
+ )
459
+
460
+
461
+ # Feature flag for the stored-summary read kinds (summary='short'/'indexed').
462
+ # On by default; set THREAD_ARCHIVE_STORED_SUMMARIES=0 (or false/no/off) to disable —
463
+ # those kinds then return a disabled notice, and everything else is unchanged.
464
+ _ENV_STORED_SUMMARIES = "THREAD_ARCHIVE_STORED_SUMMARIES"
465
+
466
+
467
+ def _stored_summaries_enabled() -> bool:
468
+ """Checked per call, so flipping the env var needs no restart for in-process
469
+ callers (a long-lived MCP server picks it up on its next environment)."""
470
+ v = os.environ.get(_ENV_STORED_SUMMARIES, "1").strip().lower()
471
+ return v not in ("0", "false", "no", "off")
472
+
473
+
474
+ def _resolve_summary_kind(summary: bool | str) -> Optional[str]:
475
+ """Map the ``summary`` knob → ``None`` (normal read), ``'toc'``, ``'short'``,
476
+ ``'indexed'``, or ``'?'`` for an unrecognised string (the caller reports it).
477
+ Bool-ish strings are accepted because MCP clients sometimes stringify booleans."""
478
+ if isinstance(summary, str):
479
+ v = summary.strip().lower()
480
+ if v in ("short", "indexed", "toc"):
481
+ return v
482
+ if v in ("true", "1", "yes"):
483
+ return "toc"
484
+ if v in ("", "false", "0", "no", "none"):
485
+ return None
486
+ return "?"
487
+ return "toc" if summary else None
488
+
489
+
490
+ def _stored_summary(thread: Thread, kind: str) -> str:
491
+ """The thread's stored summary: ``short`` (``Thread.summary``, a few sentences)
492
+ or ``indexed`` (``Thread.indexed_summary``, structured markdown with event
493
+ anchors). Written by the summarizer pipeline, so not every thread has them;
494
+ absence names whichever alternative exists rather than returning empty."""
495
+ text = thread.summary if kind == "short" else thread.indexed_summary
496
+ other_kind = "indexed" if kind == "short" else "short"
497
+ other_text = thread.indexed_summary if kind == "short" else thread.summary
498
+ if not (text and text.strip()):
499
+ hint = (
500
+ f" The {other_kind} summary exists: summary='{other_kind}'."
501
+ if other_text and other_text.strip()
502
+ else " Neither stored summary exists; summary=true gives the message TOC."
503
+ )
504
+ return f"Thread {thread.id} has no {kind} summary.{hint}"
505
+ return (
506
+ f"# Thread {thread.id}: {thread.title or thread.name or '(untitled)'} "
507
+ f"({kind} summary)\n\n{text.strip()}"
508
+ )
509
+
510
+
511
+ def _thread_read_summary(
512
+ thread: Thread, steps: list[dict], limit: int, offset: int, *, session: Optional[Session] = None
513
+ ) -> str:
514
+ """Compact TOC — one row per message (user step / assistant step), with preview."""
515
+ rows = []
516
+ for st in steps:
517
+ if st["role"] == "user":
518
+ rows.append((st["id"], "user", st["ts"], st["content"]))
519
+ else:
520
+ preview = next((b["content"] for b in st.get("blocks", []) if b.get("type") == "text"), None)
521
+ rows.append((st["id"], "asst", st["ts"], preview or "[tool use]"))
522
+ total = len(rows)
523
+ if total == 0:
524
+ if thread.thread_type == "topic":
525
+ return _topic_read_message(thread, session=session)
526
+ return f"Thread {thread.id} has no messages yet"
527
+ if offset < 0:
528
+ offset = max(0, total + offset)
529
+ if offset >= total:
530
+ return f"Thread {thread.id}: offset {offset} is past the end ({total} messages total)"
531
+ page = rows[offset:offset + limit]
532
+ lines = [
533
+ f"# Thread {thread.id}: {thread.title or thread.name or '(untitled)'} ({total} messages)",
534
+ f"Created: {_fmt_ts(thread.inserted_at)}",
535
+ f"Showing {offset + 1}-{offset + len(page)} of {total}",
536
+ "",
537
+ "| # | Role | Event ID | Time | Preview |",
538
+ "|---|------|----------|------|---------|",
539
+ ]
540
+ for i, (eid, role, ts, preview) in enumerate(page):
541
+ num = offset + i + 1
542
+ pv = (preview or "").replace("|", "/").replace("\n", " ").strip()
543
+ if len(pv) > 70:
544
+ pv = pv[:67] + "..."
545
+ lines.append(f"| {num} | {role} | {eid} | {_fmt_hm(ts)} | {pv} |")
546
+ return "\n".join(lines)
547
+
548
+
549
+ def read_thread(
550
+ thread_id: int | str,
551
+ *,
552
+ limit: int = 200,
553
+ offset: int = 0,
554
+ summary: bool | str = False,
555
+ mode: Optional[str] = None,
556
+ user_only: Optional[bool] = None,
557
+ tool_results: bool = False,
558
+ max_chars: int = 0,
559
+ after_event: Optional[int] = None,
560
+ session: Optional[Session] = None,
561
+ ) -> str:
562
+ """Read a thread's conversation as a transcript, reconstructed from its events.
563
+
564
+ ``thread_id`` is either the archive's integer thread id or a provider **session
565
+ id** (the uuid/source_id a tool knows the conversation by) — see
566
+ :func:`resolve_thread_ref`. ``mode`` picks the view: ``user`` (default) = only
567
+ the user turns; ``chat`` = user + assistant visible text (thinking + tool calls
568
+ stripped); ``full`` = the whole transcript including tool calls. ``tool_results``
569
+ (default off) adds tool *output* under each call — only meaningful in ``full``
570
+ (where calls are shown). The read is paginated by turns and size-budgeted at
571
+ ``max_chars`` (default ~48k chars): a thread bigger than one chunk ends in a
572
+ CHUNKED footer naming the next offset. ``after_event`` resumes from the turn after
573
+ an event id; ``summary`` picks a summary view instead of the transcript —
574
+ ``True``/``'toc'`` = compact per-message TOC, ``'short'`` = the stored short
575
+ summary (``Thread.summary``), ``'indexed'`` = the stored indexed summary
576
+ (``Thread.indexed_summary``, structured, with event anchors). ``user_only`` is a
577
+ back-compat alias for ``mode`` (True→user, False→full); ``mode`` wins. Returns a
578
+ message string if absent.
579
+ """
580
+ summary_kind = _resolve_summary_kind(summary)
581
+ if summary_kind == "?":
582
+ return (
583
+ f"Unknown summary kind {summary!r} — use 'short' (stored short summary), "
584
+ f"'indexed' (stored indexed summary), or true/'toc' (compact message TOC)."
585
+ )
586
+ if summary_kind in ("short", "indexed") and not _stored_summaries_enabled():
587
+ return (
588
+ f"Stored-summary reads are disabled ({_ENV_STORED_SUMMARIES} is off). "
589
+ f"summary=true still gives the compact message TOC."
590
+ )
591
+
592
+ with use_session(session) as s:
593
+ resolved = resolve_thread_ref(s, thread_id)
594
+ if resolved is None:
595
+ return f"Thread {thread_id} not found."
596
+ thread_id = resolved
597
+ thread = s.get(Thread, thread_id)
598
+ if thread is None:
599
+ return f"Thread {thread_id} not found."
600
+ if summary_kind in ("short", "indexed"):
601
+ return _stored_summary(thread, summary_kind)
602
+ events = s.execute(
603
+ select(Event).where(Event.thread_id == thread_id).order_by(Event.id)
604
+ ).scalars().all()
605
+
606
+ steps = _build_steps(_slot_queued_events(events))
607
+
608
+ if summary_kind == "toc":
609
+ return _thread_read_summary(
610
+ thread, steps, limit if limit and limit > 0 else 200, offset, session=session
611
+ )
612
+
613
+ resolved_user_only, strip_tools, strip_thinking = resolve_read_view(mode, user_only)
614
+ budget = max_chars if max_chars and max_chars > 0 else DEFAULT_READ_CHAR_BUDGET
615
+
616
+ turns = _group_steps_into_turns(steps)
617
+
618
+ # after_event → offset: resume from the turn AFTER the last turn at/with that id.
619
+ if after_event is not None:
620
+ last_before = -1
621
+ for i, turn in enumerate(turns):
622
+ if any((st["id"] or 0) <= after_event for st in turn):
623
+ last_before = i
624
+ offset = last_before + 1
625
+
626
+ total = len(turns)
627
+ if offset < 0:
628
+ offset = max(0, total + offset)
629
+ remaining = turns[offset:]
630
+
631
+ page, consumed, total_chars = _accumulate_turns(
632
+ remaining, limit, budget,
633
+ strip_tools=strip_tools, strip_thinking=strip_thinking,
634
+ include_results=tool_results, user_only=resolved_user_only,
635
+ )
636
+
637
+ if not page:
638
+ if offset > 0:
639
+ return f"Thread {thread_id}: offset {offset} is past the end ({total} turns total)"
640
+ if thread.thread_type == "topic":
641
+ return _topic_read_message(thread, session=session)
642
+ return f"Thread {thread_id} has no messages yet"
643
+
644
+ next_offset = offset + consumed
645
+ label = "user turns" if resolved_user_only else "turns"
646
+ header = (
647
+ f"# Thread {thread_id}: {thread.title or thread.name or '(untitled)'}\n"
648
+ f"Created: {_fmt_ts(thread.inserted_at)}\n"
649
+ f"Messages: {total} {label}, showing {offset + 1}-{next_offset} (~{total_chars} chars)\n\n"
650
+ )
651
+ body = "\n\n".join(page)
652
+
653
+ footer = ""
654
+ if next_offset < total:
655
+ remaining_turns = total - next_offset
656
+ cont = f"thread_id={thread_id}, offset={next_offset}"
657
+ if not resolved_user_only:
658
+ cont += ", user_only=false"
659
+ why = f"~{budget}-char budget" if budget > 0 else f"{limit}-turn limit"
660
+ footer = (
661
+ f"\n\n---\n"
662
+ f"⚠ CHUNKED at the {why} — {remaining_turns} of {total} {label} not shown "
663
+ f"(this is NOT the whole thread). "
664
+ f"Read the next chunk: thread_read({cont})"
665
+ )
666
+ return header + body + footer
667
+
668
+
669
+ # Cap a single tool result so a megabyte of command output never ships to the
670
+ # viewer whole; the UI shows the head and flags the truncation.
671
+ _TOOL_OUTPUT_CAP = 20_000
672
+
673
+ # Placeholder model strings that aren't a real model id (mirrors the importer's
674
+ # NON_MODEL_VALUES) — kept out of a message's model list.
675
+ _NON_MODEL_VALUES = ("", "unknown", "<synthetic>")
676
+
677
+ # The api_request lifecycle events (normally hidden by _SKIP_TYPES) that carry a
678
+ # message's model/token/stop-reason — folded into per-message meta, not rendered.
679
+ _REQUEST_TYPES = frozenset({"api_request_started", "api_request_completed"})
680
+
681
+
682
+ def _iso(dt) -> Optional[str]:
683
+ return dt.isoformat() if hasattr(dt, "isoformat") else (str(dt) if dt else None)
684
+
685
+
686
+ def _new_meta(role: str, ev: Event) -> dict:
687
+ """A fresh per-message meta record. Assistant messages carry the model/request
688
+ fields the info drawer fills from api_request events; a user (or other-role)
689
+ message just carries its timestamp."""
690
+ meta: dict = {"ts": _iso(ev.occurred_at)}
691
+ if role == "assistant":
692
+ meta.update(models=[], requests=0, stop_reason=None,
693
+ tokens={"input": 0, "output": 0, "thinking": 0})
694
+ return meta
695
+
696
+
697
+ def _fold_request(meta: dict, event_type: str, p: dict) -> None:
698
+ """Fold one api_request_{started,completed} payload into a message's meta: the
699
+ model (deduped), and — from the *completed* event — token counts, a request
700
+ tally, and the stop reason. This is what lets the drawer show that a single
701
+ turn's tool loop ran N requests, and which model(s) answered it."""
702
+ model = p.get("model")
703
+ if model and model not in _NON_MODEL_VALUES and model not in meta["models"]:
704
+ meta["models"].append(model)
705
+ if event_type == "api_request_completed":
706
+ meta["requests"] += 1
707
+ meta["tokens"]["input"] += int(p.get("input_tokens") or 0)
708
+ meta["tokens"]["output"] += int(p.get("output_tokens") or 0)
709
+ meta["tokens"]["thinking"] += int(p.get("thinking_tokens") or 0)
710
+ stop = p.get("stop_reason")
711
+ if stop:
712
+ meta["stop_reason"] = stop
713
+
714
+
715
+ def _structured_event(
716
+ ev: Event, *, include_thinking: bool, include_tools: bool, rendered_text: Container[str]
717
+ ) -> Optional[tuple[str, dict]]:
718
+ """Like the string transcript renderer, but returns ``(role, block)`` where ``block`` is a
719
+ typed renderable dict — so the web viewer can render markdown, syntax-highlighted
720
+ code, and collapsible tool calls instead of a pre-flattened string. Mirrors the
721
+ same event-type handling; returns None to skip lifecycle/empty events."""
722
+ et = ev.event_type
723
+ if et in _SKIP_TYPES:
724
+ return None
725
+ p = _payload(ev)
726
+
727
+ if et in ("user_message_sent", "thread_message_sent"):
728
+ content = p.get("content", "")
729
+ return ("user", {"type": "text", "text": content}) if content.strip() else None
730
+ if et == "text_complete":
731
+ text = p.get("text", "")
732
+ return ("assistant", {"type": "text", "text": text}) if text.strip() else None
733
+ if et == "thinking_complete":
734
+ if not include_thinking:
735
+ return None
736
+ text = p.get("text", "")
737
+ return ("assistant", {"type": "thinking", "text": text}) if text.strip() else None
738
+ if et in ("tool_use_complete", "tool_use_started"):
739
+ if not include_tools:
740
+ return None
741
+ return ("assistant", {
742
+ "type": "tool_use",
743
+ "name": p.get("tool_name", "unknown"),
744
+ "input": p.get("input", {}),
745
+ })
746
+ if et == "tool_execution_completed":
747
+ if not include_tools:
748
+ return None
749
+ out = str(p.get("output", ""))
750
+ return ("assistant", {
751
+ "type": "tool_result",
752
+ "output": out[:_TOOL_OUTPUT_CAP],
753
+ "truncated": len(out) > _TOOL_OUTPUT_CAP,
754
+ })
755
+ if et == "tool_execution_error":
756
+ if not include_tools:
757
+ return None
758
+ return ("assistant", {"type": "tool_error", "error": str(p.get("error", ""))})
759
+ if et == "context_summary":
760
+ content = p.get("content", "")
761
+ if not content.strip():
762
+ return None
763
+ # Claude Code records a model_refusal_fallback (a message the active model's
764
+ # safeguards flagged, retried on a stronger model) as a context_summary. Surface
765
+ # it as a distinct, legible safeguard notice rather than a generic "context
766
+ # summary" — it explains an otherwise-mysterious mid-turn model switch.
767
+ low = content.lower()
768
+ if "safeguard" in low and "flag" in low:
769
+ return ("assistant", {"type": "safeguard_notice", "text": content})
770
+ return ("assistant", {"type": "context_summary", "text": content})
771
+ if et == "model_change":
772
+ # A manual `/model` switch — a standalone divider between turns (own role, so it
773
+ # renders bare, not inside a bubble). Genuine context, not gated behind tools.
774
+ to = p.get("to")
775
+ if not to:
776
+ return None
777
+ return ("model_switch", {"type": "model_switch", "kind": "user",
778
+ "from_model": None, "to_model": to})
779
+ if et == "message":
780
+ # A preserved non-standard-role turn — shown under its own role (genuine
781
+ # content, not machinery, so not gated behind include_tools).
782
+ content = p.get("content", "")
783
+ return (p.get("role") or "message", {"type": "text", "text": content}) if content.strip() else None
784
+ if et == "ide_context":
785
+ if not include_tools:
786
+ return None
787
+ return ("assistant", {
788
+ "type": "ide_context",
789
+ "context_type": p.get("context_type") or "context",
790
+ "file_path": p.get("file_path"),
791
+ "text": p.get("content", ""),
792
+ })
793
+ if et == "content_block":
794
+ data = p.get("data")
795
+ # A model-fallback marker (Claude Code retried a safeguard-flagged message on a
796
+ # stronger model) carries the from/to models — surface it as a first-class model
797
+ # switch marker, not a cryptic "block · fallback". Genuine conversation context,
798
+ # so shown regardless of the tools toggle.
799
+ if p.get("block_type") == "fallback":
800
+ raw = data.get("raw") if isinstance(data, dict) else None
801
+ frm = to = None
802
+ if isinstance(raw, dict):
803
+ frm = (raw.get("from") or {}).get("model")
804
+ to = (raw.get("to") or {}).get("model")
805
+ return ("assistant", {"type": "model_switch", "kind": "fallback",
806
+ "from_model": frm, "to_model": to})
807
+ if not include_tools:
808
+ return None
809
+ view = _content_block_view(p, rendered_text)
810
+ if view is None:
811
+ return None
812
+ block_type, text = view
813
+ return ("assistant", {"type": "content_block", "block_type": block_type, "text": text})
814
+ # An unrecognized, non-lifecycle type: surface it in the machinery view rather
815
+ # than silently dropping it (a new importer-preserved type stays visible).
816
+ if not include_tools:
817
+ return None
818
+ return ("assistant", {"type": "unknown", "event_type": et, "text": _unknown_payload_text(p)})
819
+
820
+
821
+ def read_thread_structured(
822
+ thread_id: int | str,
823
+ *,
824
+ include_thinking: bool = True,
825
+ include_tools: bool = True,
826
+ session: Optional[Session] = None,
827
+ ) -> dict:
828
+ """Reconstruct a thread as structured messages for the web viewer.
829
+
830
+ ``thread_id`` accepts an integer thread id or a provider session id, same as
831
+ :func:`read_thread` (see :func:`resolve_thread_ref`). Returns
832
+ ``{thread_id, title, source, messages}`` where ``messages`` is a list of
833
+ ``{role, blocks, meta}`` — same-role events grouped into a message, but an assistant
834
+ turn's tool loop is split at each api_request boundary so every model inference (one
835
+ tool-call/response iteration) is its own message. Each block is a typed dict
836
+ (:func:`_structured_event`); ``meta`` is per-message info (timestamp, and for
837
+ assistant messages the model/token/stop-reason folded from that inference's
838
+ api_request events) for the viewer's info drawer and per-message model tint. A model
839
+ switch mid-turn thus lands on a message boundary instead of hiding inside one merged
840
+ bubble. Providers without api_request events keep one message per turn (nothing to
841
+ split on). The string :func:`read_thread` stays the canonical transcript (CLI /
842
+ MCP); this is the render-friendly sibling."""
843
+ with use_session(session) as s:
844
+ resolved = resolve_thread_ref(s, thread_id)
845
+ if resolved is None:
846
+ return {"thread_id": thread_id, "title": None, "source": None, "messages": []}
847
+ thread = s.get(Thread, resolved)
848
+ if thread is None:
849
+ return {"thread_id": thread_id, "title": None, "source": None, "messages": []}
850
+ events = s.execute(
851
+ select(Event).where(Event.thread_id == resolved).order_by(Event.id)
852
+ ).scalars().all()
853
+ events = _slot_queued_events(events)
854
+ rendered_text = _rendered_text(events)
855
+
856
+ messages: list[dict] = []
857
+ current: Optional[dict] = None
858
+ # api_request events for an inference precede its first visible block; buffer them
859
+ # until the assistant message they belong to exists (and drop the buffer at a role
860
+ # change so a fully-hidden inference's request can't leak onto the next message).
861
+ pending: list[tuple[str, dict]] = []
862
+ # A single assistant turn is a tool loop of N model inferences, each opened by an
863
+ # api_request_started. We split the turn into one message per inference — a
864
+ # tool-call/response iteration — rather than collapse the whole turn into one bubble.
865
+ # That keeps every message's model exact, so a mid-turn model switch surfaces as a
866
+ # fresh (differently-tinted) message instead of hiding inside one merged turn.
867
+ split = False # an api_request_started opened a new inference; next block starts a message
868
+ for ev in events:
869
+ if ev.event_type in _REQUEST_TYPES:
870
+ p = _payload(ev)
871
+ # A new inference inside the current assistant turn → force the next visible
872
+ # block to start a fresh message, and buffer this request so its model/tokens
873
+ # fold into that new message rather than the inference that just ended.
874
+ if (
875
+ ev.event_type == "api_request_started"
876
+ and current is not None
877
+ and current["role"] == "assistant"
878
+ and current["blocks"]
879
+ ):
880
+ split = True
881
+ if not split and current is not None and current["role"] == "assistant":
882
+ _fold_request(current["meta"], ev.event_type, p)
883
+ else:
884
+ pending.append((ev.event_type, p))
885
+ continue
886
+ rendered = _structured_event(
887
+ ev, include_thinking=include_thinking, include_tools=include_tools,
888
+ rendered_text=rendered_text,
889
+ )
890
+ if rendered is None:
891
+ continue
892
+ role, block = rendered
893
+ if split or current is None or current["role"] != role:
894
+ current = {"role": role, "blocks": [], "meta": _new_meta(role, ev)}
895
+ if role == "assistant":
896
+ for pet, pp in pending:
897
+ _fold_request(current["meta"], pet, pp)
898
+ pending = []
899
+ split = False
900
+ messages.append(current)
901
+ current["blocks"].append(block)
902
+
903
+ return {
904
+ "thread_id": thread.id,
905
+ "title": thread.title or thread.name,
906
+ "source": thread.source,
907
+ "messages": messages,
908
+ }