scrollback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
scrollback/web/app.py ADDED
@@ -0,0 +1,359 @@
1
+ """FastAPI application exposing a read-only JSON API over the Store.
2
+
3
+ Design notes
4
+ ------------
5
+ * Strictly read-only: there are no mutating endpoints. The Store and its
6
+ adapters never write to the agents' data.
7
+ * Intended to bind to 127.0.0.1 only (enforced by the `web` CLI command).
8
+ * The frontend is static (HTML/CSS/JS) served from `web/static/`; the
9
+ browser talks to the JSON endpoints below.
10
+
11
+ Endpoints
12
+ ---------
13
+ GET /api/sources -> available source adapters
14
+ GET /api/sessions?source&dir&q&limit -> session summaries (newest first)
15
+ GET /api/sessions/{source}/{id} -> full session with messages/parts
16
+ GET /api/search?q&dir&limit -> search hits across sessions
17
+ GET /api/export/{source}/{id}?format&reasoning&tools -> rendered document
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ try:
25
+ from fastapi import FastAPI, HTTPException, Query, Response
26
+ from fastapi.staticfiles import StaticFiles
27
+ except ModuleNotFoundError as exc: # pragma: no cover - guidance path
28
+ raise SystemExit(
29
+ "The web app needs FastAPI/uvicorn. Install with:\n"
30
+ ' pip install "scrollback[web]"\n'
31
+ "or:\n"
32
+ " pip install fastapi uvicorn"
33
+ ) from exc
34
+
35
+ from pathlib import Path
36
+
37
+ from datetime import datetime, timezone
38
+
39
+ from .. import __version__, export
40
+ from ..serialize import message_dict, search_hit, session_detail, session_summary
41
+ from ..store import Store
42
+
43
+
44
+ def _parse_dt(s: str | None) -> datetime | None:
45
+ """Parse an ISO date/datetime from a query param; None if blank/invalid."""
46
+ if not s:
47
+ return None
48
+ raw = s.strip()
49
+ try:
50
+ if len(raw) == 10:
51
+ return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=timezone.utc)
52
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
53
+ dt = datetime.fromisoformat(iso)
54
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
55
+ except ValueError:
56
+ return None
57
+
58
+ _STATIC_DIR = Path(__file__).parent / "static"
59
+
60
+ # Content types for the export endpoint.
61
+ _MEDIA = {
62
+ "markdown": "text/markdown; charset=utf-8",
63
+ "md": "text/markdown; charset=utf-8",
64
+ "json": "application/json; charset=utf-8",
65
+ "html": "text/html; charset=utf-8",
66
+ "text": "text/plain; charset=utf-8",
67
+ "txt": "text/plain; charset=utf-8",
68
+ }
69
+ _EXT = {"markdown": "md", "md": "md", "json": "json", "html": "html",
70
+ "text": "txt", "txt": "txt"}
71
+
72
+
73
+ def create_app(
74
+ store: Store | None = None,
75
+ *,
76
+ on_idle=None,
77
+ idle_timeout: float = 0.0,
78
+ allowed_hosts: list[str] | None = None,
79
+ ):
80
+ """Build the FastAPI app. A custom Store can be injected for tests.
81
+
82
+ If `idle_timeout` > 0 and `on_idle` is provided, the app runs a watchdog:
83
+ the frontend pings `/api/heartbeat` periodically, and if no ping arrives
84
+ for `idle_timeout` seconds (i.e. the window was closed), `on_idle()` is
85
+ called -- used to auto-stop the server and free the port.
86
+
87
+ `allowed_hosts` guards against DNS-rebinding: requests whose Host header's
88
+ hostname is not in the allowlist are rejected. Defaults to loopback names
89
+ (localhost / 127.0.0.1 / ::1). Pass an explicit list when binding to a
90
+ non-loopback address. `None` => loopback-only; an empty list disables the
91
+ check (not recommended).
92
+ """
93
+ app = FastAPI(title="scrollback", version=__version__)
94
+ _store = store if store is not None else Store()
95
+ _install_host_guard(app, allowed_hosts)
96
+
97
+ # Translate unexpected source/IO failures (locked/corrupt DB, unreadable
98
+ # files) into a clean 503 instead of a leaked 500 + traceback.
99
+ import sqlite3
100
+
101
+ from fastapi.responses import JSONResponse
102
+
103
+ @app.exception_handler(sqlite3.Error)
104
+ async def _sqlite_error(_request, exc): # pragma: no cover - error path
105
+ return JSONResponse(status_code=503, content={"detail": "data source unavailable"})
106
+
107
+ @app.exception_handler(OSError)
108
+ async def _os_error(_request, exc): # pragma: no cover - error path
109
+ return JSONResponse(status_code=503, content={"detail": "data source unavailable"})
110
+
111
+ watchdog_on = idle_timeout > 0 and on_idle is not None
112
+ if watchdog_on:
113
+ _install_heartbeat_watchdog(app, on_idle, idle_timeout)
114
+ else:
115
+ # Always expose the config endpoint so the frontend can ask once and
116
+ # skip heartbeats when auto-shutdown is not in effect.
117
+ @app.get("/api/heartbeat-config")
118
+ def heartbeat_config_off() -> dict[str, float]:
119
+ return {"interval": 0.0, "enabled": 0.0}
120
+
121
+ # -- API ---------------------------------------------------------------
122
+
123
+ @app.get("/api/sources")
124
+ def api_sources() -> list[dict[str, Any]]:
125
+ # Report every KNOWN adapter, marking which have data on this machine.
126
+ # The store holds the available ones; we additionally surface any
127
+ # registered-but-unavailable adapters so the UI can show them greyed.
128
+ from ..sources import registry
129
+
130
+ out: list[dict[str, Any]] = []
131
+ available_names = set()
132
+ for s in _store.sources:
133
+ available_names.add(s.name)
134
+ out.append({
135
+ "name": s.name,
136
+ "label": s.label,
137
+ "available": True,
138
+ "location": str(s.location()) if s.location() else None,
139
+ })
140
+ for s in registry.all_sources():
141
+ if s.name in available_names:
142
+ continue
143
+ out.append({
144
+ "name": s.name,
145
+ "label": s.label,
146
+ "available": False,
147
+ "location": None,
148
+ })
149
+ return out
150
+
151
+ @app.get("/api/sessions")
152
+ def api_sessions(
153
+ source: str | None = None,
154
+ dir: str | None = None,
155
+ q: str | None = None,
156
+ since: str | None = None,
157
+ until: str | None = None,
158
+ fold: bool = True,
159
+ offset: int = Query(default=0, ge=0),
160
+ limit: int = Query(default=60, ge=1, le=2000),
161
+ ) -> dict[str, Any]:
162
+ if source and source not in {s.name for s in _store.sources}:
163
+ raise HTTPException(status_code=400, detail=f"unknown source: {source}")
164
+ st = _store.with_sources([source]) if source else _store
165
+ # Fetch one extra to tell the client whether more pages exist.
166
+ rows = st.list_sessions(
167
+ directory=dir, query=q,
168
+ since=_parse_dt(since), until=_parse_dt(until),
169
+ offset=offset, limit=limit + 1, fold_subagents=fold,
170
+ )
171
+ has_more = len(rows) > limit
172
+ rows = rows[:limit]
173
+ return {
174
+ "sessions": [session_summary(s) for s in rows],
175
+ "offset": offset,
176
+ "limit": limit,
177
+ "has_more": has_more,
178
+ }
179
+
180
+ @app.get("/api/sessions/{source}/{session_id}")
181
+ def api_session_detail(source: str, session_id: str) -> dict[str, Any]:
182
+ """Full session including all messages. For very large sessions the
183
+ frontend should prefer the meta + windowed messages endpoints."""
184
+ sess = _store.load_session(session_id, source=source)
185
+ if sess is None:
186
+ raise HTTPException(status_code=404, detail="session not found")
187
+ return session_detail(sess)
188
+
189
+ @app.get("/api/sessions/{source}/{session_id}/meta")
190
+ def api_session_meta(source: str, session_id: str) -> dict[str, Any]:
191
+ sess = _store.load_session_meta(session_id, source=source)
192
+ if sess is None:
193
+ raise HTTPException(status_code=404, detail="session not found")
194
+ return session_summary(sess)
195
+
196
+ @app.get("/api/sessions/{source}/{session_id}/messages")
197
+ def api_session_messages(
198
+ source: str,
199
+ session_id: str,
200
+ offset: int = Query(default=0, ge=0),
201
+ limit: int = Query(default=40, ge=1, le=500),
202
+ ) -> dict[str, Any]:
203
+ msgs = _store.load_messages(
204
+ session_id, source=source, offset=offset, limit=limit + 1
205
+ )
206
+ has_more = len(msgs) > limit
207
+ msgs = msgs[:limit]
208
+ return {
209
+ "messages": [message_dict(m) for m in msgs],
210
+ "offset": offset,
211
+ "limit": limit,
212
+ "has_more": has_more,
213
+ }
214
+
215
+ @app.get("/api/search")
216
+ def api_search(
217
+ q: str,
218
+ dir: str | None = None,
219
+ since: str | None = None,
220
+ until: str | None = None,
221
+ limit: int = Query(default=100, ge=1, le=2000),
222
+ ) -> list[dict[str, Any]]:
223
+ if not q.strip():
224
+ return []
225
+ hits = _store.search(
226
+ q, directory=dir, since=_parse_dt(since), until=_parse_dt(until), limit=limit
227
+ )
228
+ return [search_hit(h) for h in hits]
229
+
230
+ @app.get("/api/export/{source}/{session_id}")
231
+ def api_export(
232
+ source: str,
233
+ session_id: str,
234
+ format: str = "markdown",
235
+ reasoning: bool = True,
236
+ tools: bool = True,
237
+ math: str = "raw",
238
+ download: bool = False,
239
+ ) -> "Response":
240
+ if format not in export.FORMATS:
241
+ raise HTTPException(status_code=400, detail=f"bad format: {format}")
242
+ if math not in export.MATH_MODES:
243
+ raise HTTPException(status_code=400, detail=f"bad math mode: {math}")
244
+ sess = _store.load_session(session_id, source=source)
245
+ if sess is None:
246
+ raise HTTPException(status_code=404, detail="session not found")
247
+ kwargs: dict[str, Any] = {}
248
+ if format != "json":
249
+ kwargs = {"include_reasoning": reasoning, "include_tools": tools, "math": math}
250
+ body = export.render(sess, format, **kwargs)
251
+ headers = {}
252
+ if download:
253
+ ext = _EXT.get(format, "txt")
254
+ fname = f"{sess.source}_{sess.short_id}.{ext}"
255
+ headers["Content-Disposition"] = f'attachment; filename="{fname}"'
256
+ return Response(content=body, media_type=_MEDIA.get(format, "text/plain"),
257
+ headers=headers)
258
+
259
+ @app.get("/print/{source}/{session_id}")
260
+ def print_view(
261
+ source: str, session_id: str, reasoning: bool = True, tools: bool = True,
262
+ math: str = "raw",
263
+ ) -> "Response":
264
+ """A print-friendly HTML page that auto-opens the print dialog.
265
+
266
+ Used by the native-window 'print' action, which opens this URL in the
267
+ user's real browser (where window.print() works)."""
268
+ if math not in export.MATH_MODES:
269
+ raise HTTPException(status_code=400, detail=f"bad math mode: {math}")
270
+ sess = _store.load_session(session_id, source=source)
271
+ if sess is None:
272
+ raise HTTPException(status_code=404, detail="session not found")
273
+ html = export.to_html(sess, include_reasoning=reasoning, include_tools=tools, math=math)
274
+ # Inject an auto-print trigger before </body>.
275
+ auto = "<script>window.addEventListener('load',()=>setTimeout(()=>window.print(),300));</script>"
276
+ if "</body>" in html:
277
+ html = html.replace("</body>", auto + "</body>", 1)
278
+ else:
279
+ html += auto
280
+ return Response(content=html, media_type="text/html; charset=utf-8")
281
+
282
+ @app.get("/api/health")
283
+ def api_health() -> dict[str, Any]:
284
+ return {"status": "ok", "version": __version__,
285
+ "sources": [s.name for s in _store.sources]}
286
+
287
+ # -- static frontend ---------------------------------------------------
288
+
289
+ if _STATIC_DIR.is_dir():
290
+ app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
291
+
292
+ return app
293
+
294
+
295
+ _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", ""}
296
+
297
+
298
+ def _install_host_guard(app: "FastAPI", allowed_hosts: list[str] | None) -> None:
299
+ """Reject requests whose Host header isn't an allowed hostname.
300
+
301
+ Defends against DNS-rebinding: a malicious page can't point its own
302
+ hostname at 127.0.0.1 and read local data, because the Host header would
303
+ be that hostname, not a loopback name. The port portion is ignored (it
304
+ can auto-change); only the hostname is checked.
305
+ """
306
+ if allowed_hosts is not None and not allowed_hosts:
307
+ return # explicitly disabled
308
+ allow = set(_LOOPBACK_HOSTS)
309
+ if allowed_hosts:
310
+ allow.update(h.lower() for h in allowed_hosts)
311
+
312
+ from starlette.middleware.base import BaseHTTPMiddleware
313
+ from starlette.responses import PlainTextResponse
314
+
315
+ class _HostGuard(BaseHTTPMiddleware):
316
+ async def dispatch(self, request, call_next):
317
+ host = request.headers.get("host", "")
318
+ # Strip the port; handle IPv6 [::1]:port form.
319
+ hostname = host.rsplit(":", 1)[0] if ":" in host and not host.endswith("]") else host
320
+ hostname = hostname.strip("[]").lower()
321
+ if hostname not in allow:
322
+ return PlainTextResponse("Forbidden: unexpected Host header", status_code=403)
323
+ return await call_next(request)
324
+
325
+ app.add_middleware(_HostGuard)
326
+
327
+
328
+ def _install_heartbeat_watchdog(app: "FastAPI", on_idle, idle_timeout: float) -> None:
329
+ """Auto-stop when the page stops sending heartbeats (window closed).
330
+
331
+ The frontend POSTs /api/heartbeat on an interval. A background thread
332
+ checks the last-seen time; if it exceeds `idle_timeout`, it calls
333
+ `on_idle()` once. A grace period before the first heartbeat avoids
334
+ shutting down during initial page load.
335
+ """
336
+ import threading
337
+ import time
338
+
339
+ state = {"last": time.monotonic() + max(idle_timeout, 10.0), "fired": False}
340
+
341
+ @app.post("/api/heartbeat")
342
+ def heartbeat() -> dict[str, str]:
343
+ state["last"] = time.monotonic()
344
+ return {"status": "ok"}
345
+
346
+ @app.get("/api/heartbeat-config")
347
+ def heartbeat_config() -> dict[str, float]:
348
+ # Tell the client how often to ping (a third of the timeout).
349
+ return {"interval": max(idle_timeout / 3.0, 2.0), "enabled": 1.0}
350
+
351
+ def watch() -> None:
352
+ while not state["fired"]:
353
+ time.sleep(1.0)
354
+ if time.monotonic() - state["last"] > idle_timeout:
355
+ state["fired"] = True
356
+ on_idle()
357
+ return
358
+
359
+ threading.Thread(target=watch, daemon=True).start()