threadshelf 1.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +763 -0
  4. package/SECURITY.md +75 -0
  5. package/bin/threadshelf-mcp.js +12 -0
  6. package/bin/threadshelf.js +87 -0
  7. package/dist/mcp/server.js +388 -0
  8. package/dist/src/chunking.js +72 -0
  9. package/dist/src/cli.js +24 -0
  10. package/dist/src/embedding.js +59 -0
  11. package/dist/src/env.js +2 -0
  12. package/dist/src/generation/config.js +344 -0
  13. package/dist/src/generation/downloader.js +172 -0
  14. package/dist/src/generation/error-log.js +34 -0
  15. package/dist/src/generation/filesystem-browser.js +83 -0
  16. package/dist/src/generation/gguf-metadata.js +179 -0
  17. package/dist/src/generation/hardware.js +87 -0
  18. package/dist/src/generation/llama-install.js +563 -0
  19. package/dist/src/generation/llama-process.js +576 -0
  20. package/dist/src/generation/llama-profile.js +136 -0
  21. package/dist/src/generation/master-prompts.js +155 -0
  22. package/dist/src/generation/model-catalog.js +276 -0
  23. package/dist/src/generation/model-discovery.js +60 -0
  24. package/dist/src/generation/model-download.js +151 -0
  25. package/dist/src/generation/openai-compatible.js +231 -0
  26. package/dist/src/generation/providers/llama-cpp.js +97 -0
  27. package/dist/src/generation/providers/openrouter.js +106 -0
  28. package/dist/src/generation/quick-setup.js +215 -0
  29. package/dist/src/generation/registry.js +23 -0
  30. package/dist/src/generation/service.js +100 -0
  31. package/dist/src/generation/threads.js +311 -0
  32. package/dist/src/generation/types.js +1 -0
  33. package/dist/src/ingest-cli.js +95 -0
  34. package/dist/src/ingest.js +257 -0
  35. package/dist/src/load-env.js +17 -0
  36. package/dist/src/model-label.js +15 -0
  37. package/dist/src/parser.js +811 -0
  38. package/dist/src/paths.js +79 -0
  39. package/dist/src/routes/collections.js +97 -0
  40. package/dist/src/routes/files.js +136 -0
  41. package/dist/src/routes/generation.js +536 -0
  42. package/dist/src/routes/health.js +6 -0
  43. package/dist/src/routes/index.js +21 -0
  44. package/dist/src/routes/ingest.js +300 -0
  45. package/dist/src/routes/insights.js +24 -0
  46. package/dist/src/routes/loopback.js +15 -0
  47. package/dist/src/routes/model-catalog.js +178 -0
  48. package/dist/src/routes/search.js +57 -0
  49. package/dist/src/routes/stream-abort.js +23 -0
  50. package/dist/src/routes/thread.js +43 -0
  51. package/dist/src/search-cli.js +93 -0
  52. package/dist/src/server.js +78 -0
  53. package/dist/src/services/collections.js +58 -0
  54. package/dist/src/services/insights.js +111 -0
  55. package/dist/src/services/search.js +68 -0
  56. package/dist/src/services/stats.js +35 -0
  57. package/dist/src/services/thread.js +140 -0
  58. package/dist/src/store.js +1138 -0
  59. package/dist/src/validation.js +250 -0
  60. package/dist/src/watch.js +83 -0
  61. package/package.json +103 -0
  62. package/public/assets/index-CIm_Idqi.js +38 -0
  63. package/public/assets/index-Dv09K2vS.css +1 -0
  64. package/public/favicon.svg +6 -0
  65. package/public/index.html +28 -0
  66. package/scripts/openrouter-export-all.js +228 -0
  67. package/scripts/openrouter-export-browser.js +153 -0
@@ -0,0 +1,1138 @@
1
+ import { connect } from '@lancedb/lancedb';
2
+ import { dataPath } from './paths.js';
3
+ import { embed, embedOne } from './embedding.js';
4
+ import { chunkTurns, isIndexableText } from './chunking.js';
5
+ import { validateTurns } from './validation.js';
6
+ import { createHash } from 'node:crypto';
7
+ import { portableModelLabel } from './model-label.js';
8
+ const DB_PATH = process.env.LANCEDB_PATH || dataPath('lancedb');
9
+ const EMBED_BATCH_SIZE = Math.max(1, Number(process.env.EMBED_BATCH_SIZE) || 25);
10
+ let db = null;
11
+ const tableCache = new Map();
12
+ const collectionWriteLocks = new Map();
13
+ const COLLECTION_STATS_CACHE_MS = 5_000;
14
+ const collectionStatsCache = new Map();
15
+ const invalidateCollectionStats = (collection) => {
16
+ collectionStatsCache.delete(collection);
17
+ };
18
+ const getDb = async () => {
19
+ if (!db) {
20
+ try {
21
+ // Server, MCP and CLI share one database. Cached table handles must see
22
+ // each other's commits (pending index jobs, retry times, deletions).
23
+ db = await connect(DB_PATH, { readConsistencyInterval: 0 });
24
+ }
25
+ catch (e) {
26
+ const err = new Error(e?.message || 'LanceDB connect failed');
27
+ err.cause = e;
28
+ throw err;
29
+ }
30
+ }
31
+ return db;
32
+ };
33
+ const openTable = async (collection) => {
34
+ const cached = tableCache.get(collection);
35
+ if (cached)
36
+ return cached;
37
+ const database = await getDb();
38
+ const names = await database.tableNames();
39
+ if (!names.includes(collection))
40
+ return null;
41
+ const opened = await database.openTable(collection);
42
+ tableCache.set(collection, opened);
43
+ return opened;
44
+ };
45
+ const escapeSqlString = (value) => value.replace(/'/g, "''");
46
+ const ensureChunkMetadataSchema = async (tbl) => {
47
+ const schema = await tbl.schema();
48
+ const existing = new Set(schema.fields.map((field) => field.name));
49
+ const missing = [
50
+ ...['provider', 'conversationKey', 'title', 'model', 'createdAt', 'generationProvider'].map((name) => ({ name, valueSql: "''" })),
51
+ { name: 'createdInThreadShelf', valueSql: 'false' },
52
+ ].filter((column) => !existing.has(column.name));
53
+ if (missing.length)
54
+ await tbl.addColumns(missing);
55
+ };
56
+ // A merge is one LanceDB commit: an insertion failure cannot expose a delete.
57
+ const replaceEmbeddedRowsLocked = async (collection, where, rows) => {
58
+ const tbl = await openTable(collection);
59
+ if (tbl) {
60
+ await ensureChunkMetadataSchema(tbl);
61
+ if (!rows.length)
62
+ await tbl.delete(where);
63
+ else
64
+ await tbl
65
+ .mergeInsert('id')
66
+ .whenMatchedUpdateAll()
67
+ .whenNotMatchedInsertAll()
68
+ .whenNotMatchedBySourceDelete({ where })
69
+ .execute(rows);
70
+ }
71
+ else if (rows.length) {
72
+ const created = await (await getDb()).createTable(collection, rows, { mode: 'create' });
73
+ tableCache.set(collection, created);
74
+ }
75
+ invalidateCollectionStats(collection);
76
+ };
77
+ const embedChunks = async (chunks, signal, onProgress) => {
78
+ const tokens = chunks.reduce((sum, chunk) => sum + Math.ceil(chunk.text.length / 4), 0);
79
+ await onProgress?.(0, chunks.length, tokens);
80
+ if (chunks.length === 0)
81
+ return [];
82
+ const rows = [];
83
+ for (let i = 0; i < chunks.length; i += EMBED_BATCH_SIZE) {
84
+ signal?.throwIfAborted();
85
+ const batch = chunks.slice(i, i + EMBED_BATCH_SIZE);
86
+ const texts = batch.map((ch) => ch.text);
87
+ const embeddings = await embed(texts);
88
+ signal?.throwIfAborted();
89
+ rows.push(...batch.map((ch, j) => ({
90
+ id: ch.id,
91
+ vector: embeddings[j],
92
+ document: ch.text,
93
+ sourceFile: ch.sourceFile,
94
+ provider: ch.provider,
95
+ conversationKey: ch.conversationKey ?? '',
96
+ title: ch.title ?? '',
97
+ role: ch.role,
98
+ turnIndex: String(ch.turnIndex),
99
+ model: ch.model ?? '',
100
+ createdAt: ch.createdAt ?? '',
101
+ createdInThreadShelf: ch.createdInThreadShelf ?? false,
102
+ generationProvider: ch.generationProvider ?? '',
103
+ })));
104
+ await onProgress?.(rows.length, chunks.length, tokens);
105
+ }
106
+ return rows;
107
+ };
108
+ // --- Stored threads ---
109
+ //
110
+ // Normalized conversation turns are persisted at ingest time in a single
111
+ // internal table so the thread view no longer depends on the original export
112
+ // file staying in place (or unchanged — LM Studio rewrites its files). User
113
+ // collection names can never start with an underscore (normalizeCollectionName
114
+ // strips them), so the "__" prefix is reserved for internal tables.
115
+ const THREADS_TABLE = '__threads';
116
+ export class StoredThreadWriteError extends Error {
117
+ constructor(message = 'Stored thread changed or disappeared before it could be saved') {
118
+ super(message);
119
+ this.name = 'StoredThreadWriteError';
120
+ }
121
+ }
122
+ const storedThreadRow = (row) => ({
123
+ collection: row.collection ?? '',
124
+ sourceFile: row.sourceFile ?? '',
125
+ conversationKey: row.conversationKey ?? '',
126
+ title: row.title ?? '',
127
+ provider: row.provider ?? '',
128
+ ordinal: Number(row.ordinal) || 0,
129
+ turnCount: Number(row.turnCount) || 0,
130
+ turnsJson: row.turnsJson ?? '',
131
+ ingestedAt: row.ingestedAt ?? '',
132
+ lastTurnAt: row.lastTurnAt ?? '',
133
+ lastModel: row.lastModel ?? '',
134
+ createdInThreadShelf: Boolean(row.createdInThreadShelf),
135
+ threadCreatedAt: row.threadCreatedAt ?? '',
136
+ hasThreadShelfTurns: Boolean(row.hasThreadShelfTurns || row.createdInThreadShelf),
137
+ indexPending: String(row.indexPending || ''),
138
+ });
139
+ // Latest turn timestamp in a conversation — powers "sort by recent" in the
140
+ // browse list without re-reading turnsJson at query time.
141
+ const latestTurnTimestamp = (turns) => {
142
+ let latest = '';
143
+ for (const turn of turns) {
144
+ const createdAt = turn?.createdAt;
145
+ if (typeof createdAt === 'string' && createdAt > latest)
146
+ latest = createdAt;
147
+ }
148
+ return latest;
149
+ };
150
+ const latestTurnModel = (turns) => {
151
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
152
+ const model = turns[index]?.model;
153
+ if (typeof model === 'string' && model.trim())
154
+ return model.trim();
155
+ }
156
+ return '';
157
+ };
158
+ let threadsSchemaMigration = null;
159
+ // Schema upgrades on the request path must remain additive and bounded. In
160
+ // particular, do not backfill metadata by materializing turnsJson for every
161
+ // archived conversation: large databases would make the first request appear
162
+ // empty or hung. Existing rows use their normal ingestedAt fallbacks and gain
163
+ // the metadata naturally the next time they are written.
164
+ const migrateThreadsSchema = async (tbl) => {
165
+ let schema = await tbl.schema();
166
+ if (!schema.fields.some((field) => field.name === 'lastTurnAt')) {
167
+ try {
168
+ await tbl.addColumns([{ name: 'lastTurnAt', valueSql: "''" }]);
169
+ }
170
+ catch (e) {
171
+ // Another process may have added the column concurrently.
172
+ const refreshed = await tbl.schema();
173
+ if (!refreshed.fields.some((field) => field.name === 'lastTurnAt'))
174
+ throw e;
175
+ }
176
+ }
177
+ schema = await tbl.schema();
178
+ const metadataColumns = [
179
+ { name: 'createdInThreadShelf', valueSql: 'false' },
180
+ { name: 'threadCreatedAt', valueSql: "''" },
181
+ { name: 'hasThreadShelfTurns', valueSql: 'false' },
182
+ { name: 'lastModel', valueSql: "''" },
183
+ { name: 'indexPending', valueSql: "''" },
184
+ { name: 'indexAttempts', valueSql: '0.0' },
185
+ { name: 'indexRetryAt', valueSql: '0.0' },
186
+ { name: 'indexError', valueSql: "''" },
187
+ ].filter((column) => !schema.fields.some((field) => field.name === column.name));
188
+ if (metadataColumns.length) {
189
+ try {
190
+ await tbl.addColumns(metadataColumns);
191
+ }
192
+ catch (e) {
193
+ // A second server/watch process may have performed the same additive
194
+ // migration between our schema read and this write.
195
+ const refreshed = await tbl.schema();
196
+ if (metadataColumns.some((column) => !refreshed.fields.some((f) => f.name === column.name))) {
197
+ throw e;
198
+ }
199
+ }
200
+ }
201
+ };
202
+ const ensureThreadsSchema = async (tbl) => {
203
+ if (!threadsSchemaMigration) {
204
+ threadsSchemaMigration = migrateThreadsSchema(tbl).catch((error) => {
205
+ threadsSchemaMigration = null;
206
+ throw error;
207
+ });
208
+ }
209
+ await threadsSchemaMigration;
210
+ };
211
+ export const replaceThreadsForFile = async (collection, sourceFile, provider, conversations) => {
212
+ return withCollectionWriteLock(THREADS_TABLE, async () => {
213
+ const rows = threadRows(collection, sourceFile, provider, conversations, 'local');
214
+ await replaceThreadRowsLocked(threadFileFilter(collection, sourceFile), rows);
215
+ invalidateCollectionStats(collection);
216
+ });
217
+ };
218
+ const threadFileFilter = (collection, sourceFile) => `collection = '${escapeSqlString(collection)}' AND sourceFile = '${escapeSqlString(sourceFile)}'`;
219
+ const threadRows = (collection, sourceFile, provider, conversations, indexPending) => {
220
+ const ingestedAt = new Date().toISOString();
221
+ return conversations.map((conversation, ordinal) => ({
222
+ collection,
223
+ sourceFile,
224
+ conversationKey: conversation.key ?? '',
225
+ title: conversation.title ?? '',
226
+ provider,
227
+ ordinal,
228
+ turnCount: conversation.turns.length,
229
+ turnsJson: JSON.stringify(conversation.turns),
230
+ ingestedAt,
231
+ lastTurnAt: latestTurnTimestamp(conversation.turns),
232
+ lastModel: latestTurnModel(conversation.turns),
233
+ createdInThreadShelf: conversation.createdInThreadShelf ?? false,
234
+ threadCreatedAt: conversation.threadCreatedAt ?? '',
235
+ indexPending,
236
+ indexAttempts: 0,
237
+ indexRetryAt: 0,
238
+ indexError: '',
239
+ hasThreadShelfTurns: conversation.createdInThreadShelf === true ||
240
+ conversation.turns.some((turn) => turn?.createdInThreadShelf === true),
241
+ }));
242
+ };
243
+ const replaceThreadRowsLocked = async (where, rows) => {
244
+ const tbl = await openTable(THREADS_TABLE);
245
+ if (tbl) {
246
+ await ensureThreadsSchema(tbl);
247
+ // Tombstones are durable deletion jobs. They contain no conversation text.
248
+ const oldRows = await tbl.query().where(where).limit(Number.MAX_SAFE_INTEGER).toArray();
249
+ const keys = new Set(rows.map((row) => JSON.stringify([row.collection, row.sourceFile, row.conversationKey])));
250
+ const tombstones = oldRows
251
+ .filter((row) => !keys.has(JSON.stringify([row.collection, row.sourceFile, row.conversationKey])))
252
+ .map((row) => ({
253
+ ...row,
254
+ title: '',
255
+ turnsJson: '[]',
256
+ lastModel: '',
257
+ turnCount: 0,
258
+ indexPending: 'delete',
259
+ }));
260
+ const next = [...rows, ...tombstones];
261
+ if (next.length)
262
+ await tbl
263
+ .mergeInsert(['collection', 'sourceFile', 'conversationKey'])
264
+ .whenMatchedUpdateAll()
265
+ .whenNotMatchedInsertAll()
266
+ .whenNotMatchedBySourceDelete({ where })
267
+ .execute(next);
268
+ }
269
+ else if (rows.length) {
270
+ const database = await getDb();
271
+ const created = await database.createTable(THREADS_TABLE, rows, { mode: 'create' });
272
+ tableCache.set(THREADS_TABLE, created);
273
+ }
274
+ };
275
+ export const updateStoredThread = async (collection, sourceFile, provider, conversation) => {
276
+ return withCollectionWriteLock(THREADS_TABLE, async () => {
277
+ const tbl = await openTable(THREADS_TABLE);
278
+ if (!tbl)
279
+ throw new Error('Stored thread table is unavailable');
280
+ await ensureThreadsSchema(tbl);
281
+ const ingestedAt = new Date().toISOString();
282
+ const where = [
283
+ `collection = '${escapeSqlString(collection)}'`,
284
+ `sourceFile = '${escapeSqlString(sourceFile)}'`,
285
+ `conversationKey = '${escapeSqlString(conversation.key)}'`,
286
+ "indexPending != 'delete'",
287
+ ].join(' AND ');
288
+ const current = await tbl.query().where(where).limit(1).toArray();
289
+ const result = await tbl.update({
290
+ where,
291
+ values: {
292
+ title: conversation.title,
293
+ provider,
294
+ turnCount: conversation.turns.length,
295
+ turnsJson: JSON.stringify(conversation.turns),
296
+ ingestedAt,
297
+ indexPending: current[0]?.indexPending === 'all' ? 'all' : 'local',
298
+ indexAttempts: 0,
299
+ indexRetryAt: 0,
300
+ indexError: '',
301
+ lastTurnAt: latestTurnTimestamp(conversation.turns),
302
+ lastModel: latestTurnModel(conversation.turns),
303
+ createdInThreadShelf: conversation.createdInThreadShelf ?? false,
304
+ threadCreatedAt: conversation.threadCreatedAt ?? '',
305
+ hasThreadShelfTurns: conversation.createdInThreadShelf === true ||
306
+ conversation.turns.some((turn) => turn?.createdInThreadShelf === true),
307
+ },
308
+ });
309
+ if (result.rowsUpdated !== 1)
310
+ throw new StoredThreadWriteError();
311
+ invalidateCollectionStats(collection);
312
+ });
313
+ };
314
+ export const updateStoredThreadFromCurrent = async (collection, sourceFile, conversationKey, update) => {
315
+ return withCollectionWriteLock(THREADS_TABLE, async () => {
316
+ const tbl = await openTable(THREADS_TABLE);
317
+ if (!tbl)
318
+ throw new StoredThreadWriteError('Stored thread table is unavailable');
319
+ await ensureThreadsSchema(tbl);
320
+ const where = [
321
+ `collection = '${escapeSqlString(collection)}'`,
322
+ `sourceFile = '${escapeSqlString(sourceFile)}'`,
323
+ `conversationKey = '${escapeSqlString(conversationKey)}'`,
324
+ "indexPending != 'delete'",
325
+ ].join(' AND ');
326
+ const rows = await tbl.query().where(where).limit(2).toArray();
327
+ if (rows.length !== 1)
328
+ throw new StoredThreadWriteError();
329
+ const next = update(storedThreadRow(rows[0]));
330
+ const conversation = next.conversation;
331
+ const result = await tbl.update({
332
+ where,
333
+ values: {
334
+ title: conversation.title,
335
+ provider: next.provider,
336
+ turnCount: conversation.turns.length,
337
+ turnsJson: JSON.stringify(conversation.turns),
338
+ ingestedAt: new Date().toISOString(),
339
+ indexPending: rows[0].indexPending === 'all' ? 'all' : 'local',
340
+ indexAttempts: 0,
341
+ indexRetryAt: 0,
342
+ indexError: '',
343
+ lastTurnAt: latestTurnTimestamp(conversation.turns),
344
+ lastModel: latestTurnModel(conversation.turns),
345
+ createdInThreadShelf: conversation.createdInThreadShelf ?? false,
346
+ threadCreatedAt: conversation.threadCreatedAt ?? '',
347
+ hasThreadShelfTurns: conversation.createdInThreadShelf === true ||
348
+ conversation.turns.some((turn) => turn?.createdInThreadShelf === true),
349
+ },
350
+ });
351
+ if (result.rowsUpdated !== 1)
352
+ throw new StoredThreadWriteError();
353
+ invalidateCollectionStats(collection);
354
+ return conversation;
355
+ });
356
+ };
357
+ export const getStoredThreads = async (collection, sourceFile) => {
358
+ try {
359
+ const tbl = await openTable(THREADS_TABLE);
360
+ if (!tbl)
361
+ return [];
362
+ await ensureThreadsSchema(tbl);
363
+ const fileFilter = `sourceFile = '${escapeSqlString(sourceFile)}' AND indexPending NOT IN ('delete', 'reset')`;
364
+ const where = collection
365
+ ? `collection = '${escapeSqlString(collection)}' AND ${fileFilter}`
366
+ : fileFilter;
367
+ const rows = await tbl.query().where(where).limit(Number.MAX_SAFE_INTEGER).toArray();
368
+ return rows
369
+ .map((row) => storedThreadRow(row))
370
+ .sort((a, b) => a.ordinal - b.ordinal);
371
+ }
372
+ catch (error) {
373
+ console.warn('[store:getStoredThreads]', error);
374
+ return [];
375
+ }
376
+ };
377
+ export const listThreadSummaries = async (collection) => {
378
+ try {
379
+ const tbl = await openTable(THREADS_TABLE);
380
+ if (!tbl)
381
+ return [];
382
+ await ensureThreadsSchema(tbl);
383
+ const baseColumns = [
384
+ 'sourceFile',
385
+ 'conversationKey',
386
+ 'title',
387
+ 'provider',
388
+ 'ordinal',
389
+ 'turnCount',
390
+ 'createdInThreadShelf',
391
+ 'threadCreatedAt',
392
+ 'hasThreadShelfTurns',
393
+ 'lastModel',
394
+ ];
395
+ const fetch = (columns) => tbl
396
+ .query()
397
+ .where(`collection = '${escapeSqlString(collection)}' AND indexPending NOT IN ('delete', 'reset')`)
398
+ .select(columns)
399
+ .limit(Number.MAX_SAFE_INTEGER)
400
+ .toArray();
401
+ let rows;
402
+ try {
403
+ rows = await fetch([...baseColumns, 'lastTurnAt']);
404
+ }
405
+ catch (e) {
406
+ // __threads created before the lastTurnAt column existed.
407
+ if (!String(e?.message || '').includes('lastTurnAt'))
408
+ throw e;
409
+ rows = await fetch(baseColumns);
410
+ }
411
+ return rows.map((row) => ({
412
+ sourceFile: row.sourceFile ?? '',
413
+ conversationKey: row.conversationKey ?? '',
414
+ title: row.title ?? '',
415
+ provider: row.provider ?? '',
416
+ ordinal: Number(row.ordinal) || 0,
417
+ turnCount: Number(row.turnCount) || 0,
418
+ lastTurnAt: row.lastTurnAt ?? '',
419
+ lastModel: row.lastModel ?? '',
420
+ createdInThreadShelf: Boolean(row.createdInThreadShelf),
421
+ threadCreatedAt: row.threadCreatedAt ?? '',
422
+ hasThreadShelfTurns: Boolean(row.hasThreadShelfTurns || row.createdInThreadShelf),
423
+ }));
424
+ }
425
+ catch (error) {
426
+ console.warn('[store:listThreadSummaries]', error);
427
+ return [];
428
+ }
429
+ };
430
+ export const deleteThreadsForCollection = async (collection) => {
431
+ return withCollectionWriteLock(THREADS_TABLE, async () => {
432
+ const tbl = await openTable(THREADS_TABLE);
433
+ if (!tbl)
434
+ return;
435
+ await ensureThreadsSchema(tbl);
436
+ await tbl.delete(`collection = '${escapeSqlString(collection)}'`);
437
+ invalidateCollectionStats(collection);
438
+ });
439
+ };
440
+ const conversationFromRow = (row) => {
441
+ try {
442
+ return {
443
+ key: String(row.conversationKey),
444
+ title: String(row.title),
445
+ turns: validateTurns(JSON.parse(String(row.turnsJson))),
446
+ createdInThreadShelf: Boolean(row.createdInThreadShelf),
447
+ threadCreatedAt: String(row.threadCreatedAt || ''),
448
+ };
449
+ }
450
+ catch {
451
+ return undefined;
452
+ }
453
+ };
454
+ const chunksFromThreadRows = (rows) => {
455
+ const invalid = [];
456
+ const chunks = rows.flatMap((row) => {
457
+ if (row.indexPending === 'delete' || row.indexPending === 'reset')
458
+ return [];
459
+ const conversation = conversationFromRow(row);
460
+ if (!conversation) {
461
+ invalid.push(row);
462
+ return [];
463
+ }
464
+ const turns = validateTurns(conversation.turns);
465
+ return chunkTurns(turns, {
466
+ sourceFile: String(row.sourceFile),
467
+ provider: row.provider,
468
+ conversationKey: String(row.conversationKey),
469
+ title: String(row.title),
470
+ }).map((chunk, index) => ({
471
+ ...chunk,
472
+ provider: chunk.createdInThreadShelf ? 'threadshelf' : chunk.provider,
473
+ id: `${row.sourceFile}|${row.conversationKey}|${chunk.turnIndex}|${index}`,
474
+ }));
475
+ });
476
+ return { chunks, invalid };
477
+ };
478
+ const readThreadScope = async (where) => {
479
+ const tbl = await openTable(THREADS_TABLE);
480
+ if (!tbl)
481
+ return [];
482
+ await ensureThreadsSchema(tbl);
483
+ return tbl.query().where(where).limit(Number.MAX_SAFE_INTEGER).toArray();
484
+ };
485
+ const snapshotFingerprint = (rows) => createHash('sha256')
486
+ .update(JSON.stringify(rows.map((row) => JSON.stringify(row)).sort()))
487
+ .digest('hex');
488
+ // The archive is authoritative. A pending marker is committed WITH its turns;
489
+ // the index can always be rebuilt after an interrupted second-table write.
490
+ // clearFirst stages the entire folder before this single archive commit.
491
+ export const replaceImportedFiles = async (collection, files, options = {}) => withCollectionWriteLock(collection, async () => {
492
+ const scope = options.clearFirst
493
+ ? `collection = '${escapeSqlString(collection)}'`
494
+ : files.map((file) => `(${threadFileFilter(collection, file.sourceFile)})`).join(' OR ');
495
+ if (!scope)
496
+ return 0;
497
+ for (let attempt = 0; attempt < 3; attempt++) {
498
+ options.signal?.throwIfAborted();
499
+ const previous = await withCollectionWriteLock(THREADS_TABLE, () => readThreadScope(scope));
500
+ const previousByKey = new Map(previous.map((row) => [JSON.stringify([row.sourceFile, row.conversationKey]), row]));
501
+ const next = [];
502
+ const matched = new Set();
503
+ for (const file of files) {
504
+ const conversations = file.conversations.map((conversation) => {
505
+ const old = previousByKey.get(JSON.stringify([file.sourceFile, conversation.key]));
506
+ if (!old || old.indexPending === 'delete')
507
+ return conversation;
508
+ matched.add(JSON.stringify([file.sourceFile, conversation.key]));
509
+ const oldConversation = conversationFromRow(old);
510
+ if (!oldConversation) {
511
+ const suffix = createHash('sha256')
512
+ .update(String(old.turnsJson))
513
+ .digest('hex')
514
+ .slice(0, 16);
515
+ next.push({
516
+ ...old,
517
+ conversationKey: `${conversation.key}:unreadable:${suffix}`,
518
+ indexPending: 'invalid',
519
+ });
520
+ return conversation;
521
+ }
522
+ const oldTurns = oldConversation.turns;
523
+ const localTurns = oldTurns.filter((turn) => turn.createdInThreadShelf);
524
+ const importedTurns = oldTurns.filter((turn) => !turn.createdInThreadShelf);
525
+ // Positional keys do not identify a conversation after a wholesale rewrite.
526
+ const text = (turn) => {
527
+ const t = turn;
528
+ return [t.user, t.ai, t.thinking];
529
+ };
530
+ const sharedPrefix = importedTurns.length > 0 &&
531
+ conversation.turns.length > 0 &&
532
+ importedTurns
533
+ .slice(0, Math.min(importedTurns.length, conversation.turns.length))
534
+ .every((turn, index) => JSON.stringify(text(turn)) === JSON.stringify(text(conversation.turns[index])));
535
+ if (localTurns.length && /:\d+$/.test(conversation.key) && !sharedPrefix) {
536
+ const suffix = createHash('sha256')
537
+ .update(String(old.turnsJson))
538
+ .digest('hex')
539
+ .slice(0, 16);
540
+ next.push({
541
+ ...old,
542
+ conversationKey: `${conversation.key}:threadshelf:${suffix}`,
543
+ indexPending: 'all',
544
+ });
545
+ return conversation;
546
+ }
547
+ return { ...conversation, turns: [...conversation.turns, ...localTurns] };
548
+ });
549
+ next.push(...threadRows(collection, file.sourceFile, file.provider, conversations, 'all'));
550
+ }
551
+ // Missing/changed keys retain the full old branch if it has local authorship.
552
+ for (const old of previous) {
553
+ if (['delete', 'reset'].includes(String(old.indexPending)) ||
554
+ matched.has(JSON.stringify([old.sourceFile, old.conversationKey])))
555
+ continue;
556
+ if (conversationFromRow(old) &&
557
+ !conversationFromRow(old).turns.some((turn) => turn.createdInThreadShelf))
558
+ continue;
559
+ next.push({ ...old, indexPending: 'all' });
560
+ }
561
+ const { chunks, invalid } = chunksFromThreadRows(next);
562
+ const embedded = await embedChunks(chunks, options.signal, options.onEmbeddingProgress);
563
+ options.signal?.throwIfAborted();
564
+ const committed = await withCollectionWriteLock(THREADS_TABLE, async () => {
565
+ if (snapshotFingerprint(previous) !== snapshotFingerprint(await readThreadScope(scope)))
566
+ return false;
567
+ options.signal?.throwIfAborted();
568
+ const resetMarker = options.clearFirst
569
+ ? threadRows(collection, 'threadshelf://collection-reset', 'threadshelf', [{ key: '__reset', title: '', turns: [] }], 'reset')
570
+ : [];
571
+ await replaceThreadRowsLocked(scope, [...next, ...resetMarker]);
572
+ const chunkScope = options.clearFirst
573
+ ? 'true'
574
+ : files.map((file) => `sourceFile = '${escapeSqlString(file.sourceFile)}'`).join(' OR ');
575
+ await replaceEmbeddedRowsLocked(collection, chunkScope, embedded);
576
+ await acknowledgeIndexLocked(scope, invalid);
577
+ return true;
578
+ });
579
+ if (committed)
580
+ return chunks.length;
581
+ }
582
+ throw new StoredThreadWriteError('Archive changed repeatedly while embedding; retry the import');
583
+ });
584
+ const acknowledgeIndexLocked = async (where, invalid = []) => {
585
+ const tbl = await openTable(THREADS_TABLE);
586
+ if (!tbl)
587
+ return;
588
+ for (const row of invalid) {
589
+ await tbl.update({
590
+ where: `${threadFileFilter(String(row.collection), String(row.sourceFile))} AND conversationKey = '${escapeSqlString(String(row.conversationKey))}'`,
591
+ values: {
592
+ indexPending: 'invalid',
593
+ indexError: 'Stored turns could not be decoded; original data retained',
594
+ indexAttempts: 1,
595
+ indexRetryAt: 0,
596
+ },
597
+ });
598
+ }
599
+ if (invalid.length)
600
+ console.error(`[index:recovery] Preserved ${invalid.length} unreadable archive row(s); healthy conversations remain searchable.`);
601
+ await tbl.delete(`(${where}) AND indexPending IN ('delete', 'reset')`);
602
+ await tbl.update({
603
+ where: `(${where}) AND indexPending NOT IN ('', 'invalid')`,
604
+ values: { indexPending: '', indexAttempts: 0, indexRetryAt: 0, indexError: '' },
605
+ });
606
+ };
607
+ const indexScope = async (collection, sourceFile) => {
608
+ const collectionWhere = `collection = '${escapeSqlString(collection)}'`;
609
+ const reset = (await readThreadScope(`${collectionWhere} AND indexPending = 'reset'`)).length > 0;
610
+ const where = reset ? collectionWhere : threadFileFilter(collection, sourceFile);
611
+ return { where, reset, rows: await readThreadScope(where) };
612
+ };
613
+ // Only snapshot validation and publication hold the archive lock. Model loading
614
+ // and inference never prevent unrelated chats from saving their answers.
615
+ export const indexStoredFile = async (collection, sourceFile, options = {}) => withCollectionWriteLock(collection, async () => {
616
+ for (let attempt = 0; attempt < 3; attempt++) {
617
+ const snapshot = await withCollectionWriteLock(THREADS_TABLE, () => indexScope(collection, sourceFile));
618
+ const { where, reset, rows } = snapshot;
619
+ if (!rows.some((row) => row.indexPending && row.indexPending !== 'invalid'))
620
+ return 0;
621
+ const full = reset || rows.some((row) => row.indexPending === 'all' || row.indexPending === 'delete');
622
+ try {
623
+ const prepared = chunksFromThreadRows(rows);
624
+ const chunks = prepared.chunks.filter((chunk) => full || chunk.createdInThreadShelf);
625
+ const embedded = await embedChunks(chunks, options.signal, options.onEmbeddingProgress);
626
+ const committed = await withCollectionWriteLock(THREADS_TABLE, async () => {
627
+ const current = await indexScope(collection, sourceFile);
628
+ if (where !== current.where ||
629
+ snapshotFingerprint(rows) !== snapshotFingerprint(current.rows))
630
+ return false;
631
+ await replaceEmbeddedRowsLocked(collection, reset
632
+ ? 'true'
633
+ : `sourceFile = '${escapeSqlString(sourceFile)}'${full ? '' : ' AND createdInThreadShelf = true'}`, embedded);
634
+ await acknowledgeIndexLocked(where, prepared.invalid);
635
+ return true;
636
+ });
637
+ if (committed)
638
+ return chunks.length;
639
+ }
640
+ catch (error) {
641
+ await withCollectionWriteLock(THREADS_TABLE, async () => {
642
+ if (snapshotFingerprint(rows) !== snapshotFingerprint(await readThreadScope(where)))
643
+ return;
644
+ const attempts = Math.max(0, ...rows.map((row) => Number(row.indexAttempts) || 0)) + 1;
645
+ const tbl = await openTable(THREADS_TABLE);
646
+ await tbl?.update({
647
+ where: `(${where}) AND indexPending NOT IN ('', 'invalid')`,
648
+ values: {
649
+ indexAttempts: attempts,
650
+ indexRetryAt: Date.now() + Math.min(15_000 * 2 ** (attempts - 1), 3_600_000),
651
+ indexError: (error instanceof Error ? error.message : 'Indexing failed').slice(0, 500),
652
+ },
653
+ });
654
+ });
655
+ throw error;
656
+ }
657
+ }
658
+ throw new StoredThreadWriteError('Archive changed repeatedly while embedding; indexing remains pending');
659
+ });
660
+ export const deleteStoredFile = async (collection, sourceFile) => withCollectionWriteLock(collection, () => withCollectionWriteLock(THREADS_TABLE, async () => {
661
+ const where = threadFileFilter(collection, sourceFile);
662
+ await replaceThreadRowsLocked(where, []);
663
+ await replaceEmbeddedRowsLocked(collection, `sourceFile = '${escapeSqlString(sourceFile)}'`, []);
664
+ await acknowledgeIndexLocked(where);
665
+ }));
666
+ let recoveringIndexes;
667
+ export const recoverPendingIndexes = (options = {}) => {
668
+ if (recoveringIndexes)
669
+ return recoveringIndexes.then(() => recoverPendingIndexes(options));
670
+ recoveringIndexes = (async () => {
671
+ const tbl = await openTable(THREADS_TABLE);
672
+ if (!tbl)
673
+ return;
674
+ await ensureThreadsSchema(tbl);
675
+ const rows = await tbl
676
+ .query()
677
+ .where("indexPending NOT IN ('', 'invalid')")
678
+ .select(['collection', 'sourceFile', 'indexPending', 'indexAttempts', 'indexRetryAt'])
679
+ .limit(Number.MAX_SAFE_INTEGER)
680
+ .toArray();
681
+ const resets = new Set(rows.filter((row) => row.indexPending === 'reset').map((row) => row.collection));
682
+ const jobs = new Map();
683
+ for (const row of rows) {
684
+ if (options.collection &&
685
+ options.collection !== 'all' &&
686
+ row.collection !== options.collection)
687
+ continue;
688
+ const key = JSON.stringify([
689
+ row.collection,
690
+ resets.has(row.collection) ? null : row.sourceFile,
691
+ ]);
692
+ jobs.set(key, [...(jobs.get(key) ?? []), row]);
693
+ }
694
+ for (const job of jobs.values()) {
695
+ const attempts = Math.max(...job.map((row) => Number(row.indexAttempts) || 0));
696
+ const retryAt = Math.max(...job.map((row) => Number(row.indexRetryAt) || 0));
697
+ if (!options.retryFailed && (attempts >= 8 || retryAt > Date.now()))
698
+ continue;
699
+ const row = job[0];
700
+ try {
701
+ await indexStoredFile(String(row.collection), String(row.sourceFile));
702
+ }
703
+ catch (error) {
704
+ console.error('[index:recovery]', error);
705
+ }
706
+ }
707
+ })().finally(() => {
708
+ recoveringIndexes = undefined;
709
+ });
710
+ return recoveringIndexes;
711
+ };
712
+ export const startIndexRecovery = () => {
713
+ const run = () => {
714
+ void recoverPendingIndexes().catch((error) => console.warn('[index:recovery]', error));
715
+ };
716
+ run();
717
+ const timer = setInterval(run, 15_000);
718
+ timer.unref();
719
+ return () => clearInterval(timer);
720
+ };
721
+ // A pending full replacement may have different turn offsets from its old
722
+ // vectors. Hide those files until recovery publishes their matching index.
723
+ const readyIndexCache = new Map();
724
+ const readyIndexFilter = async (collection) => {
725
+ const tbl = await openTable(THREADS_TABLE);
726
+ if (!tbl)
727
+ return '';
728
+ await ensureThreadsSchema(tbl);
729
+ const version = await tbl.version();
730
+ const cached = readyIndexCache.get(collection);
731
+ if (cached?.version === version)
732
+ return cached.filter;
733
+ const rows = await tbl
734
+ .query()
735
+ .where(`collection = '${escapeSqlString(collection)}' AND indexPending IN ('all', 'delete', 'reset', 'invalid')`)
736
+ .select(['sourceFile', 'conversationKey', 'indexPending'])
737
+ .limit(Number.MAX_SAFE_INTEGER)
738
+ .toArray();
739
+ const excluded = rows.map((row) => row.indexPending === 'invalid'
740
+ ? `(sourceFile = '${escapeSqlString(String(row.sourceFile))}' AND conversationKey = '${escapeSqlString(String(row.conversationKey))}')`
741
+ : `sourceFile = '${escapeSqlString(String(row.sourceFile))}'`);
742
+ const filter = rows.some((row) => row.indexPending === 'reset')
743
+ ? 'false'
744
+ : excluded.length
745
+ ? `NOT (${[...new Set(excluded)].join(' OR ')})`
746
+ : '';
747
+ readyIndexCache.set(collection, { version, filter });
748
+ return filter;
749
+ };
750
+ export const renameStoredThread = async (collection, sourceFile, conversationKey, title) => withCollectionWriteLock(THREADS_TABLE, async () => {
751
+ const tbl = await openTable(THREADS_TABLE);
752
+ if (!tbl)
753
+ throw new StoredThreadWriteError();
754
+ await ensureThreadsSchema(tbl);
755
+ const where = `${threadFileFilter(collection, sourceFile)} AND conversationKey = '${escapeSqlString(conversationKey)}' AND indexPending NOT IN ('delete', 'reset')`;
756
+ const result = await tbl.update({ where, values: { title } });
757
+ if (result.rowsUpdated !== 1)
758
+ throw new StoredThreadWriteError();
759
+ invalidateCollectionStats(collection);
760
+ const [row] = await tbl.query().where(where).limit(1).toArray();
761
+ return storedThreadRow(row);
762
+ });
763
+ export const searchCollection = async (collection, query, opts = {}, queryEmbedding) => {
764
+ const n = opts.n ?? 15;
765
+ const roles = opts.roles?.length ? opts.roles : null;
766
+ const modelFilter = normalizeModelFilter(opts.model);
767
+ const dateFilter = buildCreatedAtFilter(opts.from, opts.to);
768
+ const keywordBoost = opts.keywordBoost === true;
769
+ const needsPostFilter = roles || modelFilter || keywordBoost;
770
+ const limit = needsPostFilter ? Math.min(Math.max(n * 8, 80), 200) : n;
771
+ const embedding = queryEmbedding ?? (await embedOne(query));
772
+ const tbl = await openTable(collection);
773
+ if (!tbl)
774
+ return [];
775
+ await ensureChunkMetadataSchema(tbl);
776
+ const filters = [
777
+ dateFilter,
778
+ await readyIndexFilter(collection),
779
+ opts.origin ? `createdInThreadShelf = ${opts.origin === 'threadshelf'}` : '',
780
+ ].filter(Boolean);
781
+ const results = await vectorSearchRows(tbl, embedding, limit, filters.join(' AND '));
782
+ let rows = results.map((row) => ({
783
+ id: row.id,
784
+ document: row.document ?? '',
785
+ metadata: {
786
+ sourceFile: row.sourceFile,
787
+ provider: row.provider || undefined,
788
+ conversationKey: row.conversationKey || undefined,
789
+ title: row.title || undefined,
790
+ role: row.role,
791
+ turnIndex: row.turnIndex,
792
+ model: portableModelLabel(row.model || undefined) || undefined,
793
+ createdAt: row.createdAt || undefined,
794
+ createdInThreadShelf: Boolean(row.createdInThreadShelf),
795
+ generationProvider: row.generationProvider || undefined,
796
+ },
797
+ distance: row._distance,
798
+ }));
799
+ if (roles?.length) {
800
+ const roleSet = new Set(roles);
801
+ rows = rows.filter((r) => roleSet.has(r.metadata.role));
802
+ }
803
+ if (opts.origin) {
804
+ const expected = opts.origin === 'threadshelf';
805
+ rows = rows.filter((row) => row.metadata.createdInThreadShelf === expected);
806
+ }
807
+ rows = rows.filter((r) => isSearchableDocument(r.document));
808
+ if (modelFilter) {
809
+ rows = rows.filter((r) => normalizeModelFilter(r.metadata.model)?.includes(modelFilter));
810
+ }
811
+ if (keywordBoost && query.trim()) {
812
+ const q = query.trim().toLowerCase();
813
+ rows.sort((a, b) => {
814
+ const aHas = a.document.toLowerCase().includes(q);
815
+ const bHas = b.document.toLowerCase().includes(q);
816
+ if (aHas && !bHas)
817
+ return -1;
818
+ if (!aHas && bHas)
819
+ return 1;
820
+ return (a.distance ?? 0) - (b.distance ?? 0);
821
+ });
822
+ }
823
+ return rows.slice(0, n);
824
+ };
825
+ // Exact-match (keyword) search: a case-insensitive substring scan pushed down
826
+ // to LanceDB as a LIKE filter. Complements vector search for identifiers,
827
+ // error strings, and code fragments the embedding model blurs away.
828
+ const escapeLikePattern = (value) => escapeSqlString(value).replace(/[\\%_]/g, '\\$&');
829
+ const countOccurrences = (haystack, needle) => {
830
+ if (!needle)
831
+ return 0;
832
+ let count = 0;
833
+ let index = haystack.indexOf(needle);
834
+ while (index !== -1) {
835
+ count++;
836
+ index = haystack.indexOf(needle, index + needle.length);
837
+ }
838
+ return count;
839
+ };
840
+ export const keywordResultComparator = (query) => (a, b) => {
841
+ const needle = query.trim().toLowerCase();
842
+ const diff = countOccurrences(b.document.toLowerCase(), needle) -
843
+ countOccurrences(a.document.toLowerCase(), needle);
844
+ if (diff !== 0)
845
+ return diff;
846
+ return (b.metadata.createdAt ?? '').localeCompare(a.metadata.createdAt ?? '');
847
+ };
848
+ export const keywordSearchCollection = async (collection, query, opts = {}) => {
849
+ const n = opts.n ?? 15;
850
+ const needle = query.trim().toLowerCase();
851
+ if (!needle)
852
+ return [];
853
+ const tbl = await openTable(collection);
854
+ if (!tbl)
855
+ return [];
856
+ await ensureChunkMetadataSchema(tbl);
857
+ const clauses = [`lower(document) LIKE '%${escapeLikePattern(needle)}%'`];
858
+ const ready = await readyIndexFilter(collection);
859
+ if (ready)
860
+ clauses.push(ready);
861
+ if (opts.roles?.length) {
862
+ clauses.push(`role IN (${opts.roles.map((role) => `'${escapeSqlString(role)}'`).join(', ')})`);
863
+ }
864
+ const dateFilter = buildCreatedAtFilter(opts.from, opts.to);
865
+ if (dateFilter)
866
+ clauses.push(dateFilter);
867
+ if (opts.origin)
868
+ clauses.push(`createdInThreadShelf = ${opts.origin === 'threadshelf'}`);
869
+ // Cap the scan the same way post-filtered vector search does; ranking picks
870
+ // the best n from that window.
871
+ const limit = Math.min(Math.max(n * 8, 80), 200);
872
+ let results;
873
+ try {
874
+ results = await tbl.query().where(clauses.join(' AND ')).limit(limit).toArray();
875
+ }
876
+ catch (e) {
877
+ if (dateFilter && String(e?.message || '').includes('createdAt'))
878
+ return [];
879
+ throw e;
880
+ }
881
+ const modelFilter = normalizeModelFilter(opts.model);
882
+ let rows = results.map((row) => ({
883
+ id: row.id,
884
+ document: row.document ?? '',
885
+ metadata: {
886
+ sourceFile: row.sourceFile,
887
+ provider: row.provider || undefined,
888
+ conversationKey: row.conversationKey || undefined,
889
+ title: row.title || undefined,
890
+ role: row.role,
891
+ turnIndex: row.turnIndex,
892
+ model: portableModelLabel(row.model || undefined) || undefined,
893
+ createdAt: row.createdAt || undefined,
894
+ createdInThreadShelf: Boolean(row.createdInThreadShelf),
895
+ generationProvider: row.generationProvider || undefined,
896
+ },
897
+ }));
898
+ rows = rows.filter((r) => isSearchableDocument(r.document));
899
+ if (modelFilter) {
900
+ rows = rows.filter((r) => normalizeModelFilter(r.metadata.model)?.includes(modelFilter));
901
+ }
902
+ rows.sort(keywordResultComparator(query));
903
+ return rows.slice(0, n);
904
+ };
905
+ // Lightweight metadata scan powering the insights dashboard: three small
906
+ // string columns, no vectors and no document text.
907
+ export const scanChunkMeta = async (collection) => {
908
+ try {
909
+ const tbl = await openTable(collection);
910
+ if (!tbl)
911
+ return [];
912
+ const fetch = (columns) => tbl.query().select(columns).limit(Number.MAX_SAFE_INTEGER).toArray();
913
+ let rows;
914
+ try {
915
+ rows = await fetch(['createdAt', 'model', 'role']);
916
+ }
917
+ catch (e) {
918
+ // Tables indexed before model/createdAt existed.
919
+ if (!String(e?.message || '').includes('No field named'))
920
+ throw e;
921
+ rows = await fetch(['role']);
922
+ }
923
+ return rows.map((row) => ({
924
+ createdAt: row.createdAt ?? '',
925
+ model: portableModelLabel(row.model ?? ''),
926
+ role: row.role ?? '',
927
+ }));
928
+ }
929
+ catch (error) {
930
+ console.warn(`[store:scanChunkMeta:${collection}]`, error);
931
+ return [];
932
+ }
933
+ };
934
+ export const listSourceFilesInCollection = async (collection) => {
935
+ try {
936
+ const tbl = await openTable(collection);
937
+ if (!tbl)
938
+ return [];
939
+ const rows = await tbl.query().select(['sourceFile']).limit(Number.MAX_SAFE_INTEGER).toArray();
940
+ const files = new Set();
941
+ for (const row of rows) {
942
+ if (row.sourceFile)
943
+ files.add(row.sourceFile);
944
+ }
945
+ return [...files].sort();
946
+ }
947
+ catch (error) {
948
+ console.warn(`[store:listSourceFiles:${collection}]`, error);
949
+ return [];
950
+ }
951
+ };
952
+ const computeCollectionStats = async (collection) => {
953
+ try {
954
+ const tbl = await openTable(collection);
955
+ const conversations = (await listThreadSummaries(collection)).length;
956
+ if (!tbl) {
957
+ return {
958
+ collection,
959
+ files: 0,
960
+ conversations,
961
+ chunks: 0,
962
+ roles: { user: 0, thinking: 0, ai: 0 },
963
+ isEmpty: true,
964
+ };
965
+ }
966
+ // Counts run natively in LanceDB; only the distinct-file scan materializes
967
+ // rows (a single column) in JS.
968
+ const [chunks, user, thinking, ai, sourceFiles] = await Promise.all([
969
+ tbl.countRows(),
970
+ tbl.countRows("role = 'user'"),
971
+ tbl.countRows("role = 'thinking'"),
972
+ tbl.countRows("role = 'ai'"),
973
+ listSourceFilesInCollection(collection),
974
+ ]);
975
+ return {
976
+ collection,
977
+ files: sourceFiles.length,
978
+ conversations,
979
+ chunks,
980
+ roles: { user, thinking, ai },
981
+ isEmpty: chunks === 0,
982
+ };
983
+ }
984
+ catch (error) {
985
+ console.warn(`[store:getCollectionStats:${collection}]`, error);
986
+ return {
987
+ collection,
988
+ files: 0,
989
+ conversations: 0,
990
+ chunks: 0,
991
+ roles: { user: 0, thinking: 0, ai: 0 },
992
+ isEmpty: true,
993
+ };
994
+ }
995
+ };
996
+ export const getCollectionStats = async (collection) => {
997
+ const cached = collectionStatsCache.get(collection);
998
+ if (cached && cached.expiresAt > Date.now())
999
+ return cached.value;
1000
+ const value = computeCollectionStats(collection).catch((error) => {
1001
+ collectionStatsCache.delete(collection);
1002
+ throw error;
1003
+ });
1004
+ collectionStatsCache.set(collection, {
1005
+ expiresAt: Date.now() + COLLECTION_STATS_CACHE_MS,
1006
+ value,
1007
+ });
1008
+ return value;
1009
+ };
1010
+ export const listCollections = async () => {
1011
+ try {
1012
+ const database = await getDb();
1013
+ const names = await database.tableNames();
1014
+ // "__"-prefixed tables are internal (e.g. __threads), never user collections.
1015
+ return names.filter((name) => name !== 'Default' && !name.startsWith('__')).sort();
1016
+ }
1017
+ catch (error) {
1018
+ console.warn('[store:listCollections]', error);
1019
+ return [];
1020
+ }
1021
+ };
1022
+ // Drop runs under the per-collection write lock so a reset cannot interleave
1023
+ // with a concurrent ingest's delete+add on the same collection.
1024
+ export const dropCollection = async (name) => {
1025
+ return withCollectionWriteLock(name, async () => {
1026
+ const database = await getDb();
1027
+ try {
1028
+ await database.dropTable(name);
1029
+ }
1030
+ catch {
1031
+ // table may not exist
1032
+ }
1033
+ tableCache.delete(name);
1034
+ invalidateCollectionStats(name);
1035
+ await deleteThreadsForCollection(name);
1036
+ });
1037
+ };
1038
+ // --- internals ---
1039
+ const vectorSearchRows = async (tbl, queryEmbedding, limit, rowFilter = '') => {
1040
+ const base = () => {
1041
+ const query = tbl.vectorSearch(queryEmbedding).distanceType('cosine');
1042
+ const filtered = rowFilter ? query.where(rowFilter) : query;
1043
+ return filtered.limit(limit);
1044
+ };
1045
+ try {
1046
+ return await base()
1047
+ .select([
1048
+ 'id',
1049
+ 'document',
1050
+ 'sourceFile',
1051
+ 'provider',
1052
+ 'conversationKey',
1053
+ 'title',
1054
+ 'role',
1055
+ 'turnIndex',
1056
+ 'model',
1057
+ 'createdAt',
1058
+ 'createdInThreadShelf',
1059
+ 'generationProvider',
1060
+ '_distance',
1061
+ ])
1062
+ .toArray();
1063
+ }
1064
+ catch (e) {
1065
+ if (rowFilter && String(e?.message || '').includes('createdAt'))
1066
+ return [];
1067
+ if (!String(e?.message || '').includes('No field named'))
1068
+ throw e;
1069
+ try {
1070
+ return await base()
1071
+ .select([
1072
+ 'id',
1073
+ 'document',
1074
+ 'sourceFile',
1075
+ 'provider',
1076
+ 'conversationKey',
1077
+ 'title',
1078
+ 'role',
1079
+ 'turnIndex',
1080
+ '_distance',
1081
+ ])
1082
+ .toArray();
1083
+ }
1084
+ catch (inner) {
1085
+ if (!String(inner?.message || '').includes('No field named'))
1086
+ throw inner;
1087
+ try {
1088
+ return await base()
1089
+ .select(['id', 'document', 'sourceFile', 'provider', 'role', 'turnIndex', '_distance'])
1090
+ .toArray();
1091
+ }
1092
+ catch (legacy) {
1093
+ if (!String(legacy?.message || '').includes('No field named'))
1094
+ throw legacy;
1095
+ return base()
1096
+ .select(['id', 'document', 'sourceFile', 'role', 'turnIndex', '_distance'])
1097
+ .toArray();
1098
+ }
1099
+ }
1100
+ }
1101
+ };
1102
+ const buildCreatedAtFilter = (from, to) => {
1103
+ const clauses = ["createdAt != ''"];
1104
+ if (from)
1105
+ clauses.push(`createdAt >= '${escapeSqlString(from)}'`);
1106
+ if (to)
1107
+ clauses.push(`createdAt <= '${escapeSqlString(to)}'`);
1108
+ return clauses.length > 1 ? clauses.join(' AND ') : '';
1109
+ };
1110
+ const normalizeModelFilter = (value) => {
1111
+ if (value === undefined || value === null)
1112
+ return '';
1113
+ return portableModelLabel(String(value)).toLowerCase();
1114
+ };
1115
+ const isSearchableDocument = (text) => {
1116
+ if (!isIndexableText(text))
1117
+ return false;
1118
+ return text.trim().length >= 8;
1119
+ };
1120
+ const withCollectionWriteLock = async (collection, fn) => {
1121
+ const previous = collectionWriteLocks.get(collection) ?? Promise.resolve();
1122
+ let release = () => { };
1123
+ const current = new Promise((resolve) => {
1124
+ release = resolve;
1125
+ });
1126
+ const chained = previous.then(() => current);
1127
+ collectionWriteLocks.set(collection, chained);
1128
+ await previous;
1129
+ try {
1130
+ return await fn();
1131
+ }
1132
+ finally {
1133
+ release();
1134
+ if (collectionWriteLocks.get(collection) === chained) {
1135
+ collectionWriteLocks.delete(collection);
1136
+ }
1137
+ }
1138
+ };