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,299 @@
1
+ """The ``archive web`` server: a socket-free router + a thin stdlib HTTP adapter.
2
+
3
+ The whole read surface the UI needs already exists as the plain Python library
4
+ (:mod:`thread_archive._api`): ``search`` / ``read_thread`` / ``status``. This is a
5
+ skin over it — no ranking/fusion logic is duplicated here. :func:`route` is a pure
6
+ ``(method, path, params) -> (status, content_type, body, headers)`` function so
7
+ tests drive it without opening a socket. :func:`serve` runs it in the foreground
8
+ (``archive web``); :func:`serve_in_thread` runs it in a background daemon thread so
9
+ the always-on ``archive watch`` process can cohost the viewer (one process, one
10
+ engine) — that's how the read surface gets a persistent URL with no extra daemon.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ from datetime import datetime
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+ from typing import Optional
21
+ from urllib.parse import parse_qs, urlparse
22
+
23
+ from .. import _api as api
24
+
25
+ STATIC_DIR = (Path(__file__).parent / "static").resolve()
26
+
27
+ # Content types for the built bundle (vite emits hashed assets/*.js|css + fonts).
28
+ _CTYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".js": "text/javascript; charset=utf-8",
31
+ ".mjs": "text/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".json": "application/json",
34
+ ".map": "application/json",
35
+ ".svg": "image/svg+xml",
36
+ ".ico": "image/x-icon",
37
+ ".png": "image/png",
38
+ ".woff": "font/woff",
39
+ ".woff2": "font/woff2",
40
+ }
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # small helpers
45
+ # ---------------------------------------------------------------------------
46
+ def _json_default(obj):
47
+ """Serialize the non-JSON-native values the api hits carry (datetimes)."""
48
+ if isinstance(obj, datetime):
49
+ return obj.isoformat()
50
+ return str(obj)
51
+
52
+
53
+ # Responses are uniform 4-tuples: (status, content_type, body, extra_headers).
54
+ Response = tuple[int, str, bytes, dict]
55
+
56
+
57
+ def _ok(obj) -> Response:
58
+ return 200, "application/json", json.dumps(obj, default=_json_default).encode(), {}
59
+
60
+
61
+ def _text(status: int, msg: str) -> Response:
62
+ return status, "text/plain; charset=utf-8", msg.encode(), {}
63
+
64
+
65
+ def _redirect(url: str) -> Response:
66
+ return 302, "text/plain; charset=utf-8", b"", {"Location": url}
67
+
68
+
69
+ def _serve_file(p: Path) -> Response:
70
+ if not p.is_file():
71
+ return _text(404, "not found")
72
+ ctype = _CTYPES.get(p.suffix.lower(), "application/octet-stream")
73
+ return 200, ctype, p.read_bytes(), {}
74
+
75
+
76
+ def _first(params: dict, key: str) -> Optional[str]:
77
+ v = params.get(key, [None])[0]
78
+ return v if v else None
79
+
80
+
81
+ def _repeated(params: dict, key: str, limit: int = 10) -> list[str]:
82
+ """Every value given for ``key``, in order, deduped and capped."""
83
+ seen: list[str] = []
84
+ for v in params.get(key, []):
85
+ v = (v or "").strip()
86
+ if v and v not in seen:
87
+ seen.append(v)
88
+ return seen[:limit]
89
+
90
+
91
+ def _int(params: dict, key: str, default: int) -> int:
92
+ try:
93
+ return int(params.get(key, [default])[0])
94
+ except (TypeError, ValueError):
95
+ return default
96
+
97
+
98
+ def _bool(params: dict, key: str, default: bool) -> bool:
99
+ v = _first(params, key)
100
+ if v is None:
101
+ return default
102
+ return v.lower() in ("1", "true", "yes", "on")
103
+
104
+
105
+ def _csv(params: dict, key: str) -> Optional[list[str]]:
106
+ v = _first(params, key)
107
+ if not v:
108
+ return None
109
+ return [s for s in (x.strip() for x in v.split(",")) if s] or None
110
+
111
+
112
+ def _list_threads(*, limit: int, q: Optional[str]) -> list[dict]:
113
+ """Recent conversation threads for the sidebar (newest first). Topics and
114
+ archived threads are excluded; ``q`` filters on title/name substring."""
115
+ from sqlalchemy import select
116
+
117
+ from .._store import Thread, get_session
118
+
119
+ api.open_archive() # ensure the engine is up for this process' home
120
+ stmt = (
121
+ select(Thread.id, Thread.title, Thread.name, Thread.source, Thread.updated_at)
122
+ .where(Thread.thread_type != "topic")
123
+ .where(Thread.archived.is_(False))
124
+ )
125
+ if q:
126
+ like = f"%{q}%"
127
+ stmt = stmt.where(Thread.title.ilike(like) | Thread.name.ilike(like))
128
+ stmt = stmt.order_by(Thread.updated_at.desc()).limit(limit)
129
+ with get_session() as s:
130
+ rows = s.execute(stmt).all()
131
+ return [
132
+ {
133
+ "id": r.id,
134
+ "title": r.title or r.name,
135
+ "source": r.source,
136
+ "updated_at": r.updated_at.isoformat() if r.updated_at else None,
137
+ }
138
+ for r in rows
139
+ ]
140
+
141
+
142
+ def resolve_archive_link(link_id: str, source: Optional[str] = None) -> Optional[int]:
143
+ """Resolve a provider session id to its archive thread id, via ``ImportState``.
144
+
145
+ This is the archive-link lookup an editor needs ("I have a session uuid, open the
146
+ conversation"), and the same lookup that lets a bare uuid be pasted straight into
147
+ ``/archive/<uuid>``. The id passed is the tail of a longer ``source_id`` the watcher
148
+ stored, joined by a provider-specific separator: claude-code stores ``{project}:{uuid}``
149
+ (``:``), codex stores the rollout filename stem ``rollout-{ts}-{uuid}`` (``-``), and
150
+ cloth stores the bare session uuid. So we match exact **or** either separator-suffix —
151
+ a bare uuid finds ``{project}:{uuid}``, ``rollout-…-{uuid}``, *and* the bare cloth uuid.
152
+ ``source`` narrows the search to one provider (an editor knows its own); omit it (None)
153
+ to resolve across every provider — what pasting a bare uuid as a thread id wants, since
154
+ the paster rarely knows which harness it came from. Newest import wins. Owned here: the
155
+ watcher cohosts the persistent server, so the archive serves its own editor
156
+ links."""
157
+ from sqlalchemy import select
158
+
159
+ from .._store import ImportState, get_session
160
+
161
+ api.open_archive()
162
+ stmt = (
163
+ select(ImportState.thread_id)
164
+ .where(ImportState.thread_id.isnot(None))
165
+ .where(
166
+ (ImportState.source_id == link_id)
167
+ | (ImportState.source_id.like(f"%:{link_id}"))
168
+ | (ImportState.source_id.like(f"%-{link_id}"))
169
+ )
170
+ .order_by(ImportState.last_import_at.desc())
171
+ )
172
+ if source:
173
+ stmt = stmt.where(ImportState.source == source.replace("_", "-"))
174
+ with get_session() as s:
175
+ return s.execute(stmt).scalars().first()
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # the router (socket-free, the testable core)
180
+ # ---------------------------------------------------------------------------
181
+ def route(method: str, path: str, params: dict) -> Response:
182
+ """Resolve one request to ``(status, content_type, body, extra_headers)``."""
183
+ if method != "GET":
184
+ return _text(405, "method not allowed")
185
+
186
+ # ---- JSON API: thin wrappers over thread_archive._api ----
187
+ if path == "/api/health":
188
+ # Cheap liveness for probes (the family manifest's health URL).
189
+ # /api/status is the real survey but counts the whole index — seconds,
190
+ # not the milliseconds a poller budgets.
191
+ paths = api.open_archive()
192
+ return _ok({"ok": paths.index_path.exists(), "home": str(paths.home)})
193
+
194
+ if path == "/api/status":
195
+ return _ok(api.status())
196
+
197
+ if path == "/api/archive-link":
198
+ # ``id`` may repeat: a caller that cannot tell which of the uuids it can
199
+ # see is the session id sends every candidate, best guess first, and the
200
+ # archive — the only party that knows what was actually imported — picks
201
+ # the first that resolves. An editor webview holds ids for turns, drafts
202
+ # and client-side threads that look exactly like a session uuid and can
203
+ # never resolve; making them harmless beats guessing right.
204
+ link_ids = _repeated(params, "id")
205
+ if not link_ids:
206
+ return _text(400, "missing id")
207
+ source = _first(params, "source")
208
+ for link_id in link_ids:
209
+ tid = resolve_archive_link(link_id, source)
210
+ if tid is not None:
211
+ url = f"/archive/{tid}"
212
+ if _bool(params, "redirect", False):
213
+ return _redirect(url)
214
+ return _ok({"thread_id": tid, "url": url, "id": link_id})
215
+ tried = ", ".join(link_ids)
216
+ return 404, "application/json", json.dumps({"error": f"no thread for id={tried}"}).encode(), {}
217
+
218
+ if path == "/api/search":
219
+ q = (_first(params, "q") or "").strip()
220
+ if not q:
221
+ return _ok({"query": "", "hits": []})
222
+ hits = api.search(
223
+ q,
224
+ limit=_int(params, "limit", 30),
225
+ source=_csv(params, "source"),
226
+ content_types=_csv(params, "content_types"),
227
+ since=_first(params, "since"),
228
+ until=_first(params, "until"),
229
+ )
230
+ return _ok({"query": q, "hits": hits})
231
+
232
+ if path.startswith("/api/read/") or path.startswith("/api/thread/"):
233
+ try:
234
+ tid = int(path.rsplit("/", 1)[-1])
235
+ except ValueError:
236
+ return _text(404, "bad thread id")
237
+ thinking = _bool(params, "thinking", path.startswith("/api/thread/"))
238
+ tools = _bool(params, "tools", True)
239
+ if path.startswith("/api/thread/"):
240
+ # structured render blocks (the viewer's reader)
241
+ return _ok(api.read_thread_structured(tid, include_thinking=thinking, include_tools=tools))
242
+ # flat transcript string (CLI-shaped; kept for back-compat). 'full' shows
243
+ # tool calls (with thinking), 'chat' is the readable assistant text only.
244
+ transcript = api.read_thread(tid, mode="full" if tools else "chat")
245
+ return _ok({"thread_id": tid, "transcript": transcript})
246
+
247
+ if path == "/api/threads":
248
+ return _ok(
249
+ {"threads": _list_threads(limit=_int(params, "limit", 100), q=_first(params, "q"))}
250
+ )
251
+
252
+ # unmatched API path — don't fall through to the SPA shell
253
+ if path.startswith("/api/"):
254
+ return _text(404, "not found")
255
+
256
+ # ---- a real static asset from the built bundle (assets/*.js|css, favicon…) ----
257
+ rel = path.lstrip("/")
258
+ if rel:
259
+ candidate = (STATIC_DIR / rel).resolve()
260
+ if (candidate == STATIC_DIR or STATIC_DIR in candidate.parents) and candidate.is_file():
261
+ return _serve_file(candidate)
262
+
263
+ # ---- SPA fallback: every other path renders the app shell (client routing) ----
264
+ return _serve_file(STATIC_DIR / "index.html")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # the HTTP adapter (the only networked part)
269
+ # ---------------------------------------------------------------------------
270
+ class _Handler(BaseHTTPRequestHandler):
271
+ def do_GET(self): # noqa: N802 — stdlib dispatch name
272
+ parsed = urlparse(self.path)
273
+ try:
274
+ status, ctype, body, headers = route("GET", parsed.path, parse_qs(parsed.query))
275
+ except Exception as exc: # noqa: BLE001 — isolate per request; never kill the loop
276
+ status, ctype, headers = 500, "application/json", {}
277
+ body = json.dumps({"error": str(exc)}).encode()
278
+ self.send_response(status)
279
+ self.send_header("Content-Type", ctype)
280
+ self.send_header("Content-Length", str(len(body)))
281
+ for key, value in headers.items():
282
+ self.send_header(key, value)
283
+ self.end_headers()
284
+ self.wfile.write(body)
285
+
286
+ def log_message(self, *args): # keep the foreground console quiet
287
+ pass
288
+
289
+
290
+ def serve_in_thread(*, host: str = "127.0.0.1", port: int = 8787) -> ThreadingHTTPServer:
291
+ """Start the viewer on a background daemon thread and return the server.
292
+
293
+ For ``archive watch --web``: the always-on watcher process cohosts the read
294
+ surface so there's a persistent URL without a second daemon. Assumes the caller
295
+ already opened the archive (the watcher does). The thread is a daemon, so it dies
296
+ with the process; the caller may ``server_close()`` on shutdown for a clean stop."""
297
+ httpd = ThreadingHTTPServer((host, port), _Handler)
298
+ threading.Thread(target=httpd.serve_forever, name="archive-web", daemon=True).start()
299
+ return httpd
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub Dark
3
+ Description: Dark theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-dark
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}:root{--bg: #fbfbfa;--panel: #ffffff;--ink: #1b1b19;--muted: #6b6b66;--line: #e6e4df;--accent: #3b6ea5;--accent-soft: #e8eef6;--user: #f3f1ec;--assistant: #ffffff;--badge: #efece6;--code-bg: #0d1117}@media(prefers-color-scheme:dark){:root{--bg: #16161a;--panel: #1d1d22;--ink: #e9e8e4;--muted: #9a988f;--line: #2c2c33;--accent: #7aa7d6;--accent-soft: #243240;--user: #232329;--assistant: #1d1d22;--badge: #2a2a31;--code-bg: #0d1117}}*{box-sizing:border-box}html,body,#root{margin:0;height:100%}body{background:var(--bg);color:var(--ink);font:14px/1.55 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;text-align:left}.app{display:flex;height:100vh}aside{width:300px;min-width:300px;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;height:100%}.brand{padding:14px 16px 10px;font-weight:600;letter-spacing:.2px}.searchbar{padding:0 12px 12px;border-bottom:1px solid var(--line)}.input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font:inherit;outline:none}.input:focus{border-color:var(--accent)}.rail-head{padding:12px 16px 6px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;gap:8px;align-items:center}.rail-filter{margin-left:auto;width:110px;padding:3px 7px;font-size:11px;text-transform:none;letter-spacing:0;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink);outline:none}.rail{overflow-y:auto;flex:1;padding-bottom:12px}.rail-item{display:block;width:100%;padding:8px 16px;border-left:2px solid transparent}.rail-item:hover{background:var(--accent-soft)}.rail-item.active{background:var(--accent-soft);border-left-color:var(--accent)}.rail-item .t{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rail-item .m{color:var(--muted);font-size:12px}.rail-empty{padding:16px;color:var(--muted)}main{flex:1;display:flex;flex-direction:column;min-width:0;height:100%}.statusbar{color:var(--muted);font-size:12px;padding:10px 28px;border-bottom:1px solid var(--line)}.statusbar.err{color:#c4543b}.content{flex:1;overflow-y:auto}.wrap{max-width:840px;margin:0 auto;padding:24px 28px 96px}h1.title{font-size:19px;margin:0 0 4px}.submeta{color:var(--muted);font-size:12px;margin-bottom:18px}.toggles{font-size:12px;color:var(--muted);margin-bottom:18px}.toggles label{margin-right:14px;cursor:pointer}.empty{color:var(--muted);padding:44px 0;text-align:center}.group{margin-bottom:22px}.gh{font-weight:600;padding:4px 0;display:flex;gap:8px;align-items:baseline;width:100%}.gh:hover{color:var(--accent)}.src{color:var(--muted);font-size:11px;font-weight:400}.hit{display:block;width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 12px;margin:8px 0;background:var(--panel)}.hit:hover{border-color:var(--accent)}.snip{white-space:pre-wrap;word-break:break-word}.meta{margin-top:5px;display:flex;gap:7px;align-items:center;flex-wrap:wrap}.badge{background:var(--badge);color:var(--muted);border-radius:5px;padding:1px 6px;font-size:11px}.badge.sem{background:var(--accent-soft);color:var(--accent)}.msg{position:relative;border-radius:10px;padding:12px 16px;margin:12px 0;border:1px solid var(--line)}.msg.user{background:var(--user)}.msg.assistant{background:var(--assistant)}.msg .role{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:8px}.msg.cont{margin-top:0;border-top:none;border-top-left-radius:0;border-top-right-radius:0;padding-top:4px}.msg:has(+.msg.cont){border-bottom-left-radius:0;border-bottom-right-radius:0}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:0 0 10px;white-space:pre-wrap}.md pre{background:var(--code-bg);border-radius:8px;padding:12px 14px;overflow-x:auto}.md code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}.md :not(pre)>code{background:var(--badge);padding:1px 5px;border-radius:4px}.md pre code{background:none;padding:0}.md a{color:var(--accent)}.md table{border-collapse:collapse;margin:8px 0}.md th,.md td{border:1px solid var(--line);padding:4px 9px}.md blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}.tool{margin:8px 0;border:1px solid var(--line);border-radius:8px;background:var(--bg)}.tool>summary{cursor:pointer;padding:7px 11px;display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted);list-style:none}.tool>summary::-webkit-details-marker{display:none}.tool>summary:before{content:"▸";font-size:10px}.tool[open]>summary:before{content:"▾"}.tool-name{color:var(--ink);font-weight:600;font-family:ui-monospace,Menlo,monospace}.tool-tag{text-transform:uppercase;letter-spacing:.06em;font-size:10px}.tool.error{border-color:#c4543b66}.tool.error .tool-tag{color:#c4543b}.tool-body{margin:0;padding:10px 13px;border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.tool.thinking summary{font-style:italic}.msg-divider{margin:14px 0}.msg-divider .model-switch{margin:0}.model-switch{display:flex;align-items:center;gap:10px;margin:12px 0 8px}.model-switch:before,.model-switch:after{content:"";flex:1;border-top:1px dashed var(--line)}.model-switch-label{font-size:11px;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;white-space:nowrap}.model-switch-label b{color:var(--ink);font-weight:600;font-family:ui-monospace,Menlo,monospace;text-transform:none}.safeguard{margin:8px 0;padding:9px 12px;border-radius:8px;border:1px solid #c9962e55;background:#c9962e14;font-size:13px}.safeguard-tag{display:block;margin-bottom:4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:#b07d1e}.safeguard .md p{margin:0}.md-raw{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:var(--ink)}.msg-foot{display:flex;justify-content:flex-end;margin-top:6px}.msg-info{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:14px;line-height:1;opacity:.35;transition:opacity .12s ease,color .12s ease}.msg:hover .msg-info{opacity:.7}.msg-info:hover,.msg-info[aria-expanded=true]{opacity:1;color:var(--accent)}.msg-meta{margin-top:8px;padding-top:10px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:8px;align-items:flex-start}.msg-meta-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:11px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.msg-meta-row span{white-space:nowrap}.chip{background:var(--accent-soft);color:var(--accent);border-radius:5px;padding:1px 7px;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.raw-toggle{padding:4px 10px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--ink);font-size:12px}.raw-toggle:hover{border-color:var(--accent)}.raw-toggle.on{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}.model-tags{display:inline-flex;flex-wrap:wrap;gap:6px;margin-left:8px;vertical-align:middle}.model-tag{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:1px 7px;border-radius:5px;color:hsl(var(--mc-h) 60% 40%);background:hsl(var(--mc-h) 62% 50% / .12);border:1px solid hsl(var(--mc-h) 55% 50% / .35)}.msg.has-model{box-shadow:inset 3px 0 hsl(var(--mc-h) 55% 50%)}.msg.has-model .role{color:hsl(var(--mc-h) 52% 42%)}.chip.model-chip{color:hsl(var(--mc-h) 60% 40%);background:hsl(var(--mc-h) 62% 50% / .13)}@media(prefers-color-scheme:dark){.model-tag{color:hsl(var(--mc-h) 70% 70%);background:hsl(var(--mc-h) 60% 60% / .16);border-color:hsl(var(--mc-h) 55% 60% / .4)}.msg.has-model{box-shadow:inset 3px 0 hsl(var(--mc-h) 58% 60%)}.msg.has-model .role{color:hsl(var(--mc-h) 62% 66%)}.chip.model-chip{color:hsl(var(--mc-h) 70% 70%);background:hsl(var(--mc-h) 55% 55% / .18)}}