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.
- scrollback/__init__.py +8 -0
- scrollback/assets/icon-256.png +0 -0
- scrollback/assets/icon.icns +0 -0
- scrollback/cli.py +1139 -0
- scrollback/clipboard.py +34 -0
- scrollback/export.py +293 -0
- scrollback/fts.py +307 -0
- scrollback/highlight.py +128 -0
- scrollback/katexbundle.py +81 -0
- scrollback/launcher_install.py +209 -0
- scrollback/launchers/scrollback.bat +19 -0
- scrollback/launchers/scrollback.command +19 -0
- scrollback/launchers/scrollback.desktop +10 -0
- scrollback/launchers/scrollback.sh +12 -0
- scrollback/mathspan.py +180 -0
- scrollback/minimd.py +205 -0
- scrollback/models.py +135 -0
- scrollback/serialize.py +83 -0
- scrollback/serverconfig.py +66 -0
- scrollback/sources/__init__.py +6 -0
- scrollback/sources/aider.py +244 -0
- scrollback/sources/base.py +117 -0
- scrollback/sources/claudecode.py +631 -0
- scrollback/sources/codex.py +281 -0
- scrollback/sources/opencode.py +357 -0
- scrollback/sources/registry.py +39 -0
- scrollback/store.py +384 -0
- scrollback/termrender.py +170 -0
- scrollback/web/__init__.py +1 -0
- scrollback/web/app.py +359 -0
- scrollback/web/static/app.js +1245 -0
- scrollback/web/static/apple-touch-icon.png +0 -0
- scrollback/web/static/favicon.png +0 -0
- scrollback/web/static/favicon.svg +41 -0
- scrollback/web/static/index.html +75 -0
- scrollback/web/static/style.css +628 -0
- scrollback/web/static/vendor/highlight.min.js +1213 -0
- scrollback/web/static/vendor/hljs-dark.min.css +10 -0
- scrollback/web/static/vendor/hljs-light.min.css +10 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/katex.min.css +1 -0
- scrollback/web/static/vendor/katex/katex.min.js +1 -0
- scrollback/web/static/vendor/marked.min.js +6 -0
- scrollback/web/static/vendor/purify.min.js +3 -0
- scrollback/webopen.py +96 -0
- scrollback-0.1.0.dist-info/METADATA +391 -0
- scrollback-0.1.0.dist-info/RECORD +69 -0
- scrollback-0.1.0.dist-info/WHEEL +4 -0
- scrollback-0.1.0.dist-info/entry_points.txt +4 -0
- 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)
|