@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { discoverCodexSessionFiles, parseCodexSessionFile } from "./codex.js";
|
|
4
|
+
import { SessionSearchDb } from "./database.js";
|
|
5
|
+
export function indexCodexSessions(options) {
|
|
6
|
+
const db = new SessionSearchDb(options.dbPath);
|
|
7
|
+
try {
|
|
8
|
+
const cutoffMs = options.days ? Date.now() - options.days * 24 * 60 * 60 * 1000 : null;
|
|
9
|
+
const files = filterSessionFiles(discoverCodexSessionFiles(options.codexHome, options.includeArchived), options.sessionIds);
|
|
10
|
+
const summary = { discovered: files.length, indexed: 0, skipped: 0, failed: [] };
|
|
11
|
+
let candidates = files.map((file) => ({ file, stat: fs.statSync(file) }));
|
|
12
|
+
if (cutoffMs) {
|
|
13
|
+
const before = candidates.length;
|
|
14
|
+
candidates = candidates.filter(({ stat }) => stat.mtimeMs >= cutoffMs);
|
|
15
|
+
summary.skipped += before - candidates.length;
|
|
16
|
+
}
|
|
17
|
+
if (options.recent) {
|
|
18
|
+
const before = candidates.length;
|
|
19
|
+
candidates = candidates
|
|
20
|
+
.sort((left, right) => right.stat.mtimeMs - left.stat.mtimeMs || left.file.localeCompare(right.file))
|
|
21
|
+
.slice(0, options.recent);
|
|
22
|
+
summary.skipped += before - candidates.length;
|
|
23
|
+
}
|
|
24
|
+
for (const { file, stat } of candidates) {
|
|
25
|
+
const source = {
|
|
26
|
+
path: file,
|
|
27
|
+
source: "codex",
|
|
28
|
+
size: stat.size,
|
|
29
|
+
mtimeMs: Math.round(stat.mtimeMs)
|
|
30
|
+
};
|
|
31
|
+
const existing = db.getSource(file);
|
|
32
|
+
if (!options.force &&
|
|
33
|
+
existing &&
|
|
34
|
+
existing.size === source.size &&
|
|
35
|
+
existing.mtimeMs === source.mtimeMs &&
|
|
36
|
+
db.sourceHasMessages(file)) {
|
|
37
|
+
summary.skipped += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = parseCodexSessionFile(file, { includeTools: options.includeTools });
|
|
42
|
+
db.upsertParsedSession(parsed, source);
|
|
43
|
+
summary.indexed += 1;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
summary.failed.push({ path: file, error: error instanceof Error ? error.message : String(error) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return summary;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
db.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function filterSessionFiles(files, sessionIds) {
|
|
56
|
+
if (!sessionIds || sessionIds.length === 0) {
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
return files.filter((file) => sessionIds.some((sessionId) => path.basename(file).includes(sessionId)));
|
|
60
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function expandHome(input) {
|
|
4
|
+
if (input === "~") {
|
|
5
|
+
return os.homedir();
|
|
6
|
+
}
|
|
7
|
+
if (input.startsWith("~/")) {
|
|
8
|
+
return path.join(os.homedir(), input.slice(2));
|
|
9
|
+
}
|
|
10
|
+
return input;
|
|
11
|
+
}
|
|
12
|
+
export function defaultCodexHome() {
|
|
13
|
+
return process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : path.join(os.homedir(), ".codex");
|
|
14
|
+
}
|
|
15
|
+
export function defaultIndexPath() {
|
|
16
|
+
if (process.env.WHERE_WAS_I_INDEX_PATH) {
|
|
17
|
+
return expandHome(process.env.WHERE_WAS_I_INDEX_PATH);
|
|
18
|
+
}
|
|
19
|
+
return path.join(os.homedir(), ".where-was-i", "index.sqlite");
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export function resetIndexFiles(dbPath) {
|
|
3
|
+
const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`];
|
|
4
|
+
const summary = { deleted: [], missing: [] };
|
|
5
|
+
for (const target of paths) {
|
|
6
|
+
if (!fs.existsSync(target)) {
|
|
7
|
+
summary.missing.push(target);
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
const stat = fs.lstatSync(target);
|
|
11
|
+
if (stat.isDirectory()) {
|
|
12
|
+
throw new Error(`Refusing to delete directory while resetting index: ${target}`);
|
|
13
|
+
}
|
|
14
|
+
fs.unlinkSync(target);
|
|
15
|
+
summary.deleted.push(target);
|
|
16
|
+
}
|
|
17
|
+
return summary;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { OpenAICompatibleEmbeddingProvider, isEmbeddingProviderConfigured } from "./embeddings.js";
|
|
2
|
+
import { semanticFreshness } from "./semantic.js";
|
|
3
|
+
export async function resolveConfiguredSearchMode(options) {
|
|
4
|
+
if (options.requestedMode !== "auto") {
|
|
5
|
+
return options.requestedMode;
|
|
6
|
+
}
|
|
7
|
+
const provider = new OpenAICompatibleEmbeddingProvider(options.embedding);
|
|
8
|
+
if (!isEmbeddingProviderConfigured(provider.config)) {
|
|
9
|
+
return "fts";
|
|
10
|
+
}
|
|
11
|
+
const freshness = await semanticFreshness({
|
|
12
|
+
dbPath: options.dbPath,
|
|
13
|
+
embedding: options.embedding,
|
|
14
|
+
probeProvider: false
|
|
15
|
+
});
|
|
16
|
+
return freshness.indexedChunks > 0 && freshness.incompatibleStoredVectors === 0 ? "hybrid" : "fts";
|
|
17
|
+
}
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { SessionSearchDb } from "./database.js";
|
|
3
|
+
import { OpenAICompatibleEmbeddingProvider } from "./embeddings.js";
|
|
4
|
+
import { searchVectorChunks } from "./vector.js";
|
|
5
|
+
export async function searchSessions(options) {
|
|
6
|
+
const mode = options.mode ?? "fts";
|
|
7
|
+
if (mode === "semantic") {
|
|
8
|
+
return searchSemanticSessions(options);
|
|
9
|
+
}
|
|
10
|
+
if (mode === "hybrid") {
|
|
11
|
+
return searchHybridSessions(options);
|
|
12
|
+
}
|
|
13
|
+
return searchFtsSessions(options);
|
|
14
|
+
}
|
|
15
|
+
export function groupSearchResultsBySession(results, maxHitsPerSession = 3) {
|
|
16
|
+
const grouped = new Map();
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
const existing = grouped.get(result.sessionId);
|
|
19
|
+
if (existing) {
|
|
20
|
+
if (existing.hits.length < maxHitsPerSession && !hasDuplicatePreview(existing.hits, result)) {
|
|
21
|
+
existing.hits.push(result);
|
|
22
|
+
}
|
|
23
|
+
existing.score = Math.max(existing.score, result.scores.final);
|
|
24
|
+
if (isBetterHit(result, existing.bestHit)) {
|
|
25
|
+
existing.bestHit = result;
|
|
26
|
+
existing.bestMessage = resolvedLocatorFromSearchResult(result);
|
|
27
|
+
existing.match = result.match;
|
|
28
|
+
existing.session.displayTitle = displayTitleForResult(result);
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const bestMessage = resolvedLocatorFromSearchResult(result);
|
|
33
|
+
grouped.set(result.sessionId, {
|
|
34
|
+
session: {
|
|
35
|
+
id: result.sessionId,
|
|
36
|
+
conversationId: result.conversationId,
|
|
37
|
+
title: isBootstrapText(result.title ?? "") ? null : result.title,
|
|
38
|
+
displayTitle: displayTitleForResult(result),
|
|
39
|
+
cwd: result.cwd,
|
|
40
|
+
sourcePath: result.sessionSourcePath,
|
|
41
|
+
startedAt: result.sessionStartedAt,
|
|
42
|
+
updatedAt: result.sessionUpdatedAt ?? result.timestamp
|
|
43
|
+
},
|
|
44
|
+
bestMessage,
|
|
45
|
+
match: result.match,
|
|
46
|
+
bestHit: result,
|
|
47
|
+
hits: [result],
|
|
48
|
+
score: result.scores.final
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return Array.from(grouped.values()).sort((left, right) => right.score - left.score);
|
|
52
|
+
}
|
|
53
|
+
export function searchFtsSessions(options) {
|
|
54
|
+
const db = new SessionSearchDb(options.dbPath);
|
|
55
|
+
try {
|
|
56
|
+
const matchedTerms = contentQueryTerms(options.query);
|
|
57
|
+
const ftsQuery = toFtsQuery(matchedTerms);
|
|
58
|
+
const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
|
|
59
|
+
const rows = db.db
|
|
60
|
+
.prepare(`SELECT
|
|
61
|
+
c.id AS chunkId,
|
|
62
|
+
c.message_id AS messageId,
|
|
63
|
+
c.session_id AS sessionId,
|
|
64
|
+
c.conversation_id AS conversationId,
|
|
65
|
+
c.turn_id AS turnId,
|
|
66
|
+
c.source AS source,
|
|
67
|
+
c.source_path AS sourcePath,
|
|
68
|
+
c.ordinal AS ordinal,
|
|
69
|
+
c.chunk_index AS chunkIndex,
|
|
70
|
+
c.role AS role,
|
|
71
|
+
c.kind AS kind,
|
|
72
|
+
c.timestamp AS timestamp,
|
|
73
|
+
s.title AS title,
|
|
74
|
+
s.cwd AS cwd,
|
|
75
|
+
s.started_at AS sessionStartedAt,
|
|
76
|
+
s.updated_at AS sessionUpdatedAt,
|
|
77
|
+
s.source_path AS sessionSourcePath,
|
|
78
|
+
c.line_start AS lineStart,
|
|
79
|
+
c.line_end AS lineEnd,
|
|
80
|
+
bm25(chunks_fts) AS score,
|
|
81
|
+
snippet(chunks_fts, 3, '[', ']', '...', 14) AS snippet,
|
|
82
|
+
c.text AS text
|
|
83
|
+
FROM chunks_fts
|
|
84
|
+
JOIN chunks c ON c.id = chunks_fts.chunk_id
|
|
85
|
+
LEFT JOIN sessions s ON s.id = c.session_id
|
|
86
|
+
WHERE chunks_fts MATCH ?
|
|
87
|
+
ORDER BY score ASC
|
|
88
|
+
LIMIT ?`)
|
|
89
|
+
.all(ftsQuery, limit);
|
|
90
|
+
return rows.map((row, index) => {
|
|
91
|
+
const locator = locatorFromSearchRow(row);
|
|
92
|
+
const finalScore = 1 / (index + 1);
|
|
93
|
+
const result = {
|
|
94
|
+
...row,
|
|
95
|
+
score: finalScore,
|
|
96
|
+
preview: previewFromText(row.text),
|
|
97
|
+
locator,
|
|
98
|
+
links: linksForLocator(locator),
|
|
99
|
+
matchMode: "fts",
|
|
100
|
+
match: {
|
|
101
|
+
mode: "fts",
|
|
102
|
+
sources: ["fts"],
|
|
103
|
+
score: finalScore,
|
|
104
|
+
rank: index + 1,
|
|
105
|
+
ftsRank: index + 1,
|
|
106
|
+
ftsScore: row.score,
|
|
107
|
+
matchedTerms,
|
|
108
|
+
matchedRoles: [row.role]
|
|
109
|
+
},
|
|
110
|
+
scores: {
|
|
111
|
+
final: finalScore,
|
|
112
|
+
ftsRank: index + 1,
|
|
113
|
+
ftsScore: row.score
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
return options.includeText ? result : withoutText(result);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
db.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function searchSemanticSessions(options) {
|
|
124
|
+
const provider = new OpenAICompatibleEmbeddingProvider(options.embedding);
|
|
125
|
+
const embedding = await provider.embedQuery(options.query);
|
|
126
|
+
const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
|
|
127
|
+
const db = new SessionSearchDb(options.dbPath);
|
|
128
|
+
try {
|
|
129
|
+
const vectorRows = searchVectorChunks(db.db, provider.config, embedding, limit);
|
|
130
|
+
return hydrateVectorResults(db, vectorRows, "semantic", options.includeText);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function searchHybridSessions(options) {
|
|
137
|
+
const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
|
|
138
|
+
const ftsResults = searchFtsSessions({ ...options, limit: Math.max(limit, 25), includeText: true });
|
|
139
|
+
const semanticResults = await searchSemanticSessions({ ...options, limit: Math.max(limit, 25), includeText: true });
|
|
140
|
+
const merged = new Map();
|
|
141
|
+
for (const [index, result] of ftsResults.entries()) {
|
|
142
|
+
const finalScore = 1 / (index + 1);
|
|
143
|
+
merged.set(result.chunkId, {
|
|
144
|
+
...result,
|
|
145
|
+
matchMode: "hybrid",
|
|
146
|
+
score: finalScore,
|
|
147
|
+
match: {
|
|
148
|
+
...result.match,
|
|
149
|
+
mode: "hybrid",
|
|
150
|
+
sources: ["fts"],
|
|
151
|
+
score: finalScore,
|
|
152
|
+
rank: index + 1,
|
|
153
|
+
ftsRank: index + 1,
|
|
154
|
+
ftsScore: result.scores.ftsScore,
|
|
155
|
+
semanticRank: undefined,
|
|
156
|
+
vectorDistance: undefined
|
|
157
|
+
},
|
|
158
|
+
scores: {
|
|
159
|
+
final: finalScore,
|
|
160
|
+
ftsRank: index + 1,
|
|
161
|
+
ftsScore: result.scores.ftsScore
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
for (const [index, result] of semanticResults.entries()) {
|
|
166
|
+
const vectorScore = 1 / (index + 1);
|
|
167
|
+
const existing = merged.get(result.chunkId);
|
|
168
|
+
if (existing) {
|
|
169
|
+
existing.scores.vectorRank = index + 1;
|
|
170
|
+
existing.scores.vectorDistance = result.scores.vectorDistance;
|
|
171
|
+
existing.scores.final += vectorScore;
|
|
172
|
+
existing.score = existing.scores.final;
|
|
173
|
+
existing.match = {
|
|
174
|
+
...existing.match,
|
|
175
|
+
sources: mergeSources(existing.match.sources, ["semantic"]),
|
|
176
|
+
score: existing.scores.final,
|
|
177
|
+
semanticRank: index + 1,
|
|
178
|
+
vectorDistance: result.scores.vectorDistance,
|
|
179
|
+
matchedRoles: mergeRoles(existing.match.matchedRoles, [result.role])
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
merged.set(result.chunkId, {
|
|
184
|
+
...result,
|
|
185
|
+
matchMode: "hybrid",
|
|
186
|
+
score: vectorScore,
|
|
187
|
+
match: {
|
|
188
|
+
...result.match,
|
|
189
|
+
mode: "hybrid",
|
|
190
|
+
sources: ["semantic"],
|
|
191
|
+
score: vectorScore,
|
|
192
|
+
rank: index + 1,
|
|
193
|
+
semanticRank: index + 1
|
|
194
|
+
},
|
|
195
|
+
scores: {
|
|
196
|
+
final: vectorScore,
|
|
197
|
+
vectorRank: index + 1,
|
|
198
|
+
vectorDistance: result.scores.vectorDistance
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return Array.from(merged.values())
|
|
204
|
+
.sort((left, right) => right.scores.final - left.scores.final)
|
|
205
|
+
.slice(0, limit)
|
|
206
|
+
.map((result) => (options.includeText ? result : withoutText(result)));
|
|
207
|
+
}
|
|
208
|
+
function hydrateVectorResults(db, vectorRows, mode, includeText = false) {
|
|
209
|
+
if (vectorRows.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
const byChunkId = new Map(vectorRows.map((row, index) => [row.chunkId, { ...row, rank: index + 1 }]));
|
|
213
|
+
const placeholders = vectorRows.map(() => "?").join(", ");
|
|
214
|
+
const rows = db.db
|
|
215
|
+
.prepare(`SELECT
|
|
216
|
+
c.id AS chunkId,
|
|
217
|
+
c.message_id AS messageId,
|
|
218
|
+
c.session_id AS sessionId,
|
|
219
|
+
c.conversation_id AS conversationId,
|
|
220
|
+
c.turn_id AS turnId,
|
|
221
|
+
c.source AS source,
|
|
222
|
+
c.source_path AS sourcePath,
|
|
223
|
+
c.ordinal AS ordinal,
|
|
224
|
+
c.chunk_index AS chunkIndex,
|
|
225
|
+
c.role AS role,
|
|
226
|
+
c.kind AS kind,
|
|
227
|
+
c.timestamp AS timestamp,
|
|
228
|
+
s.title AS title,
|
|
229
|
+
s.cwd AS cwd,
|
|
230
|
+
s.started_at AS sessionStartedAt,
|
|
231
|
+
s.updated_at AS sessionUpdatedAt,
|
|
232
|
+
s.source_path AS sessionSourcePath,
|
|
233
|
+
c.line_start AS lineStart,
|
|
234
|
+
c.line_end AS lineEnd,
|
|
235
|
+
c.text AS text
|
|
236
|
+
FROM chunks c
|
|
237
|
+
LEFT JOIN sessions s ON s.id = c.session_id
|
|
238
|
+
WHERE c.id IN (${placeholders})`)
|
|
239
|
+
.all(...vectorRows.map((row) => row.chunkId));
|
|
240
|
+
return rows
|
|
241
|
+
.map((row) => {
|
|
242
|
+
const vector = byChunkId.get(row.chunkId);
|
|
243
|
+
if (!vector) {
|
|
244
|
+
throw new Error(`Missing vector row for chunk ${row.chunkId}.`);
|
|
245
|
+
}
|
|
246
|
+
const vectorRankScore = 1 / vector.rank;
|
|
247
|
+
const locator = locatorFromSearchRow(row);
|
|
248
|
+
const result = {
|
|
249
|
+
...row,
|
|
250
|
+
score: vectorRankScore,
|
|
251
|
+
snippet: snippetFromText(row.text),
|
|
252
|
+
preview: previewFromText(row.text),
|
|
253
|
+
locator,
|
|
254
|
+
links: linksForLocator(locator),
|
|
255
|
+
matchMode: mode,
|
|
256
|
+
match: {
|
|
257
|
+
mode,
|
|
258
|
+
sources: ["semantic"],
|
|
259
|
+
score: vectorRankScore,
|
|
260
|
+
rank: vector.rank,
|
|
261
|
+
semanticRank: vector.rank,
|
|
262
|
+
vectorDistance: vector.distance,
|
|
263
|
+
matchedTerms: [],
|
|
264
|
+
matchedRoles: [row.role]
|
|
265
|
+
},
|
|
266
|
+
scores: {
|
|
267
|
+
final: vectorRankScore,
|
|
268
|
+
vectorRank: vector.rank,
|
|
269
|
+
vectorDistance: vector.distance
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return includeText ? result : withoutText(result);
|
|
273
|
+
})
|
|
274
|
+
.sort((left, right) => (left.scores.vectorRank ?? 0) - (right.scores.vectorRank ?? 0));
|
|
275
|
+
}
|
|
276
|
+
export function readSession(dbPath, sessionId, limit, offset) {
|
|
277
|
+
const db = new SessionSearchDb(dbPath);
|
|
278
|
+
try {
|
|
279
|
+
return db.listSessionMessages(sessionId, limit, offset);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
db.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
export function listRecentSessions(dbPath, limit, offset) {
|
|
286
|
+
const db = new SessionSearchDb(dbPath);
|
|
287
|
+
try {
|
|
288
|
+
return db.listRecentSessions(limit, offset).map((session) => ({
|
|
289
|
+
id: session.id,
|
|
290
|
+
conversationId: session.conversationId,
|
|
291
|
+
title: isBootstrapText(session.title ?? "") ? null : session.title,
|
|
292
|
+
displayTitle: displayTitleForSession(session.title, session.firstUserText),
|
|
293
|
+
cwd: session.cwd,
|
|
294
|
+
sourcePath: session.sourcePath,
|
|
295
|
+
startedAt: session.startedAt,
|
|
296
|
+
updatedAt: session.updatedAt,
|
|
297
|
+
messageCount: session.messageCount,
|
|
298
|
+
turnCount: session.turnCount,
|
|
299
|
+
links: linksForConversationId(session.conversationId, session.sourcePath)
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
db.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export function readMessageContext(dbPath, messageId, before, after) {
|
|
307
|
+
const db = new SessionSearchDb(dbPath);
|
|
308
|
+
try {
|
|
309
|
+
return db.listMessagesAround(messageId, before, after);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
db.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export function resolveMessageLocator(dbPath, messageId) {
|
|
316
|
+
const db = new SessionSearchDb(dbPath);
|
|
317
|
+
try {
|
|
318
|
+
const message = db.getMessage(messageId);
|
|
319
|
+
return message ? resolvedLocatorFromMessage(message) : null;
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
db.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export function readTurn(dbPath, turnId, limit) {
|
|
326
|
+
const db = new SessionSearchDb(dbPath);
|
|
327
|
+
try {
|
|
328
|
+
return db.listTurnMessages(turnId, limit);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
db.close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
export function indexStats(dbPath) {
|
|
335
|
+
if (!fs.existsSync(dbPath)) {
|
|
336
|
+
return emptyIndexStats();
|
|
337
|
+
}
|
|
338
|
+
const db = new SessionSearchDb(dbPath);
|
|
339
|
+
try {
|
|
340
|
+
return db.stats();
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
db.close();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function emptyIndexStats() {
|
|
347
|
+
return {
|
|
348
|
+
sourceFiles: 0,
|
|
349
|
+
sessions: 0,
|
|
350
|
+
turns: 0,
|
|
351
|
+
messages: 0,
|
|
352
|
+
chunks: 0,
|
|
353
|
+
vectors: 0,
|
|
354
|
+
embeddingProviders: 0,
|
|
355
|
+
schemaVersion: 0,
|
|
356
|
+
lastIndexedAt: null
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const QUERY_STOPWORDS = new Set([
|
|
360
|
+
"a",
|
|
361
|
+
"about",
|
|
362
|
+
"again",
|
|
363
|
+
"an",
|
|
364
|
+
"and",
|
|
365
|
+
"any",
|
|
366
|
+
"can",
|
|
367
|
+
"could",
|
|
368
|
+
"find",
|
|
369
|
+
"for",
|
|
370
|
+
"from",
|
|
371
|
+
"had",
|
|
372
|
+
"i",
|
|
373
|
+
"in",
|
|
374
|
+
"last",
|
|
375
|
+
"look",
|
|
376
|
+
"looking",
|
|
377
|
+
"me",
|
|
378
|
+
"of",
|
|
379
|
+
"on",
|
|
380
|
+
"or",
|
|
381
|
+
"session",
|
|
382
|
+
"sessions",
|
|
383
|
+
"some",
|
|
384
|
+
"that",
|
|
385
|
+
"the",
|
|
386
|
+
"this",
|
|
387
|
+
"to",
|
|
388
|
+
"talk",
|
|
389
|
+
"talked",
|
|
390
|
+
"talking",
|
|
391
|
+
"we",
|
|
392
|
+
"week",
|
|
393
|
+
"where",
|
|
394
|
+
"with",
|
|
395
|
+
"you"
|
|
396
|
+
]);
|
|
397
|
+
function contentQueryTerms(query) {
|
|
398
|
+
const tokens = uniqueTokens(Array.from(query.matchAll(/[\p{L}\p{N}_]+/gu), (match) => match[0].toLowerCase()));
|
|
399
|
+
if (tokens.length === 0) {
|
|
400
|
+
throw new Error("Search query must contain at least one word or number.");
|
|
401
|
+
}
|
|
402
|
+
const contentTerms = tokens.filter((token) => !QUERY_STOPWORDS.has(token));
|
|
403
|
+
return contentTerms.length > 0 ? contentTerms : tokens;
|
|
404
|
+
}
|
|
405
|
+
function toFtsQuery(tokens) {
|
|
406
|
+
return tokens.map((token) => ftsTerm(token)).join(" AND ");
|
|
407
|
+
}
|
|
408
|
+
function ftsTerm(token) {
|
|
409
|
+
const variants = new Set([token]);
|
|
410
|
+
if (token.endsWith("s") && token.length > 3) {
|
|
411
|
+
variants.add(token.slice(0, -1));
|
|
412
|
+
}
|
|
413
|
+
if (variants.size === 1) {
|
|
414
|
+
return `${token}*`;
|
|
415
|
+
}
|
|
416
|
+
return `(${Array.from(variants, (variant) => `${variant}*`).join(" OR ")})`;
|
|
417
|
+
}
|
|
418
|
+
function uniqueTokens(tokens) {
|
|
419
|
+
return Array.from(new Set(tokens));
|
|
420
|
+
}
|
|
421
|
+
function withoutText(row) {
|
|
422
|
+
const { text: _text, ...rest } = row;
|
|
423
|
+
return rest;
|
|
424
|
+
}
|
|
425
|
+
function snippetFromText(text) {
|
|
426
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
427
|
+
}
|
|
428
|
+
function previewFromText(text) {
|
|
429
|
+
return snippetFromText(text);
|
|
430
|
+
}
|
|
431
|
+
function locatorFromSearchRow(row) {
|
|
432
|
+
return {
|
|
433
|
+
source: row.source,
|
|
434
|
+
sourcePath: row.sourcePath,
|
|
435
|
+
lineStart: row.lineStart,
|
|
436
|
+
lineEnd: row.lineEnd,
|
|
437
|
+
sessionId: row.sessionId,
|
|
438
|
+
conversationId: row.conversationId,
|
|
439
|
+
turnId: row.turnId,
|
|
440
|
+
messageId: row.messageId,
|
|
441
|
+
role: row.role,
|
|
442
|
+
timestamp: row.timestamp
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function locatorFromMessage(message) {
|
|
446
|
+
return {
|
|
447
|
+
source: message.source,
|
|
448
|
+
sourcePath: message.sourcePath,
|
|
449
|
+
lineStart: message.lineStart,
|
|
450
|
+
lineEnd: message.lineEnd,
|
|
451
|
+
sessionId: message.sessionId,
|
|
452
|
+
conversationId: message.conversationId,
|
|
453
|
+
turnId: message.turnId,
|
|
454
|
+
messageId: message.id,
|
|
455
|
+
role: message.role,
|
|
456
|
+
timestamp: message.timestamp
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function linksForLocator(locator) {
|
|
460
|
+
return linksForConversationId(locator.source === "codex" ? locator.conversationId : null, `${locator.sourcePath}:${locator.lineStart}`);
|
|
461
|
+
}
|
|
462
|
+
export function linksForConversationId(conversationId, source) {
|
|
463
|
+
const codex = conversationId ? codexThreadLink(conversationId) : null;
|
|
464
|
+
return {
|
|
465
|
+
codex,
|
|
466
|
+
openCommand: codex ? openCommandForUri(codex) : null,
|
|
467
|
+
source
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
export function codexThreadLink(conversationId) {
|
|
471
|
+
return `codex://threads/${encodeURIComponent(conversationId)}`;
|
|
472
|
+
}
|
|
473
|
+
function shellQuote(value) {
|
|
474
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
475
|
+
}
|
|
476
|
+
export function openCommandForUri(uri) {
|
|
477
|
+
if (process.platform === "win32") {
|
|
478
|
+
return `start "" ${windowsQuote(uri)}`;
|
|
479
|
+
}
|
|
480
|
+
if (process.platform === "darwin") {
|
|
481
|
+
return `open ${shellQuote(uri)}`;
|
|
482
|
+
}
|
|
483
|
+
return `xdg-open ${shellQuote(uri)}`;
|
|
484
|
+
}
|
|
485
|
+
function windowsQuote(value) {
|
|
486
|
+
return `"${value.replaceAll('"', '\\"')}"`;
|
|
487
|
+
}
|
|
488
|
+
function resolvedLocatorFromSearchResult(result) {
|
|
489
|
+
return {
|
|
490
|
+
messageId: result.messageId,
|
|
491
|
+
sessionId: result.sessionId,
|
|
492
|
+
conversationId: result.conversationId,
|
|
493
|
+
turnId: result.turnId,
|
|
494
|
+
role: result.role,
|
|
495
|
+
timestamp: result.timestamp,
|
|
496
|
+
preview: result.preview,
|
|
497
|
+
locator: result.locator,
|
|
498
|
+
links: result.links
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function resolvedLocatorFromMessage(message) {
|
|
502
|
+
const locator = locatorFromMessage(message);
|
|
503
|
+
return {
|
|
504
|
+
messageId: message.id,
|
|
505
|
+
sessionId: message.sessionId,
|
|
506
|
+
conversationId: message.conversationId,
|
|
507
|
+
turnId: message.turnId,
|
|
508
|
+
role: message.role,
|
|
509
|
+
timestamp: message.timestamp,
|
|
510
|
+
preview: previewFromText(message.text),
|
|
511
|
+
locator,
|
|
512
|
+
links: linksForLocator(locator)
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function displayTitleForResult(result) {
|
|
516
|
+
if (!isBootstrapText(result.preview)) {
|
|
517
|
+
return result.preview;
|
|
518
|
+
}
|
|
519
|
+
if (result.title && !isBootstrapText(result.title)) {
|
|
520
|
+
return result.title;
|
|
521
|
+
}
|
|
522
|
+
return "(untitled session)";
|
|
523
|
+
}
|
|
524
|
+
function displayTitleForSession(title, firstUserText) {
|
|
525
|
+
if (title && !isBootstrapText(title)) {
|
|
526
|
+
return previewFromText(title);
|
|
527
|
+
}
|
|
528
|
+
if (firstUserText && !isBootstrapText(firstUserText)) {
|
|
529
|
+
return previewFromText(firstUserText);
|
|
530
|
+
}
|
|
531
|
+
return "(untitled session)";
|
|
532
|
+
}
|
|
533
|
+
function hasDuplicatePreview(results, candidate) {
|
|
534
|
+
const normalized = normalizePreview(candidate.preview);
|
|
535
|
+
return results.some((result) => normalizePreview(result.preview) === normalized);
|
|
536
|
+
}
|
|
537
|
+
function normalizePreview(value) {
|
|
538
|
+
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
|
539
|
+
}
|
|
540
|
+
function isBetterHit(candidate, current) {
|
|
541
|
+
const scoreDelta = candidate.scores.final - current.scores.final;
|
|
542
|
+
if (Math.abs(scoreDelta) > Number.EPSILON) {
|
|
543
|
+
return scoreDelta > 0;
|
|
544
|
+
}
|
|
545
|
+
if (isBootstrapText(current.preview) !== isBootstrapText(candidate.preview)) {
|
|
546
|
+
return !isBootstrapText(candidate.preview);
|
|
547
|
+
}
|
|
548
|
+
if (current.role !== "user" && candidate.role === "user") {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
return candidate.timestamp !== null && current.timestamp !== null && candidate.timestamp < current.timestamp;
|
|
552
|
+
}
|
|
553
|
+
function isBootstrapText(text) {
|
|
554
|
+
const normalized = text.trim();
|
|
555
|
+
return normalized.startsWith("# AGENTS.md instructions") || normalized.startsWith("<INSTRUCTIONS>");
|
|
556
|
+
}
|
|
557
|
+
function mergeSources(existing, next) {
|
|
558
|
+
return Array.from(new Set([...existing, ...next]));
|
|
559
|
+
}
|
|
560
|
+
function mergeRoles(existing, next) {
|
|
561
|
+
return Array.from(new Set([...existing, ...next]));
|
|
562
|
+
}
|