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
scrollback/minimd.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""A tiny, safe, stdlib-only Markdown-to-HTML renderer.
|
|
2
|
+
|
|
3
|
+
Scope is deliberately small -- the common constructs that appear in AI
|
|
4
|
+
chat transcripts: fenced code blocks, ATX headings, unordered/ordered
|
|
5
|
+
lists, blockquotes, horizontal rules, paragraphs, and inline spans
|
|
6
|
+
(code, bold, italic, links). Everything is HTML-escaped first, so the
|
|
7
|
+
output is safe to drop into an export without an external dependency and
|
|
8
|
+
without risking HTML/script injection from transcript content.
|
|
9
|
+
|
|
10
|
+
This is NOT a CommonMark implementation; it trades completeness for zero
|
|
11
|
+
dependencies and predictable, safe output. The richer browser view uses
|
|
12
|
+
the vendored `marked` + `highlight.js`; this module exists so the static
|
|
13
|
+
HTML *export* renders nicely on its own (e.g. for printing/sharing).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import html as _html
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from . import highlight as _highlight
|
|
22
|
+
from . import mathspan as _mathspan
|
|
23
|
+
|
|
24
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
25
|
+
_UL_ITEM = re.compile(r"^[ \t]*[-*+]\s+(.*)$")
|
|
26
|
+
_OL_ITEM = re.compile(r"^[ \t]*\d+[.)]\s+(.*)$")
|
|
27
|
+
_HR = re.compile(r"^(?:-{3,}|\*{3,}|_{3,})\s*$")
|
|
28
|
+
_FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})\s*([\w+-]*)\s*$")
|
|
29
|
+
_BLOCKQUOTE = re.compile(r"^>\s?(.*)$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render(text: str, *, math: str = "raw") -> str:
|
|
33
|
+
"""Render Markdown `text` to a safe HTML fragment.
|
|
34
|
+
|
|
35
|
+
`math` controls how delimited-LaTeX spans (`$...$`, `$$...$$`,
|
|
36
|
+
`\\(...\\)`, `\\[...\\]`) are handled. In every mode the span is first
|
|
37
|
+
shielded from the Markdown pass so `\\`, `_`, `*`, `^` survive intact:
|
|
38
|
+
|
|
39
|
+
- ``"raw"`` -- restore the original delimited source verbatim
|
|
40
|
+
(default; matches the historical behaviour but no longer mangled).
|
|
41
|
+
- ``"latex"`` -- show the LaTeX source verbatim, wrapped so a renderer
|
|
42
|
+
will not typeset it; best for copying into a paper.
|
|
43
|
+
- ``"rendered"`` -- emit a placeholder element the client typesets with
|
|
44
|
+
KaTeX (the static export embeds KaTeX so this works offline).
|
|
45
|
+
"""
|
|
46
|
+
masked, tokens = _mathspan.protect(text)
|
|
47
|
+
html = _render_blocks(masked)
|
|
48
|
+
return _mathspan.restore(html, tokens, lambda span: _math_html(span, math))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _math_html(span: _mathspan.Span, mode: str) -> str:
|
|
52
|
+
"""Render one math span to HTML for the static export, per `mode`."""
|
|
53
|
+
if mode == "rendered":
|
|
54
|
+
# Emit the LaTeX in a span the client-side KaTeX pass typesets; the
|
|
55
|
+
# body is escaped so it is inert until KaTeX reads textContent.
|
|
56
|
+
cls = "math-tex math-display" if span.display else "math-tex"
|
|
57
|
+
disp = "true" if span.display else "false"
|
|
58
|
+
return f'<span class="{cls}" data-display="{disp}">{_html.escape(span.body)}</span>'
|
|
59
|
+
if mode == "latex":
|
|
60
|
+
# Verbatim source, never typeset, never mangled.
|
|
61
|
+
return f'<code class="math-src">{_html.escape(span.raw)}</code>'
|
|
62
|
+
# raw: restore the original delimited source verbatim, as inert text.
|
|
63
|
+
return _html.escape(span.raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_blocks(text: str) -> str:
|
|
67
|
+
"""Render Markdown block structure (math already masked by `render`)."""
|
|
68
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
69
|
+
out: list[str] = []
|
|
70
|
+
i = 0
|
|
71
|
+
n = len(lines)
|
|
72
|
+
|
|
73
|
+
while i < n:
|
|
74
|
+
line = lines[i]
|
|
75
|
+
|
|
76
|
+
# Fenced code block.
|
|
77
|
+
m = _FENCE.match(line)
|
|
78
|
+
if m:
|
|
79
|
+
fence = m.group(1)[0]
|
|
80
|
+
lang = m.group(2).strip()
|
|
81
|
+
j = i + 1
|
|
82
|
+
buf: list[str] = []
|
|
83
|
+
while j < n and not _is_closing_fence(lines[j], fence):
|
|
84
|
+
buf.append(lines[j])
|
|
85
|
+
j += 1
|
|
86
|
+
raw = "\n".join(buf)
|
|
87
|
+
if lang:
|
|
88
|
+
# Highlighter escapes internally and emits only safe spans.
|
|
89
|
+
code = _highlight.highlight(raw, lang)
|
|
90
|
+
cls = f' class="language-{_html.escape(lang)}"'
|
|
91
|
+
else:
|
|
92
|
+
code = _html.escape(raw)
|
|
93
|
+
cls = ""
|
|
94
|
+
out.append(f"<pre><code{cls}>{code}</code></pre>")
|
|
95
|
+
i = j + 1
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Blank line.
|
|
99
|
+
if not line.strip():
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Horizontal rule.
|
|
104
|
+
if _HR.match(line):
|
|
105
|
+
out.append("<hr>")
|
|
106
|
+
i += 1
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Heading.
|
|
110
|
+
m = _HEADING.match(line)
|
|
111
|
+
if m:
|
|
112
|
+
level = len(m.group(1))
|
|
113
|
+
out.append(f"<h{level}>{_inline(m.group(2).strip())}</h{level}>")
|
|
114
|
+
i += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Blockquote (consecutive '>' lines).
|
|
118
|
+
if _BLOCKQUOTE.match(line):
|
|
119
|
+
buf = []
|
|
120
|
+
while i < n and _BLOCKQUOTE.match(lines[i]):
|
|
121
|
+
buf.append(_BLOCKQUOTE.match(lines[i]).group(1))
|
|
122
|
+
i += 1
|
|
123
|
+
inner = _render_blocks("\n".join(buf))
|
|
124
|
+
out.append(f"<blockquote>{inner}</blockquote>")
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Lists (unordered / ordered).
|
|
128
|
+
if _UL_ITEM.match(line) or _OL_ITEM.match(line):
|
|
129
|
+
ordered = bool(_OL_ITEM.match(line))
|
|
130
|
+
pat = _OL_ITEM if ordered else _UL_ITEM
|
|
131
|
+
items: list[str] = []
|
|
132
|
+
while i < n and pat.match(lines[i]):
|
|
133
|
+
items.append(_inline(pat.match(lines[i]).group(1).strip()))
|
|
134
|
+
i += 1
|
|
135
|
+
tag = "ol" if ordered else "ul"
|
|
136
|
+
li = "".join(f"<li>{it}</li>" for it in items)
|
|
137
|
+
out.append(f"<{tag}>{li}</{tag}>")
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Paragraph: gather consecutive non-blank, non-special lines.
|
|
141
|
+
buf = [line]
|
|
142
|
+
i += 1
|
|
143
|
+
while i < n and lines[i].strip() and not _starts_block(lines[i]):
|
|
144
|
+
buf.append(lines[i])
|
|
145
|
+
i += 1
|
|
146
|
+
para = "<br>".join(_inline(b.strip()) for b in buf)
|
|
147
|
+
out.append(f"<p>{para}</p>")
|
|
148
|
+
|
|
149
|
+
return "\n".join(out)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_closing_fence(line: str, fence_char: str) -> bool:
|
|
153
|
+
m = _FENCE.match(line)
|
|
154
|
+
return bool(m and m.group(1)[0] == fence_char)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _starts_block(line: str) -> bool:
|
|
158
|
+
return bool(
|
|
159
|
+
_HEADING.match(line)
|
|
160
|
+
or _UL_ITEM.match(line)
|
|
161
|
+
or _OL_ITEM.match(line)
|
|
162
|
+
or _HR.match(line)
|
|
163
|
+
or _FENCE.match(line)
|
|
164
|
+
or _BLOCKQUOTE.match(line)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# -- inline spans ----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
# Process inline code first (and protect its contents), then links, then
|
|
171
|
+
# emphasis. All raw text is escaped before markup substitution.
|
|
172
|
+
_CODE_SPAN = re.compile(r"`([^`]+)`")
|
|
173
|
+
_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
|
|
174
|
+
_BOLD = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
|
|
175
|
+
_ITALIC = re.compile(r"(?<![*\w])\*([^*\n]+)\*(?![*\w])|(?<![_\w])_([^_\n]+)_(?![_\w])")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _inline(text: str) -> str:
|
|
179
|
+
# Extract code spans first so their contents are not touched by other
|
|
180
|
+
# rules; replace with placeholders, then restore at the end.
|
|
181
|
+
placeholders: list[str] = []
|
|
182
|
+
|
|
183
|
+
def _stash_code(m: re.Match) -> str:
|
|
184
|
+
placeholders.append(f"<code>{_html.escape(m.group(1))}</code>")
|
|
185
|
+
return f"\x00{len(placeholders) - 1}\x00"
|
|
186
|
+
|
|
187
|
+
tmp = _CODE_SPAN.sub(_stash_code, text)
|
|
188
|
+
tmp = _html.escape(tmp)
|
|
189
|
+
|
|
190
|
+
# Links: [label](url) -> escape both parts.
|
|
191
|
+
def _link(m: re.Match) -> str:
|
|
192
|
+
label = m.group(1)
|
|
193
|
+
url = m.group(2)
|
|
194
|
+
return f'<a href="{_html.escape(url, quote=True)}">{label}</a>'
|
|
195
|
+
|
|
196
|
+
# Note: tmp is already escaped, so the bracket/paren chars survive as-is.
|
|
197
|
+
tmp = _LINK.sub(_link, tmp)
|
|
198
|
+
tmp = _BOLD.sub(lambda m: f"<strong>{m.group(1) or m.group(2)}</strong>", tmp)
|
|
199
|
+
tmp = _ITALIC.sub(lambda m: f"<em>{m.group(1) or m.group(2)}</em>", tmp)
|
|
200
|
+
|
|
201
|
+
# Restore code spans.
|
|
202
|
+
def _restore(m: re.Match) -> str:
|
|
203
|
+
return placeholders[int(m.group(1))]
|
|
204
|
+
|
|
205
|
+
return re.sub(r"\x00(\d+)\x00", _restore, tmp)
|
scrollback/models.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Common data model shared by all source adapters.
|
|
2
|
+
|
|
3
|
+
Every adapter normalizes its agent's on-disk representation into these
|
|
4
|
+
immutable dataclasses, so the rest of the program (CLI, search, export,
|
|
5
|
+
web) is agent-agnostic. Keeping these as plain data structures (rather
|
|
6
|
+
than behavior-rich classes) follows the "functions over data structures"
|
|
7
|
+
principle: many functions operate on these few shapes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
PartType = Literal[
|
|
17
|
+
"text",
|
|
18
|
+
"reasoning",
|
|
19
|
+
"tool",
|
|
20
|
+
"file",
|
|
21
|
+
"patch",
|
|
22
|
+
"step-start",
|
|
23
|
+
"step-finish",
|
|
24
|
+
"compaction",
|
|
25
|
+
"unknown",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
Role = Literal["user", "assistant", "system", "tool"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_dt(ms_or_iso: int | float | str | None) -> datetime | None:
|
|
32
|
+
"""Best-effort conversion of a timestamp to an aware UTC datetime.
|
|
33
|
+
|
|
34
|
+
Accepts epoch milliseconds (opencode) or ISO-8601 strings (Claude Code).
|
|
35
|
+
Returns None when the input is missing or unparseable.
|
|
36
|
+
"""
|
|
37
|
+
if ms_or_iso is None:
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(ms_or_iso, (int, float)):
|
|
40
|
+
# opencode stores epoch milliseconds.
|
|
41
|
+
return datetime.fromtimestamp(ms_or_iso / 1000.0, tz=timezone.utc)
|
|
42
|
+
if isinstance(ms_or_iso, str):
|
|
43
|
+
s = ms_or_iso.strip()
|
|
44
|
+
if not s:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
# Handle trailing Z.
|
|
48
|
+
if s.endswith("Z"):
|
|
49
|
+
s = s[:-1] + "+00:00"
|
|
50
|
+
dt = datetime.fromisoformat(s)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
# Force tz-awareness: a timezone-less timestamp would otherwise be a
|
|
54
|
+
# naive datetime, which raises TypeError when sorted alongside the
|
|
55
|
+
# aware datetimes used elsewhere. Assume UTC when no offset is given.
|
|
56
|
+
if dt.tzinfo is None:
|
|
57
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
58
|
+
return dt
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Part:
|
|
64
|
+
"""A single content block within a message.
|
|
65
|
+
|
|
66
|
+
`text` holds a human-readable rendering of the part regardless of type
|
|
67
|
+
(the message body, the reasoning text, a tool's input/output summary).
|
|
68
|
+
`raw` preserves the adapter's original parsed object for fidelity.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
id: str
|
|
72
|
+
type: PartType
|
|
73
|
+
text: str = ""
|
|
74
|
+
tool_name: str | None = None
|
|
75
|
+
tool_status: str | None = None
|
|
76
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Message:
|
|
81
|
+
"""One turn in a conversation, composed of ordered parts."""
|
|
82
|
+
|
|
83
|
+
id: str
|
|
84
|
+
role: Role
|
|
85
|
+
created: datetime | None
|
|
86
|
+
parts: tuple[Part, ...] = ()
|
|
87
|
+
model: str | None = None
|
|
88
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def text(self) -> str:
|
|
92
|
+
"""Concatenated text of all textual parts (text + reasoning)."""
|
|
93
|
+
return "\n".join(p.text for p in self.parts if p.text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class Session:
|
|
98
|
+
"""A whole conversation: metadata plus (optionally) its messages.
|
|
99
|
+
|
|
100
|
+
Listing operations populate metadata only and leave `messages` empty
|
|
101
|
+
for speed; loading a single session populates `messages`.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
source: str # adapter name, e.g. "opencode" / "claudecode"
|
|
106
|
+
title: str
|
|
107
|
+
directory: str | None
|
|
108
|
+
created: datetime | None
|
|
109
|
+
updated: datetime | None
|
|
110
|
+
model: str | None = None
|
|
111
|
+
agent: str | None = None
|
|
112
|
+
parent_id: str | None = None
|
|
113
|
+
message_count: int | None = None
|
|
114
|
+
# Usage accounting (opencode tracks these; None when unknown).
|
|
115
|
+
cost: float | None = None
|
|
116
|
+
tokens_input: int | None = None
|
|
117
|
+
tokens_output: int | None = None
|
|
118
|
+
# Children populated when subagent folding is enabled.
|
|
119
|
+
children: tuple["Session", ...] = ()
|
|
120
|
+
messages: tuple[Message, ...] = ()
|
|
121
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def short_id(self) -> str:
|
|
125
|
+
"""A compact id suitable for display and prefix selection."""
|
|
126
|
+
return self.id[:12]
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def is_subagent(self) -> bool:
|
|
130
|
+
"""True if this session was spawned by another (has a parent)."""
|
|
131
|
+
return bool(self.parent_id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Re-export the converter for adapters.
|
|
135
|
+
__all__ = ["Part", "Message", "Session", "PartType", "Role", "_to_dt"]
|
scrollback/serialize.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Plain-dict serializers for the API layer.
|
|
2
|
+
|
|
3
|
+
Kept separate from `export.py` (which renders human-facing documents) and
|
|
4
|
+
from `models.py` (which stays behavior-free). These produce the JSON
|
|
5
|
+
shapes the web frontend consumes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .models import Message, Part, Session
|
|
14
|
+
from .store import SearchHit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iso(dt: datetime | None) -> str | None:
|
|
18
|
+
return dt.isoformat() if dt else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def session_summary(s: Session) -> dict[str, Any]:
|
|
22
|
+
"""Lightweight session metadata for list views."""
|
|
23
|
+
return {
|
|
24
|
+
"id": s.id,
|
|
25
|
+
"source": s.source,
|
|
26
|
+
"short_id": s.short_id,
|
|
27
|
+
"title": s.title,
|
|
28
|
+
"directory": s.directory,
|
|
29
|
+
"created": _iso(s.created),
|
|
30
|
+
"updated": _iso(s.updated),
|
|
31
|
+
"model": s.model,
|
|
32
|
+
"agent": s.agent,
|
|
33
|
+
"parent_id": s.parent_id,
|
|
34
|
+
"is_subagent": s.is_subagent,
|
|
35
|
+
"message_count": s.message_count,
|
|
36
|
+
"cost": s.cost,
|
|
37
|
+
"tokens_input": s.tokens_input,
|
|
38
|
+
"tokens_output": s.tokens_output,
|
|
39
|
+
"git_branch": (s.raw or {}).get("git_branch"),
|
|
40
|
+
"children": [session_summary(c) for c in s.children],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def part_dict(p: Part) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"id": p.id,
|
|
47
|
+
"type": p.type,
|
|
48
|
+
"text": p.text,
|
|
49
|
+
"tool_name": p.tool_name,
|
|
50
|
+
"tool_status": p.tool_status,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def message_dict(m: Message) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"id": m.id,
|
|
57
|
+
"role": m.role,
|
|
58
|
+
"created": _iso(m.created),
|
|
59
|
+
"model": m.model,
|
|
60
|
+
"parts": [part_dict(p) for p in m.parts],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def session_detail(s: Session) -> dict[str, Any]:
|
|
65
|
+
"""Full session including messages/parts."""
|
|
66
|
+
data = session_summary(s)
|
|
67
|
+
data["messages"] = [message_dict(m) for m in s.messages]
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def search_hit(h: SearchHit) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"source": h.session.source,
|
|
74
|
+
"session_id": h.session.id,
|
|
75
|
+
"short_id": h.session.short_id,
|
|
76
|
+
"title": h.session.title,
|
|
77
|
+
"directory": h.session.directory,
|
|
78
|
+
"message_id": h.message.id,
|
|
79
|
+
"role": h.message.role,
|
|
80
|
+
"part_type": h.part.type,
|
|
81
|
+
"tool_name": h.part.tool_name,
|
|
82
|
+
"snippet": h.snippet,
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Single source of truth for the web server's host/port configuration.
|
|
2
|
+
|
|
3
|
+
Resolution order for host and port (highest precedence first):
|
|
4
|
+
1. an explicit CLI flag (`--host` / `--port`)
|
|
5
|
+
2. an environment variable (`SCROLLBACK_HOST` / `SCROLLBACK_PORT`)
|
|
6
|
+
3. the built-in default
|
|
7
|
+
|
|
8
|
+
This lets launchers (which just call `scrollback web --window`) be
|
|
9
|
+
configured without editing any script -- set the env var. It also keeps
|
|
10
|
+
the default in exactly one place rather than scattered magic numbers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
DEFAULT_PORT = 8765
|
|
20
|
+
|
|
21
|
+
_ENV_HOST = "SCROLLBACK_HOST"
|
|
22
|
+
_ENV_PORT = "SCROLLBACK_PORT"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_host() -> str:
|
|
26
|
+
return os.environ.get(_ENV_HOST, DEFAULT_HOST)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def default_port() -> int:
|
|
30
|
+
raw = os.environ.get(_ENV_PORT)
|
|
31
|
+
if raw:
|
|
32
|
+
try:
|
|
33
|
+
return int(raw)
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
return DEFAULT_PORT
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_port_free(host: str, port: int) -> bool:
|
|
40
|
+
"""Return True if a TCP server can bind (host, port) right now."""
|
|
41
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
42
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
43
|
+
try:
|
|
44
|
+
sock.bind((host, port))
|
|
45
|
+
return True
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_port(host: str, port: int, *, strict: bool = False, tries: int = 20) -> int:
|
|
51
|
+
"""Return a bindable port.
|
|
52
|
+
|
|
53
|
+
If `port` is free, use it. Otherwise, unless `strict`, scan upward for
|
|
54
|
+
the next free port (port+1, port+2, ...). Raises OSError if `strict`
|
|
55
|
+
and the port is taken, or if no free port is found within `tries`.
|
|
56
|
+
"""
|
|
57
|
+
if is_port_free(host, port):
|
|
58
|
+
return port
|
|
59
|
+
if strict:
|
|
60
|
+
raise OSError(f"port {port} on {host} is already in use")
|
|
61
|
+
for candidate in range(port + 1, port + 1 + tries):
|
|
62
|
+
if is_port_free(host, candidate):
|
|
63
|
+
return candidate
|
|
64
|
+
raise OSError(
|
|
65
|
+
f"no free port found near {port} on {host} (tried {tries} ports)"
|
|
66
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Source adapters: one per supported AI agent.
|
|
2
|
+
|
|
3
|
+
Each adapter implements `Source` (see base.py) and is registered in
|
|
4
|
+
`registry.py`. Adding support for a new agent means writing one adapter
|
|
5
|
+
and adding it to the registry -- nothing else in the program changes.
|
|
6
|
+
"""
|