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,79 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * Single source of truth for "where does ThreadShelf read and write things".
7
+ *
8
+ * Two kinds of location, deliberately kept apart:
9
+ *
10
+ * - **Package files** (built UI, browser export scripts) live next to the
11
+ * installed module and are resolved from `import.meta.url`. They must never
12
+ * be resolved from `process.cwd()`: `npx threadshelf` runs with the user's
13
+ * shell directory as cwd, which has nothing to do with the package.
14
+ * - **Persistent user data** (LanceDB, uploads, collections, generation config)
15
+ * lives outside the package entirely. An npm/npx install directory is
16
+ * disposable — npm may wipe its `_npx` cache at any time — so nothing the
17
+ * user cares about may be stored there.
18
+ *
19
+ * Running from a repository checkout keeps the historical repo-local layout so
20
+ * development and the existing test harness are unaffected.
21
+ */
22
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
23
+ /**
24
+ * This module lives at `<root>/src/paths.ts` in development and at
25
+ * `<root>/dist/src/paths.js` once compiled, so the package root is one or two
26
+ * levels up depending on which copy is running.
27
+ */
28
+ const resolvePackageRoot = (dir) => {
29
+ const parent = dirname(dir);
30
+ return basename(parent) === 'dist' ? dirname(parent) : parent;
31
+ };
32
+ const PACKAGE_ROOT = resolvePackageRoot(moduleDir);
33
+ /** Root of the installed package (or the repo in development). Static assets only. */
34
+ export const packageRoot = () => PACKAGE_ROOT;
35
+ /** Path to a file shipped inside the package, e.g. the built UI or an export script. */
36
+ export const packagePath = (...segments) => join(PACKAGE_ROOT, ...segments);
37
+ /**
38
+ * True when running from a source checkout rather than an installed package.
39
+ * The published tarball ships `dist/`, never `src/*.ts`, so this cannot be
40
+ * confused by an end user's install.
41
+ */
42
+ export const isRepoCheckout = () => existsSync(join(PACKAGE_ROOT, 'src', 'server.ts'));
43
+ /** Per-user data directory for an installed package. */
44
+ export const userDataDir = (env = process.env, platform = process.platform, home = homedir()) => {
45
+ if (platform === 'win32') {
46
+ const localAppData = env.LOCALAPPDATA?.trim();
47
+ return join(localAppData || join(home, 'AppData', 'Local'), 'ThreadShelf');
48
+ }
49
+ return join(home, '.threadshelf');
50
+ };
51
+ const explicitDataDir = () => process.env.THREADSHELF_DATA_DIR?.trim() || '';
52
+ /** Whether persistent files use the repo-local dotfile layout or the flat user-data layout. */
53
+ const useRepoLayout = () => !explicitDataDir() && isRepoCheckout();
54
+ /** Root directory for everything persistent. Never inside the package for an installed copy. */
55
+ export const dataDir = () => resolve(explicitDataDir() || (isRepoCheckout() ? PACKAGE_ROOT : userDataDir()));
56
+ /**
57
+ * Repo layout keeps the dotfile names the project has always used; the
58
+ * user-data layout drops the dots because the directory is already dedicated.
59
+ */
60
+ const LAYOUT = {
61
+ lancedb: { repo: ['.lancedb'], user: ['lancedb'] },
62
+ uploads: { repo: ['.uploads'], user: ['uploads'] },
63
+ collections: { repo: ['.collections.json'], user: ['collections.json'] },
64
+ generationConfig: { repo: ['.threadshelf', 'generation.json'], user: ['generation.json'] },
65
+ masterPrompts: { repo: ['.threadshelf', 'master-prompts.json'], user: ['master-prompts.json'] },
66
+ generationErrorLog: {
67
+ repo: ['.threadshelf', 'generation-errors.log'],
68
+ user: ['generation-errors.log'],
69
+ },
70
+ models: { repo: ['.threadshelf', 'models'], user: ['models'] },
71
+ tools: { repo: ['.threadshelf', 'tools'], user: ['tools'] },
72
+ modelCache: { repo: ['.threadshelf', 'model-cache'], user: ['model-cache'] },
73
+ env: { repo: ['.env'], user: ['.env'] },
74
+ };
75
+ /** Absolute path of a persistent file or directory. Callers still apply their own env overrides. */
76
+ export const dataPath = (key) => {
77
+ const entry = LAYOUT[key];
78
+ return resolve(dataDir(), ...(useRepoLayout() ? entry.repo : entry.user));
79
+ };
@@ -0,0 +1,97 @@
1
+ import { Router } from 'express';
2
+ import { dropCollection } from '../store.js';
3
+ import { getAllCollections, addManualCollection, ensureCollectionExists, deleteCollectionFull, } from '../services/collections.js';
4
+ import { getStatsForCollection } from '../services/stats.js';
5
+ import { ValidationError, normalizeCollectionName, normalizeCollectionSelector, assertDeletableCollection, assertClearableCollection, } from '../validation.js';
6
+ const router = Router();
7
+ const sendValidationError = (res, err) => {
8
+ res.status(400).json({ error: err.message, field: err.field });
9
+ };
10
+ router.get('/api/collections', async (_req, res) => {
11
+ try {
12
+ const collections = await getAllCollections();
13
+ res.json({ collections });
14
+ }
15
+ catch (e) {
16
+ console.error('[/api/collections]', e);
17
+ res.status(500).json({ collections: ['chunks'] });
18
+ }
19
+ });
20
+ router.post('/api/collections', async (req, res) => {
21
+ let name;
22
+ try {
23
+ name = normalizeCollectionName(req.body?.name, { field: 'name' });
24
+ }
25
+ catch (e) {
26
+ if (e instanceof ValidationError)
27
+ return sendValidationError(res, e);
28
+ throw e;
29
+ }
30
+ try {
31
+ await addManualCollection(name);
32
+ res.json({ ok: true, collection: name });
33
+ }
34
+ catch (e) {
35
+ console.error('[/api/collections POST]', e);
36
+ res.status(500).json({ error: e.message });
37
+ }
38
+ });
39
+ router.post('/api/collections/:name/clear', async (req, res) => {
40
+ let name;
41
+ try {
42
+ name = assertClearableCollection(req.params.name);
43
+ }
44
+ catch (e) {
45
+ if (e instanceof ValidationError)
46
+ return sendValidationError(res, e);
47
+ throw e;
48
+ }
49
+ try {
50
+ await dropCollection(name);
51
+ await ensureCollectionExists(name);
52
+ res.json({ ok: true, collection: name });
53
+ }
54
+ catch (e) {
55
+ console.error('[/api/collections/:name/clear]', e);
56
+ res.status(500).json({ error: e.message });
57
+ }
58
+ });
59
+ router.delete('/api/collections/:name', async (req, res) => {
60
+ let name;
61
+ try {
62
+ name = assertDeletableCollection(req.params.name);
63
+ }
64
+ catch (e) {
65
+ if (e instanceof ValidationError)
66
+ return sendValidationError(res, e);
67
+ throw e;
68
+ }
69
+ try {
70
+ const uploadsDir = process.env.UPLOADS_DIR || '.uploads';
71
+ await deleteCollectionFull(name, uploadsDir);
72
+ res.json({ ok: true, collection: name });
73
+ }
74
+ catch (e) {
75
+ console.error('[/api/collections/:name DELETE]', e);
76
+ res.status(500).json({ error: e.message });
77
+ }
78
+ });
79
+ router.get('/api/collections/:name/stats', async (req, res) => {
80
+ let collection;
81
+ try {
82
+ collection = normalizeCollectionSelector(req.params.name);
83
+ }
84
+ catch (e) {
85
+ if (e instanceof ValidationError)
86
+ return sendValidationError(res, e);
87
+ throw e;
88
+ }
89
+ try {
90
+ res.json(await getStatsForCollection(collection));
91
+ }
92
+ catch (e) {
93
+ console.error('[/api/collections/:name/stats]', e);
94
+ res.status(500).json({ error: e.message });
95
+ }
96
+ });
97
+ export default router;
@@ -0,0 +1,136 @@
1
+ import { Router } from 'express';
2
+ import { readFile, stat } from 'fs/promises';
3
+ import { basename } from 'path';
4
+ import { listSourceFilesInCollection, listThreadSummaries, } from '../store.js';
5
+ import { listConversationsFromExport } from '../parser.js';
6
+ import { getAllCollections } from '../services/collections.js';
7
+ import { ValidationError, normalizeCollectionSelector } from '../validation.js';
8
+ const router = Router();
9
+ // Parsed-export cache keyed by collection+file. Bounded so a long-running
10
+ // server browsing many collections cannot grow it without limit; on overflow
11
+ // the oldest half is evicted (Map preserves insertion order).
12
+ const FILE_PARSE_CACHE_MAX = 512;
13
+ const fileParseCache = new Map();
14
+ const pruneFileParseCache = () => {
15
+ if (fileParseCache.size < FILE_PARSE_CACHE_MAX)
16
+ return;
17
+ const evict = Math.ceil(FILE_PARSE_CACHE_MAX / 2);
18
+ for (const key of [...fileParseCache.keys()].slice(0, evict)) {
19
+ fileParseCache.delete(key);
20
+ }
21
+ };
22
+ const expandFile = async (sourceFile, collection) => {
23
+ const info = await stat(sourceFile);
24
+ const cacheKey = `${collection}\0${sourceFile}`;
25
+ const cached = fileParseCache.get(cacheKey);
26
+ if (cached && cached.mtimeMs === info.mtimeMs) {
27
+ return cached.files;
28
+ }
29
+ const raw = await readFile(sourceFile, 'utf-8');
30
+ const parsed = listConversationsFromExport(raw);
31
+ if (parsed.error || !parsed.conversations.length) {
32
+ const fallback = [
33
+ {
34
+ sourceFile,
35
+ collection,
36
+ conversationKey: '',
37
+ title: basename(sourceFile),
38
+ turnCount: 0,
39
+ },
40
+ ];
41
+ pruneFileParseCache();
42
+ fileParseCache.set(cacheKey, { mtimeMs: info.mtimeMs, files: fallback });
43
+ return fallback;
44
+ }
45
+ const expanded = parsed.conversations.map((conversation) => ({
46
+ sourceFile,
47
+ collection,
48
+ conversationKey: conversation.key,
49
+ title: conversation.title,
50
+ turnCount: conversation.turnCount,
51
+ }));
52
+ pruneFileParseCache();
53
+ fileParseCache.set(cacheKey, { mtimeMs: info.mtimeMs, files: expanded });
54
+ return expanded;
55
+ };
56
+ // A source file may have been moved, rewritten, or deleted after indexing (e.g.
57
+ // LM Studio rewrites its conversation files as you keep chatting). Never let one
58
+ // unreadable file reject the whole listing — fall back to a minimal entry so the
59
+ // rest of the conversations still show.
60
+ const expandFileSafe = async (sourceFile, collection) => {
61
+ try {
62
+ return await expandFile(sourceFile, collection);
63
+ }
64
+ catch {
65
+ return [
66
+ {
67
+ sourceFile,
68
+ collection,
69
+ conversationKey: '',
70
+ title: basename(sourceFile),
71
+ turnCount: 0,
72
+ },
73
+ ];
74
+ }
75
+ };
76
+ // Conversations indexed since threads storage exists are listed straight from
77
+ // the __threads table (no file I/O). Files indexed before that fall back to
78
+ // re-parsing the export on disk.
79
+ const listCollectionFiles = async (collection) => {
80
+ const [sourceFiles, summaries] = await Promise.all([
81
+ listSourceFilesInCollection(collection),
82
+ listThreadSummaries(collection),
83
+ ]);
84
+ const bySource = new Map();
85
+ for (const summary of summaries) {
86
+ const group = bySource.get(summary.sourceFile) ?? [];
87
+ group.push(summary);
88
+ bySource.set(summary.sourceFile, group);
89
+ }
90
+ // __threads is authoritative for normalized conversations. Chunk tables are
91
+ // only a search index and can legitimately lag behind (empty/new chats or a
92
+ // temporarily failed embedding), so never use them as the listing's root.
93
+ const knownSources = new Set([...sourceFiles, ...summaries.map((row) => row.sourceFile)]);
94
+ const expanded = await Promise.all([...knownSources].sort().map(async (sourceFile) => {
95
+ const rows = bySource.get(sourceFile);
96
+ if (rows?.length) {
97
+ return [...rows]
98
+ .sort((a, b) => a.ordinal - b.ordinal)
99
+ .map((row) => ({
100
+ sourceFile,
101
+ collection,
102
+ conversationKey: row.conversationKey,
103
+ title: row.title || basename(sourceFile),
104
+ turnCount: row.turnCount,
105
+ provider: row.provider || undefined,
106
+ lastTurnAt: row.lastTurnAt || undefined,
107
+ createdInThreadShelf: row.createdInThreadShelf,
108
+ hasThreadShelfTurns: row.hasThreadShelfTurns,
109
+ }));
110
+ }
111
+ return expandFileSafe(sourceFile, collection);
112
+ }));
113
+ return expanded.flat();
114
+ };
115
+ router.get('/api/files', async (req, res) => {
116
+ let collection;
117
+ try {
118
+ collection = normalizeCollectionSelector(req.query?.collection);
119
+ }
120
+ catch (e) {
121
+ if (e instanceof ValidationError) {
122
+ return res.status(400).json({ error: e.message, field: e.field });
123
+ }
124
+ throw e;
125
+ }
126
+ try {
127
+ const collections = collection === 'all' ? await getAllCollections() : [collection];
128
+ const files = (await Promise.all(collections.map(listCollectionFiles))).flat();
129
+ res.json({ files });
130
+ }
131
+ catch (e) {
132
+ console.error('[/api/files]', e);
133
+ res.status(500).json({ error: e.message });
134
+ }
135
+ });
136
+ export default router;