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
@@ -0,0 +1,244 @@
1
+ """Aider source adapter (read-only Markdown chat logs).
2
+
3
+ Aider records its conversation per project in a Markdown file at the repo
4
+ root::
5
+
6
+ <project>/.aider.chat.history.md
7
+
8
+ The file accumulates across runs. Aider delimits each run with a line::
9
+
10
+ # aider chat started at 2025-01-31 12:34:56
11
+
12
+ Within a run, user turns are written as level-4 headings (``#### <text>``)
13
+ and assistant replies as the prose that follows. We treat each "chat
14
+ started at" block as one session, so a project's history file yields
15
+ multiple sessions ordered by start time.
16
+
17
+ Because these files are scattered one-per-project rather than in a single
18
+ well-known directory, the adapter searches a configurable set of roots
19
+ (``SCROLLBACK_AIDER_DIRS``, colon-separated) and otherwise the current
20
+ working directory tree (depth-limited). Set the env var to your projects
21
+ parent directory to index everything.
22
+
23
+ NOTE: written to Aider's documented log format; not verified against a live
24
+ .aider.chat.history.md on the development machine. Parsing is tolerant.
25
+
26
+ All reads are read-only.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import os
33
+ import re
34
+ from collections.abc import Iterator
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+
38
+ from ..models import Message, Part, Session
39
+ from .base import Source
40
+
41
+ _HISTORY_NAME = ".aider.chat.history.md"
42
+ _STARTED_RE = re.compile(r"^#+\s*aider chat started at\s+(?P<ts>.+?)\s*$", re.IGNORECASE)
43
+ _USER_RE = re.compile(r"^####\s+(?P<text>.*)$")
44
+ _MAX_DEPTH = 6
45
+
46
+ # Directory names skipped during the walk: heavy build dirs plus macOS
47
+ # TCC-protected user folders (scanning these triggers system permission
48
+ # prompts and is never where project code lives).
49
+ _SKIP_DIRS = {
50
+ ".git", "node_modules", ".venv", "venv", "__pycache__", ".tox", ".mypy_cache",
51
+ "Library", "Pictures", "Photos Library.photoslibrary", "Music", "Movies",
52
+ "Desktop", "Documents", "Downloads", "Applications", ".Trash",
53
+ }
54
+
55
+
56
+ def _search_roots() -> list[Path]:
57
+ """Roots to scan for Aider history.
58
+
59
+ Aider stores `.aider.chat.history.md` per project, scattered across the
60
+ filesystem -- there is no single well-known location. Rather than walk
61
+ broad/protected directories (which triggers macOS permission prompts and
62
+ is slow), scrollback only scans Aider when the user explicitly opts in
63
+ via SCROLLBACK_AIDER_DIRS (colon-separated project/parent dirs). With no
64
+ env var set, the Aider source is simply unavailable.
65
+ """
66
+ env = os.environ.get("SCROLLBACK_AIDER_DIRS")
67
+ if not env:
68
+ return []
69
+ return [Path(p).expanduser() for p in env.split(os.pathsep) if p]
70
+
71
+
72
+ def _is_unsafe_root(root: Path) -> bool:
73
+ """Refuse to walk the filesystem root, the home dir itself, or any
74
+ TCC-protected/system location -- even if explicitly configured."""
75
+ try:
76
+ resolved = root.resolve()
77
+ except OSError:
78
+ return True
79
+ # Never walk '/' or a top-level mount, or the home directory directly.
80
+ if len(resolved.parts) <= 1:
81
+ return True
82
+ if resolved == Path.home().resolve():
83
+ return True
84
+ # Never walk a protected top-level user folder (e.g. ~/Pictures).
85
+ home = Path.home().resolve()
86
+ if resolved.parent == home and resolved.name in _SKIP_DIRS:
87
+ return True
88
+ return False
89
+
90
+
91
+ def _find_history_files(roots: list[Path]) -> list[Path]:
92
+ found: list[Path] = []
93
+ for root in roots:
94
+ if not root.is_dir():
95
+ # A direct path to a history file is also accepted.
96
+ if root.name == _HISTORY_NAME and root.is_file():
97
+ found.append(root)
98
+ continue
99
+ if _is_unsafe_root(root):
100
+ continue # never walk roots / home / protected folders
101
+ # Depth-limited walk; skip heavy + protected subdirectories.
102
+ base_depth = len(root.parts)
103
+ for dirpath, dirnames, filenames in os.walk(root):
104
+ depth = len(Path(dirpath).parts) - base_depth
105
+ if depth > _MAX_DEPTH:
106
+ dirnames[:] = []
107
+ continue
108
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
109
+ if _HISTORY_NAME in filenames:
110
+ found.append(Path(dirpath) / _HISTORY_NAME)
111
+ return sorted(set(found))
112
+
113
+
114
+ class AiderSource(Source):
115
+ name = "aider"
116
+ label = "Aider"
117
+
118
+ def __init__(self, roots: list[Path] | None = None) -> None:
119
+ self._roots = roots if roots is not None else _search_roots()
120
+
121
+ # -- availability / location -------------------------------------------
122
+
123
+ def is_available(self) -> bool:
124
+ return bool(_find_history_files(self._roots))
125
+
126
+ def location(self) -> Path | None:
127
+ files = _find_history_files(self._roots)
128
+ # Report the common parent (or the single file's dir) for diagnostics.
129
+ return files[0].parent if files else None
130
+
131
+ # -- discovery ----------------------------------------------------------
132
+
133
+ def list_sessions(self) -> Iterator[Session]:
134
+ for f in _find_history_files(self._roots):
135
+ project = f.parent
136
+ for blk in _split_sessions(f):
137
+ yield _session_from_block(blk, project, f, with_messages=False)
138
+
139
+ def load_session(self, session_id: str) -> Session | None:
140
+ for f in _find_history_files(self._roots):
141
+ project = f.parent
142
+ for blk in _split_sessions(f):
143
+ sid = _session_id(f, blk["start_raw"], blk["index"])
144
+ if sid == session_id or sid.startswith(session_id):
145
+ return _session_from_block(blk, project, f, with_messages=True)
146
+ return None
147
+
148
+
149
+ # -- parsing helpers -------------------------------------------------------
150
+
151
+
152
+ def _session_id(path: Path, start_raw: str, index: int) -> str:
153
+ h = hashlib.sha1(f"{path}|{start_raw}|{index}".encode()).hexdigest()[:12]
154
+ return f"aider-{h}"
155
+
156
+
157
+ def _parse_ts(raw: str) -> datetime | None:
158
+ raw = raw.strip()
159
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S"):
160
+ try:
161
+ return datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
162
+ except ValueError:
163
+ continue
164
+ return None
165
+
166
+
167
+ def _split_sessions(path: Path) -> list[dict]:
168
+ """Split a history file into per-run blocks by 'chat started at' lines."""
169
+ try:
170
+ text = path.read_text(encoding="utf-8", errors="replace")
171
+ except OSError:
172
+ return []
173
+ lines = text.splitlines()
174
+ blocks: list[dict] = []
175
+ current: dict | None = None
176
+ for line in lines:
177
+ m = _STARTED_RE.match(line)
178
+ if m:
179
+ if current is not None:
180
+ blocks.append(current)
181
+ current = {"start_raw": m.group("ts"), "lines": [], "index": len(blocks)}
182
+ continue
183
+ if current is None:
184
+ # Content before the first marker -> implicit first block.
185
+ current = {"start_raw": "", "lines": [], "index": 0}
186
+ current["lines"].append(line)
187
+ if current is not None:
188
+ blocks.append(current)
189
+ return blocks
190
+
191
+
192
+ def _block_messages(block: dict) -> list[Message]:
193
+ """Turn a run-block's lines into user/assistant messages.
194
+
195
+ Level-4 headings start a user turn; the prose until the next heading is
196
+ the assistant reply.
197
+ """
198
+ messages: list[Message] = []
199
+ idx = 0
200
+ role: str | None = None
201
+ buf: list[str] = []
202
+
203
+ def flush() -> None:
204
+ nonlocal idx, buf, role
205
+ if role and buf:
206
+ text = "\n".join(buf).strip()
207
+ if text:
208
+ messages.append(Message(
209
+ id=f"{block['index']}:{idx}", role=role, created=None,
210
+ parts=(Part(id=f"{block['index']}:{idx}:0", type="text", text=text),),
211
+ ))
212
+ idx += 1
213
+ buf = []
214
+
215
+ for line in block["lines"]:
216
+ m = _USER_RE.match(line)
217
+ if m:
218
+ flush()
219
+ role = "user"
220
+ buf = [m.group("text")]
221
+ flush()
222
+ role = "assistant"
223
+ continue
224
+ buf.append(line)
225
+ flush()
226
+ return messages
227
+
228
+
229
+ def _session_from_block(block: dict, project: Path, path: Path, *, with_messages: bool) -> Session:
230
+ start = _parse_ts(block["start_raw"])
231
+ msgs = _block_messages(block)
232
+ first_user = next((m.text for m in msgs if m.role == "user" and m.text), "")
233
+ title = " ".join(first_user.split())[:60] if first_user else project.name
234
+ return Session(
235
+ id=_session_id(path, block["start_raw"], block["index"]),
236
+ source=AiderSource.name,
237
+ title=title or project.name,
238
+ directory=str(project),
239
+ created=start,
240
+ updated=start,
241
+ message_count=len(msgs),
242
+ messages=tuple(msgs) if with_messages else (),
243
+ raw={"path": str(path)},
244
+ )
@@ -0,0 +1,117 @@
1
+ """The Source adapter contract.
2
+
3
+ A Source reads ONE agent's local, on-disk session store in read-only mode
4
+ and normalizes it into the common model (Session/Message/Part). All
5
+ methods must be side-effect free with respect to the agent's data: an
6
+ adapter must never write to, lock for writing, or otherwise mutate the
7
+ source store.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import abc
13
+ from collections.abc import Iterable, Iterator
14
+ from pathlib import Path
15
+
16
+ from ..models import Message, Session
17
+
18
+
19
+ class Source(abc.ABC):
20
+ """Read-only adapter for a single AI-agent session store."""
21
+
22
+ #: Stable machine name used in CLI flags and ids, e.g. "opencode".
23
+ name: str = "base"
24
+ #: Human-readable label for display, e.g. "opencode".
25
+ label: str = "Base"
26
+
27
+ @abc.abstractmethod
28
+ def is_available(self) -> bool:
29
+ """Return True if this agent's data store exists on this machine."""
30
+
31
+ @abc.abstractmethod
32
+ def location(self) -> Path | None:
33
+ """Return the path this adapter reads from (for diagnostics)."""
34
+
35
+ @abc.abstractmethod
36
+ def list_sessions(self) -> Iterator[Session]:
37
+ """Yield sessions with metadata only (no messages), newest concern aside.
38
+
39
+ Ordering is not guaranteed here; callers sort as needed.
40
+ """
41
+
42
+ @abc.abstractmethod
43
+ def load_session(self, session_id: str) -> Session | None:
44
+ """Return a single session fully populated with messages, or None."""
45
+
46
+ def load_session_meta(self, session_id: str) -> Session | None:
47
+ """Return a single session's metadata only (no messages).
48
+
49
+ Used by the web app to show a session header without paying the cost
50
+ of loading every message. Default implementation loads everything and
51
+ strips the messages; adapters should override for efficiency.
52
+ """
53
+ from dataclasses import replace
54
+
55
+ sess = self.load_session(session_id)
56
+ if sess is None:
57
+ return None
58
+ return replace(sess, messages=())
59
+
60
+ def load_messages(
61
+ self, session_id: str, *, offset: int = 0, limit: int | None = None
62
+ ) -> list[Message]:
63
+ """Return a slice of a session's messages (for windowed loading).
64
+
65
+ Default implementation loads the whole session then slices; adapters
66
+ should override to avoid loading everything for huge transcripts.
67
+ """
68
+ sess = self.load_session(session_id)
69
+ if sess is None:
70
+ return []
71
+ msgs = list(sess.messages)
72
+ if offset:
73
+ msgs = msgs[offset:]
74
+ if limit is not None:
75
+ msgs = msgs[:limit]
76
+ return msgs
77
+
78
+ def resolve_session_id(self, selector: str) -> str | None:
79
+ """Resolve a selector (full id, prefix, or 'latest') to a full id.
80
+
81
+ Default implementation scans `list_sessions`. Adapters may override
82
+ with a cheaper lookup.
83
+ """
84
+ selector = selector.strip()
85
+ sessions = list(self.list_sessions())
86
+ if not sessions:
87
+ return None
88
+ if selector == "latest":
89
+ sessions.sort(
90
+ key=lambda s: (s.updated or s.created or _MIN_DT()),
91
+ reverse=True,
92
+ )
93
+ return sessions[0].id
94
+ # Exact id first, then unique prefix.
95
+ for s in sessions:
96
+ if s.id == selector:
97
+ return s.id
98
+ matches = [s.id for s in sessions if s.id.startswith(selector)]
99
+ if len(matches) == 1:
100
+ return matches[0]
101
+ return None
102
+
103
+ def iter_messages(self, session_id: str) -> Iterable[Message]:
104
+ """Convenience: yield the messages of one session."""
105
+ sess = self.load_session(session_id)
106
+ return sess.messages if sess else ()
107
+
108
+ def resume_command(self, session: "Session") -> str | None:
109
+ """Return the shell command to resume `session` in its native agent,
110
+ or None if the agent has no by-id resume. Override per adapter."""
111
+ return None
112
+
113
+
114
+ def _MIN_DT():
115
+ from datetime import datetime, timezone
116
+
117
+ return datetime.min.replace(tzinfo=timezone.utc)