@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.
- package/LICENSE +89 -0
- package/README.md +167 -0
- package/dist/src/cli.js +423 -0
- package/dist/src/core/codex.js +303 -0
- package/dist/src/core/config.js +168 -0
- package/dist/src/core/database.js +432 -0
- package/dist/src/core/doctor.js +113 -0
- package/dist/src/core/embeddings.js +118 -0
- package/dist/src/core/indexer.js +60 -0
- package/dist/src/core/paths.js +20 -0
- package/dist/src/core/reset.js +18 -0
- package/dist/src/core/search-mode.js +17 -0
- package/dist/src/core/search.js +562 -0
- package/dist/src/core/semantic.js +220 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/vector.js +311 -0
- package/dist/src/mcp.js +345 -0
- package/dist/src/web-client.js +61 -0
- package/dist/src/web-settings.js +157 -0
- package/dist/src/web-style.js +797 -0
- package/dist/src/web-utils.js +81 -0
- package/dist/src/web-views.js +389 -0
- package/dist/src/web.js +512 -0
- package/docs/assets/web-ui.png +0 -0
- package/package.json +64 -0
package/dist/src/web.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { mergeEmbeddingConfig } from "./core/config.js";
|
|
5
|
+
import { isEmbeddingProviderConfigured, OpenAICompatibleEmbeddingProvider } from "./core/embeddings.js";
|
|
6
|
+
import { indexCodexSessions } from "./core/indexer.js";
|
|
7
|
+
import { resolveConfiguredSearchMode } from "./core/search-mode.js";
|
|
8
|
+
import { groupSearchResultsBySession, indexStats, listRecentSessions, readMessageContext, readSession, searchSessions } from "./core/search.js";
|
|
9
|
+
import { indexSemanticChunks, semanticFreshness } from "./core/semantic.js";
|
|
10
|
+
import { WEB_CLIENT_SCRIPT } from "./web-client.js";
|
|
11
|
+
import { saveSettings, settingsViewModel } from "./web-settings.js";
|
|
12
|
+
import { renderEmbeddingPanelUpdate, renderHomePage, renderSearchPage, renderSessionPage, renderSettingsPage } from "./web-views.js";
|
|
13
|
+
import { editableConfigPath, parseWebSearchMode, runtimeConfig, sessionHref } from "./web-utils.js";
|
|
14
|
+
const DEFAULT_WEB_PORT = 8765;
|
|
15
|
+
const DEFAULT_WEB_HOST = "127.0.0.1";
|
|
16
|
+
const MAX_BODY_BYTES = 128 * 1024;
|
|
17
|
+
const CONTEXT_BEFORE = 8;
|
|
18
|
+
const CONTEXT_AFTER = 12;
|
|
19
|
+
const SESSION_PREVIEW_LIMIT = 200;
|
|
20
|
+
const SESSION_FULL_LIMIT = 1000;
|
|
21
|
+
export async function startWebServer(options = {}) {
|
|
22
|
+
const port = options.port ?? DEFAULT_WEB_PORT;
|
|
23
|
+
const state = {
|
|
24
|
+
allowedHosts: new Set(),
|
|
25
|
+
allowedOrigins: new Set(),
|
|
26
|
+
csrfToken: randomBytes(32).toString("base64url"),
|
|
27
|
+
embeddingJob: null
|
|
28
|
+
};
|
|
29
|
+
const server = http.createServer((request, response) => {
|
|
30
|
+
void handleRequest(request, response, options, state).catch((error) => {
|
|
31
|
+
sendError(response, 500, error instanceof Error ? error.message : String(error));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
server.once("error", reject);
|
|
36
|
+
server.listen(port, DEFAULT_WEB_HOST, () => {
|
|
37
|
+
server.off("error", reject);
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
const address = server.address();
|
|
42
|
+
const url = `http://${DEFAULT_WEB_HOST}:${address.port}/`;
|
|
43
|
+
for (const host of [`${DEFAULT_WEB_HOST}:${address.port}`, `localhost:${address.port}`]) {
|
|
44
|
+
state.allowedHosts.add(host);
|
|
45
|
+
state.allowedOrigins.add(`http://${host}`);
|
|
46
|
+
}
|
|
47
|
+
if (options.openBrowser) {
|
|
48
|
+
openBrowser(url);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
url,
|
|
52
|
+
server,
|
|
53
|
+
close: () => new Promise((resolve, reject) => {
|
|
54
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
55
|
+
server.closeAllConnections();
|
|
56
|
+
})
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function handleRequest(request, response, options, state) {
|
|
60
|
+
setSecurityHeaders(response);
|
|
61
|
+
if (!request.url || !request.method) {
|
|
62
|
+
sendError(response, 400, "Bad request");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!isAllowedHost(request, state)) {
|
|
66
|
+
sendError(response, 403, "Forbidden");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const url = new URL(request.url, "http://where-was-i.local");
|
|
70
|
+
if (request.method === "GET") {
|
|
71
|
+
await handleGet(url, response, options, state);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (request.method === "POST") {
|
|
75
|
+
if (!isAllowedOrigin(request, state)) {
|
|
76
|
+
sendError(response, 403, "Forbidden");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const body = await readRequestBody(request);
|
|
80
|
+
if (url.pathname === "/settings") {
|
|
81
|
+
handleSettingsPost(body, response, options, state);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (url.pathname === "/index") {
|
|
85
|
+
handleIndexPost(body, response, options, state);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (url.pathname === "/embeddings") {
|
|
89
|
+
await handleEmbeddingsPost(body, response, options, state);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
sendError(response, 405, "Method not allowed");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
sendError(response, 405, "Method not allowed");
|
|
96
|
+
}
|
|
97
|
+
async function handleGet(url, response, options, state) {
|
|
98
|
+
if (url.pathname === "/favicon.ico") {
|
|
99
|
+
response.writeHead(204);
|
|
100
|
+
response.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (url.pathname === "/app.js") {
|
|
104
|
+
sendJavaScript(response, WEB_CLIENT_SCRIPT);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (url.pathname === "/health") {
|
|
108
|
+
sendJson(response, { ok: true });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (url.pathname === "/") {
|
|
112
|
+
await sendHome(response, options, state, homeFlashFromParams(url.searchParams), embeddingJobForHome(state, url.searchParams.get("embeddingJob")));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname === "/search") {
|
|
116
|
+
await sendSearch(response, options, url.searchParams);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (url.pathname.startsWith("/embedding-jobs/")) {
|
|
120
|
+
await sendEmbeddingJob(response, options, state, url);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (url.pathname.startsWith("/sessions/")) {
|
|
124
|
+
sendSessionDetail(response, options, url);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname === "/settings") {
|
|
128
|
+
sendSettings(response, options, state, {
|
|
129
|
+
saved: url.searchParams.get("saved") === "1"
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
sendError(response, 404, "Not found");
|
|
134
|
+
}
|
|
135
|
+
async function sendHome(response, options, state, flash = null, embeddingJob = null) {
|
|
136
|
+
const config = runtimeConfig(options);
|
|
137
|
+
const status = await statusSnapshot(config);
|
|
138
|
+
sendHtml(response, renderHomePage(status, listRecentSessions(config.dbPath, 30), state.csrfToken, flash, embeddingJob));
|
|
139
|
+
}
|
|
140
|
+
async function sendSearch(response, options, params) {
|
|
141
|
+
const query = (params.get("q") ?? "").trim();
|
|
142
|
+
const requestedMode = parseWebSearchMode(params.get("mode") ?? "auto");
|
|
143
|
+
const config = runtimeConfig(options);
|
|
144
|
+
const state = query ? await searchForPage(config, query, requestedMode) : null;
|
|
145
|
+
sendHtml(response, renderSearchPage(query, requestedMode, state));
|
|
146
|
+
}
|
|
147
|
+
async function sendEmbeddingJob(response, options, state, url) {
|
|
148
|
+
const jobId = decodeURIComponent(url.pathname.slice("/embedding-jobs/".length));
|
|
149
|
+
if (!jobId) {
|
|
150
|
+
sendError(response, 404, "Embedding job not found");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const job = embeddingJobForHome(state, jobId);
|
|
154
|
+
const config = runtimeConfig(options);
|
|
155
|
+
if (!job) {
|
|
156
|
+
sendJson(response, renderEmbeddingPanelUpdate(await statusSnapshot(config), null));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const status = await statusSnapshot(config);
|
|
160
|
+
sendJson(response, renderEmbeddingPanelUpdate(status, job));
|
|
161
|
+
}
|
|
162
|
+
function sendSessionDetail(response, options, url) {
|
|
163
|
+
const sessionId = decodeURIComponent(url.pathname.slice("/sessions/".length));
|
|
164
|
+
if (!sessionId) {
|
|
165
|
+
sendError(response, 404, "Session not found");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const config = runtimeConfig(options);
|
|
169
|
+
const selectedMessageId = url.searchParams.get("messageId");
|
|
170
|
+
const query = url.searchParams.get("q") ?? "";
|
|
171
|
+
const mode = parseWebSearchMode(url.searchParams.get("mode") ?? "auto");
|
|
172
|
+
const full = url.searchParams.get("full") === "1";
|
|
173
|
+
const detail = sessionMessagesForDetail(config, sessionId, selectedMessageId, full);
|
|
174
|
+
if (detail.messages.length === 0) {
|
|
175
|
+
sendError(response, 404, "Session not found");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const firstMessage = detail.messages[0];
|
|
179
|
+
const referenceMessage = detail.selectedMessage ?? firstMessage;
|
|
180
|
+
const fullHref = selectedMessageId && !full && detail.selectedMessage
|
|
181
|
+
? sessionHref(sessionId, selectedMessageId, query, mode, true)
|
|
182
|
+
: null;
|
|
183
|
+
sendHtml(response, renderSessionPage({
|
|
184
|
+
sessionId,
|
|
185
|
+
messages: detail.messages,
|
|
186
|
+
selectedMessageId: detail.selectedMessage?.id ?? selectedMessageId,
|
|
187
|
+
query,
|
|
188
|
+
mode,
|
|
189
|
+
codexUrl: `codex://threads/${encodeURIComponent(referenceMessage.conversationId)}`,
|
|
190
|
+
sourcePath: referenceMessage.sourcePath,
|
|
191
|
+
backHref: query ? `/search?q=${encodeURIComponent(query)}&mode=${encodeURIComponent(mode)}` : "/",
|
|
192
|
+
contextNotice: detail.contextNotice,
|
|
193
|
+
fullHref
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function sendSettings(response, options, state, extra = {}) {
|
|
197
|
+
const model = {
|
|
198
|
+
...settingsViewModel(editableConfigPath(options), extra.saved ?? false),
|
|
199
|
+
...extra
|
|
200
|
+
};
|
|
201
|
+
sendHtml(response, renderSettingsPage(model, state.csrfToken));
|
|
202
|
+
}
|
|
203
|
+
function handleSettingsPost(body, response, options, state) {
|
|
204
|
+
const params = new URLSearchParams(body);
|
|
205
|
+
if (!isValidToken(params.get("settingsToken"), state.csrfToken)) {
|
|
206
|
+
sendError(response, 403, "Forbidden");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const result = saveSettings(editableConfigPath(options), params);
|
|
210
|
+
if (result.errors.length > 0) {
|
|
211
|
+
sendSettings(response, options, state, {
|
|
212
|
+
values: result.values,
|
|
213
|
+
errors: result.errors,
|
|
214
|
+
saved: false
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
response.writeHead(303, { location: "/settings?saved=1" });
|
|
219
|
+
response.end();
|
|
220
|
+
}
|
|
221
|
+
function handleIndexPost(body, response, options, state) {
|
|
222
|
+
const params = new URLSearchParams(body);
|
|
223
|
+
if (!isValidToken(params.get("actionToken"), state.csrfToken)) {
|
|
224
|
+
sendError(response, 403, "Forbidden");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const config = runtimeConfig(options);
|
|
228
|
+
const summary = indexCodexSessions({
|
|
229
|
+
dbPath: config.dbPath,
|
|
230
|
+
codexHome: config.codexHome
|
|
231
|
+
});
|
|
232
|
+
redirectHome(response, {
|
|
233
|
+
action: "index",
|
|
234
|
+
indexed: String(summary.indexed),
|
|
235
|
+
skipped: String(summary.skipped),
|
|
236
|
+
failed: String(summary.failed.length)
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function handleEmbeddingsPost(body, response, options, state) {
|
|
240
|
+
const params = new URLSearchParams(body);
|
|
241
|
+
if (!isValidToken(params.get("actionToken"), state.csrfToken)) {
|
|
242
|
+
sendError(response, 403, "Forbidden");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const config = runtimeConfig(options);
|
|
246
|
+
const embedding = mergeEmbeddingConfig(config);
|
|
247
|
+
const provider = new OpenAICompatibleEmbeddingProvider(embedding);
|
|
248
|
+
if (!isEmbeddingProviderConfigured(provider.config)) {
|
|
249
|
+
redirectHome(response, {
|
|
250
|
+
action: "embeddings",
|
|
251
|
+
available: "0",
|
|
252
|
+
failed: "Configure embeddings before indexing semantic vectors."
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (state.embeddingJob?.status === "running") {
|
|
257
|
+
redirectHome(response, {
|
|
258
|
+
embeddingJob: state.embeddingJob.id
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const job = {
|
|
263
|
+
id: randomBytes(12).toString("base64url"),
|
|
264
|
+
status: "running",
|
|
265
|
+
startedAt: new Date().toISOString(),
|
|
266
|
+
completedAt: null,
|
|
267
|
+
progress: null,
|
|
268
|
+
summary: null,
|
|
269
|
+
error: null
|
|
270
|
+
};
|
|
271
|
+
state.embeddingJob = job;
|
|
272
|
+
void runEmbeddingJob(job, config);
|
|
273
|
+
redirectHome(response, {
|
|
274
|
+
embeddingJob: job.id
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async function runEmbeddingJob(job, config) {
|
|
278
|
+
try {
|
|
279
|
+
const summary = await indexSemanticChunks({
|
|
280
|
+
dbPath: config.dbPath,
|
|
281
|
+
embedding: mergeEmbeddingConfig(config),
|
|
282
|
+
maxChunks: config.semantic.startupMaxChunks ?? undefined,
|
|
283
|
+
onProgress: (progress) => {
|
|
284
|
+
job.progress = progress;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
job.summary = summary;
|
|
288
|
+
job.status = summary.available ? "complete" : "failed";
|
|
289
|
+
job.error = summary.failed;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
job.status = "failed";
|
|
293
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
job.completedAt = new Date().toISOString();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function isAllowedHost(request, state) {
|
|
300
|
+
const host = request.headers.host?.toLowerCase();
|
|
301
|
+
return Boolean(host && state.allowedHosts.has(host));
|
|
302
|
+
}
|
|
303
|
+
function isAllowedOrigin(request, state) {
|
|
304
|
+
const origin = request.headers.origin;
|
|
305
|
+
return typeof origin !== "string" || state.allowedOrigins.has(origin);
|
|
306
|
+
}
|
|
307
|
+
function isValidToken(submitted, expected) {
|
|
308
|
+
if (!submitted || submitted.length !== expected.length) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
return timingSafeEqual(Buffer.from(submitted), Buffer.from(expected));
|
|
312
|
+
}
|
|
313
|
+
function redirectHome(response, params) {
|
|
314
|
+
const search = new URLSearchParams(params);
|
|
315
|
+
response.writeHead(303, { location: `/?${search.toString()}` });
|
|
316
|
+
response.end();
|
|
317
|
+
}
|
|
318
|
+
function homeFlashFromParams(params) {
|
|
319
|
+
const action = params.get("action");
|
|
320
|
+
if (action === "index") {
|
|
321
|
+
const indexed = params.get("indexed") ?? "0";
|
|
322
|
+
const skipped = params.get("skipped") ?? "0";
|
|
323
|
+
const failed = params.get("failed") ?? "0";
|
|
324
|
+
return {
|
|
325
|
+
kind: failed === "0" ? "success" : "alert",
|
|
326
|
+
message: `Index complete: ${indexed} indexed, ${skipped} skipped, ${failed} failed.`
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (action === "embeddings") {
|
|
330
|
+
const failed = params.get("failed") ?? "";
|
|
331
|
+
if (params.get("available") === "0") {
|
|
332
|
+
return {
|
|
333
|
+
kind: "alert",
|
|
334
|
+
message: failed || "Embedding provider is unavailable."
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const indexed = params.get("indexed") ?? "0";
|
|
338
|
+
return {
|
|
339
|
+
kind: "success",
|
|
340
|
+
message: indexed === "0" ? "Embeddings are already up to date." : `Embedded ${indexed} chunk${indexed === "1" ? "" : "s"}.`
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function embeddingJobForHome(state, requestedId) {
|
|
346
|
+
const job = state.embeddingJob;
|
|
347
|
+
if (!job) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
if (requestedId !== job.id) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const progress = job.progress;
|
|
354
|
+
return {
|
|
355
|
+
id: job.id,
|
|
356
|
+
status: job.status,
|
|
357
|
+
startedAt: job.startedAt,
|
|
358
|
+
completedAt: job.completedAt,
|
|
359
|
+
phase: progress?.phase ?? null,
|
|
360
|
+
providerId: progress?.providerId ?? job.summary?.providerId ?? null,
|
|
361
|
+
model: progress?.model ?? job.summary?.model ?? null,
|
|
362
|
+
indexed: progress?.indexed ?? job.summary?.indexed ?? 0,
|
|
363
|
+
total: progress?.total ?? null,
|
|
364
|
+
failed: job.error ?? job.summary?.failed ?? progress?.failed ?? null
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function sessionMessagesForDetail(config, sessionId, messageId, full) {
|
|
368
|
+
if (messageId && !full) {
|
|
369
|
+
const context = readMessageContext(config.dbPath, messageId, CONTEXT_BEFORE, CONTEXT_AFTER);
|
|
370
|
+
if (context.target?.sessionId === sessionId) {
|
|
371
|
+
return {
|
|
372
|
+
messages: [...context.before, context.target, ...context.after],
|
|
373
|
+
selectedMessage: context.target,
|
|
374
|
+
contextNotice: `Showing ${context.before.length} messages before and ${context.after.length} after the selected hit.`
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const limit = full ? SESSION_FULL_LIMIT : SESSION_PREVIEW_LIMIT;
|
|
379
|
+
const messages = readSession(config.dbPath, sessionId, limit);
|
|
380
|
+
return {
|
|
381
|
+
messages,
|
|
382
|
+
selectedMessage: messages.find((message) => message.id === messageId) ?? null,
|
|
383
|
+
contextNotice: !full && messages.length === SESSION_PREVIEW_LIMIT
|
|
384
|
+
? `Showing the first ${SESSION_PREVIEW_LIMIT} messages. Open from a search hit for focused context.`
|
|
385
|
+
: null
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function searchForPage(config, query, requestedMode) {
|
|
389
|
+
const embedding = mergeEmbeddingConfig(config);
|
|
390
|
+
const provider = new OpenAICompatibleEmbeddingProvider(embedding);
|
|
391
|
+
const semanticConfigured = isEmbeddingProviderConfigured(provider.config);
|
|
392
|
+
const selectedMode = await resolveConfiguredSearchMode({
|
|
393
|
+
dbPath: config.dbPath,
|
|
394
|
+
requestedMode,
|
|
395
|
+
embedding
|
|
396
|
+
});
|
|
397
|
+
try {
|
|
398
|
+
const results = await searchSessions({
|
|
399
|
+
dbPath: config.dbPath,
|
|
400
|
+
query,
|
|
401
|
+
limit: 25,
|
|
402
|
+
mode: selectedMode,
|
|
403
|
+
embedding
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
requestedMode,
|
|
407
|
+
selectedMode,
|
|
408
|
+
warning: requestedMode === "auto" && selectedMode === "fts" && semanticConfigured
|
|
409
|
+
? "Semantic search is not ready, so full-text search was used."
|
|
410
|
+
: null,
|
|
411
|
+
groups: groupSearchResultsBySession(results, 4)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (selectedMode === "fts") {
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
const results = await searchSessions({
|
|
419
|
+
dbPath: config.dbPath,
|
|
420
|
+
query,
|
|
421
|
+
limit: 25,
|
|
422
|
+
mode: "fts"
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
requestedMode,
|
|
426
|
+
selectedMode: "fts",
|
|
427
|
+
warning: `Semantic search failed, so full-text search was used. ${error instanceof Error ? error.message : String(error)}`,
|
|
428
|
+
groups: groupSearchResultsBySession(results, 4)
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function statusSnapshot(config) {
|
|
433
|
+
const stats = indexStats(config.dbPath);
|
|
434
|
+
const embedding = mergeEmbeddingConfig(config);
|
|
435
|
+
const provider = new OpenAICompatibleEmbeddingProvider(embedding);
|
|
436
|
+
const semanticConfigured = isEmbeddingProviderConfigured(provider.config);
|
|
437
|
+
const semantic = await semanticFreshness({
|
|
438
|
+
dbPath: config.dbPath,
|
|
439
|
+
embedding,
|
|
440
|
+
probeProvider: false
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
index: stats,
|
|
444
|
+
semanticConfigured,
|
|
445
|
+
semanticIndexedChunks: semantic.indexedChunks,
|
|
446
|
+
semanticTotalEligibleChunks: semantic.totalEligibleChunks,
|
|
447
|
+
semanticMissingChunks: semantic.missingChunks,
|
|
448
|
+
semanticLastIndexedAt: semantic.lastIndexedAt,
|
|
449
|
+
semanticIncompatibleStoredVectors: semantic.incompatibleStoredVectors,
|
|
450
|
+
recommendedMode: semanticConfigured && semantic.indexedChunks > 0 && semantic.incompatibleStoredVectors === 0 ? "hybrid" : "fts"
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function sendHtml(response, html) {
|
|
454
|
+
response.writeHead(200, {
|
|
455
|
+
"content-type": "text/html; charset=utf-8",
|
|
456
|
+
"cache-control": "no-store"
|
|
457
|
+
});
|
|
458
|
+
response.end(html);
|
|
459
|
+
}
|
|
460
|
+
function sendJson(response, value) {
|
|
461
|
+
response.writeHead(200, {
|
|
462
|
+
"content-type": "application/json; charset=utf-8",
|
|
463
|
+
"cache-control": "no-store"
|
|
464
|
+
});
|
|
465
|
+
response.end(JSON.stringify(value));
|
|
466
|
+
}
|
|
467
|
+
function sendJavaScript(response, source) {
|
|
468
|
+
response.writeHead(200, {
|
|
469
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
470
|
+
"cache-control": "no-store"
|
|
471
|
+
});
|
|
472
|
+
response.end(source);
|
|
473
|
+
}
|
|
474
|
+
function sendError(response, status, message) {
|
|
475
|
+
response.writeHead(status, {
|
|
476
|
+
"content-type": "text/plain; charset=utf-8",
|
|
477
|
+
"cache-control": "no-store"
|
|
478
|
+
});
|
|
479
|
+
response.end(message);
|
|
480
|
+
}
|
|
481
|
+
function setSecurityHeaders(response) {
|
|
482
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
483
|
+
response.setHeader("referrer-policy", "same-origin");
|
|
484
|
+
response.setHeader("cross-origin-resource-policy", "same-origin");
|
|
485
|
+
response.setHeader("content-security-policy", "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
486
|
+
}
|
|
487
|
+
function readRequestBody(request) {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
let total = 0;
|
|
490
|
+
const chunks = [];
|
|
491
|
+
request.on("data", (chunk) => {
|
|
492
|
+
total += chunk.length;
|
|
493
|
+
if (total > MAX_BODY_BYTES) {
|
|
494
|
+
reject(new Error("Request body too large."));
|
|
495
|
+
request.destroy();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
chunks.push(chunk);
|
|
499
|
+
});
|
|
500
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
501
|
+
request.on("error", reject);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
function openBrowser(url) {
|
|
505
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
506
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
507
|
+
const child = spawn(command, args, {
|
|
508
|
+
detached: true,
|
|
509
|
+
stdio: "ignore"
|
|
510
|
+
});
|
|
511
|
+
child.unref();
|
|
512
|
+
}
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kyleparrott/where-was-i",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Kyle Parrott",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"description": "Local-first CLI and MCP server for finding coding-agent sessions you almost remember.",
|
|
9
|
+
"homepage": "https://github.com/kyleparrott/where-was-i#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/kyleparrott/where-was-i.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/kyleparrott/where-was-i/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"codex",
|
|
19
|
+
"mcp",
|
|
20
|
+
"search",
|
|
21
|
+
"sqlite",
|
|
22
|
+
"fts",
|
|
23
|
+
"embeddings",
|
|
24
|
+
"where-was-i",
|
|
25
|
+
"wwi"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"where-was-i": "dist/src/cli.js",
|
|
29
|
+
"wwi": "dist/src/cli.js",
|
|
30
|
+
"where-was-i-mcp": "dist/src/mcp.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"test": "npm run clean && npm run build && node --test dist/tests/*.test.js",
|
|
36
|
+
"cli": "npm run build && node dist/src/cli.js",
|
|
37
|
+
"prepack": "npm run clean && npm run build"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist/src",
|
|
41
|
+
"docs/assets",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"os": [
|
|
46
|
+
"darwin",
|
|
47
|
+
"linux"
|
|
48
|
+
],
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
+
"better-sqlite3": "^12.9.0",
|
|
52
|
+
"commander": "^14.0.3",
|
|
53
|
+
"sqlite-vec": "^0.1.9",
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
58
|
+
"@types/node": "^25.0.0",
|
|
59
|
+
"typescript": "^6.0.3"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=22"
|
|
63
|
+
}
|
|
64
|
+
}
|