rewound 0.2.0
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.
- package/LICENSE +31 -0
- package/README.md +126 -0
- package/dist/adapters/claude-code.js +184 -0
- package/dist/cli.js +226 -0
- package/dist/db.js +441 -0
- package/dist/indexer.js +100 -0
- package/dist/mcp.js +192 -0
- package/dist/pricing.js +21 -0
- package/dist/search.js +62 -0
- package/dist/server.js +176 -0
- package/dist/types.js +1 -0
- package/dist/web/html.js +14 -0
- package/dist/web/layout.js +342 -0
- package/dist/web/pages/search.js +110 -0
- package/dist/web/pages/session.js +49 -0
- package/dist/web/pages/stats.js +53 -0
- package/dist/web/pages/timeline.js +38 -0
- package/package.json +61 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { escapeHtml } from "./html.js";
|
|
2
|
+
const NAV_ITEMS = [
|
|
3
|
+
{ key: "search", href: "/", label: "Search" },
|
|
4
|
+
{ key: "timeline", href: "/timeline", label: "Timeline" },
|
|
5
|
+
{ key: "stats", href: "/stats", label: "Stats" },
|
|
6
|
+
];
|
|
7
|
+
// Small magnifying-glass mark in the brand blue/orange, inlined as a data URI
|
|
8
|
+
// so the favicon never triggers an external request (README promises zero
|
|
9
|
+
// network calls, and the phone workflow is often offline/Tailscale-only).
|
|
10
|
+
const FAVICON_SVG = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='13' cy='13' r='9' fill='none' stroke='#2563eb' stroke-width='4'/><line x1='20' y1='20' x2='28' y2='28' stroke='#ea580c' stroke-width='4' stroke-linecap='round'/></svg>`;
|
|
11
|
+
const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}`;
|
|
12
|
+
// Colorblind-safe palette: blue (primary) / orange (accent) only.
|
|
13
|
+
// Never use red/green to convey status — pair icons or labels with color instead.
|
|
14
|
+
// navigator.clipboard requires a secure context (HTTPS or localhost). The
|
|
15
|
+
// primary phone workflow is plain HTTP over Tailscale, so fall back to a
|
|
16
|
+
// hidden-textarea + execCommand copy when the Clipboard API is unavailable.
|
|
17
|
+
// Copy buttons use a delegated click listener + data-copy-target (rather than
|
|
18
|
+
// per-button onclick handlers) so an arbitrary number of copy buttons can
|
|
19
|
+
// appear on one page (e.g. one per search result) without inline JS that
|
|
20
|
+
// would need to interpolate untrusted ids into a JS string literal.
|
|
21
|
+
const COPY_SCRIPT = `
|
|
22
|
+
function copyText(text) {
|
|
23
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
24
|
+
navigator.clipboard.writeText(text);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
var ta = document.createElement("textarea");
|
|
28
|
+
ta.value = text;
|
|
29
|
+
ta.setAttribute("readonly", "");
|
|
30
|
+
ta.style.position = "fixed";
|
|
31
|
+
ta.style.opacity = "0";
|
|
32
|
+
document.body.appendChild(ta);
|
|
33
|
+
ta.select();
|
|
34
|
+
document.execCommand("copy");
|
|
35
|
+
document.body.removeChild(ta);
|
|
36
|
+
}
|
|
37
|
+
document.addEventListener("click", function (e) {
|
|
38
|
+
var btn = e.target.closest && e.target.closest(".copy-btn");
|
|
39
|
+
if (!btn) return;
|
|
40
|
+
var targetId = btn.getAttribute("data-copy-target");
|
|
41
|
+
var el = targetId && document.getElementById(targetId);
|
|
42
|
+
if (!el) return;
|
|
43
|
+
copyText(el.textContent);
|
|
44
|
+
var original = btn.textContent;
|
|
45
|
+
btn.textContent = "Copied ✓";
|
|
46
|
+
btn.disabled = true;
|
|
47
|
+
setTimeout(function () {
|
|
48
|
+
btn.textContent = original;
|
|
49
|
+
btn.disabled = false;
|
|
50
|
+
}, 1500);
|
|
51
|
+
});
|
|
52
|
+
`;
|
|
53
|
+
const STYLES = `
|
|
54
|
+
:root {
|
|
55
|
+
color-scheme: light dark;
|
|
56
|
+
--bg: #ffffff;
|
|
57
|
+
--fg: #1a1a2e;
|
|
58
|
+
--muted: #5b6472;
|
|
59
|
+
--border: #e2e5ea;
|
|
60
|
+
--primary: #2563eb;
|
|
61
|
+
--primary-fg: #ffffff;
|
|
62
|
+
--accent: #ea580c;
|
|
63
|
+
--surface: #f6f7f9;
|
|
64
|
+
--shadow: 0 4px 16px rgba(26, 26, 46, 0.08);
|
|
65
|
+
--mark-bg: rgba(234, 88, 12, 0.22);
|
|
66
|
+
|
|
67
|
+
--space-1: 0.25rem;
|
|
68
|
+
--space-2: 0.5rem;
|
|
69
|
+
--space-3: 0.75rem;
|
|
70
|
+
--space-4: 1rem;
|
|
71
|
+
--space-6: 1.5rem;
|
|
72
|
+
--space-8: 2rem;
|
|
73
|
+
|
|
74
|
+
--radius-sm: 0.5rem;
|
|
75
|
+
--radius: 0.75rem;
|
|
76
|
+
|
|
77
|
+
--fs-xs: 0.75rem;
|
|
78
|
+
--fs-sm: 0.875rem;
|
|
79
|
+
--fs-base: 1rem;
|
|
80
|
+
--fs-lg: 1.125rem;
|
|
81
|
+
--fs-xl: 1.375rem;
|
|
82
|
+
--fs-2xl: 2rem;
|
|
83
|
+
|
|
84
|
+
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
85
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (prefers-color-scheme: dark) {
|
|
89
|
+
:root {
|
|
90
|
+
--bg: #101218;
|
|
91
|
+
--fg: #e7e9ee;
|
|
92
|
+
--muted: #9aa3b2;
|
|
93
|
+
--border: #2a2e38;
|
|
94
|
+
--primary: #5b9bf7;
|
|
95
|
+
--primary-fg: #0b1220;
|
|
96
|
+
--accent: #ff9d52;
|
|
97
|
+
--surface: #171a22;
|
|
98
|
+
--shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
99
|
+
--mark-bg: rgba(255, 157, 82, 0.3);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
* { box-sizing: border-box; }
|
|
104
|
+
body {
|
|
105
|
+
margin: 0;
|
|
106
|
+
font-family: var(--font-sans);
|
|
107
|
+
font-size: var(--fs-base);
|
|
108
|
+
color: var(--fg);
|
|
109
|
+
background: var(--bg);
|
|
110
|
+
line-height: 1.5;
|
|
111
|
+
}
|
|
112
|
+
a { color: var(--primary); }
|
|
113
|
+
code, pre { font-family: var(--font-mono); }
|
|
114
|
+
.cost, .count, .tabular, .stat-number { font-variant-numeric: tabular-nums; }
|
|
115
|
+
|
|
116
|
+
header.site {
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-wrap: wrap;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: var(--space-3);
|
|
121
|
+
padding: var(--space-3) var(--space-4);
|
|
122
|
+
border-bottom: 1px solid var(--border);
|
|
123
|
+
}
|
|
124
|
+
header.site .brand {
|
|
125
|
+
font-weight: 700;
|
|
126
|
+
font-size: var(--fs-lg);
|
|
127
|
+
margin-right: auto;
|
|
128
|
+
letter-spacing: -0.02em;
|
|
129
|
+
}
|
|
130
|
+
.brand-accent { color: var(--accent); font-weight: 800; }
|
|
131
|
+
nav.site-nav { display: flex; gap: var(--space-1); }
|
|
132
|
+
nav.site-nav a {
|
|
133
|
+
padding: 0.5rem var(--space-3);
|
|
134
|
+
min-height: 44px;
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
border-radius: var(--radius-sm);
|
|
138
|
+
text-decoration: none;
|
|
139
|
+
color: var(--muted);
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
font-size: var(--fs-sm);
|
|
142
|
+
}
|
|
143
|
+
nav.site-nav a[aria-current="page"] {
|
|
144
|
+
background: var(--primary);
|
|
145
|
+
color: var(--primary-fg);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main { max-width: 960px; margin: 0 auto; padding: var(--space-4); }
|
|
149
|
+
|
|
150
|
+
/* Filesystem paths, session ids, and cost/count text can be long unbroken
|
|
151
|
+
strings with no natural break point — without this, a long project dir
|
|
152
|
+
in an h1 or table cell forces horizontal scroll on narrow viewports. */
|
|
153
|
+
h1, h2, h3, .muted, td, th, code { overflow-wrap: anywhere; }
|
|
154
|
+
|
|
155
|
+
h1 { font-size: var(--fs-2xl); margin: 0 0 var(--space-2); }
|
|
156
|
+
h2 { font-size: var(--fs-xl); margin: var(--space-6) 0 var(--space-3); }
|
|
157
|
+
h3 { font-size: var(--fs-lg); margin: 0 0 var(--space-1); }
|
|
158
|
+
|
|
159
|
+
.card {
|
|
160
|
+
border: 1px solid var(--border);
|
|
161
|
+
border-radius: var(--radius);
|
|
162
|
+
padding: var(--space-3) var(--space-4);
|
|
163
|
+
margin-bottom: var(--space-3);
|
|
164
|
+
background: var(--surface);
|
|
165
|
+
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
|
166
|
+
}
|
|
167
|
+
.card:hover {
|
|
168
|
+
box-shadow: var(--shadow);
|
|
169
|
+
transform: translateY(-1px);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.muted { color: var(--muted); font-size: var(--fs-sm); }
|
|
173
|
+
.badge {
|
|
174
|
+
display: inline-block;
|
|
175
|
+
padding: 0.15rem 0.6rem;
|
|
176
|
+
border-radius: 999px;
|
|
177
|
+
font-size: var(--fs-xs);
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
background: var(--surface);
|
|
180
|
+
border: 1px solid var(--border);
|
|
181
|
+
color: var(--muted);
|
|
182
|
+
}
|
|
183
|
+
.badge.accent { border-color: var(--accent); color: var(--accent); }
|
|
184
|
+
.tool-chip { font-family: var(--font-mono); }
|
|
185
|
+
.role-label { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
186
|
+
|
|
187
|
+
.message { border-left: 4px solid var(--border); }
|
|
188
|
+
.message-user { border-left-color: var(--primary); }
|
|
189
|
+
.message-assistant { border-left-color: var(--accent); }
|
|
190
|
+
|
|
191
|
+
.session-header .resume-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
|
|
192
|
+
|
|
193
|
+
.day-header {
|
|
194
|
+
font-size: var(--fs-sm);
|
|
195
|
+
text-transform: uppercase;
|
|
196
|
+
letter-spacing: 0.05em;
|
|
197
|
+
color: var(--muted);
|
|
198
|
+
border-bottom: 1px solid var(--border);
|
|
199
|
+
padding-bottom: var(--space-2);
|
|
200
|
+
}
|
|
201
|
+
.session-row { padding: var(--space-2) var(--space-4); }
|
|
202
|
+
.session-row-title { font-weight: 600; margin-bottom: var(--space-1); }
|
|
203
|
+
.session-row-title a { text-decoration: none; }
|
|
204
|
+
.session-row-title a:hover { text-decoration: underline; }
|
|
205
|
+
|
|
206
|
+
.project-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
|
|
207
|
+
.project-link { display: block; text-decoration: none; color: var(--fg); font-weight: 600; }
|
|
208
|
+
.project-link:hover { border-color: var(--primary); color: var(--primary); }
|
|
209
|
+
|
|
210
|
+
.stat-cards { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-4); }
|
|
211
|
+
.stat-card { flex: 1 1 10rem; text-align: center; }
|
|
212
|
+
.stat-number { font-size: var(--fs-2xl); font-weight: 700; }
|
|
213
|
+
.stat-label { color: var(--muted); font-size: var(--fs-sm); }
|
|
214
|
+
.sparkline .bar { fill: var(--primary); }
|
|
215
|
+
|
|
216
|
+
.error-page { text-align: center; padding: var(--space-8) var(--space-4); }
|
|
217
|
+
.error-code { font-size: var(--fs-2xl); font-weight: 800; color: var(--muted); }
|
|
218
|
+
|
|
219
|
+
mark {
|
|
220
|
+
background: var(--mark-bg);
|
|
221
|
+
color: inherit;
|
|
222
|
+
border-radius: 0.2rem;
|
|
223
|
+
padding: 0 0.15rem;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.hero { text-align: center; padding: var(--space-8) var(--space-4) var(--space-6); }
|
|
227
|
+
.hero-tagline { font-size: var(--fs-lg); color: var(--muted); margin: 0 0 var(--space-6); }
|
|
228
|
+
form.filters { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-4); }
|
|
229
|
+
form.filters-hero { justify-content: center; }
|
|
230
|
+
form.filters-hero input[name="q"] {
|
|
231
|
+
font-size: var(--fs-lg);
|
|
232
|
+
text-align: center;
|
|
233
|
+
flex: 1 1 100%;
|
|
234
|
+
max-width: 32rem;
|
|
235
|
+
}
|
|
236
|
+
.example-chips {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-wrap: wrap;
|
|
239
|
+
gap: var(--space-2);
|
|
240
|
+
justify-content: center;
|
|
241
|
+
margin-top: var(--space-4);
|
|
242
|
+
}
|
|
243
|
+
.chip {
|
|
244
|
+
display: inline-flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
min-height: 44px;
|
|
247
|
+
padding: 0 var(--space-4);
|
|
248
|
+
border-radius: 999px;
|
|
249
|
+
border: 1px solid var(--border);
|
|
250
|
+
background: var(--surface);
|
|
251
|
+
color: var(--fg);
|
|
252
|
+
text-decoration: none;
|
|
253
|
+
font-size: var(--fs-sm);
|
|
254
|
+
}
|
|
255
|
+
.chip:hover { border-color: var(--primary); color: var(--primary); }
|
|
256
|
+
|
|
257
|
+
.hit-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin-bottom: var(--space-2); }
|
|
258
|
+
.hit-title { margin: 0 0 var(--space-2); }
|
|
259
|
+
.hit-title a { text-decoration: none; }
|
|
260
|
+
.hit-title a:hover { text-decoration: underline; }
|
|
261
|
+
.snippet { margin: 0 0 var(--space-3); }
|
|
262
|
+
.hit-footer { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
|
263
|
+
.hit-footer code { flex: 1 1 auto; }
|
|
264
|
+
|
|
265
|
+
input, select, button {
|
|
266
|
+
font: inherit;
|
|
267
|
+
padding: 0.6rem var(--space-3);
|
|
268
|
+
min-height: 44px;
|
|
269
|
+
border-radius: var(--radius-sm);
|
|
270
|
+
border: 1px solid var(--border);
|
|
271
|
+
background: var(--bg);
|
|
272
|
+
color: var(--fg);
|
|
273
|
+
}
|
|
274
|
+
button, .copy-btn {
|
|
275
|
+
background: var(--primary);
|
|
276
|
+
color: var(--primary-fg);
|
|
277
|
+
border-color: var(--primary);
|
|
278
|
+
cursor: pointer;
|
|
279
|
+
font-weight: 600;
|
|
280
|
+
}
|
|
281
|
+
.copy-btn { padding: 0.4rem var(--space-3); min-height: 44px; font-size: var(--fs-xs); }
|
|
282
|
+
.copy-btn:disabled { opacity: 0.8; cursor: default; }
|
|
283
|
+
|
|
284
|
+
/* Text links that act as a card/row's primary tap target (not inline prose)
|
|
285
|
+
need a real 44px hit box even though the text itself renders smaller. */
|
|
286
|
+
.tap-target { display: inline-flex; align-items: center; min-height: 44px; }
|
|
287
|
+
|
|
288
|
+
pre { white-space: pre-wrap; word-break: break-word; }
|
|
289
|
+
table { border-collapse: collapse; width: 100%; }
|
|
290
|
+
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
291
|
+
|
|
292
|
+
nav.pagination {
|
|
293
|
+
display: flex;
|
|
294
|
+
gap: var(--space-3);
|
|
295
|
+
align-items: center;
|
|
296
|
+
padding: var(--space-3) 0;
|
|
297
|
+
font-size: var(--fs-sm);
|
|
298
|
+
}
|
|
299
|
+
nav.pagination a {
|
|
300
|
+
padding: 0.5rem var(--space-3);
|
|
301
|
+
min-height: 44px;
|
|
302
|
+
display: inline-flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
border: 1px solid var(--border);
|
|
305
|
+
border-radius: var(--radius-sm);
|
|
306
|
+
text-decoration: none;
|
|
307
|
+
font-weight: 600;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@media (max-width: 600px) {
|
|
311
|
+
main { padding: var(--space-3); }
|
|
312
|
+
form.filters { flex-direction: column; }
|
|
313
|
+
table, thead, tbody, th, td, tr { display: block; }
|
|
314
|
+
thead { display: none; }
|
|
315
|
+
td { border: none; padding: 0.15rem 0; }
|
|
316
|
+
tr { border-bottom: 1px solid var(--border); padding-bottom: var(--space-2); margin-bottom: var(--space-2); }
|
|
317
|
+
}
|
|
318
|
+
`;
|
|
319
|
+
export function renderLayout(opts) {
|
|
320
|
+
const navHtml = NAV_ITEMS.map((item) => {
|
|
321
|
+
const current = item.key === opts.activeNav ? ' aria-current="page"' : "";
|
|
322
|
+
return `<a href="${item.href}"${current}>${escapeHtml(item.label)}</a>`;
|
|
323
|
+
}).join("");
|
|
324
|
+
return `<!doctype html>
|
|
325
|
+
<html lang="en">
|
|
326
|
+
<head>
|
|
327
|
+
<meta charset="utf-8" />
|
|
328
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
329
|
+
<title>${escapeHtml(opts.title)} · rewound</title>
|
|
330
|
+
<link rel="icon" href="${FAVICON_HREF}" />
|
|
331
|
+
<style>${STYLES}</style>
|
|
332
|
+
<script>${COPY_SCRIPT}</script>
|
|
333
|
+
</head>
|
|
334
|
+
<body>
|
|
335
|
+
<header class="site">
|
|
336
|
+
<span class="brand">agent<span class="brand-accent">grep</span></span>
|
|
337
|
+
<nav class="site-nav">${navHtml}</nav>
|
|
338
|
+
</header>
|
|
339
|
+
<main>${opts.body}</main>
|
|
340
|
+
</body>
|
|
341
|
+
</html>`;
|
|
342
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { escapeHtml, highlightSnippetHtml } from "../html.js";
|
|
2
|
+
import { collapseSnippetWhitespace } from "../../search.js";
|
|
3
|
+
const EXAMPLE_QUERIES = ["auth bug", "database migration", "failing test", "refactor", "TODO"];
|
|
4
|
+
function pageQueryString(opts, page) {
|
|
5
|
+
const params = new URLSearchParams();
|
|
6
|
+
if (opts.q)
|
|
7
|
+
params.set("q", opts.q);
|
|
8
|
+
if (opts.project)
|
|
9
|
+
params.set("project", opts.project);
|
|
10
|
+
if (opts.since)
|
|
11
|
+
params.set("since", opts.since);
|
|
12
|
+
if (opts.role)
|
|
13
|
+
params.set("role", opts.role);
|
|
14
|
+
if (opts.sidechains)
|
|
15
|
+
params.set("sidechains", "1");
|
|
16
|
+
if (opts.allMatches)
|
|
17
|
+
params.set("all", "1");
|
|
18
|
+
if (page > 1)
|
|
19
|
+
params.set("page", String(page));
|
|
20
|
+
// URLSearchParams percent-encodes every value, so the only raw character
|
|
21
|
+
// left over is the "&" joining params — escape it for valid HTML markup.
|
|
22
|
+
const qs = params.toString().replace(/&/g, "&");
|
|
23
|
+
return qs ? `?${qs}` : "";
|
|
24
|
+
}
|
|
25
|
+
function renderFilters(opts, variant) {
|
|
26
|
+
const projectOptions = opts.projects
|
|
27
|
+
.map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
|
|
28
|
+
.join("");
|
|
29
|
+
const roleOptions = ["", "user", "assistant"]
|
|
30
|
+
.map((r) => {
|
|
31
|
+
const label = r === "" ? "Any role" : r;
|
|
32
|
+
return `<option value="${r}"${r === opts.role ? " selected" : ""}>${escapeHtml(label)}</option>`;
|
|
33
|
+
})
|
|
34
|
+
.join("");
|
|
35
|
+
const formClass = variant === "hero" ? "filters filters-hero" : "filters";
|
|
36
|
+
const autofocus = variant === "hero" ? " autofocus" : "";
|
|
37
|
+
return `
|
|
38
|
+
<form class="${formClass}" method="get" action="/">
|
|
39
|
+
<input type="text" name="q" placeholder="Search your agent history..." value="${escapeHtml(opts.q)}"${autofocus} />
|
|
40
|
+
<input type="text" name="project" list="project-options" placeholder="filter by project (optional)" value="${escapeHtml(opts.project)}" />
|
|
41
|
+
<datalist id="project-options">${projectOptions}</datalist>
|
|
42
|
+
<input type="text" name="since" placeholder="since: 7d, 24h, or ISO" value="${escapeHtml(opts.since)}" />
|
|
43
|
+
<select name="role">${roleOptions}</select>
|
|
44
|
+
<label><input type="checkbox" name="sidechains" value="1" ${opts.sidechains ? "checked" : ""} /> include subagent traffic</label>
|
|
45
|
+
<button type="submit">Search</button>
|
|
46
|
+
</form>`;
|
|
47
|
+
}
|
|
48
|
+
function renderHero(opts) {
|
|
49
|
+
const filters = renderFilters(opts, "hero");
|
|
50
|
+
const chips = EXAMPLE_QUERIES.map((q) => `<a class="chip" href="/?q=${encodeURIComponent(q)}">${escapeHtml(q)}</a>`).join("");
|
|
51
|
+
return `
|
|
52
|
+
<section class="hero">
|
|
53
|
+
<p class="hero-tagline">Search everything your AI agents have ever done — every session, every project.</p>
|
|
54
|
+
${filters}
|
|
55
|
+
<div class="example-chips">${chips}</div>
|
|
56
|
+
</section>`;
|
|
57
|
+
}
|
|
58
|
+
function renderHitCard(hit, index) {
|
|
59
|
+
const heading = escapeHtml(hit.title ?? hit.sessionId);
|
|
60
|
+
const sidechainBadge = hit.isSidechain ? `<span class="badge accent">subagent</span>` : "";
|
|
61
|
+
const resumeCmd = `claude --resume ${hit.sessionId}`;
|
|
62
|
+
const resumeId = `resume-hit-${index}`;
|
|
63
|
+
return `
|
|
64
|
+
<article class="card hit-card">
|
|
65
|
+
<div class="hit-meta muted">
|
|
66
|
+
<span class="badge">${escapeHtml(hit.projectDir)}</span>
|
|
67
|
+
<span>${escapeHtml(hit.ts)}</span>
|
|
68
|
+
<span class="badge">${escapeHtml(hit.role)}</span>
|
|
69
|
+
${sidechainBadge}
|
|
70
|
+
</div>
|
|
71
|
+
<h3 class="hit-title"><a class="tap-target" href="/session/${encodeURIComponent(hit.sessionId)}">${heading}</a></h3>
|
|
72
|
+
<p class="snippet">${highlightSnippetHtml(collapseSnippetWhitespace(hit.snippet))}</p>
|
|
73
|
+
${hit.matchesInSession > 1 ? `<p class="muted">+${hit.matchesInSession - 1} more in this session</p>` : ""}
|
|
74
|
+
<div class="hit-footer">
|
|
75
|
+
<span class="cost muted" title="Estimated cost at API list prices">$${hit.estCostUsd.toFixed(4)}</span>
|
|
76
|
+
<code id="${resumeId}">${escapeHtml(resumeCmd)}</code>
|
|
77
|
+
<button type="button" class="copy-btn" data-copy-target="${resumeId}">Copy</button>
|
|
78
|
+
</div>
|
|
79
|
+
</article>`;
|
|
80
|
+
}
|
|
81
|
+
function renderPagination(opts) {
|
|
82
|
+
const prev = opts.page > 1
|
|
83
|
+
? `<a rel="prev" href="/${pageQueryString(opts, opts.page - 1)}">← Previous</a>`
|
|
84
|
+
: "";
|
|
85
|
+
const next = opts.hasMore
|
|
86
|
+
? `<a rel="next" href="/${pageQueryString(opts, opts.page + 1)}">Next →</a>`
|
|
87
|
+
: "";
|
|
88
|
+
if (!prev && !next)
|
|
89
|
+
return "";
|
|
90
|
+
return `<nav class="pagination">${prev} ${next}</nav>`;
|
|
91
|
+
}
|
|
92
|
+
export function renderSearchPage(opts) {
|
|
93
|
+
if (!opts.q) {
|
|
94
|
+
return renderHero(opts);
|
|
95
|
+
}
|
|
96
|
+
const filters = renderFilters(opts, "compact");
|
|
97
|
+
if (opts.hits.length === 0) {
|
|
98
|
+
return `${filters}<p class="muted">No results for "${escapeHtml(opts.q)}".</p>`;
|
|
99
|
+
}
|
|
100
|
+
const cards = opts.hits.map(renderHitCard).join("");
|
|
101
|
+
return `${filters}${renderGroupToggle(opts)}${cards}${renderPagination(opts)}`;
|
|
102
|
+
}
|
|
103
|
+
function renderGroupToggle(opts) {
|
|
104
|
+
if (opts.allMatches) {
|
|
105
|
+
const qs = pageQueryString({ ...opts, allMatches: false }, 1);
|
|
106
|
+
return `<p class="muted group-toggle">showing all matches · <a href="/${qs}">one best hit per session</a></p>`;
|
|
107
|
+
}
|
|
108
|
+
const qs = pageQueryString({ ...opts, allMatches: true }, 1);
|
|
109
|
+
return `<p class="muted group-toggle">one best hit per session · <a href="/${qs}">show all matches</a></p>`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { escapeHtml } from "../html.js";
|
|
2
|
+
function renderMessage(m) {
|
|
3
|
+
const sidechainBadge = m.isSidechain ? `<span class="badge accent">subagent</span>` : "";
|
|
4
|
+
const modelBadge = m.model ? `<span class="badge">${escapeHtml(m.model)}</span>` : "";
|
|
5
|
+
const meta = `<div class="muted">${escapeHtml(m.ts)} · <span class="role-label">${escapeHtml(m.role)}</span> ${modelBadge} ${sidechainBadge}</div>`;
|
|
6
|
+
const text = escapeHtml(m.text);
|
|
7
|
+
const roleClass = m.role === "assistant" ? "message-assistant" : "message-user";
|
|
8
|
+
const cardClass = `card message ${roleClass}`;
|
|
9
|
+
if (m.tools.length === 0) {
|
|
10
|
+
return `<article class="${cardClass}">${meta}<pre>${text}</pre></article>`;
|
|
11
|
+
}
|
|
12
|
+
const toolChips = m.tools.map((t) => `<span class="badge tool-chip">${escapeHtml(t)}</span>`).join(" ");
|
|
13
|
+
return `<article class="${cardClass}">${meta}<details><summary>🔧 ${m.tools.length} tool call${m.tools.length === 1 ? "" : "s"}: ${toolChips}</summary><pre>${text}</pre></details></article>`;
|
|
14
|
+
}
|
|
15
|
+
function renderPagination(sessionId, p) {
|
|
16
|
+
if (p.totalPages <= 1)
|
|
17
|
+
return "";
|
|
18
|
+
const prev = p.page > 1
|
|
19
|
+
? `<a rel="prev" href="/session/${encodeURIComponent(sessionId)}?page=${p.page - 1}">← Earlier</a>`
|
|
20
|
+
: "";
|
|
21
|
+
const next = p.page < p.totalPages
|
|
22
|
+
? `<a rel="next" href="/session/${encodeURIComponent(sessionId)}?page=${p.page + 1}">Later →</a>`
|
|
23
|
+
: "";
|
|
24
|
+
return `<nav class="pagination"><span class="muted">Page ${p.page} of ${p.totalPages}</span> ${prev} ${next}</nav>`;
|
|
25
|
+
}
|
|
26
|
+
export function renderSessionPage(session, messages, pagination) {
|
|
27
|
+
const heading = escapeHtml(session.title ?? session.id);
|
|
28
|
+
const archivedBadge = session.archived
|
|
29
|
+
? `<span class="badge accent">archived</span>`
|
|
30
|
+
: "";
|
|
31
|
+
const resumeCmd = `claude --resume ${session.id}`;
|
|
32
|
+
const header = `
|
|
33
|
+
<header class="session-header">
|
|
34
|
+
<h1>${heading} ${archivedBadge}</h1>
|
|
35
|
+
<div class="muted">
|
|
36
|
+
${escapeHtml(session.projectDir)} · branch ${escapeHtml(session.gitBranch ?? "?")} ·
|
|
37
|
+
${escapeHtml(session.startedAt ?? "?")} – ${escapeHtml(session.endedAt ?? "?")} ·
|
|
38
|
+
${session.messageCount} messages · <span title="Estimated cost at API list prices">est. API $${session.estCostUsd.toFixed(4)}</span> ·
|
|
39
|
+
${session.models.map(escapeHtml).join(", ") || "no model recorded"}
|
|
40
|
+
</div>
|
|
41
|
+
<p class="resume-row">
|
|
42
|
+
<code id="resume-cmd">${escapeHtml(resumeCmd)}</code>
|
|
43
|
+
<button type="button" class="copy-btn" data-copy-target="resume-cmd">Copy</button>
|
|
44
|
+
</p>
|
|
45
|
+
</header>`;
|
|
46
|
+
const transcript = messages.map(renderMessage).join("");
|
|
47
|
+
const paginationHtml = pagination ? renderPagination(session.id, pagination) : "";
|
|
48
|
+
return `${header}${paginationHtml}<section class="transcript">${transcript}</section>${paginationHtml}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { escapeHtml } from "../html.js";
|
|
2
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
3
|
+
function toDateKey(d) {
|
|
4
|
+
return d.toISOString().slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
export function fillDailySeries(counts, days, now) {
|
|
7
|
+
const byDate = new Map(counts.map((c) => [c.date, c.count]));
|
|
8
|
+
const series = [];
|
|
9
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
10
|
+
const d = new Date(now.getTime() - i * MS_PER_DAY);
|
|
11
|
+
const date = toDateKey(d);
|
|
12
|
+
series.push({ date, count: byDate.get(date) ?? 0 });
|
|
13
|
+
}
|
|
14
|
+
return series;
|
|
15
|
+
}
|
|
16
|
+
function renderSparklineSvg(series) {
|
|
17
|
+
const width = 300;
|
|
18
|
+
const height = 60;
|
|
19
|
+
const max = Math.max(1, ...series.map((d) => d.count));
|
|
20
|
+
const n = Math.max(1, series.length);
|
|
21
|
+
const barWidth = width / n;
|
|
22
|
+
const bars = series
|
|
23
|
+
.map((d, i) => {
|
|
24
|
+
const barHeight = (d.count / max) * (height - 4);
|
|
25
|
+
const x = i * barWidth;
|
|
26
|
+
const y = height - barHeight;
|
|
27
|
+
return `<rect class="bar" x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(barWidth - 1).toFixed(2)}" height="${barHeight.toFixed(2)}"><title>${escapeHtml(d.date)}: ${d.count}</title></rect>`;
|
|
28
|
+
})
|
|
29
|
+
.join("");
|
|
30
|
+
return `<svg class="sparkline" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="messages per day, last ${series.length} days">${bars}</svg>`;
|
|
31
|
+
}
|
|
32
|
+
function renderProjectTable(rows) {
|
|
33
|
+
const body = rows
|
|
34
|
+
.map((r) => `<tr><td>${escapeHtml(r.projectDir)}</td><td class="tabular">${r.sessions}</td><td class="tabular">${r.messages}</td><td class="cost">$${r.estCostUsd.toFixed(4)}</td></tr>`)
|
|
35
|
+
.join("");
|
|
36
|
+
return `<table><thead><tr><th>Project</th><th>Sessions</th><th>Messages</th><th>Est. API cost</th></tr></thead><tbody>${body}</tbody></table>`;
|
|
37
|
+
}
|
|
38
|
+
function renderStatCards(opts) {
|
|
39
|
+
const cards = [
|
|
40
|
+
{ value: String(opts.totalSessions), label: "Sessions" },
|
|
41
|
+
{ value: String(opts.totalMessages), label: "Messages" },
|
|
42
|
+
{ value: `$${opts.totalCostUsd.toFixed(4)}`, label: "Est. API cost" },
|
|
43
|
+
];
|
|
44
|
+
return `<div class="stat-cards">${cards
|
|
45
|
+
.map((c) => `<div class="card stat-card"><div class="stat-number">${c.value}</div><div class="stat-label">${c.label}</div></div>`)
|
|
46
|
+
.join("")}</div>`;
|
|
47
|
+
}
|
|
48
|
+
export function renderStatsPage(opts) {
|
|
49
|
+
const summary = renderStatCards(opts);
|
|
50
|
+
const sparkline = `<h2>Messages / day (last ${opts.dailyCounts.length || 30} days)</h2>${renderSparklineSvg(opts.dailyCounts)}`;
|
|
51
|
+
const costNote = `<p class="muted">Cost figures are token usage valued at Anthropic API list prices — on a subscription plan your real spend is far lower.</p>`;
|
|
52
|
+
return `${summary}${costNote}<h2>By project (${opts.byProject.length})</h2>${renderProjectTable(opts.byProject)}${sparkline}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { escapeHtml } from "../html.js";
|
|
2
|
+
function renderProjectList(projects) {
|
|
3
|
+
const items = projects
|
|
4
|
+
.map((p) => `<li><a class="card project-link" href="/timeline?project=${encodeURIComponent(p)}">${escapeHtml(p)}</a></li>`)
|
|
5
|
+
.join("");
|
|
6
|
+
return `<p class="muted">Pick a project to see its timeline:</p><ul class="project-list">${items}</ul>`;
|
|
7
|
+
}
|
|
8
|
+
function dayKey(startedAt) {
|
|
9
|
+
return startedAt ? startedAt.slice(0, 10) : "unknown date";
|
|
10
|
+
}
|
|
11
|
+
function renderSessionRow(s) {
|
|
12
|
+
const heading = escapeHtml(s.title ?? s.id);
|
|
13
|
+
return `
|
|
14
|
+
<article class="card session-row">
|
|
15
|
+
<div class="session-row-title"><a class="tap-target" href="/session/${encodeURIComponent(s.id)}">${heading}</a></div>
|
|
16
|
+
<div class="muted">${escapeHtml(s.startedAt ?? "?")} · <span class="count">${s.messageCount} msgs</span> · <span class="cost" title="Estimated cost at API list prices">$${s.estCostUsd.toFixed(4)}</span></div>
|
|
17
|
+
</article>`;
|
|
18
|
+
}
|
|
19
|
+
export function renderTimelinePage(opts) {
|
|
20
|
+
if (!opts.selectedProject) {
|
|
21
|
+
return renderProjectList(opts.projects);
|
|
22
|
+
}
|
|
23
|
+
const heading = `<h1>${escapeHtml(opts.selectedProject)}</h1>`;
|
|
24
|
+
if (opts.sessions.length === 0) {
|
|
25
|
+
return `${heading}<p class="muted">No sessions found for this project.</p>`;
|
|
26
|
+
}
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
for (const s of opts.sessions) {
|
|
29
|
+
const key = dayKey(s.startedAt);
|
|
30
|
+
const group = groups.get(key) ?? [];
|
|
31
|
+
group.push(s);
|
|
32
|
+
groups.set(key, group);
|
|
33
|
+
}
|
|
34
|
+
const days = Array.from(groups.entries())
|
|
35
|
+
.map(([date, sessions]) => `<section class="day-group"><h2 class="day-header">${escapeHtml(date)}</h2>${sessions.map(renderSessionRow).join("")}</section>`)
|
|
36
|
+
.join("");
|
|
37
|
+
return `${heading}${days}`;
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rewound",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Grep for everything your AI coding agents ever did. Local SQLite/FTS5 search over Claude Code session transcripts: CLI, MCP server, and web UI.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"author": "David Erner",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Dashorama/rewound.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Dashorama/rewound#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Dashorama/rewound/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"ai-agents",
|
|
19
|
+
"agent-memory",
|
|
20
|
+
"session-history",
|
|
21
|
+
"transcripts",
|
|
22
|
+
"search",
|
|
23
|
+
"full-text-search",
|
|
24
|
+
"fts5",
|
|
25
|
+
"sqlite",
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"rewound": "dist/cli.js",
|
|
32
|
+
"rw": "dist/cli.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"prepare": "npm run build",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
+
"better-sqlite3": "^11.3.0",
|
|
51
|
+
"commander": "^12.1.0",
|
|
52
|
+
"fastify": "^5.10.0",
|
|
53
|
+
"zod": "^4.4.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
57
|
+
"@types/node": "^20.14.0",
|
|
58
|
+
"typescript": "^5.5.4",
|
|
59
|
+
"vitest": "^2.0.5"
|
|
60
|
+
}
|
|
61
|
+
}
|