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,95 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { basename, resolve } from 'path';
|
|
3
|
+
import { ingestFolder } from './ingest.js';
|
|
4
|
+
import { watchFolder } from './watch.js';
|
|
5
|
+
import { normalizeCollectionName } from './validation.js';
|
|
6
|
+
import { recoverPendingIndexes, startIndexRecovery } from './store.js';
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const clearFirst = args.includes('--clear');
|
|
9
|
+
const watchMode = args.includes('--watch');
|
|
10
|
+
const readNumberFlag = (name, fallback) => {
|
|
11
|
+
const index = args.indexOf(name);
|
|
12
|
+
if (index === -1)
|
|
13
|
+
return fallback;
|
|
14
|
+
const value = Number(args[index + 1]);
|
|
15
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
16
|
+
console.error(`${name} expects a non-negative number of milliseconds`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
const debounceMs = readNumberFlag('--debounce', 2000);
|
|
22
|
+
const positional = [];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === undefined || arg === '--' || arg === '--clear' || arg === '--watch')
|
|
26
|
+
continue;
|
|
27
|
+
if (arg === '--debounce') {
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
positional.push(arg);
|
|
32
|
+
}
|
|
33
|
+
const folder = positional[0];
|
|
34
|
+
const collectionArg = positional[1] ?? 'chunks';
|
|
35
|
+
if (!folder || positional.length > 2) {
|
|
36
|
+
console.error('Usage: npm run ingest -- <folder> [collection] -- [--clear] [--watch] [--debounce <ms>]');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
let collection;
|
|
40
|
+
try {
|
|
41
|
+
collection = normalizeCollectionName(collectionArg);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
console.error(e.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const startedAt = Date.now();
|
|
48
|
+
const resolvedFolder = resolve(folder);
|
|
49
|
+
let lastLoggedFile = '';
|
|
50
|
+
try {
|
|
51
|
+
const result = await ingestFolder(collection, resolvedFolder, {
|
|
52
|
+
clearFirst,
|
|
53
|
+
onProgress: (event) => {
|
|
54
|
+
const fileLabel = event.currentFile ? basename(event.currentFile) : '';
|
|
55
|
+
if (fileLabel && fileLabel !== lastLoggedFile) {
|
|
56
|
+
lastLoggedFile = fileLabel;
|
|
57
|
+
console.error(`[ingest] ${event.processedFiles}/${event.totalFiles} ${fileLabel}`);
|
|
58
|
+
}
|
|
59
|
+
if (event.status === 'starting') {
|
|
60
|
+
console.error(`[ingest] collection=${collection} folder=${resolvedFolder}`);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
await recoverPendingIndexes({ collection });
|
|
65
|
+
console.error(`[ingest] done files=${result.files.length} chunks=${result.ingested} errors=${result.errors.length} elapsedMs=${Date.now() - startedAt}`);
|
|
66
|
+
console.log(JSON.stringify({ collection, ...result }, null, 2));
|
|
67
|
+
if (!watchMode) {
|
|
68
|
+
process.exit(result.errors.length > 0 ? 2 : 0);
|
|
69
|
+
}
|
|
70
|
+
const stopRecovery = startIndexRecovery();
|
|
71
|
+
const watcher = watchFolder(collection, resolvedFolder, {
|
|
72
|
+
debounceMs,
|
|
73
|
+
onBatch: (files, batchResult, error) => {
|
|
74
|
+
if (error) {
|
|
75
|
+
console.error(`[watch] re-index failed: ${error.message}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const names = files.map((file) => basename(file)).join(', ');
|
|
79
|
+
console.error(`[watch] re-indexed ${files.length} file(s) (${names}) chunks=${batchResult?.ingested ?? 0} errors=${batchResult?.errors.length ?? 0}`);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
console.error(`[watch] watching ${resolvedFolder} (debounce ${debounceMs}ms) — Ctrl+C to stop`);
|
|
83
|
+
const shutdown = async () => {
|
|
84
|
+
stopRecovery();
|
|
85
|
+
console.error('[watch] stopping…');
|
|
86
|
+
await watcher.close();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
process.on('SIGINT', shutdown);
|
|
90
|
+
process.on('SIGTERM', shutdown);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
console.error(`[ingest] failed: ${e.message}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { readdir, readFile } from 'fs/promises';
|
|
2
|
+
import { join, relative, resolve } from 'path';
|
|
3
|
+
import { parseConversationGroups, detectProvider } from './parser.js';
|
|
4
|
+
import { replaceImportedFiles } from './store.js';
|
|
5
|
+
const SKIP_FILES = new Set([
|
|
6
|
+
'users.json',
|
|
7
|
+
'projects.json',
|
|
8
|
+
'user.json',
|
|
9
|
+
'message_feedback.json',
|
|
10
|
+
'shared_conversations.json',
|
|
11
|
+
'sora.json',
|
|
12
|
+
'applet_access_history.json',
|
|
13
|
+
].map((n) => n.toLowerCase()));
|
|
14
|
+
// Name-level filter shared with watch mode (which only has a path, no dirent).
|
|
15
|
+
export const isExportFileName = (name) => {
|
|
16
|
+
const lowerName = name.toLowerCase();
|
|
17
|
+
if (SKIP_FILES.has(lowerName))
|
|
18
|
+
return false;
|
|
19
|
+
if (!lowerName.endsWith('.json') && /^file[-_]/.test(lowerName))
|
|
20
|
+
return false;
|
|
21
|
+
return lowerName.endsWith('.json') || !name.includes('.');
|
|
22
|
+
};
|
|
23
|
+
const isPotentialExportFile = (entry) => {
|
|
24
|
+
return entry.isFile() && isExportFileName(entry.name);
|
|
25
|
+
};
|
|
26
|
+
const getCanonicalExportKey = (fileName) => {
|
|
27
|
+
const lower = fileName.replace(/\\/g, '/').toLowerCase();
|
|
28
|
+
return lower.endsWith('.json') ? lower.slice(0, -5) : lower;
|
|
29
|
+
};
|
|
30
|
+
const collectExportEntries = async (folderPath) => {
|
|
31
|
+
const entries = [];
|
|
32
|
+
const walk = async (currentDir) => {
|
|
33
|
+
const dirEntries = await readdir(currentDir, { withFileTypes: true });
|
|
34
|
+
for (const entry of dirEntries) {
|
|
35
|
+
const fullPath = join(currentDir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
await walk(fullPath);
|
|
38
|
+
}
|
|
39
|
+
else if (isPotentialExportFile(entry)) {
|
|
40
|
+
entries.push({
|
|
41
|
+
name: entry.name,
|
|
42
|
+
relativePath: relative(folderPath, fullPath),
|
|
43
|
+
fullPath,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await walk(folderPath);
|
|
49
|
+
return entries;
|
|
50
|
+
};
|
|
51
|
+
const chooseExportFiles = (entries) => {
|
|
52
|
+
const byKey = new Map();
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const key = getCanonicalExportKey(entry.relativePath || entry.name);
|
|
55
|
+
const existing = byKey.get(key);
|
|
56
|
+
const isJson = entry.name.toLowerCase().endsWith('.json');
|
|
57
|
+
if (!existing || (isJson && !existing.name.toLowerCase().endsWith('.json'))) {
|
|
58
|
+
byKey.set(key, entry);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...byKey.values()].map((entry) => entry.fullPath);
|
|
62
|
+
};
|
|
63
|
+
export const listExportFiles = async (folderPath) => {
|
|
64
|
+
const resolved = resolve(folderPath);
|
|
65
|
+
const entries = await collectExportEntries(resolved);
|
|
66
|
+
return chooseExportFiles(entries);
|
|
67
|
+
};
|
|
68
|
+
export const ingestFolder = async (collection, folderPath, opts = {}) => {
|
|
69
|
+
const resolved = resolve(folderPath);
|
|
70
|
+
opts.signal?.throwIfAborted();
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await collectExportEntries(resolved);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return {
|
|
77
|
+
conversations: 0,
|
|
78
|
+
ingested: 0,
|
|
79
|
+
totalTokens: 0,
|
|
80
|
+
files: [],
|
|
81
|
+
errors: [`Folder error: ${e.message}`],
|
|
82
|
+
providers: {},
|
|
83
|
+
elapsedMs: 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const files = chooseExportFiles(entries);
|
|
87
|
+
return ingestFilesInternal(collection, files, opts, opts.clearFirst === true);
|
|
88
|
+
};
|
|
89
|
+
// Ingest an explicit list of export files (watch mode re-indexes just the
|
|
90
|
+
// files that changed). `clearFirst` is intentionally ignored here — clearing
|
|
91
|
+
// belongs to whole-folder ingests.
|
|
92
|
+
export const ingestFiles = async (collection, files, opts = {}) => ingestFilesInternal(collection, files, opts, false);
|
|
93
|
+
const ingestFilesInternal = async (collection, files, opts, clearFirst) => {
|
|
94
|
+
const { onProgress = () => { } } = opts;
|
|
95
|
+
opts.signal?.throwIfAborted();
|
|
96
|
+
const errors = [];
|
|
97
|
+
const totalFiles = files.length;
|
|
98
|
+
let processedFiles = 0;
|
|
99
|
+
let totalChunks = 0;
|
|
100
|
+
let totalTokens = 0;
|
|
101
|
+
let totalConversations = 0;
|
|
102
|
+
const ingestedFiles = [];
|
|
103
|
+
const stagedFiles = [];
|
|
104
|
+
const skippedFiles = [];
|
|
105
|
+
const providers = {};
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
onProgress({
|
|
108
|
+
status: 'starting',
|
|
109
|
+
totalFiles,
|
|
110
|
+
processedFiles: 0,
|
|
111
|
+
totalChunks: 0,
|
|
112
|
+
totalTokens: 0,
|
|
113
|
+
elapsedMs: 0,
|
|
114
|
+
});
|
|
115
|
+
for (const filePath of files) {
|
|
116
|
+
opts.signal?.throwIfAborted();
|
|
117
|
+
try {
|
|
118
|
+
onProgress({
|
|
119
|
+
status: 'progress',
|
|
120
|
+
phase: 'reading',
|
|
121
|
+
totalFiles,
|
|
122
|
+
processedFiles,
|
|
123
|
+
currentFile: filePath,
|
|
124
|
+
totalChunks,
|
|
125
|
+
totalTokens,
|
|
126
|
+
elapsedMs: Date.now() - startTime,
|
|
127
|
+
errorsCount: errors.length,
|
|
128
|
+
providers,
|
|
129
|
+
});
|
|
130
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
131
|
+
opts.signal?.throwIfAborted();
|
|
132
|
+
// Parse once, use for both detection and extraction
|
|
133
|
+
let jsonData;
|
|
134
|
+
try {
|
|
135
|
+
jsonData = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
errors.push(`${filePath}: Invalid JSON`);
|
|
139
|
+
processedFiles++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const provider = detectProvider(jsonData);
|
|
143
|
+
providers[provider] = (providers[provider] || 0) + 1;
|
|
144
|
+
const parsed = parseConversationGroups(jsonData);
|
|
145
|
+
if (parsed.error) {
|
|
146
|
+
errors.push(`${filePath}: ${parsed.error}`);
|
|
147
|
+
processedFiles++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (parsed.conversations.length === 0) {
|
|
151
|
+
skippedFiles.push(filePath);
|
|
152
|
+
processedFiles++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
totalConversations += parsed.conversations.length;
|
|
156
|
+
onProgress({
|
|
157
|
+
status: 'progress',
|
|
158
|
+
phase: clearFirst ? 'reading' : 'embedding',
|
|
159
|
+
progressPercent: clearFirst ? (20 * processedFiles) / Math.max(totalFiles, 1) : undefined,
|
|
160
|
+
totalFiles,
|
|
161
|
+
processedFiles,
|
|
162
|
+
currentFile: filePath,
|
|
163
|
+
totalChunks,
|
|
164
|
+
totalTokens,
|
|
165
|
+
elapsedMs: Date.now() - startTime,
|
|
166
|
+
errorsCount: errors.length,
|
|
167
|
+
providers,
|
|
168
|
+
});
|
|
169
|
+
const imported = { sourceFile: filePath, provider, conversations: parsed.conversations };
|
|
170
|
+
if (clearFirst)
|
|
171
|
+
stagedFiles.push(imported);
|
|
172
|
+
else {
|
|
173
|
+
const previousTokens = totalTokens;
|
|
174
|
+
totalChunks += await replaceImportedFiles(collection, [imported], {
|
|
175
|
+
signal: opts.signal,
|
|
176
|
+
onEmbeddingProgress: (done, total, tokens) => {
|
|
177
|
+
totalTokens = previousTokens + tokens;
|
|
178
|
+
onProgress({
|
|
179
|
+
status: 'progress',
|
|
180
|
+
phase: 'embedding',
|
|
181
|
+
totalFiles,
|
|
182
|
+
processedFiles,
|
|
183
|
+
currentFile: filePath,
|
|
184
|
+
totalChunks,
|
|
185
|
+
totalTokens,
|
|
186
|
+
elapsedMs: Date.now() - startTime,
|
|
187
|
+
embeddingDone: done,
|
|
188
|
+
embeddingTotal: total,
|
|
189
|
+
progressPercent: (95 * (processedFiles + done / Math.max(total, 1))) / Math.max(totalFiles, 1),
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
ingestedFiles.push(filePath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (opts.signal?.aborted)
|
|
198
|
+
throw opts.signal.reason;
|
|
199
|
+
errors.push(`${filePath}: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
processedFiles++;
|
|
202
|
+
onProgress({
|
|
203
|
+
status: 'progress',
|
|
204
|
+
phase: clearFirst ? 'reading' : undefined,
|
|
205
|
+
progressPercent: ((clearFirst ? 20 : 95) * processedFiles) / Math.max(totalFiles, 1),
|
|
206
|
+
totalFiles,
|
|
207
|
+
processedFiles,
|
|
208
|
+
currentFile: filePath,
|
|
209
|
+
totalChunks,
|
|
210
|
+
totalTokens,
|
|
211
|
+
elapsedMs: Date.now() - startTime,
|
|
212
|
+
errorsCount: errors.length,
|
|
213
|
+
providers,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const replacementSkipped = clearFirst && (errors.length > 0 || skippedFiles.length > 0 || stagedFiles.length === 0);
|
|
217
|
+
if (clearFirst && !replacementSkipped) {
|
|
218
|
+
try {
|
|
219
|
+
totalChunks = await replaceImportedFiles(collection, stagedFiles, {
|
|
220
|
+
clearFirst: true,
|
|
221
|
+
signal: opts.signal,
|
|
222
|
+
onEmbeddingProgress: (done, total, tokens) => {
|
|
223
|
+
totalTokens = tokens;
|
|
224
|
+
onProgress({
|
|
225
|
+
status: 'progress',
|
|
226
|
+
phase: 'embedding',
|
|
227
|
+
totalFiles,
|
|
228
|
+
processedFiles,
|
|
229
|
+
totalChunks: done,
|
|
230
|
+
totalTokens,
|
|
231
|
+
elapsedMs: Date.now() - startTime,
|
|
232
|
+
embeddingDone: done,
|
|
233
|
+
embeddingTotal: total,
|
|
234
|
+
progressPercent: 20 + (75 * done) / Math.max(total, 1),
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
ingestedFiles.push(...stagedFiles.map((file) => file.sourceFile));
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (opts.signal?.aborted)
|
|
242
|
+
throw opts.signal.reason;
|
|
243
|
+
errors.push(`Collection replacement: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
conversations: replacementSkipped ? 0 : totalConversations,
|
|
248
|
+
ingested: totalChunks,
|
|
249
|
+
totalTokens,
|
|
250
|
+
files: ingestedFiles,
|
|
251
|
+
errors,
|
|
252
|
+
...(skippedFiles.length ? { skippedFiles } : {}),
|
|
253
|
+
...(replacementSkipped ? { replacementSkipped: true } : {}),
|
|
254
|
+
providers,
|
|
255
|
+
elapsedMs: Date.now() - startTime,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { loadEnvFile } from 'node:process';
|
|
2
|
+
import { dataPath } from './paths.js';
|
|
3
|
+
/**
|
|
4
|
+
* Loads local development/runtime settings without overwriting variables that
|
|
5
|
+
* were explicitly provided by the parent process.
|
|
6
|
+
*/
|
|
7
|
+
export const loadThreadShelfEnv = (path = dataPath('env')) => {
|
|
8
|
+
try {
|
|
9
|
+
loadEnvFile(path);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT')
|
|
14
|
+
return false;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Keep provider IDs such as "google/gemini-2.0" intact while removing private
|
|
2
|
+
// filesystem prefixes from legacy local-model values.
|
|
3
|
+
export const portableModelLabel = (value) => {
|
|
4
|
+
if (!value)
|
|
5
|
+
return '';
|
|
6
|
+
const model = String(value).trim();
|
|
7
|
+
if (!/^[a-z]:[\\/]/i.test(model) && !model.startsWith('/') && !model.includes('\\')) {
|
|
8
|
+
return model;
|
|
9
|
+
}
|
|
10
|
+
return model
|
|
11
|
+
.replace(/\\/g, '/')
|
|
12
|
+
.split('/')
|
|
13
|
+
.at(-1)
|
|
14
|
+
.replace(/\.gguf$/i, '');
|
|
15
|
+
};
|