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,215 @@
1
+ import { inspectHardware, judgeFit } from './hardware.js';
2
+ import { defaultLlamaInstallRoot, findLlamaExecutables, installLlamaCpp, readInstalledSource, resolveLlamaRelease, sourceFromRelease, } from './llama-install.js';
3
+ import { getCatalogModel, quantQualityTier, searchCatalogModels, TRUSTED_PUBLISHERS, } from './model-catalog.js';
4
+ import { downloadModel, modelFilesPresent, planModelDownload, } from './model-download.js';
5
+ import { createHash } from 'crypto';
6
+ import { updateGenerationConfig } from './config.js';
7
+ const CANDIDATE_LIMIT = 8;
8
+ /**
9
+ * A stable identity for "what this plan will fetch": versions, digests and
10
+ * sizes, nothing else. Hardware readings and warnings are deliberately excluded
11
+ * — free VRAM drifts constantly and would invalidate a plan the user is still
12
+ * reading. The server compares this against the value the client approved before
13
+ * it downloads anything.
14
+ */
15
+ export const quickSetupFingerprint = (plan) => {
16
+ const material = {
17
+ runtime: {
18
+ action: plan.runtime.action,
19
+ tag: plan.runtime.tag,
20
+ variant: plan.runtime.variant,
21
+ sha256: plan.runtime.sha256 ?? null,
22
+ companions: plan.runtime.companions.map((companion) => companion.sha256),
23
+ },
24
+ model: plan.model
25
+ ? {
26
+ action: plan.model.action,
27
+ repoId: plan.model.repoId,
28
+ quant: plan.model.quant,
29
+ totalBytes: plan.model.totalBytes,
30
+ files: plan.model.files.map((file) => file.sha256 ?? `size:${file.sizeBytes}`),
31
+ }
32
+ : null,
33
+ totalDownloadBytes: plan.totalDownloadBytes,
34
+ };
35
+ return createHash('sha256').update(JSON.stringify(material)).digest('hex').slice(0, 32);
36
+ };
37
+ export const quickSetupFingerprintMatches = (plan, approved) => typeof approved === 'string' &&
38
+ /^[a-f0-9]{32}$/.test(approved) &&
39
+ approved === quickSetupFingerprint(plan);
40
+ const percentOf = (done, total) => total && total > 0 ? Math.min(100, Math.round((done / total) * 100)) : undefined;
41
+ const findExistingInstall = async (variant, tag) => {
42
+ const executables = await findLlamaExecutables();
43
+ for (const executable of executables) {
44
+ // The install directory is `<root>/llama.cpp/<tag>-<flavor>`; matching on the
45
+ // recorded metadata is more reliable than parsing that path back apart.
46
+ const marker = executable.match(/^(.*[\\/]llama\.cpp[\\/][^\\/]+)[\\/]/);
47
+ if (!marker?.[1])
48
+ continue;
49
+ const installed = await readInstalledSource(marker[1]);
50
+ if (installed?.tag === tag && (installed.flavor ?? 'cpu') === variant)
51
+ return executable;
52
+ }
53
+ return undefined;
54
+ };
55
+ /**
56
+ * Picks the most-downloaded GGUF repository from a trusted publisher whose
57
+ * recommended quantisation fits the detected memory budget.
58
+ */
59
+ export const chooseSetupModel = async (profile, fetchImpl = fetch) => {
60
+ const { models } = await searchCatalogModels({ limit: 40, sort: 'downloads', fetchImpl });
61
+ const candidates = models
62
+ .filter((model) => model.trustedPublisher && model.gated === false)
63
+ .slice(0, CANDIDATE_LIMIT);
64
+ for (const candidate of candidates) {
65
+ const detail = await getCatalogModel(candidate.id, fetchImpl).catch(() => null);
66
+ if (!detail)
67
+ continue;
68
+ // Best quality tier that fits, then the largest file within that tier. Going
69
+ // by size alone would happily pick a legacy `Q4_1` over a better `Q4_K_M`.
70
+ const fitting = detail.quants
71
+ .filter((quant) => quantQualityTier(quant.label) > 1 && judgeFit(quant.totalBytes, profile) === 'fits')
72
+ .sort((a, b) => quantQualityTier(b.label) - quantQualityTier(a.label) || b.totalBytes - a.totalBytes)[0];
73
+ if (fitting)
74
+ return { detail, quant: fitting.label };
75
+ }
76
+ return null;
77
+ };
78
+ export const buildQuickSetupPlan = async ({ variant, repoId, quant, releaseTag, fetchImpl = fetch, } = {}) => {
79
+ const hardware = await inspectHardware();
80
+ const warnings = [];
81
+ const chosenVariant = variant ?? (process.platform === 'darwin' ? 'cpu' : hardware.suggestedVariant);
82
+ const release = await resolveLlamaRelease({ tag: releaseTag, fetchImpl });
83
+ let source;
84
+ try {
85
+ source = sourceFromRelease(release, { variant: chosenVariant });
86
+ }
87
+ catch (error) {
88
+ // A GPU build may simply not exist for this platform in this release.
89
+ warnings.push(`${chosenVariant.toUpperCase()} build unavailable (${error instanceof Error ? error.message : 'unknown'}); falling back to CPU.`);
90
+ source = sourceFromRelease(release, { variant: 'cpu' });
91
+ }
92
+ const effectiveVariant = source.flavor ?? 'cpu';
93
+ const existingExecutable = await findExistingInstall(effectiveVariant, source.tag);
94
+ const runtime = {
95
+ action: existingExecutable ? 'reuse' : 'install',
96
+ variant: effectiveVariant,
97
+ tag: source.tag,
98
+ url: existingExecutable ? undefined : source.url,
99
+ sha256: existingExecutable ? undefined : source.sha256,
100
+ sizeBytes: existingExecutable ? undefined : source.sizeBytes,
101
+ companions: existingExecutable
102
+ ? []
103
+ : (source.companions ?? []).map((companion) => ({
104
+ url: companion.url,
105
+ sha256: companion.sha256,
106
+ sizeBytes: companion.sizeBytes,
107
+ })),
108
+ destination: defaultLlamaInstallRoot(),
109
+ executablePath: existingExecutable,
110
+ releaseUrl: source.releaseUrl,
111
+ };
112
+ let model;
113
+ const selection = repoId
114
+ ? { detail: await getCatalogModel(repoId, fetchImpl), quant }
115
+ : await chooseSetupModel(hardware, fetchImpl);
116
+ if (!selection) {
117
+ warnings.push('No catalog model matched the detected memory budget. Pick one manually from the model browser.');
118
+ }
119
+ else {
120
+ const downloadPlan = await planModelDownload({
121
+ repoId: selection.detail.id,
122
+ quant: selection.quant,
123
+ detail: selection.detail,
124
+ fetchImpl,
125
+ });
126
+ // A model already on disk must not be presented as a fresh multi-gigabyte
127
+ // transfer; the downloader would skip it anyway.
128
+ const present = await modelFilesPresent(downloadPlan);
129
+ model = {
130
+ action: present ? 'reuse' : 'download',
131
+ repoId: downloadPlan.repoId,
132
+ quant: downloadPlan.quant,
133
+ totalBytes: downloadPlan.totalBytes,
134
+ directory: downloadPlan.directory,
135
+ files: downloadPlan.files.map((file) => ({
136
+ url: file.url,
137
+ sha256: file.sha256,
138
+ sizeBytes: file.sizeBytes,
139
+ })),
140
+ license: downloadPlan.license,
141
+ contextLength: downloadPlan.contextLength,
142
+ fit: judgeFit(downloadPlan.totalBytes, hardware),
143
+ };
144
+ if (downloadPlan.requiresToken) {
145
+ warnings.push(`${downloadPlan.repoId} is gated and needs HF_TOKEN before it can download.`);
146
+ }
147
+ }
148
+ if (hardware.detectionSource === 'none') {
149
+ warnings.push('No accelerator detected. Generation will run on CPU.');
150
+ }
151
+ // The figure on the confirm button must cover every byte the run will fetch,
152
+ // runtime archive and companions included — not just the model.
153
+ const runtimeBytes = runtime.action === 'install'
154
+ ? (runtime.sizeBytes ?? 0) +
155
+ runtime.companions.reduce((sum, companion) => sum + (companion.sizeBytes ?? 0), 0)
156
+ : 0;
157
+ const plan = {
158
+ hardware,
159
+ runtime,
160
+ model,
161
+ totalDownloadBytes: runtimeBytes + (model?.action === 'download' ? model.totalBytes : 0),
162
+ warnings,
163
+ fingerprint: '',
164
+ };
165
+ return { ...plan, fingerprint: quickSetupFingerprint(plan) };
166
+ };
167
+ export const runQuickSetupPlan = async (plan, { onProgress, signal, fetchImpl = fetch, } = {}) => {
168
+ let executablePath = plan.runtime.executablePath;
169
+ if (plan.runtime.action === 'install') {
170
+ signal?.throwIfAborted();
171
+ onProgress?.({ step: 'runtime', phase: 'resolving' });
172
+ // The plan is re-resolved rather than trusted: a client must not be able to
173
+ // hand the server an arbitrary URL to fetch and execute.
174
+ const release = await resolveLlamaRelease({ tag: plan.runtime.tag, fetchImpl });
175
+ const source = sourceFromRelease(release, { variant: plan.runtime.variant });
176
+ const result = await installLlamaCpp(source, {
177
+ signal,
178
+ onProgress: (progress) => onProgress?.({
179
+ step: 'runtime',
180
+ phase: progress.phase,
181
+ percent: percentOf(progress.downloadedBytes ?? 0, progress.totalBytes),
182
+ }),
183
+ });
184
+ executablePath = result.executablePath;
185
+ }
186
+ if (!executablePath)
187
+ throw new Error('llama.cpp setup did not produce an executable');
188
+ let modelPath;
189
+ if (plan.model) {
190
+ signal?.throwIfAborted();
191
+ onProgress?.({ step: 'model', phase: 'resolving' });
192
+ const downloadPlan = await planModelDownload({
193
+ repoId: plan.model.repoId,
194
+ quant: plan.model.quant,
195
+ fetchImpl,
196
+ });
197
+ const result = await downloadModel(downloadPlan, {
198
+ signal,
199
+ onProgress: (progress) => onProgress?.({
200
+ step: 'model',
201
+ phase: progress.phase,
202
+ file: 'file' in progress ? progress.file : undefined,
203
+ percent: progress.phase === 'downloading'
204
+ ? percentOf(progress.downloadedBytes, progress.totalBytes)
205
+ : undefined,
206
+ }),
207
+ });
208
+ modelPath = result.primaryPath;
209
+ }
210
+ // Pin the executable so later runs do not re-discover a different build.
211
+ await updateGenerationConfig({ llamaCpp: { executablePath } }).catch(() => undefined);
212
+ onProgress?.({ step: 'done', executablePath, modelPath });
213
+ return { executablePath, modelPath };
214
+ };
215
+ export const trustedSetupPublishers = TRUSTED_PUBLISHERS;
@@ -0,0 +1,23 @@
1
+ import { createLlamaCppProvider } from './providers/llama-cpp.js';
2
+ import { createOpenRouterProvider } from './providers/openrouter.js';
3
+ const providers = new Map([
4
+ ['llama-cpp', createLlamaCppProvider()],
5
+ ['openrouter', createOpenRouterProvider()],
6
+ ]);
7
+ export const getGenerationProvider = (id) => {
8
+ const provider = providers.get(id);
9
+ if (!provider)
10
+ throw new Error(`Unknown generation provider: ${id}`);
11
+ return provider;
12
+ };
13
+ export const listGenerationProviders = () => [...providers.values()];
14
+ export const setGenerationProviderForTests = (id, provider) => {
15
+ const previous = providers.get(id);
16
+ providers.set(id, provider);
17
+ return () => {
18
+ if (previous)
19
+ providers.set(id, previous);
20
+ else
21
+ providers.delete(id);
22
+ };
23
+ };
@@ -0,0 +1,100 @@
1
+ import { ValidationError } from '../validation.js';
2
+ import { persistGenerationError } from './error-log.js';
3
+ import { getGenerationProvider } from './registry.js';
4
+ const PROVIDERS = new Set(['llama-cpp', 'openrouter']);
5
+ const ROLES = new Set(['system', 'user', 'assistant']);
6
+ const MAX_MESSAGES = 500;
7
+ const MAX_CONTEXT_CHARS = 4_000_000;
8
+ export const validateChatRequest = (value) => {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
10
+ throw new ValidationError('Invalid chat request');
11
+ }
12
+ const raw = value;
13
+ if (typeof raw.provider !== 'string' || !PROVIDERS.has(raw.provider)) {
14
+ throw new ValidationError('Invalid provider', { field: 'provider' });
15
+ }
16
+ if (typeof raw.model !== 'string' || !raw.model.trim() || raw.model.length > 4096) {
17
+ throw new ValidationError('Invalid model', { field: 'model' });
18
+ }
19
+ if (!Array.isArray(raw.messages) ||
20
+ raw.messages.length === 0 ||
21
+ raw.messages.length > MAX_MESSAGES) {
22
+ throw new ValidationError(`Invalid messages: expected 1-${MAX_MESSAGES} entries`, {
23
+ field: 'messages',
24
+ });
25
+ }
26
+ let totalChars = 0;
27
+ const messages = raw.messages.map((entry, index) => {
28
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
29
+ throw new ValidationError(`Invalid messages[${index}]`, { field: 'messages' });
30
+ }
31
+ const message = entry;
32
+ if (typeof message.role !== 'string' || !ROLES.has(message.role)) {
33
+ throw new ValidationError(`Invalid messages[${index}].role`, { field: 'messages' });
34
+ }
35
+ if (typeof message.content !== 'string' || !message.content.trim()) {
36
+ throw new ValidationError(`Invalid messages[${index}].content`, { field: 'messages' });
37
+ }
38
+ totalChars += message.content.length;
39
+ return {
40
+ role: message.role,
41
+ content: message.content,
42
+ };
43
+ });
44
+ if (totalChars > MAX_CONTEXT_CHARS) {
45
+ throw new ValidationError(`Conversation context exceeds ${MAX_CONTEXT_CHARS} characters`, {
46
+ field: 'messages',
47
+ });
48
+ }
49
+ const temperature = raw.temperature === undefined ? 0.7 : Number(raw.temperature);
50
+ if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
51
+ throw new ValidationError('Invalid temperature: expected 0-2', { field: 'temperature' });
52
+ }
53
+ const maxTokens = raw.maxTokens === undefined ? 1024 : Number(raw.maxTokens);
54
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 32768) {
55
+ throw new ValidationError('Invalid maxTokens: expected integer from 1 to 32768', {
56
+ field: 'maxTokens',
57
+ });
58
+ }
59
+ if (raw.openRouterZdr !== undefined && typeof raw.openRouterZdr !== 'boolean') {
60
+ throw new ValidationError('Invalid openRouterZdr: expected boolean', {
61
+ field: 'openRouterZdr',
62
+ });
63
+ }
64
+ if (raw.persistDiagnostics !== undefined && typeof raw.persistDiagnostics !== 'boolean') {
65
+ throw new ValidationError('Invalid persistDiagnostics: expected boolean', {
66
+ field: 'persistDiagnostics',
67
+ });
68
+ }
69
+ return {
70
+ provider: raw.provider,
71
+ model: raw.model.trim(),
72
+ messages,
73
+ temperature,
74
+ maxTokens,
75
+ openRouterZdr: raw.provider === 'openrouter' ? raw.openRouterZdr : undefined,
76
+ persistDiagnostics: raw.persistDiagnostics,
77
+ };
78
+ };
79
+ export const generateChat = async (input, signal) => {
80
+ const request = validateChatRequest(input);
81
+ try {
82
+ return await getGenerationProvider(request.provider).chat(request, signal);
83
+ }
84
+ catch (error) {
85
+ if (!signal?.aborted)
86
+ await persistGenerationError(request, error).catch(() => undefined);
87
+ throw error;
88
+ }
89
+ };
90
+ export const generateChatStream = async (input, onDelta, signal) => {
91
+ const request = validateChatRequest(input);
92
+ try {
93
+ return await getGenerationProvider(request.provider).chatStream(request, onDelta, signal);
94
+ }
95
+ catch (error) {
96
+ if (!signal?.aborted)
97
+ await persistGenerationError(request, error).catch(() => undefined);
98
+ throw error;
99
+ }
100
+ };
@@ -0,0 +1,311 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getStoredThreads, listThreadSummaries, deleteStoredFile, replaceThreadsForFile, StoredThreadWriteError, updateStoredThread, updateStoredThreadFromCurrent, renameStoredThread, indexStoredFile, } from '../store.js';
3
+ import { validateTurns, ValidationError } from '../validation.js';
4
+ import { addManualCollection } from '../services/collections.js';
5
+ import { portableModelLabel } from '../model-label.js';
6
+ export const THREADSHELF_CHAT_COLLECTION = 'threadshelf_conversations';
7
+ const LEGACY_THREADSHELF_CHAT_COLLECTION = '__threadshelf_chats';
8
+ const THREADSHELF_CHAT_SOURCE_PREFIX = 'threadshelf://chat/';
9
+ const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ // Longest chat title we persist, for both auto-derived and user-set names.
11
+ export const CHAT_TITLE_MAX = 200;
12
+ export class ThreadShelfChatNotFoundError extends Error {
13
+ constructor() {
14
+ super('ThreadShelf chat not found');
15
+ this.name = 'ThreadShelfChatNotFoundError';
16
+ }
17
+ }
18
+ export class ThreadShelfChatBusyError extends Error {
19
+ constructor() {
20
+ super('This ThreadShelf chat is already generating a response');
21
+ this.name = 'ThreadShelfChatBusyError';
22
+ }
23
+ }
24
+ const activeChats = new Set();
25
+ const acquireGenerationKey = (key) => {
26
+ if (activeChats.has(key))
27
+ throw new ThreadShelfChatBusyError();
28
+ activeChats.add(key);
29
+ return () => activeChats.delete(key);
30
+ };
31
+ export const acquireThreadShelfChat = (id) => {
32
+ const normalized = assertThreadShelfChatId(id);
33
+ return acquireGenerationKey(`chat\0${normalized}`);
34
+ };
35
+ export const assertThreadShelfChatId = (value) => {
36
+ if (typeof value !== 'string' || !THREAD_ID_PATTERN.test(value)) {
37
+ throw new ThreadShelfChatNotFoundError();
38
+ }
39
+ return value.toLowerCase();
40
+ };
41
+ const sourceFileForId = (id) => `${THREADSHELF_CHAT_SOURCE_PREFIX}${id}`;
42
+ const idFromSourceFile = (sourceFile) => {
43
+ if (!sourceFile.startsWith(THREADSHELF_CHAT_SOURCE_PREFIX))
44
+ return null;
45
+ const id = sourceFile.slice(THREADSHELF_CHAT_SOURCE_PREFIX.length);
46
+ return THREAD_ID_PATTERN.test(id) ? id.toLowerCase() : null;
47
+ };
48
+ const parseTurns = (row) => {
49
+ return validateTurns(JSON.parse(row.turnsJson));
50
+ };
51
+ const markCreatedChatTurns = (turns) => turns.map((turn) => ({
52
+ ...turn,
53
+ ...(turn.model ? { model: portableModelLabel(turn.model) } : {}),
54
+ createdInThreadShelf: true,
55
+ }));
56
+ const toChat = (row) => {
57
+ const id = idFromSourceFile(row.sourceFile);
58
+ if (!id || !row.createdInThreadShelf)
59
+ throw new ThreadShelfChatNotFoundError();
60
+ const createdAt = row.threadCreatedAt || row.ingestedAt;
61
+ return {
62
+ id,
63
+ title: row.title || 'New chat',
64
+ createdAt,
65
+ updatedAt: row.lastTurnAt || row.ingestedAt || createdAt,
66
+ turnCount: row.turnCount,
67
+ model: portableModelLabel(row.lastModel) ||
68
+ portableModelLabel([...parseTurns(row)].reverse().find((turn) => turn.model)?.model) ||
69
+ undefined,
70
+ createdInThreadShelf: true,
71
+ turns: markCreatedChatTurns(parseTurns(row)),
72
+ };
73
+ };
74
+ const conversationFromChat = (chat) => ({
75
+ key: chat.id,
76
+ title: chat.title,
77
+ turns: chat.turns,
78
+ createdInThreadShelf: true,
79
+ threadCreatedAt: chat.createdAt,
80
+ });
81
+ const insertChat = async (chat) => {
82
+ await replaceThreadsForFile(THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id), 'threadshelf', [conversationFromChat(chat)]);
83
+ };
84
+ const updateChat = async (chat) => {
85
+ await updateStoredThread(THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id), 'threadshelf', conversationFromChat(chat));
86
+ };
87
+ const indexWithStatus = async (target) => {
88
+ try {
89
+ const indexedChunks = await indexStoredFile(target.collection, target.sourceFile);
90
+ return { saved: true, indexed: true, indexedChunks };
91
+ }
92
+ catch (error) {
93
+ const warning = `Conversation was saved, but semantic indexing failed and will be retried: ${error instanceof Error ? error.message : String(error)}`;
94
+ console.warn('[generation:index]', warning);
95
+ // The pending marker was written atomically with the turns. The server's
96
+ // recovery worker retries it from current storage, including after restart.
97
+ return { saved: true, indexed: false, indexedChunks: 0, warning };
98
+ }
99
+ };
100
+ const migrateLegacyChat = async (row) => {
101
+ const chat = toChat(row);
102
+ await addManualCollection(THREADSHELF_CHAT_COLLECTION);
103
+ await insertChat(chat);
104
+ await replaceThreadsForFile(LEGACY_THREADSHELF_CHAT_COLLECTION, row.sourceFile, 'threadshelf', []);
105
+ await indexWithStatus({
106
+ collection: THREADSHELF_CHAT_COLLECTION,
107
+ sourceFile: row.sourceFile,
108
+ });
109
+ return chat;
110
+ };
111
+ // This title is persisted, so truncating here discards the text permanently.
112
+ // Keep it whole up to the same ceiling `renameChat` enforces; shortening for
113
+ // display is the UI's job (see `.r-title` in `_search.scss`).
114
+ export const titleFromPrompt = (prompt) => {
115
+ const compact = prompt.replace(/\s+/g, ' ').trim();
116
+ return compact.length > CHAT_TITLE_MAX ? `${compact.slice(0, CHAT_TITLE_MAX - 1)}…` : compact;
117
+ };
118
+ export const createThreadShelfChat = async (title = 'New chat', initialTurns = []) => {
119
+ const now = new Date().toISOString();
120
+ const turns = markCreatedChatTurns(initialTurns.map((turn) => ({ ...turn, createdAt: turn.createdAt ?? now })));
121
+ const firstPrompt = turns.find((turn) => typeof turn.user === 'string')?.user;
122
+ const model = [...turns].reverse().find((turn) => turn.model)?.model;
123
+ const chat = {
124
+ id: randomUUID(),
125
+ title: title === 'New chat' && firstPrompt ? titleFromPrompt(firstPrompt) : title,
126
+ createdAt: now,
127
+ updatedAt: now,
128
+ turnCount: turns.length,
129
+ model,
130
+ createdInThreadShelf: true,
131
+ turns,
132
+ };
133
+ await addManualCollection(THREADSHELF_CHAT_COLLECTION);
134
+ await insertChat(chat);
135
+ if (turns.length) {
136
+ await indexWithStatus({
137
+ collection: THREADSHELF_CHAT_COLLECTION,
138
+ sourceFile: sourceFileForId(chat.id),
139
+ });
140
+ }
141
+ return chat;
142
+ };
143
+ export const renameThreadShelfChat = async (value, title) => {
144
+ const trimmed = title.replace(/\s+/g, ' ').trim();
145
+ // A bare Error would surface as a 502; an empty title is a client mistake.
146
+ if (!trimmed)
147
+ throw new ValidationError('A chat title cannot be empty', { field: 'title' });
148
+ const chat = await getThreadShelfChat(value);
149
+ return toChat(await renameStoredThread(THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id), chat.id, trimmed.slice(0, CHAT_TITLE_MAX)));
150
+ };
151
+ export const deleteThreadShelfChat = async (value) => {
152
+ const release = acquireThreadShelfChat(value);
153
+ try {
154
+ const chat = await getThreadShelfChat(value);
155
+ // A crash during legacy migration can leave both copies. Remove the fallback
156
+ // first so deleting the primary copy cannot make the legacy one reappear.
157
+ await deleteStoredFile(LEGACY_THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id));
158
+ await deleteStoredFile(THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id));
159
+ }
160
+ finally {
161
+ release();
162
+ }
163
+ };
164
+ export const getThreadShelfChat = async (value) => {
165
+ const id = assertThreadShelfChatId(value);
166
+ const sourceFile = sourceFileForId(id);
167
+ const rows = await getStoredThreads(THREADSHELF_CHAT_COLLECTION, sourceFile);
168
+ const row = rows.find((candidate) => candidate.conversationKey === id);
169
+ if (row) {
170
+ const chat = toChat(row);
171
+ if (parseTurns(row).some((turn) => turn.createdInThreadShelf !== true)) {
172
+ await updateChat(chat);
173
+ await indexWithStatus({
174
+ collection: THREADSHELF_CHAT_COLLECTION,
175
+ sourceFile,
176
+ });
177
+ }
178
+ return chat;
179
+ }
180
+ const legacyRows = await getStoredThreads(LEGACY_THREADSHELF_CHAT_COLLECTION, sourceFile);
181
+ const legacy = legacyRows.find((candidate) => candidate.conversationKey === id);
182
+ if (!legacy)
183
+ throw new ThreadShelfChatNotFoundError();
184
+ return migrateLegacyChat(legacy);
185
+ };
186
+ export const listThreadShelfChats = async () => {
187
+ const [legacyRows, rows] = await Promise.all([
188
+ listThreadSummaries(LEGACY_THREADSHELF_CHAT_COLLECTION),
189
+ listThreadSummaries(THREADSHELF_CHAT_COLLECTION),
190
+ ]);
191
+ const chats = new Map();
192
+ for (const row of [...legacyRows, ...rows]) {
193
+ const id = idFromSourceFile(row.sourceFile);
194
+ if (!id || !row.createdInThreadShelf)
195
+ continue;
196
+ const createdAt = row.threadCreatedAt;
197
+ chats.set(id, {
198
+ id,
199
+ title: row.title || 'New chat',
200
+ createdAt,
201
+ updatedAt: row.lastTurnAt || createdAt,
202
+ turnCount: row.turnCount,
203
+ model: portableModelLabel(row.lastModel) || undefined,
204
+ createdInThreadShelf: true,
205
+ });
206
+ }
207
+ return [...chats.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
208
+ };
209
+ export const appendThreadShelfChatExchange = async (value, prompt, response) => {
210
+ const chat = await getThreadShelfChat(value);
211
+ const now = new Date().toISOString();
212
+ const provenance = {
213
+ createdAt: now,
214
+ createdInThreadShelf: true,
215
+ generationProvider: response.provider,
216
+ };
217
+ await updateStoredThreadFromCurrent(THREADSHELF_CHAT_COLLECTION, sourceFileForId(chat.id), chat.id, (current) => {
218
+ const turns = [
219
+ ...parseTurns(current),
220
+ { user: prompt, model: response.model, ...provenance },
221
+ ];
222
+ if (response.reasoning?.trim())
223
+ turns.push({ thinking: response.reasoning, model: response.model, ...provenance });
224
+ turns.push({ ai: response.content, model: response.model, ...provenance });
225
+ return {
226
+ provider: 'threadshelf',
227
+ conversation: {
228
+ key: current.conversationKey,
229
+ title: current.turnCount === 0 && current.title === 'New chat'
230
+ ? titleFromPrompt(prompt)
231
+ : current.title,
232
+ turns,
233
+ createdInThreadShelf: true,
234
+ threadCreatedAt: current.threadCreatedAt,
235
+ },
236
+ };
237
+ });
238
+ const persistence = await indexWithStatus({
239
+ collection: THREADSHELF_CHAT_COLLECTION,
240
+ sourceFile: sourceFileForId(chat.id),
241
+ });
242
+ const updated = await getThreadShelfChat(chat.id);
243
+ return { chat: updated, persistence };
244
+ };
245
+ export const acquireStoredThreadGeneration = (target) => target.collection === THREADSHELF_CHAT_COLLECTION && idFromSourceFile(target.sourceFile)
246
+ ? acquireThreadShelfChat(idFromSourceFile(target.sourceFile))
247
+ : acquireGenerationKey(`stored\0${target.collection}\0${target.sourceFile}\0${target.conversationKey}`);
248
+ export const resolveStoredThreadGenerationTarget = async (collection, sourceFile, conversationKey) => {
249
+ const rows = await getStoredThreads(collection === 'all' ? null : collection, sourceFile);
250
+ const candidates = conversationKey
251
+ ? rows.filter((row) => row.conversationKey === conversationKey)
252
+ : rows;
253
+ const row = [...candidates].sort((a, b) => b.ingestedAt.localeCompare(a.ingestedAt))[0];
254
+ if (!row)
255
+ return null;
256
+ return {
257
+ collection: row.collection,
258
+ sourceFile: row.sourceFile,
259
+ conversationKey: row.conversationKey,
260
+ title: row.title,
261
+ provider: row.provider,
262
+ createdInThreadShelf: row.createdInThreadShelf,
263
+ threadCreatedAt: row.threadCreatedAt,
264
+ turns: parseTurns(row),
265
+ };
266
+ };
267
+ export const appendStoredThreadExchange = async (target, prompt, response) => {
268
+ const now = new Date().toISOString();
269
+ const provenance = {
270
+ createdAt: now,
271
+ createdInThreadShelf: true,
272
+ generationProvider: response.provider,
273
+ };
274
+ try {
275
+ await updateStoredThreadFromCurrent(target.collection, target.sourceFile, target.conversationKey, (current) => {
276
+ const currentTurns = parseTurns(current);
277
+ const turns = [
278
+ ...currentTurns,
279
+ { user: prompt, model: response.model, ...provenance },
280
+ ];
281
+ if (response.reasoning?.trim()) {
282
+ turns.push({ thinking: response.reasoning, model: response.model, ...provenance });
283
+ }
284
+ turns.push({ ai: response.content, model: response.model, ...provenance });
285
+ return {
286
+ provider: current.provider || target.provider,
287
+ conversation: {
288
+ key: current.conversationKey,
289
+ title: current.title || target.title,
290
+ turns,
291
+ createdInThreadShelf: current.createdInThreadShelf,
292
+ threadCreatedAt: current.threadCreatedAt,
293
+ },
294
+ };
295
+ });
296
+ }
297
+ catch (error) {
298
+ if (!(error instanceof StoredThreadWriteError))
299
+ throw error;
300
+ return {
301
+ saved: false,
302
+ indexed: false,
303
+ indexedChunks: 0,
304
+ warning: `${error.message}. The generated response remains available in this browser session but was not added to the archive.`,
305
+ };
306
+ }
307
+ return indexWithStatus({
308
+ collection: target.collection,
309
+ sourceFile: target.sourceFile,
310
+ });
311
+ };
@@ -0,0 +1 @@
1
+ export {};