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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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
|
+
}
|