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
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { search, collapseSnippetWhitespace } from "./search.js";
|
|
5
|
+
import { getSessionByIdOrPrefix, getMessagesForSession, parseJsonStringArray, hasAnyMessages } from "./db.js";
|
|
6
|
+
const MAX_RESPONSE_BYTES = 8192;
|
|
7
|
+
const EXCERPT_CHARS = 700;
|
|
8
|
+
const SUMMARY_TEXT_CHARS = 500;
|
|
9
|
+
const DEFAULT_SEARCH_LIMIT = 5;
|
|
10
|
+
const MAX_SEARCH_LIMIT = 20;
|
|
11
|
+
const DEFAULT_EXCERPT_CONTEXT = 3;
|
|
12
|
+
const MAX_EXCERPT_CONTEXT = 20;
|
|
13
|
+
function truncate(text, maxChars) {
|
|
14
|
+
if (text.length <= maxChars)
|
|
15
|
+
return text;
|
|
16
|
+
return text.slice(0, maxChars) + "…";
|
|
17
|
+
}
|
|
18
|
+
// Windows `text` to `maxChars` centered on the first query term found in it (case-
|
|
19
|
+
// insensitive substring search), so a hit whose match falls past `maxChars` still shows
|
|
20
|
+
// the match instead of unrelated leading text. FTS5 uses porter stemming, so a query term
|
|
21
|
+
// may not appear verbatim (e.g. "trigger" matching stored "triggers") — falls back to a
|
|
22
|
+
// leading truncate() in that case, same as before this existed.
|
|
23
|
+
function centeredExcerpt(text, query, maxChars) {
|
|
24
|
+
if (text.length <= maxChars)
|
|
25
|
+
return text;
|
|
26
|
+
const lowerText = text.toLowerCase();
|
|
27
|
+
let matchIndex = -1;
|
|
28
|
+
for (const term of query.trim().split(/\s+/).filter(Boolean)) {
|
|
29
|
+
const idx = lowerText.indexOf(term.toLowerCase());
|
|
30
|
+
if (idx !== -1 && (matchIndex === -1 || idx < matchIndex))
|
|
31
|
+
matchIndex = idx;
|
|
32
|
+
}
|
|
33
|
+
if (matchIndex === -1)
|
|
34
|
+
return truncate(text, maxChars);
|
|
35
|
+
const half = Math.floor(maxChars / 2);
|
|
36
|
+
const start = Math.max(0, Math.min(matchIndex - half, text.length - maxChars));
|
|
37
|
+
const end = start + maxChars;
|
|
38
|
+
const prefix = start > 0 ? "…" : "";
|
|
39
|
+
const suffix = end < text.length ? "…" : "";
|
|
40
|
+
return prefix + text.slice(start, end) + suffix;
|
|
41
|
+
}
|
|
42
|
+
// Byte-safe truncation for the joinWithinBudget backstop below: char-level truncate()
|
|
43
|
+
// already keeps normal content well under budget, this only fires as a last resort.
|
|
44
|
+
function truncateBytes(text, maxBytes) {
|
|
45
|
+
const buf = Buffer.from(text, "utf8");
|
|
46
|
+
if (buf.byteLength <= maxBytes)
|
|
47
|
+
return text;
|
|
48
|
+
return buf.subarray(0, Math.max(0, maxBytes - 1)).toString("utf8") + "…";
|
|
49
|
+
}
|
|
50
|
+
// Joins parts with `separator`, dropping trailing parts (and noting it) once the
|
|
51
|
+
// joined text would exceed `budgetBytes` — keeps any single MCP response bounded
|
|
52
|
+
// regardless of how many hits/messages matched. Callers should already truncate
|
|
53
|
+
// each part to a sane char length; this also hard-truncates a lone first part that
|
|
54
|
+
// alone exceeds the budget, so the cap holds even if a caller forgets to.
|
|
55
|
+
function joinWithinBudget(parts, separator, budgetBytes) {
|
|
56
|
+
const kept = [];
|
|
57
|
+
let usedBytes = 0;
|
|
58
|
+
let truncated = false;
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
const partBytes = Buffer.byteLength(part, "utf8");
|
|
61
|
+
if (kept.length === 0 && partBytes > budgetBytes) {
|
|
62
|
+
kept.push(truncateBytes(part, budgetBytes));
|
|
63
|
+
truncated = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const additional = partBytes + (kept.length > 0 ? Buffer.byteLength(separator, "utf8") : 0);
|
|
67
|
+
if (kept.length > 0 && usedBytes + additional > budgetBytes) {
|
|
68
|
+
truncated = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
kept.push(part);
|
|
72
|
+
usedBytes += additional;
|
|
73
|
+
}
|
|
74
|
+
const joined = kept.join(separator);
|
|
75
|
+
return truncated ? `${joined}${separator}…(response truncated to stay under ~${budgetBytes} bytes)` : joined;
|
|
76
|
+
}
|
|
77
|
+
function textResult(text, isError = false) {
|
|
78
|
+
return { content: [{ type: "text", text }], isError };
|
|
79
|
+
}
|
|
80
|
+
export function createMcpServer(db) {
|
|
81
|
+
const server = new McpServer({ name: "rewound", version: "0.1.0" });
|
|
82
|
+
server.registerTool("search_history", {
|
|
83
|
+
description: "Full-text search over past AI coding agent session transcripts on this machine. " +
|
|
84
|
+
"Returns ranked excerpts with session id/title/date; use get_session_excerpt for more context around a hit.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
query: z.string().min(1).describe("Search terms (plain words, matched as an AND of terms)"),
|
|
87
|
+
project: z.string().optional().describe("Filter to sessions whose project directory contains this substring"),
|
|
88
|
+
since: z.string().optional().describe("ISO timestamp or relative shorthand like '7d' / '24h'"),
|
|
89
|
+
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional().describe(`Max hits (default ${DEFAULT_SEARCH_LIMIT})`),
|
|
90
|
+
all_matches: z
|
|
91
|
+
.boolean()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Return every matching message instead of one best hit per session"),
|
|
94
|
+
},
|
|
95
|
+
}, async ({ query, project, since, limit, all_matches }) => {
|
|
96
|
+
const hits = search(db, query, {
|
|
97
|
+
project,
|
|
98
|
+
since,
|
|
99
|
+
allMatches: all_matches,
|
|
100
|
+
limit: limit ?? DEFAULT_SEARCH_LIMIT,
|
|
101
|
+
});
|
|
102
|
+
if (hits.length === 0) {
|
|
103
|
+
if (!hasAnyMessages(db)) {
|
|
104
|
+
return textResult(`No matches for "${query}" — the index is empty. Run \`rewound index\` on this machine first to index its session transcripts.`);
|
|
105
|
+
}
|
|
106
|
+
return textResult(`No matches for "${query}".`);
|
|
107
|
+
}
|
|
108
|
+
const blocks = hits.map((h) => [
|
|
109
|
+
`session: ${h.sessionId}${h.title ? ` (${h.title})` : ""}`,
|
|
110
|
+
`project: ${h.projectDir} date: ${h.ts} match_uuid: ${h.uuid}` +
|
|
111
|
+
(h.matchesInSession > 1 ? ` matches_in_session: ${h.matchesInSession}` : ""),
|
|
112
|
+
collapseSnippetWhitespace(centeredExcerpt(h.text, query, EXCERPT_CHARS)),
|
|
113
|
+
].join("\n"));
|
|
114
|
+
const body = joinWithinBudget(blocks, "\n\n---\n\n", MAX_RESPONSE_BYTES - 200);
|
|
115
|
+
return textResult(`${body}\n\nUse get_session_excerpt(session_id, match_uuid) for more context around a hit.`);
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("get_session_summary", {
|
|
118
|
+
description: "Summary of one past session: title, project, dates, message count, tools/models used, " +
|
|
119
|
+
"first user prompt and last assistant response (truncated).",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
session_id: z.string().describe("Session id or a unique id prefix"),
|
|
122
|
+
},
|
|
123
|
+
}, async ({ session_id }) => {
|
|
124
|
+
const session = getSessionByIdOrPrefix(db, session_id);
|
|
125
|
+
if (!session)
|
|
126
|
+
return textResult(`No session found matching "${session_id}".`, true);
|
|
127
|
+
const messages = getMessagesForSession(db, session.id);
|
|
128
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
129
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
130
|
+
const toolsUsed = new Set();
|
|
131
|
+
for (const m of messages) {
|
|
132
|
+
for (const t of parseJsonStringArray(m.tools))
|
|
133
|
+
toolsUsed.add(t);
|
|
134
|
+
}
|
|
135
|
+
const lines = [
|
|
136
|
+
`session: ${session.id}`,
|
|
137
|
+
`title: ${truncate(session.title ?? "(untitled)", SUMMARY_TEXT_CHARS)}`,
|
|
138
|
+
`project: ${session.projectDir} branch: ${session.gitBranch ?? "?"}`,
|
|
139
|
+
`started: ${session.startedAt ?? "?"} ended: ${session.endedAt ?? "?"}`,
|
|
140
|
+
`messages: ${session.messageCount}`,
|
|
141
|
+
`models: ${truncate(session.models.join(", ") || "(none)", SUMMARY_TEXT_CHARS)}`,
|
|
142
|
+
`tools used: ${truncate(Array.from(toolsUsed).join(", ") || "(none)", SUMMARY_TEXT_CHARS)}`,
|
|
143
|
+
`first user prompt: ${truncate(firstUser?.text ?? "", SUMMARY_TEXT_CHARS)}`,
|
|
144
|
+
`last assistant response: ${truncate(lastAssistant?.text ?? "", SUMMARY_TEXT_CHARS)}`,
|
|
145
|
+
];
|
|
146
|
+
return textResult(joinWithinBudget(lines, "\n", MAX_RESPONSE_BYTES));
|
|
147
|
+
});
|
|
148
|
+
server.registerTool("get_session_excerpt", {
|
|
149
|
+
description: "Readable excerpt of a session: the message matching match_uuid plus `context` messages on " +
|
|
150
|
+
"each side (or the start of the session if match_uuid is omitted).",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
session_id: z.string().describe("Session id or a unique id prefix"),
|
|
153
|
+
match_uuid: z.string().optional().describe("uuid of the message to center the excerpt on (from search_history)"),
|
|
154
|
+
context: z
|
|
155
|
+
.number()
|
|
156
|
+
.int()
|
|
157
|
+
.min(0)
|
|
158
|
+
.max(MAX_EXCERPT_CONTEXT)
|
|
159
|
+
.optional()
|
|
160
|
+
.describe(`Messages of context on each side (default ${DEFAULT_EXCERPT_CONTEXT})`),
|
|
161
|
+
},
|
|
162
|
+
}, async ({ session_id, match_uuid, context }) => {
|
|
163
|
+
const session = getSessionByIdOrPrefix(db, session_id);
|
|
164
|
+
if (!session)
|
|
165
|
+
return textResult(`No session found matching "${session_id}".`, true);
|
|
166
|
+
const messages = getMessagesForSession(db, session.id);
|
|
167
|
+
const windowSize = context ?? DEFAULT_EXCERPT_CONTEXT;
|
|
168
|
+
let start = 0;
|
|
169
|
+
let end = Math.min(messages.length, windowSize * 2 + 1);
|
|
170
|
+
if (match_uuid) {
|
|
171
|
+
const idx = messages.findIndex((m) => m.uuid === match_uuid);
|
|
172
|
+
if (idx === -1) {
|
|
173
|
+
return textResult(`No message with uuid "${match_uuid}" in session "${session.id}".`, true);
|
|
174
|
+
}
|
|
175
|
+
start = Math.max(0, idx - windowSize);
|
|
176
|
+
end = Math.min(messages.length, idx + windowSize + 1);
|
|
177
|
+
}
|
|
178
|
+
const lines = messages.slice(start, end).map((m) => {
|
|
179
|
+
const tools = parseJsonStringArray(m.tools);
|
|
180
|
+
const toolSummary = tools.length ? ` [tools: ${tools.join(", ")}]` : "";
|
|
181
|
+
const sidechain = m.is_sidechain ? " (sidechain)" : "";
|
|
182
|
+
return `[${m.ts}] ${m.role}${sidechain}: ${truncate(m.text, EXCERPT_CHARS)}${toolSummary}`;
|
|
183
|
+
});
|
|
184
|
+
return textResult(joinWithinBudget(lines, "\n", MAX_RESPONSE_BYTES));
|
|
185
|
+
});
|
|
186
|
+
return server;
|
|
187
|
+
}
|
|
188
|
+
export async function startMcpServer(db) {
|
|
189
|
+
const server = createMcpServer(db);
|
|
190
|
+
const transport = new StdioServerTransport();
|
|
191
|
+
await server.connect(transport);
|
|
192
|
+
}
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// $/Mtok. Estimated — update here when Anthropic changes pricing.
|
|
2
|
+
export const PRICING_TABLE = [
|
|
3
|
+
{ match: "opus", input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
4
|
+
{ match: "sonnet", input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
5
|
+
{ match: "haiku", input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
6
|
+
];
|
|
7
|
+
export function findPricingRow(model) {
|
|
8
|
+
if (!model)
|
|
9
|
+
return undefined;
|
|
10
|
+
const lower = model.toLowerCase();
|
|
11
|
+
return PRICING_TABLE.find((row) => lower.includes(row.match));
|
|
12
|
+
}
|
|
13
|
+
export function estimateCostUsd(model, usage) {
|
|
14
|
+
const row = findPricingRow(model);
|
|
15
|
+
if (!row)
|
|
16
|
+
return 0;
|
|
17
|
+
return ((usage.input / 1_000_000) * row.input +
|
|
18
|
+
(usage.output / 1_000_000) * row.output +
|
|
19
|
+
(usage.cacheRead / 1_000_000) * row.cacheRead +
|
|
20
|
+
(usage.cacheWrite / 1_000_000) * row.cacheWrite);
|
|
21
|
+
}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { searchMessagesRaw } from "./db.js";
|
|
2
|
+
// Snippets lifted from code/tool dumps carry embedded newlines, tabs and
|
|
3
|
+
// indentation that wreck scannability in a result list. Collapse for display
|
|
4
|
+
// only — stored text and raw snippet data are untouched.
|
|
5
|
+
export function collapseSnippetWhitespace(snippet) {
|
|
6
|
+
return snippet.replace(/\s+/g, " ").trim();
|
|
7
|
+
}
|
|
8
|
+
const RELATIVE_SINCE_RE = /^(\d+)([hd])$/;
|
|
9
|
+
export function resolveSince(since, now = new Date()) {
|
|
10
|
+
if (!since)
|
|
11
|
+
return undefined;
|
|
12
|
+
const m = RELATIVE_SINCE_RE.exec(since);
|
|
13
|
+
if (!m)
|
|
14
|
+
return since; // assume already an ISO timestamp
|
|
15
|
+
const amount = Number(m[1]);
|
|
16
|
+
const unitMs = m[2] === "h" ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
|
17
|
+
return new Date(now.getTime() - amount * unitMs).toISOString();
|
|
18
|
+
}
|
|
19
|
+
// FTS5 treats bare user input as query syntax (":", "-", etc. are operators).
|
|
20
|
+
// Quote each term so search input can never throw a MATCH syntax error;
|
|
21
|
+
// --raw opts out for users who want real FTS5 query syntax.
|
|
22
|
+
export function buildMatchExpression(query, raw) {
|
|
23
|
+
const trimmed = query.trim();
|
|
24
|
+
if (raw)
|
|
25
|
+
return trimmed;
|
|
26
|
+
const terms = trimmed.split(/\s+/).filter(Boolean);
|
|
27
|
+
return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" ");
|
|
28
|
+
}
|
|
29
|
+
export function search(db, query, opts) {
|
|
30
|
+
const matchExpr = buildMatchExpression(query, Boolean(opts.raw));
|
|
31
|
+
const rawOpts = {
|
|
32
|
+
project: opts.project,
|
|
33
|
+
since: resolveSince(opts.since),
|
|
34
|
+
role: opts.role,
|
|
35
|
+
sidechains: opts.sidechains,
|
|
36
|
+
allMatches: opts.allMatches,
|
|
37
|
+
limit: opts.limit,
|
|
38
|
+
offset: opts.offset,
|
|
39
|
+
};
|
|
40
|
+
let rows;
|
|
41
|
+
try {
|
|
42
|
+
rows = searchMessagesRaw(db, matchExpr, rawOpts);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Malformed FTS5 syntax (only reachable via --raw): fail soft with no hits.
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return rows.map((r) => ({
|
|
49
|
+
sessionId: r.sessionId,
|
|
50
|
+
uuid: r.uuid,
|
|
51
|
+
role: r.role,
|
|
52
|
+
ts: r.ts,
|
|
53
|
+
projectDir: r.projectDir,
|
|
54
|
+
title: r.title,
|
|
55
|
+
snippet: r.snippet,
|
|
56
|
+
text: r.text,
|
|
57
|
+
model: r.model,
|
|
58
|
+
isSidechain: r.isSidechain,
|
|
59
|
+
estCostUsd: r.estCostUsd,
|
|
60
|
+
matchesInSession: r.matchesInSession,
|
|
61
|
+
}));
|
|
62
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { search } from "./search.js";
|
|
3
|
+
import { listRecentProjects, listSessions, getSessionByIdOrPrefix, getMessagesForSession, getStats, getDailyMessageCounts, parseJsonStringArray, } from "./db.js";
|
|
4
|
+
import { renderLayout } from "./web/layout.js";
|
|
5
|
+
import { escapeHtml } from "./web/html.js";
|
|
6
|
+
import { renderSearchPage } from "./web/pages/search.js";
|
|
7
|
+
import { renderSessionPage } from "./web/pages/session.js";
|
|
8
|
+
import { renderTimelinePage } from "./web/pages/timeline.js";
|
|
9
|
+
import { fillDailySeries, renderStatsPage } from "./web/pages/stats.js";
|
|
10
|
+
const PAGE_SIZE = 25;
|
|
11
|
+
const STATS_DAYS = 30;
|
|
12
|
+
const TIMELINE_SESSION_LIMIT = 500;
|
|
13
|
+
// Cap the search page's project suggestions so page weight stays bounded
|
|
14
|
+
// regardless of corpus size — the underlying filter still accepts any
|
|
15
|
+
// substring typed in, this just limits the datalist hint list.
|
|
16
|
+
const DEFAULT_SEARCH_PROJECT_SUGGESTIONS = 50;
|
|
17
|
+
// Same reasoning for the timeline's "pick a project" list.
|
|
18
|
+
const DEFAULT_TIMELINE_PROJECT_LIMIT = 100;
|
|
19
|
+
// Cap messages rendered per session page — real corpus sessions can run to
|
|
20
|
+
// 10k+ messages / several MB of HTML, which is unusable over a phone/Tailscale
|
|
21
|
+
// connection. Defaults to the most recent page (where a resumed session's
|
|
22
|
+
// context actually is); ?page= navigates to earlier parts of the transcript.
|
|
23
|
+
const DEFAULT_SESSION_PAGE_SIZE = 150;
|
|
24
|
+
function parseRole(v) {
|
|
25
|
+
return v === "user" || v === "assistant" ? v : undefined;
|
|
26
|
+
}
|
|
27
|
+
// Fastify parses a repeated query param (?q=a&q=b) as an array rather than
|
|
28
|
+
// erroring, so every query field must be coerced through this before use.
|
|
29
|
+
function firstQueryValue(v) {
|
|
30
|
+
if (Array.isArray(v))
|
|
31
|
+
return typeof v[0] === "string" ? v[0] : undefined;
|
|
32
|
+
return typeof v === "string" ? v : undefined;
|
|
33
|
+
}
|
|
34
|
+
// `message` is always escaped internally so callers can pass raw untrusted
|
|
35
|
+
// text (e.g. a session id) straight in; `extraHtml` is an explicit raw-HTML
|
|
36
|
+
// slot for callers that already built safe markup (e.g. a pre-escaped link).
|
|
37
|
+
function renderErrorBody(code, message, extraHtml = "") {
|
|
38
|
+
return `<div class="error-page"><div class="error-code">${code}</div><p>${escapeHtml(message)}</p>${extraHtml}</div>`;
|
|
39
|
+
}
|
|
40
|
+
export function buildServer(opts) {
|
|
41
|
+
const { db } = opts;
|
|
42
|
+
const searchProjectSuggestions = opts.searchProjectSuggestions ?? DEFAULT_SEARCH_PROJECT_SUGGESTIONS;
|
|
43
|
+
const timelineProjectLimit = opts.timelineProjectLimit ?? DEFAULT_TIMELINE_PROJECT_LIMIT;
|
|
44
|
+
const sessionPageSize = opts.sessionPageSize ?? DEFAULT_SESSION_PAGE_SIZE;
|
|
45
|
+
const app = Fastify({ logger: false });
|
|
46
|
+
app.setNotFoundHandler((_req, reply) => {
|
|
47
|
+
reply
|
|
48
|
+
.code(404)
|
|
49
|
+
.type("text/html; charset=utf-8")
|
|
50
|
+
.send(renderLayout({
|
|
51
|
+
title: "Not found",
|
|
52
|
+
body: renderErrorBody(404, "Page not found.", '<p><a class="tap-target" href="/">Back to search</a></p>'),
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
app.setErrorHandler((err, _req, reply) => {
|
|
56
|
+
reply
|
|
57
|
+
.code(500)
|
|
58
|
+
.type("text/html; charset=utf-8")
|
|
59
|
+
.send(renderLayout({
|
|
60
|
+
title: "Error",
|
|
61
|
+
body: renderErrorBody(500, "Something went wrong handling your request.", `<p class="muted">${escapeHtml(err.message)}</p>`),
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
app.get("/", async (req, reply) => {
|
|
65
|
+
const query = req.query;
|
|
66
|
+
const q = (firstQueryValue(query.q) ?? "").trim();
|
|
67
|
+
const page = Math.max(1, parseInt(firstQueryValue(query.page) ?? "1", 10) || 1);
|
|
68
|
+
const project = firstQueryValue(query.project) || undefined;
|
|
69
|
+
const since = firstQueryValue(query.since) || undefined;
|
|
70
|
+
const role = parseRole(firstQueryValue(query.role));
|
|
71
|
+
const sidechains = firstQueryValue(query.sidechains) === "1";
|
|
72
|
+
const allMatches = firstQueryValue(query.all) === "1";
|
|
73
|
+
const searchOpts = {
|
|
74
|
+
project,
|
|
75
|
+
since,
|
|
76
|
+
role,
|
|
77
|
+
sidechains,
|
|
78
|
+
allMatches,
|
|
79
|
+
limit: PAGE_SIZE + 1,
|
|
80
|
+
offset: (page - 1) * PAGE_SIZE,
|
|
81
|
+
};
|
|
82
|
+
const hits = q ? search(db, q, searchOpts) : [];
|
|
83
|
+
const hasMore = hits.length > PAGE_SIZE;
|
|
84
|
+
const pageHits = hits.slice(0, PAGE_SIZE);
|
|
85
|
+
const projects = listRecentProjects(db, searchProjectSuggestions);
|
|
86
|
+
const body = renderSearchPage({
|
|
87
|
+
q,
|
|
88
|
+
project: project ?? "",
|
|
89
|
+
since: since ?? "",
|
|
90
|
+
role: role ?? "",
|
|
91
|
+
sidechains,
|
|
92
|
+
allMatches,
|
|
93
|
+
hits: pageHits,
|
|
94
|
+
projects,
|
|
95
|
+
page,
|
|
96
|
+
hasMore,
|
|
97
|
+
});
|
|
98
|
+
reply
|
|
99
|
+
.type("text/html; charset=utf-8")
|
|
100
|
+
.send(renderLayout({ title: "Search", activeNav: "search", body }));
|
|
101
|
+
});
|
|
102
|
+
app.get("/session/:id", async (req, reply) => {
|
|
103
|
+
const { id } = req.params;
|
|
104
|
+
const session = getSessionByIdOrPrefix(db, id);
|
|
105
|
+
if (!session) {
|
|
106
|
+
reply
|
|
107
|
+
.code(404)
|
|
108
|
+
.type("text/html; charset=utf-8")
|
|
109
|
+
.send(renderLayout({
|
|
110
|
+
title: "Not found",
|
|
111
|
+
body: renderErrorBody(404, `No session found matching "${id}".`, '<p><a class="tap-target" href="/">Back to search</a></p>'),
|
|
112
|
+
}));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const totalPages = Math.max(1, Math.ceil(session.messageCount / sessionPageSize));
|
|
116
|
+
const query = req.query;
|
|
117
|
+
const requestedPage = parseInt(firstQueryValue(query.page) ?? "", 10);
|
|
118
|
+
const page = Number.isInteger(requestedPage) && requestedPage >= 1 && requestedPage <= totalPages
|
|
119
|
+
? requestedPage
|
|
120
|
+
: totalPages;
|
|
121
|
+
const offset = (page - 1) * sessionPageSize;
|
|
122
|
+
const messages = getMessagesForSession(db, session.id, {
|
|
123
|
+
limit: sessionPageSize,
|
|
124
|
+
offset,
|
|
125
|
+
}).map((r) => ({
|
|
126
|
+
uuid: r.uuid,
|
|
127
|
+
role: r.role,
|
|
128
|
+
ts: r.ts,
|
|
129
|
+
text: r.text,
|
|
130
|
+
tools: parseJsonStringArray(r.tools),
|
|
131
|
+
model: r.model ?? undefined,
|
|
132
|
+
isSidechain: Boolean(r.is_sidechain),
|
|
133
|
+
}));
|
|
134
|
+
const body = renderSessionPage(session, messages, { page, totalPages });
|
|
135
|
+
reply
|
|
136
|
+
.type("text/html; charset=utf-8")
|
|
137
|
+
.send(renderLayout({ title: session.title ?? session.id, body }));
|
|
138
|
+
});
|
|
139
|
+
app.get("/timeline", async (req, reply) => {
|
|
140
|
+
const query = req.query;
|
|
141
|
+
const project = firstQueryValue(query.project) || undefined;
|
|
142
|
+
const projects = listRecentProjects(db, timelineProjectLimit);
|
|
143
|
+
const sessions = project ? listSessions(db, { project, limit: TIMELINE_SESSION_LIMIT }) : [];
|
|
144
|
+
const body = renderTimelinePage({
|
|
145
|
+
projects,
|
|
146
|
+
selectedProject: project,
|
|
147
|
+
sessions: sessions.map((s) => ({
|
|
148
|
+
id: s.id,
|
|
149
|
+
title: s.title,
|
|
150
|
+
startedAt: s.startedAt,
|
|
151
|
+
estCostUsd: s.estCostUsd,
|
|
152
|
+
messageCount: s.messageCount,
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
reply
|
|
156
|
+
.type("text/html; charset=utf-8")
|
|
157
|
+
.send(renderLayout({ title: "Timeline", activeNav: "timeline", body }));
|
|
158
|
+
});
|
|
159
|
+
app.get("/stats", async (_req, reply) => {
|
|
160
|
+
const stats = getStats(db, 20);
|
|
161
|
+
const since = new Date(Date.now() - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000).toISOString();
|
|
162
|
+
const daily = getDailyMessageCounts(db, since);
|
|
163
|
+
const dailyCounts = fillDailySeries(daily, STATS_DAYS, new Date());
|
|
164
|
+
const body = renderStatsPage({
|
|
165
|
+
totalSessions: stats.totalSessions,
|
|
166
|
+
totalMessages: stats.totalMessages,
|
|
167
|
+
totalCostUsd: stats.totalCostUsd,
|
|
168
|
+
byProject: stats.byProject,
|
|
169
|
+
dailyCounts,
|
|
170
|
+
});
|
|
171
|
+
reply
|
|
172
|
+
.type("text/html; charset=utf-8")
|
|
173
|
+
.send(renderLayout({ title: "Stats", activeNav: "stats", body }));
|
|
174
|
+
});
|
|
175
|
+
return app;
|
|
176
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/web/html.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function escapeHtml(input) {
|
|
2
|
+
return input
|
|
3
|
+
.replace(/&/g, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """)
|
|
7
|
+
.replace(/'/g, "'");
|
|
8
|
+
}
|
|
9
|
+
// FTS5 snippet() wraps matches in \x01...\x02 marker bytes (see search.ts).
|
|
10
|
+
// Escape first (control bytes pass through untouched), then swap markers for
|
|
11
|
+
// <mark> tags so injected match text can never break out of the tag.
|
|
12
|
+
export function highlightSnippetHtml(snippet) {
|
|
13
|
+
return escapeHtml(snippet).replace(/\x01/g, "<mark>").replace(/\x02/g, "</mark>");
|
|
14
|
+
}
|