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.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { searchAcrossCollections } from './services/search.js';
|
|
3
|
+
import { recoverPendingIndexes } from './store.js';
|
|
4
|
+
import { normalizeCollectionSelector, normalizeCount, normalizeDateRange, normalizeOptionalString, normalizeQuery, normalizeRoles, normalizeSearchMode, } from './validation.js';
|
|
5
|
+
const USAGE = `Usage: npm run search -- "<query>" -- [options]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--collection <name> Collection to search, or "all" (default: all)
|
|
9
|
+
--mode <mode> "semantic" (default) or "keyword" (exact substring)
|
|
10
|
+
--roles <list> Comma list of user,thinking,ai
|
|
11
|
+
--model <text> Only results whose model contains this text
|
|
12
|
+
--from <date> Inclusive ISO/YYYY-MM-DD lower bound
|
|
13
|
+
--to <date> Inclusive ISO/YYYY-MM-DD upper bound
|
|
14
|
+
--n <count> Max results, 1-50 (default: 10)
|
|
15
|
+
--json Print raw JSON instead of readable output`;
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const flags = new Map();
|
|
18
|
+
const positional = [];
|
|
19
|
+
let json = false;
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
if (arg === '--') {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
else if (arg === '--json') {
|
|
26
|
+
json = true;
|
|
27
|
+
}
|
|
28
|
+
else if (arg.startsWith('--')) {
|
|
29
|
+
const value = args[i + 1];
|
|
30
|
+
if (value === undefined || value.startsWith('--')) {
|
|
31
|
+
console.error(`Missing value for ${arg}\n\n${USAGE}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
flags.set(arg.slice(2), value);
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
positional.push(arg);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (positional.length !== 1) {
|
|
42
|
+
console.error(USAGE);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const query = normalizeQuery(positional[0], { field: 'query' });
|
|
47
|
+
const collection = normalizeCollectionSelector(flags.get('collection'), {
|
|
48
|
+
defaultValue: 'all',
|
|
49
|
+
});
|
|
50
|
+
const mode = normalizeSearchMode(flags.get('mode'));
|
|
51
|
+
const roles = normalizeRoles(flags.get('roles'));
|
|
52
|
+
const model = normalizeOptionalString(flags.get('model'), { field: 'model' });
|
|
53
|
+
const { from, to } = normalizeDateRange(flags.get('from'), flags.get('to'));
|
|
54
|
+
const n = normalizeCount(flags.get('n'), { defaultValue: 10 });
|
|
55
|
+
await recoverPendingIndexes({ collection });
|
|
56
|
+
const results = await searchAcrossCollections(query, collection, {
|
|
57
|
+
n,
|
|
58
|
+
mode,
|
|
59
|
+
roles: roles ?? undefined,
|
|
60
|
+
model,
|
|
61
|
+
from,
|
|
62
|
+
to,
|
|
63
|
+
keywordBoost: mode === 'semantic',
|
|
64
|
+
});
|
|
65
|
+
if (json) {
|
|
66
|
+
console.log(JSON.stringify({ query, collection, mode, results }, null, 2));
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
if (results.length === 0) {
|
|
70
|
+
console.log(`No results for "${query}" in ${collection}.`);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
for (const [index, result] of results.entries()) {
|
|
74
|
+
const meta = result.metadata;
|
|
75
|
+
const score = result.distance != null ? ` score=${(1 - result.distance).toFixed(3)}` : '';
|
|
76
|
+
const modelLabel = meta.model ? ` model=${meta.model}` : '';
|
|
77
|
+
const dateLabel = meta.createdAt ? ` date=${meta.createdAt.slice(0, 10)}` : '';
|
|
78
|
+
const collectionLabel = meta.collection ? `${meta.collection} · ` : '';
|
|
79
|
+
const snippet = result.document.replace(/\s+/g, ' ').trim();
|
|
80
|
+
const preview = snippet.length > 240 ? `${snippet.slice(0, 237)}...` : snippet;
|
|
81
|
+
console.log(`${index + 1}. [${meta.role}]${score}${modelLabel}${dateLabel}`);
|
|
82
|
+
console.log(` ${collectionLabel}${meta.sourceFile}`);
|
|
83
|
+
if (meta.title)
|
|
84
|
+
console.log(` "${meta.title}"`);
|
|
85
|
+
console.log(` ${preview}`);
|
|
86
|
+
console.log('');
|
|
87
|
+
}
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
console.error(`[search] ${e.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import './env.js';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import apiRouter from './routes/index.js';
|
|
5
|
+
import { dataDir, packagePath } from './paths.js';
|
|
6
|
+
import { startIndexRecovery } from './store.js';
|
|
7
|
+
const app = express();
|
|
8
|
+
app.use(express.json({ limit: '512kb' }));
|
|
9
|
+
// Package assets resolve against the installed module, never process.cwd():
|
|
10
|
+
// `npx threadshelf` runs from whatever directory the user happens to be in.
|
|
11
|
+
const PUBLIC = packagePath('public');
|
|
12
|
+
app.use(express.static(PUBLIC));
|
|
13
|
+
// Serve the browser-console export scripts so the UI can offer a "copy script"
|
|
14
|
+
// button (e.g. the OpenRouter exporter). Read-only static files.
|
|
15
|
+
app.use('/scripts', express.static(packagePath('scripts')));
|
|
16
|
+
const normalizeRequestHost = (value) => {
|
|
17
|
+
const host = String(value || '')
|
|
18
|
+
.trim()
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
if (!host)
|
|
21
|
+
return '';
|
|
22
|
+
if (host.startsWith('['))
|
|
23
|
+
return host.replace(/]:\d+$/, ']').replace(/^\[(.*)]$/, '$1');
|
|
24
|
+
return host.replace(/:\d+$/, '');
|
|
25
|
+
};
|
|
26
|
+
const localHosts = () => {
|
|
27
|
+
const configuredHost = normalizeRequestHost(process.env.HOST || '127.0.0.1');
|
|
28
|
+
const configuredAllowedHosts = (process.env.ALLOWED_HOSTS || '')
|
|
29
|
+
.split(',')
|
|
30
|
+
.map((host) => normalizeRequestHost(host))
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
return new Set(['localhost', '127.0.0.1', '::1', configuredHost, ...configuredAllowedHosts].filter((host) => host && host !== '0.0.0.0' && host !== '::'));
|
|
33
|
+
};
|
|
34
|
+
app.use('/api', (req, res, next) => {
|
|
35
|
+
const allowedHosts = localHosts();
|
|
36
|
+
const host = normalizeRequestHost(req.headers.host);
|
|
37
|
+
if (!allowedHosts.has(host)) {
|
|
38
|
+
return res.status(403).json({ error: 'Forbidden host' });
|
|
39
|
+
}
|
|
40
|
+
const origin = req.headers.origin;
|
|
41
|
+
if (origin) {
|
|
42
|
+
try {
|
|
43
|
+
const originHost = normalizeRequestHost(new URL(origin).host);
|
|
44
|
+
if (!allowedHosts.has(originHost)) {
|
|
45
|
+
return res.status(403).json({ error: 'Forbidden origin' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return res.status(403).json({ error: 'Invalid origin' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
next();
|
|
53
|
+
});
|
|
54
|
+
app.use(apiRouter);
|
|
55
|
+
app.use('/api', (_req, res) => {
|
|
56
|
+
res.status(404).json({ error: 'Not found' });
|
|
57
|
+
});
|
|
58
|
+
app.get(/.*/, (_req, res, next) => {
|
|
59
|
+
res.sendFile(join(PUBLIC, 'index.html'), (err) => {
|
|
60
|
+
if (err)
|
|
61
|
+
next(err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
app.use((err, _req, res, _next) => {
|
|
65
|
+
console.error('Express error', err);
|
|
66
|
+
if (err.name === 'MulterError') {
|
|
67
|
+
return res.status(400).json({ error: err.message });
|
|
68
|
+
}
|
|
69
|
+
res.status(500).json({ error: err?.message || 'Server error' });
|
|
70
|
+
});
|
|
71
|
+
const portArg = process.argv[2];
|
|
72
|
+
const PORT = Number(portArg ?? process.env.PORT) || 3000;
|
|
73
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
74
|
+
app.listen(PORT, HOST, () => {
|
|
75
|
+
startIndexRecovery();
|
|
76
|
+
console.log(`Server: http://localhost:${PORT}`);
|
|
77
|
+
console.log('Data directory:', dataDir());
|
|
78
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { dataPath } from '../paths.js';
|
|
4
|
+
import { listCollections, dropCollection } from '../store.js';
|
|
5
|
+
// Overridable so test servers with an isolated LanceDB do not share (and
|
|
6
|
+
// pollute) the real registry file in the repo/app directory.
|
|
7
|
+
const COLLECTIONS_FILE = process.env.COLLECTIONS_PATH || dataPath('collections');
|
|
8
|
+
export const readManualCollections = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(COLLECTIONS_FILE, 'utf-8');
|
|
11
|
+
const data = JSON.parse(raw);
|
|
12
|
+
return Array.isArray(data.collections) ? data.collections : [];
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export const writeManualCollections = async (collections) => {
|
|
19
|
+
await writeFile(COLLECTIONS_FILE, JSON.stringify({ collections }, null, 2));
|
|
20
|
+
};
|
|
21
|
+
export const getAllCollections = async () => {
|
|
22
|
+
const [tableCollections, manualCollections] = await Promise.all([
|
|
23
|
+
listCollections(),
|
|
24
|
+
readManualCollections(),
|
|
25
|
+
]);
|
|
26
|
+
return [...new Set([...tableCollections, ...manualCollections, 'chunks'])].sort();
|
|
27
|
+
};
|
|
28
|
+
export const addManualCollection = async (name) => {
|
|
29
|
+
const collections = await readManualCollections();
|
|
30
|
+
if (!collections.includes(name)) {
|
|
31
|
+
collections.push(name);
|
|
32
|
+
collections.sort();
|
|
33
|
+
await writeManualCollections(collections);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
export const removeManualCollection = async (name) => {
|
|
37
|
+
const collections = await readManualCollections();
|
|
38
|
+
const filtered = collections.filter((c) => c !== name);
|
|
39
|
+
if (filtered.length !== collections.length) {
|
|
40
|
+
await writeManualCollections(filtered);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
export const ensureCollectionExists = async (name) => {
|
|
44
|
+
const all = await getAllCollections();
|
|
45
|
+
if (!all.includes(name)) {
|
|
46
|
+
await addManualCollection(name);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
export const deleteCollectionFull = async (name, uploadsDir) => {
|
|
50
|
+
const { existsSync } = await import('fs');
|
|
51
|
+
const { rm } = await import('fs/promises');
|
|
52
|
+
await dropCollection(name);
|
|
53
|
+
await removeManualCollection(name);
|
|
54
|
+
const uploadDir = join(uploadsDir, name);
|
|
55
|
+
if (existsSync(uploadDir)) {
|
|
56
|
+
await rm(uploadDir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getCollectionStats, listThreadSummaries, scanChunkMeta, } from '../store.js';
|
|
2
|
+
import { getAllCollections } from './collections.js';
|
|
3
|
+
const TOP_MODELS = 10;
|
|
4
|
+
const TOP_THREADS = 10;
|
|
5
|
+
const normalizeModelName = (model) => model.replace(/^models\//, '').trim();
|
|
6
|
+
const monthOf = (iso) => {
|
|
7
|
+
// createdAt is a normalized ISO string; anything shorter is unusable.
|
|
8
|
+
return /^\d{4}-\d{2}/.test(iso) ? iso.slice(0, 7) : null;
|
|
9
|
+
};
|
|
10
|
+
// Fill gaps so the chart shows a continuous timeline (quiet months at zero).
|
|
11
|
+
const fillMonthGaps = (buckets) => {
|
|
12
|
+
const months = [...buckets.keys()].sort();
|
|
13
|
+
if (months.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const [first] = months;
|
|
16
|
+
const last = months[months.length - 1];
|
|
17
|
+
const out = [];
|
|
18
|
+
let [year, month] = first.split('-').map(Number);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const key = `${year}-${String(month).padStart(2, '0')}`;
|
|
21
|
+
out.push({ month: key, count: buckets.get(key) ?? 0 });
|
|
22
|
+
if (key === last || out.length > 600)
|
|
23
|
+
break;
|
|
24
|
+
month++;
|
|
25
|
+
if (month > 12) {
|
|
26
|
+
month = 1;
|
|
27
|
+
year++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
export const getInsights = async (collection) => {
|
|
33
|
+
const collections = collection === 'all' ? await getAllCollections() : [collection];
|
|
34
|
+
const [statsList, summariesList, chunkMetaList] = await Promise.all([
|
|
35
|
+
Promise.all(collections.map((name) => getCollectionStats(name))),
|
|
36
|
+
Promise.all(collections.map((name) => listThreadSummaries(name))),
|
|
37
|
+
Promise.all(collections.map((name) => scanChunkMeta(name))),
|
|
38
|
+
]);
|
|
39
|
+
const totals = { collections: 0, files: 0, chunks: 0, conversations: 0, turns: 0 };
|
|
40
|
+
const roles = { user: 0, thinking: 0, ai: 0 };
|
|
41
|
+
for (const stats of statsList) {
|
|
42
|
+
totals.files += stats.files;
|
|
43
|
+
totals.chunks += stats.chunks;
|
|
44
|
+
roles.user += stats.roles.user;
|
|
45
|
+
roles.thinking += stats.roles.thinking;
|
|
46
|
+
roles.ai += stats.roles.ai;
|
|
47
|
+
if (!stats.isEmpty)
|
|
48
|
+
totals.collections++;
|
|
49
|
+
}
|
|
50
|
+
const providerMap = new Map();
|
|
51
|
+
const longest = [];
|
|
52
|
+
for (let i = 0; i < collections.length; i++) {
|
|
53
|
+
const summaries = summariesList[i] ?? [];
|
|
54
|
+
totals.conversations += summaries.length;
|
|
55
|
+
for (const row of summaries) {
|
|
56
|
+
totals.turns += row.turnCount;
|
|
57
|
+
const provider = row.provider || 'unknown';
|
|
58
|
+
const entry = providerMap.get(provider) ?? { conversations: 0, turns: 0 };
|
|
59
|
+
entry.conversations++;
|
|
60
|
+
entry.turns += row.turnCount;
|
|
61
|
+
providerMap.set(provider, entry);
|
|
62
|
+
longest.push({
|
|
63
|
+
collection: collections[i],
|
|
64
|
+
sourceFile: row.sourceFile,
|
|
65
|
+
conversationKey: row.conversationKey,
|
|
66
|
+
title: row.title,
|
|
67
|
+
provider: row.provider,
|
|
68
|
+
turnCount: row.turnCount,
|
|
69
|
+
lastTurnAt: row.lastTurnAt,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
longest.sort((a, b) => b.turnCount - a.turnCount);
|
|
74
|
+
const activityBuckets = new Map();
|
|
75
|
+
const modelCounts = new Map();
|
|
76
|
+
let firstActivity = null;
|
|
77
|
+
let lastActivity = null;
|
|
78
|
+
for (const rows of chunkMetaList) {
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const month = monthOf(row.createdAt);
|
|
81
|
+
if (month) {
|
|
82
|
+
activityBuckets.set(month, (activityBuckets.get(month) ?? 0) + 1);
|
|
83
|
+
if (!firstActivity || row.createdAt < firstActivity)
|
|
84
|
+
firstActivity = row.createdAt;
|
|
85
|
+
if (!lastActivity || row.createdAt > lastActivity)
|
|
86
|
+
lastActivity = row.createdAt;
|
|
87
|
+
}
|
|
88
|
+
const model = normalizeModelName(row.model);
|
|
89
|
+
if (model)
|
|
90
|
+
modelCounts.set(model, (modelCounts.get(model) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const topModels = [...modelCounts.entries()]
|
|
94
|
+
.map(([model, count]) => ({ model, count }))
|
|
95
|
+
.sort((a, b) => b.count - a.count)
|
|
96
|
+
.slice(0, TOP_MODELS);
|
|
97
|
+
const providers = [...providerMap.entries()]
|
|
98
|
+
.map(([provider, entry]) => ({ provider, ...entry }))
|
|
99
|
+
.sort((a, b) => b.turns - a.turns);
|
|
100
|
+
return {
|
|
101
|
+
collection,
|
|
102
|
+
totals,
|
|
103
|
+
roles,
|
|
104
|
+
activity: fillMonthGaps(activityBuckets),
|
|
105
|
+
topModels,
|
|
106
|
+
providers,
|
|
107
|
+
longestThreads: longest.slice(0, TOP_THREADS),
|
|
108
|
+
firstActivity,
|
|
109
|
+
lastActivity,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { searchCollection, keywordSearchCollection, keywordResultComparator, } from '../store.js';
|
|
2
|
+
import { embedOne } from '../embedding.js';
|
|
3
|
+
import { getAllCollections } from './collections.js';
|
|
4
|
+
export const searchAcrossCollections = async (query, collection, opts = {}) => {
|
|
5
|
+
const n = opts.n ?? 15;
|
|
6
|
+
const model = opts.model;
|
|
7
|
+
const searchLimit = model ? 50 : n;
|
|
8
|
+
if (opts.mode === 'keyword') {
|
|
9
|
+
return keywordSearchAcrossCollections(query, collection, { ...opts, n });
|
|
10
|
+
}
|
|
11
|
+
// Generate the query vector once. "All collections" used to repeat the
|
|
12
|
+
// exact same model inference for every collection, multiplying latency as a
|
|
13
|
+
// user's archive grew.
|
|
14
|
+
const queryEmbedding = await embedOne(query);
|
|
15
|
+
if (collection === 'all') {
|
|
16
|
+
const collections = await getAllCollections();
|
|
17
|
+
const merged = await Promise.all(collections.map(async (name) => {
|
|
18
|
+
const rows = await searchCollection(name, query, { ...opts, n: searchLimit }, queryEmbedding);
|
|
19
|
+
return rows.map((row) => ({
|
|
20
|
+
...row,
|
|
21
|
+
metadata: { ...row.metadata, collection: name },
|
|
22
|
+
}));
|
|
23
|
+
}));
|
|
24
|
+
let results = merged.flat();
|
|
25
|
+
results = filterResultsByModel(results, model);
|
|
26
|
+
results.sort(searchResultComparator(query, opts.keywordBoost === true));
|
|
27
|
+
return results.slice(0, n);
|
|
28
|
+
}
|
|
29
|
+
const rawResults = await searchCollection(collection, query, { ...opts, n: searchLimit }, queryEmbedding);
|
|
30
|
+
return filterResultsByModel(rawResults, model).slice(0, n);
|
|
31
|
+
};
|
|
32
|
+
// Exact substring search. Model/date/role filtering happens inside
|
|
33
|
+
// keywordSearchCollection; here we only fan out and merge-rank.
|
|
34
|
+
const keywordSearchAcrossCollections = async (query, collection, opts) => {
|
|
35
|
+
const n = opts.n ?? 15;
|
|
36
|
+
if (collection !== 'all') {
|
|
37
|
+
return keywordSearchCollection(collection, query, opts);
|
|
38
|
+
}
|
|
39
|
+
const collections = await getAllCollections();
|
|
40
|
+
const merged = await Promise.all(collections.map(async (name) => {
|
|
41
|
+
const rows = await keywordSearchCollection(name, query, opts);
|
|
42
|
+
return rows.map((row) => ({
|
|
43
|
+
...row,
|
|
44
|
+
metadata: { ...row.metadata, collection: name },
|
|
45
|
+
}));
|
|
46
|
+
}));
|
|
47
|
+
const results = merged.flat();
|
|
48
|
+
results.sort(keywordResultComparator(query));
|
|
49
|
+
return results.slice(0, n);
|
|
50
|
+
};
|
|
51
|
+
export const searchResultComparator = (query, keywordBoost) => (a, b) => {
|
|
52
|
+
if (keywordBoost) {
|
|
53
|
+
const needle = query.trim().toLowerCase();
|
|
54
|
+
const aHas = needle.length > 0 && a.document.toLowerCase().includes(needle);
|
|
55
|
+
const bHas = needle.length > 0 && b.document.toLowerCase().includes(needle);
|
|
56
|
+
if (aHas !== bHas)
|
|
57
|
+
return aHas ? -1 : 1;
|
|
58
|
+
}
|
|
59
|
+
return (a.distance ?? Number.POSITIVE_INFINITY) - (b.distance ?? Number.POSITIVE_INFINITY);
|
|
60
|
+
};
|
|
61
|
+
const filterResultsByModel = (results, model) => {
|
|
62
|
+
if (!model)
|
|
63
|
+
return results;
|
|
64
|
+
const needle = model.toLowerCase();
|
|
65
|
+
return results.filter((row) => String(row.metadata.model || '')
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.includes(needle));
|
|
68
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getCollectionStats } from '../store.js';
|
|
2
|
+
import { getAllCollections } from './collections.js';
|
|
3
|
+
export const getAllCollectionStats = async () => {
|
|
4
|
+
const collections = await getAllCollections();
|
|
5
|
+
const stats = await Promise.all(collections.map((name) => getCollectionStats(name)));
|
|
6
|
+
const totals = {
|
|
7
|
+
files: 0,
|
|
8
|
+
conversations: 0,
|
|
9
|
+
chunks: 0,
|
|
10
|
+
roles: { user: 0, thinking: 0, ai: 0 },
|
|
11
|
+
indexedCollections: 0,
|
|
12
|
+
};
|
|
13
|
+
for (const item of stats) {
|
|
14
|
+
totals.files += item.files;
|
|
15
|
+
totals.conversations += item.conversations;
|
|
16
|
+
totals.chunks += item.chunks;
|
|
17
|
+
totals.roles.user += item.roles.user;
|
|
18
|
+
totals.roles.thinking += item.roles.thinking;
|
|
19
|
+
totals.roles.ai += item.roles.ai;
|
|
20
|
+
if (!item.isEmpty)
|
|
21
|
+
totals.indexedCollections++;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
collection: 'all',
|
|
25
|
+
...totals,
|
|
26
|
+
totalCollections: collections.length,
|
|
27
|
+
isEmpty: totals.chunks === 0,
|
|
28
|
+
collections: stats,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export const getStatsForCollection = async (collection) => {
|
|
32
|
+
if (collection === 'all')
|
|
33
|
+
return getAllCollectionStats();
|
|
34
|
+
return getCollectionStats(collection);
|
|
35
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { parseExport, getConversationFromExport } from '../parser.js';
|
|
3
|
+
import { getStoredThreads, listSourceFilesInCollection } from '../store.js';
|
|
4
|
+
import { validateTurns } from '../validation.js';
|
|
5
|
+
import { getAllCollections } from './collections.js';
|
|
6
|
+
import { portableModelLabel } from '../model-label.js';
|
|
7
|
+
const sanitizeTurns = (turns) => {
|
|
8
|
+
const portable = turns.map((turn) => turn.model ? { ...turn, model: portableModelLabel(turn.model) } : turn);
|
|
9
|
+
try {
|
|
10
|
+
validateTurns(portable);
|
|
11
|
+
return portable;
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
console.warn('[thread] dropped invalid turns:', e.message);
|
|
15
|
+
return portable.filter((turn) => turn &&
|
|
16
|
+
(typeof turn.user === 'string' ||
|
|
17
|
+
typeof turn.thinking === 'string' ||
|
|
18
|
+
typeof turn.ai === 'string'));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const parseStoredTurns = (turnsJson) => {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(turnsJson);
|
|
24
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
// With collection "all" the same source file can be stored once per collection.
|
|
31
|
+
// Keep the most recently ingested copy so the thread matches the freshest index.
|
|
32
|
+
const pickNewestGroup = (rows) => {
|
|
33
|
+
const byCollection = new Map();
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
const group = byCollection.get(row.collection) ?? [];
|
|
36
|
+
group.push(row);
|
|
37
|
+
byCollection.set(row.collection, group);
|
|
38
|
+
}
|
|
39
|
+
let newest = [];
|
|
40
|
+
let newestAt = '';
|
|
41
|
+
for (const group of byCollection.values()) {
|
|
42
|
+
const at = group[0]?.ingestedAt ?? '';
|
|
43
|
+
if (!newest.length || at > newestAt) {
|
|
44
|
+
newest = group;
|
|
45
|
+
newestAt = at;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return newest;
|
|
49
|
+
};
|
|
50
|
+
const loadStoredThread = async (sourceFile, collection, conversationKey) => {
|
|
51
|
+
const rows = await getStoredThreads(collection === 'all' ? null : collection, sourceFile);
|
|
52
|
+
if (!rows.length)
|
|
53
|
+
return null;
|
|
54
|
+
const stored = pickNewestGroup(rows);
|
|
55
|
+
if (conversationKey) {
|
|
56
|
+
const row = stored.find((entry) => entry.conversationKey === conversationKey);
|
|
57
|
+
// A stale link (e.g. an old URL after re-indexing a rewritten export) falls
|
|
58
|
+
// back to re-parsing the source file below.
|
|
59
|
+
if (!row)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
sourceFile,
|
|
63
|
+
conversationKey: row.conversationKey,
|
|
64
|
+
title: row.title || undefined,
|
|
65
|
+
createdInThreadShelf: row.createdInThreadShelf,
|
|
66
|
+
threadCreatedAt: row.threadCreatedAt || undefined,
|
|
67
|
+
turns: sanitizeTurns(parseStoredTurns(row.turnsJson)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (stored.length === 1) {
|
|
71
|
+
const row = stored[0];
|
|
72
|
+
return {
|
|
73
|
+
sourceFile,
|
|
74
|
+
conversationKey: row.conversationKey,
|
|
75
|
+
title: row.title || undefined,
|
|
76
|
+
createdInThreadShelf: row.createdInThreadShelf,
|
|
77
|
+
threadCreatedAt: row.threadCreatedAt || undefined,
|
|
78
|
+
turns: sanitizeTurns(parseStoredTurns(row.turnsJson)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// No key against a multi-conversation export: flatten in original file order,
|
|
82
|
+
// mirroring the legacy parseExport behaviour.
|
|
83
|
+
return {
|
|
84
|
+
sourceFile,
|
|
85
|
+
conversationKey: '',
|
|
86
|
+
createdInThreadShelf: stored.every((row) => row.createdInThreadShelf),
|
|
87
|
+
threadCreatedAt: stored.find((row) => row.threadCreatedAt)?.threadCreatedAt,
|
|
88
|
+
turns: sanitizeTurns(stored.flatMap((row) => parseStoredTurns(row.turnsJson))),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const loadThreadFromSourceFile = async (sourceFile, collection, conversationKey) => {
|
|
92
|
+
const allowedFiles = collection === 'all'
|
|
93
|
+
? (await Promise.all((await getAllCollections()).map((n) => listSourceFilesInCollection(n)))).flat()
|
|
94
|
+
: await listSourceFilesInCollection(collection);
|
|
95
|
+
if (!allowedFiles.includes(sourceFile)) {
|
|
96
|
+
throw new NotFoundError('File not in collection or not found');
|
|
97
|
+
}
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = await readFile(sourceFile, 'utf-8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new NotFoundError('Source file is no longer readable and this collection predates stored threads — re-index to restore thread view');
|
|
104
|
+
}
|
|
105
|
+
const parsed = conversationKey
|
|
106
|
+
? getConversationFromExport(raw, conversationKey)
|
|
107
|
+
: { conversation: null, error: null };
|
|
108
|
+
if (parsed.error)
|
|
109
|
+
throw new NotFoundError(parsed.error);
|
|
110
|
+
const fallback = !conversationKey ? parseExport(raw) : null;
|
|
111
|
+
if (fallback?.error)
|
|
112
|
+
throw new BadRequestError(fallback.error);
|
|
113
|
+
const selected = parsed.conversation;
|
|
114
|
+
const turns = sanitizeTurns(selected?.turns || fallback?.turns || []);
|
|
115
|
+
return {
|
|
116
|
+
sourceFile,
|
|
117
|
+
conversationKey: selected?.key || conversationKey || '',
|
|
118
|
+
title: selected?.title,
|
|
119
|
+
createdInThreadShelf: false,
|
|
120
|
+
turns,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
export const loadThread = async (sourceFile, collection, conversationKey) => {
|
|
124
|
+
const stored = await loadStoredThread(sourceFile, collection, conversationKey);
|
|
125
|
+
if (stored)
|
|
126
|
+
return stored;
|
|
127
|
+
return loadThreadFromSourceFile(sourceFile, collection, conversationKey);
|
|
128
|
+
};
|
|
129
|
+
export class NotFoundError extends Error {
|
|
130
|
+
constructor(message) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = 'NotFoundError';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export class BadRequestError extends Error {
|
|
136
|
+
constructor(message) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = 'BadRequestError';
|
|
139
|
+
}
|
|
140
|
+
}
|