@kyleparrott/where-was-i 0.1.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.
@@ -0,0 +1,81 @@
1
+ import path from "node:path";
2
+ import { defaultConfigPath, loadWhereWasIConfig } from "./core/config.js";
3
+ import { expandHome } from "./core/paths.js";
4
+ export function runtimeConfig(options) {
5
+ const config = loadWhereWasIConfig(options.configPath);
6
+ return {
7
+ ...config,
8
+ dbPath: expandHome(options.dbPath ?? config.dbPath)
9
+ };
10
+ }
11
+ export function editableConfigPath(options) {
12
+ return path.resolve(expandHome(options.configPath ?? defaultConfigPath()));
13
+ }
14
+ export function parseWebSearchMode(value) {
15
+ if (value === "auto" || value === "fts" || value === "hybrid" || value === "semantic") {
16
+ return value;
17
+ }
18
+ return "auto";
19
+ }
20
+ export function sessionHref(sessionId, messageId, query, mode, full = false) {
21
+ const params = new URLSearchParams({
22
+ messageId,
23
+ q: query,
24
+ mode
25
+ });
26
+ if (full) {
27
+ params.set("full", "1");
28
+ }
29
+ return `/sessions/${encodeURIComponent(sessionId)}?${params.toString()}#${messageDomId(messageId)}`;
30
+ }
31
+ export function messageDomId(messageId) {
32
+ return `msg-${messageId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
33
+ }
34
+ export function renderEmpty(message) {
35
+ return `<div class="empty">${escapeHtml(message)}</div>`;
36
+ }
37
+ export function formatCount(count, noun) {
38
+ return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`;
39
+ }
40
+ export function formatDate(value) {
41
+ if (!value) {
42
+ return "unknown";
43
+ }
44
+ const date = new Date(value);
45
+ if (Number.isNaN(date.getTime())) {
46
+ return value;
47
+ }
48
+ return date.toLocaleString(undefined, {
49
+ month: "short",
50
+ day: "numeric",
51
+ hour: "numeric",
52
+ minute: "2-digit"
53
+ });
54
+ }
55
+ export function shortPath(value) {
56
+ const home = process.env.HOME;
57
+ const shortened = home && value.startsWith(home) ? `~${value.slice(home.length)}` : value;
58
+ if (shortened.length <= 56) {
59
+ return shortened;
60
+ }
61
+ return `...${shortened.slice(-53)}`;
62
+ }
63
+ export function preview(value, max) {
64
+ const normalized = value.replace(/\s+/g, " ").trim();
65
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}...` : normalized;
66
+ }
67
+ export function isBootstrapText(text) {
68
+ const normalized = text.trim();
69
+ return normalized.startsWith("# AGENTS.md instructions") || normalized.startsWith("<INSTRUCTIONS>");
70
+ }
71
+ export function escapeHtml(value) {
72
+ return value
73
+ .replaceAll("&", "&amp;")
74
+ .replaceAll("<", "&lt;")
75
+ .replaceAll(">", "&gt;")
76
+ .replaceAll('"', "&quot;")
77
+ .replaceAll("'", "&#39;");
78
+ }
79
+ export function escapeAttr(value) {
80
+ return escapeHtml(value);
81
+ }
@@ -0,0 +1,389 @@
1
+ import { STARTUP_MODES } from "./web-settings.js";
2
+ import { WEB_STYLE } from "./web-style.js";
3
+ import { escapeAttr, escapeHtml, formatCount, formatDate, isBootstrapText, messageDomId, preview, renderEmpty, sessionHref, shortPath } from "./web-utils.js";
4
+ export function renderHomePage(status, recent, actionToken, flash, embeddingJob) {
5
+ const body = `
6
+ <section class="hero">
7
+ <div>
8
+ <p class="eyebrow">Local Codex History</p>
9
+ <h1>Where Was I?</h1>
10
+ </div>
11
+ ${renderStatusPills(status)}
12
+ </section>
13
+ ${flash ? `<div class="${escapeAttr(flash.kind)}">${escapeHtml(flash.message)}</div>` : ""}
14
+ ${renderSearchForm("", "auto")}
15
+ ${renderIndexActions(status, actionToken, embeddingJob)}
16
+ <section class="section">
17
+ <div class="section-header">
18
+ <h2>Recent Threads</h2>
19
+ <span>${formatCount(status.index.sessions, "thread")}</span>
20
+ </div>
21
+ <div class="thread-list">
22
+ ${recent.length > 0
23
+ ? recent.map((session) => renderRecentSession(session)).join("")
24
+ : renderEmpty("No indexed sessions yet. Run `wwi index --recent 100`, then refresh this page.")}
25
+ </div>
26
+ </section>
27
+ `;
28
+ return layout("Where Was I", "home", body, {
29
+ clientScript: embeddingJob?.status === "running"
30
+ });
31
+ }
32
+ export function renderEmbeddingPanelUpdate(status, embeddingJob) {
33
+ const button = embeddingButtonState(embeddingJob);
34
+ return {
35
+ job: embeddingJob,
36
+ statusHtml: renderEmbeddingStatus(status),
37
+ jobHtml: embeddingJob ? renderEmbeddingJob(embeddingJob) : "",
38
+ buttonText: button.text,
39
+ buttonDisabled: button.disabled,
40
+ done: embeddingJob?.status !== "running"
41
+ };
42
+ }
43
+ export function renderSearchPage(query, requestedMode, state) {
44
+ const body = `
45
+ <section class="page-heading">
46
+ <div>
47
+ <p class="eyebrow">Search</p>
48
+ <h1>${query ? escapeHtml(query) : "Search Sessions"}</h1>
49
+ </div>
50
+ ${state ? `<span class="mode-chip">${escapeHtml(state.selectedMode)}</span>` : ""}
51
+ </section>
52
+ ${renderSearchForm(query, requestedMode)}
53
+ ${!query
54
+ ? renderEmpty("Enter a query to search indexed messages.")
55
+ : state
56
+ ? renderSearchResults(query, requestedMode, state)
57
+ : ""}
58
+ `;
59
+ return layout("Search - Where Was I", "search", body);
60
+ }
61
+ export function renderSessionPage(state) {
62
+ const firstMessage = state.messages[0];
63
+ const body = `
64
+ <section class="detail-top">
65
+ <div>
66
+ <a class="subtle-link" href="${escapeAttr(state.backHref)}">Back</a>
67
+ <h1>${escapeHtml(sessionTitle(state.messages))}</h1>
68
+ <p class="meta-line">${escapeHtml(formatDate(firstMessage?.timestamp ?? null))} &middot; ${escapeHtml(state.sessionId)}</p>
69
+ </div>
70
+ <div class="detail-actions">
71
+ <a class="button primary" href="${escapeAttr(state.codexUrl)}">Open in Codex</a>
72
+ <span class="source-pill" title="${escapeAttr(state.sourcePath)}">${escapeHtml(shortPath(state.sourcePath))}</span>
73
+ </div>
74
+ </section>
75
+ ${state.contextNotice
76
+ ? `<div class="notice"><div>${escapeHtml(state.contextNotice)}</div>${state.fullHref ? `<div class="context-actions"><a class="button small" href="${escapeAttr(state.fullHref)}">Load larger preview</a></div>` : ""}</div>`
77
+ : ""}
78
+ <section class="message-stream">
79
+ ${state.messages.map((message) => renderMessage(message, state.selectedMessageId)).join("")}
80
+ </section>
81
+ `;
82
+ return layout("Session - Where Was I", "session", body);
83
+ }
84
+ export function renderSettingsPage(model, settingsToken) {
85
+ const body = `
86
+ <section class="page-heading">
87
+ <div>
88
+ <p class="eyebrow">Settings</p>
89
+ <h1>Configuration</h1>
90
+ <p class="meta-line">${escapeHtml(model.configPath)}</p>
91
+ </div>
92
+ ${model.saved ? `<span class="success-pill">Saved</span>` : ""}
93
+ </section>
94
+ ${model.errors.length > 0 ? `<div class="alert">${model.errors.map(escapeHtml).join("<br>")}</div>` : ""}
95
+ <form method="post" action="/settings" class="settings-form">
96
+ <input type="hidden" name="settingsToken" value="${escapeAttr(settingsToken)}">
97
+ <fieldset>
98
+ <legend>Paths</legend>
99
+ ${textField("dbPath", "Index path", model.values.dbPath)}
100
+ ${textField("codexHome", "Codex home", model.values.codexHome)}
101
+ </fieldset>
102
+ <fieldset>
103
+ <legend>Startup</legend>
104
+ ${selectField("startupLexical", "Lexical index", model.values.startupLexical, STARTUP_MODES)}
105
+ ${selectField("startupSemantic", "Semantic index", model.values.startupSemantic, STARTUP_MODES)}
106
+ ${textField("semanticStartupMaxChunks", "Semantic startup chunk cap", model.values.semanticStartupMaxChunks)}
107
+ </fieldset>
108
+ <fieldset>
109
+ <legend>Embeddings</legend>
110
+ ${textField("embeddingBaseUrl", "Base URL", model.values.embeddingBaseUrl)}
111
+ ${textField("embeddingModel", "Model", model.values.embeddingModel)}
112
+ ${textField("embeddingApiKeyEnv", "API key env var", model.values.embeddingApiKeyEnv)}
113
+ ${passwordField("embeddingApiKey", "Direct API key", model.values.embeddingApiKeyStored)}
114
+ ${model.values.embeddingApiKeyStored ? checkboxField("embeddingApiKeyClear", "Clear stored direct API key", model.values.embeddingApiKeyClear) : ""}
115
+ ${textField("embeddingTimeoutMs", "Timeout ms", model.values.embeddingTimeoutMs)}
116
+ </fieldset>
117
+ <div class="form-actions">
118
+ <button class="button primary" type="submit">Save Settings</button>
119
+ <a class="button" href="/">Cancel</a>
120
+ </div>
121
+ </form>
122
+ `;
123
+ return layout("Settings - Where Was I", "settings", body);
124
+ }
125
+ function renderStatusPills(status) {
126
+ return `
127
+ <div class="status-pills">
128
+ <span>${formatCount(status.index.messages, "message")}</span>
129
+ <span>${escapeHtml(status.recommendedMode)}</span>
130
+ ${status.semanticConfigured
131
+ ? `<span>${formatCount(status.semanticIndexedChunks, "vector")}</span>`
132
+ : `<span>fts only</span>`}
133
+ </div>
134
+ `;
135
+ }
136
+ function renderIndexActions(status, actionToken, embeddingJob) {
137
+ const button = embeddingButtonState(embeddingJob);
138
+ return `
139
+ <section class="index-actions">
140
+ <div class="index-action">
141
+ <div>
142
+ <h2>Index</h2>
143
+ <p>${escapeHtml(status.index.lastIndexedAt ? `Last indexed ${formatDate(status.index.lastIndexedAt)}` : "No index has been built yet.")}</p>
144
+ </div>
145
+ <form method="post" action="/index">
146
+ <input type="hidden" name="actionToken" value="${escapeAttr(actionToken)}">
147
+ <button class="button primary" type="submit">Index now</button>
148
+ </form>
149
+ </div>
150
+ <div
151
+ class="index-action"
152
+ id="embedding-action"
153
+ ${embeddingJob ? `data-embedding-job-id="${escapeAttr(embeddingJob.id)}" data-embedding-job-status="${escapeAttr(embeddingJob.status)}"` : ""}
154
+ >
155
+ <div>
156
+ <h2>Embeddings</h2>
157
+ <div id="embedding-status">${renderEmbeddingStatus(status)}</div>
158
+ <div id="embedding-job-slot">${embeddingJob ? renderEmbeddingJob(embeddingJob) : ""}</div>
159
+ </div>
160
+ ${status.semanticConfigured
161
+ ? `<form method="post" action="/embeddings">
162
+ <input type="hidden" name="actionToken" value="${escapeAttr(actionToken)}">
163
+ <button id="embedding-submit" class="button primary" type="submit"${button.disabled ? " disabled" : ""}>${escapeHtml(button.text)}</button>
164
+ </form>`
165
+ : `<a class="button" href="/settings">Configure</a>`}
166
+ </div>
167
+ </section>
168
+ `;
169
+ }
170
+ function embeddingButtonState(embeddingJob) {
171
+ const running = embeddingJob?.status === "running";
172
+ return {
173
+ text: running ? "Regenerating..." : "Regenerate embeddings",
174
+ disabled: running
175
+ };
176
+ }
177
+ function renderEmbeddingStatus(status) {
178
+ if (!status.semanticConfigured) {
179
+ return "<p>Semantic search is off. Full-text search is ready after indexing.</p>";
180
+ }
181
+ const total = status.semanticTotalEligibleChunks;
182
+ const indexed = status.semanticIndexedChunks;
183
+ const missing = status.semanticMissingChunks;
184
+ const last = status.semanticLastIndexedAt ? ` Last embedded ${formatDate(status.semanticLastIndexedAt)}.` : "";
185
+ if (status.semanticIncompatibleStoredVectors > 0) {
186
+ return `<p>${escapeHtml(`${indexed} of ${total} chunks embedded. ${status.semanticIncompatibleStoredVectors} stored vectors use another provider.${last}`)}</p>`;
187
+ }
188
+ const missingText = missing === null ? "" : missing > 0 ? ` ${missing} missing.` : " All eligible chunks are embedded.";
189
+ return `<p>${escapeHtml(`${indexed} of ${total} chunks embedded.${missingText}${last}`)}</p>`;
190
+ }
191
+ function renderEmbeddingJob(job) {
192
+ const percent = embeddingJobPercent(job);
193
+ const total = job.total;
194
+ const indeterminate = job.status === "running" && total === null;
195
+ const meta = job.model ? ` ${job.model}` : "";
196
+ const label = job.status === "running"
197
+ ? total === null
198
+ ? `Checking embedding provider${meta}...`
199
+ : `Regenerating embeddings: ${job.indexed} of ${total} chunks.`
200
+ : job.status === "complete"
201
+ ? total === 0 || job.indexed === 0
202
+ ? "Embedding run complete: everything is already up to date."
203
+ : `Embedding run complete: ${job.indexed} chunk${job.indexed === 1 ? "" : "s"} processed.`
204
+ : `Embedding run failed: ${job.failed ?? "embedding provider failed."}`;
205
+ return `
206
+ <div class="embedding-job ${escapeAttr(job.status)}">
207
+ <div class="progress-row">
208
+ <span>${escapeHtml(label)}</span>
209
+ ${total !== null || job.status !== "running" ? `<span>${percent}%</span>` : ""}
210
+ </div>
211
+ <div
212
+ class="progress-track${indeterminate ? " indeterminate" : ""}"
213
+ role="progressbar"
214
+ aria-valuemin="0"
215
+ aria-valuemax="${escapeAttr(String(total ?? 100))}"
216
+ aria-valuenow="${escapeAttr(String(total === null ? percent : Math.min(job.indexed, total)))}"
217
+ >
218
+ <span style="width: ${percent}%"></span>
219
+ </div>
220
+ </div>
221
+ `;
222
+ }
223
+ function embeddingJobPercent(job) {
224
+ if (job.status === "complete") {
225
+ return 100;
226
+ }
227
+ if (job.status === "failed") {
228
+ const denominator = Math.max(job.total ?? (job.indexed || 1), 1);
229
+ return Math.max(0, Math.min(100, Math.round(((job.indexed || 0) / denominator) * 100)));
230
+ }
231
+ if (job.total === null) {
232
+ return 18;
233
+ }
234
+ if (job.total === 0) {
235
+ return 100;
236
+ }
237
+ return Math.max(0, Math.min(100, Math.round((job.indexed / job.total) * 100)));
238
+ }
239
+ function renderSearchForm(query, mode) {
240
+ return `
241
+ <form class="search-box" action="/search" method="get">
242
+ <input name="q" value="${escapeAttr(query)}" placeholder="Search previous sessions" autocomplete="off" autofocus>
243
+ <select name="mode" aria-label="Search mode">
244
+ ${["auto", "fts", "hybrid", "semantic"].map((candidate) => option(candidate, candidate, mode)).join("")}
245
+ </select>
246
+ <button type="submit">Search</button>
247
+ </form>
248
+ `;
249
+ }
250
+ function renderRecentSession(session) {
251
+ const href = `/sessions/${encodeURIComponent(session.id)}`;
252
+ return `
253
+ <article class="thread-row">
254
+ <a class="thread-main" href="${escapeAttr(href)}">
255
+ <span class="thread-title">${escapeHtml(session.displayTitle)}</span>
256
+ <span class="thread-meta">${escapeHtml(formatDate(session.updatedAt))} &middot; ${escapeHtml(session.cwd ?? "unknown cwd")}</span>
257
+ </a>
258
+ <div class="row-actions">
259
+ <span>${session.messageCount} msg</span>
260
+ ${session.links.codex ? `<a href="${escapeAttr(session.links.codex)}">Codex</a>` : ""}
261
+ </div>
262
+ </article>
263
+ `;
264
+ }
265
+ function renderSearchResults(query, mode, state) {
266
+ if (state.groups.length === 0) {
267
+ return renderEmpty("No matching sessions. If this is your first run, build the index with `wwi index --recent 100`.");
268
+ }
269
+ return `
270
+ ${state.warning ? `<div class="notice">${escapeHtml(state.warning)}</div>` : ""}
271
+ <section class="section">
272
+ <div class="section-header">
273
+ <h2>Results</h2>
274
+ <span>${formatCount(state.groups.length, "thread")}</span>
275
+ </div>
276
+ <div class="result-list">
277
+ ${state.groups.map((group) => renderSearchGroup(query, mode, group)).join("")}
278
+ </div>
279
+ </section>
280
+ `;
281
+ }
282
+ function renderSearchGroup(query, mode, group) {
283
+ const bestHref = sessionHref(group.session.id, group.bestMessage.messageId, query, mode);
284
+ return `
285
+ <article class="result-row">
286
+ <div class="result-head">
287
+ <a class="result-title" href="${escapeAttr(bestHref)}">${escapeHtml(group.session.displayTitle)}</a>
288
+ <span class="score">${group.score.toFixed(3)}</span>
289
+ </div>
290
+ <p class="preview">${escapeHtml(group.bestMessage.preview)}</p>
291
+ <div class="thread-meta">${escapeHtml(formatDate(group.session.updatedAt))} &middot; ${escapeHtml(group.session.cwd ?? "unknown cwd")}</div>
292
+ <div class="hit-list">
293
+ ${group.hits.map((hit) => renderHit(query, mode, hit.messageId, hit.role, hit.preview, group.session.id)).join("")}
294
+ </div>
295
+ <div class="result-actions">
296
+ <a class="button small" href="${escapeAttr(bestHref)}">Inspect</a>
297
+ ${group.bestMessage.links.codex ? `<a class="button small primary" href="${escapeAttr(group.bestMessage.links.codex)}">Open in Codex</a>` : ""}
298
+ </div>
299
+ </article>
300
+ `;
301
+ }
302
+ function renderHit(query, mode, messageId, role, previewText, sessionId) {
303
+ return `
304
+ <a class="hit-chip" href="${escapeAttr(sessionHref(sessionId, messageId, query, mode))}">
305
+ <span>${escapeHtml(role)}</span>
306
+ ${escapeHtml(previewText)}
307
+ </a>
308
+ `;
309
+ }
310
+ function renderMessage(message, selectedMessageId) {
311
+ const selected = selectedMessageId === message.id;
312
+ return `
313
+ <article id="${escapeAttr(messageDomId(message.id))}" class="message ${escapeAttr(message.role)}${selected ? " selected" : ""}">
314
+ <header>
315
+ <span class="role">${escapeHtml(message.role)}</span>
316
+ <span>${escapeHtml(formatDate(message.timestamp))}</span>
317
+ <span class="mono">${escapeHtml(message.id)}</span>
318
+ </header>
319
+ <pre>${escapeHtml(message.text)}</pre>
320
+ <footer>
321
+ <span>${escapeHtml(`${message.sourcePath}:${message.lineStart}`)}</span>
322
+ </footer>
323
+ </article>
324
+ `;
325
+ }
326
+ function textField(name, label, value) {
327
+ return `
328
+ <label>
329
+ <span>${escapeHtml(label)}</span>
330
+ <input name="${escapeAttr(name)}" value="${escapeAttr(value)}">
331
+ </label>
332
+ `;
333
+ }
334
+ function passwordField(name, label, stored) {
335
+ return `
336
+ <label>
337
+ <span>${escapeHtml(label)}</span>
338
+ <input name="${escapeAttr(name)}" type="password" autocomplete="off" placeholder="${stored ? "Stored, leave blank to keep" : ""}">
339
+ </label>
340
+ `;
341
+ }
342
+ function checkboxField(name, label, checked) {
343
+ return `
344
+ <label class="check-row">
345
+ <input name="${escapeAttr(name)}" type="checkbox"${checked ? " checked" : ""}>
346
+ <span>${escapeHtml(label)}</span>
347
+ </label>
348
+ `;
349
+ }
350
+ function selectField(name, label, value, options) {
351
+ return `
352
+ <label>
353
+ <span>${escapeHtml(label)}</span>
354
+ <select name="${escapeAttr(name)}">
355
+ ${options.map((candidate) => option(candidate, candidate, value)).join("")}
356
+ </select>
357
+ </label>
358
+ `;
359
+ }
360
+ function option(value, label, selected) {
361
+ return `<option value="${escapeAttr(value)}"${value === selected ? " selected" : ""}>${escapeHtml(label)}</option>`;
362
+ }
363
+ function layout(title, active, body, options = {}) {
364
+ return `<!doctype html>
365
+ <html lang="en">
366
+ <head>
367
+ <meta charset="utf-8">
368
+ <meta name="viewport" content="width=device-width, initial-scale=1">
369
+ <title>${escapeHtml(title)}</title>
370
+ <style>${WEB_STYLE}</style>
371
+ ${options.clientScript ? `<script src="/app.js" defer></script>` : ""}
372
+ </head>
373
+ <body>
374
+ <header class="app-header">
375
+ <a class="brand" href="/">Where Was I</a>
376
+ <nav>
377
+ <a${active === "home" ? " aria-current=\"page\"" : ""} href="/">Recent</a>
378
+ <a${active === "search" ? " aria-current=\"page\"" : ""} href="/search">Search</a>
379
+ <a${active === "settings" ? " aria-current=\"page\"" : ""} href="/settings">Settings</a>
380
+ </nav>
381
+ </header>
382
+ <main>${body}</main>
383
+ </body>
384
+ </html>`;
385
+ }
386
+ function sessionTitle(messages) {
387
+ const firstNonBootstrap = messages.find((message) => message.role === "user" && !isBootstrapText(message.text));
388
+ return preview(firstNonBootstrap?.text ?? messages[0]?.text ?? "Session", 90);
389
+ }