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,536 @@
1
+ import { Router } from 'express';
2
+ import { getGenerationConfig, llamaCppConfigChanged, updateGenerationConfig, } from '../generation/config.js';
3
+ import { browseDirectories, isLoopbackRequest } from '../generation/filesystem-browser.js';
4
+ import { requireLoopback } from './loopback.js';
5
+ import { getManagedLlamaStatus, getLlamaRuntimeDiagnostics, LlamaModelBusyError, stopManagedLlamaServer, withLlamaRuntimeControl, } from '../generation/llama-process.js';
6
+ import { createMasterPrompt, deleteMasterPrompt, listMasterPrompts, setActiveMasterPrompt, updateMasterPrompt, } from '../generation/master-prompts.js';
7
+ import { listGenerationProviders, getGenerationProvider } from '../generation/registry.js';
8
+ import { generateChat, generateChatStream } from '../generation/service.js';
9
+ import { acquireThreadShelfChat, acquireStoredThreadGeneration, appendStoredThreadExchange, appendThreadShelfChatExchange, createThreadShelfChat, deleteThreadShelfChat, renameThreadShelfChat, getThreadShelfChat, listThreadShelfChats, resolveStoredThreadGenerationTarget, ThreadShelfChatBusyError, ThreadShelfChatNotFoundError, } from '../generation/threads.js';
10
+ import { NotFoundError, BadRequestError } from '../services/thread.js';
11
+ import { normalizeCollectionSelector, normalizeOptionalString, normalizeQuery, validateTurns, ValidationError, } from '../validation.js';
12
+ const router = Router();
13
+ const providerIds = new Set(['llama-cpp', 'openrouter']);
14
+ const openRouterSorts = new Set(['default', 'most-popular', 'newest']);
15
+ class GenerationConflictError extends Error {
16
+ }
17
+ const parseProvider = (value) => {
18
+ if (typeof value !== 'string' || !providerIds.has(value)) {
19
+ throw new ValidationError('Invalid provider', { field: 'provider' });
20
+ }
21
+ return value;
22
+ };
23
+ const errorResponse = (res, error) => {
24
+ if (error instanceof ValidationError) {
25
+ res.status(400).json({ error: error.message, field: error.field });
26
+ return;
27
+ }
28
+ if (error instanceof NotFoundError) {
29
+ res.status(404).json({ error: error.message });
30
+ return;
31
+ }
32
+ if (error instanceof BadRequestError) {
33
+ res.status(400).json({ error: error.message });
34
+ return;
35
+ }
36
+ if (error instanceof LlamaModelBusyError) {
37
+ res.status(409).json({ error: error.message });
38
+ return;
39
+ }
40
+ if (error instanceof ThreadShelfChatNotFoundError) {
41
+ res.status(404).json({ error: error.message });
42
+ return;
43
+ }
44
+ if (error instanceof ThreadShelfChatBusyError) {
45
+ res.status(409).json({ error: error.message });
46
+ return;
47
+ }
48
+ if (error instanceof GenerationConflictError) {
49
+ res.status(409).json({ error: error.message });
50
+ return;
51
+ }
52
+ console.error('[/api/generation]', error);
53
+ res.status(502).json({ error: error instanceof Error ? error.message : 'Generation failed' });
54
+ };
55
+ router.get('/api/generation/config', requireLoopback, async (_req, res) => {
56
+ try {
57
+ const [config, statuses] = await Promise.all([
58
+ getGenerationConfig(),
59
+ Promise.all(listGenerationProviders().map((provider) => provider.status())),
60
+ ]);
61
+ res.json({ config, providers: statuses });
62
+ }
63
+ catch (error) {
64
+ errorResponse(res, error);
65
+ }
66
+ });
67
+ router.put('/api/generation/config', requireLoopback, async (req, res) => {
68
+ try {
69
+ const update = async () => {
70
+ const previous = await getGenerationConfig();
71
+ const config = await updateGenerationConfig(req.body);
72
+ if (llamaCppConfigChanged(previous, config))
73
+ await stopManagedLlamaServer();
74
+ const statuses = await Promise.all(listGenerationProviders().map((provider) => provider.status()));
75
+ return { config, providers: statuses };
76
+ };
77
+ const result = req.body?.llamaCpp === undefined ? await update() : await withLlamaRuntimeControl(update);
78
+ res.json(result);
79
+ }
80
+ catch (error) {
81
+ errorResponse(res, error);
82
+ }
83
+ });
84
+ router.get('/api/generation/directories', async (req, res) => {
85
+ try {
86
+ const forwardedFor = [req.headers['x-forwarded-for'], req.headers['x-real-ip']]
87
+ .flatMap((value) => (Array.isArray(value) ? value : value ? [value] : []))
88
+ .map(String);
89
+ if (!isLoopbackRequest(req.socket.remoteAddress, forwardedFor, req.headers.forwarded, req.hostname)) {
90
+ res.status(403).json({ error: 'System folder browsing is available only from localhost' });
91
+ return;
92
+ }
93
+ res.json(await browseDirectories(typeof req.query.path === 'string' ? req.query.path : undefined));
94
+ }
95
+ catch (error) {
96
+ errorResponse(res, error);
97
+ }
98
+ });
99
+ router.get('/api/generation/runtime', async (_req, res) => {
100
+ try {
101
+ const config = await getGenerationConfig();
102
+ if (!config.llamaCpp.baseUrl) {
103
+ res.json({ backend: 'llama.cpp', runtime: getManagedLlamaStatus() });
104
+ return;
105
+ }
106
+ const models = await getGenerationProvider('llama-cpp').listModels();
107
+ res.json({
108
+ backend: 'llama.cpp',
109
+ runtime: {
110
+ state: 'external',
111
+ model: models.find((model) => model.loaded)?.id,
112
+ detail: `Connected to an existing local server at ${config.llamaCpp.baseUrl}.`,
113
+ },
114
+ });
115
+ }
116
+ catch (error) {
117
+ errorResponse(res, error);
118
+ }
119
+ });
120
+ router.get('/api/generation/runtime/logs', requireLoopback, async (_req, res) => {
121
+ try {
122
+ res.json(await getLlamaRuntimeDiagnostics());
123
+ }
124
+ catch (error) {
125
+ errorResponse(res, error);
126
+ }
127
+ });
128
+ router.post('/api/generation/runtime/eject', requireLoopback, async (req, res) => {
129
+ try {
130
+ const result = await withLlamaRuntimeControl(async () => {
131
+ const config = await getGenerationConfig();
132
+ if (config.llamaCpp.baseUrl) {
133
+ const requestedModel = req.body?.model;
134
+ if (typeof requestedModel !== 'string' || !requestedModel.trim()) {
135
+ throw new GenerationConflictError('No model is loaded on the external llama.cpp server');
136
+ }
137
+ const model = normalizeQuery(requestedModel, { field: 'model', maxLength: 4096 });
138
+ const response = await fetch(`${config.llamaCpp.baseUrl}/models/unload`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ model }),
142
+ signal: AbortSignal.timeout(30_000),
143
+ });
144
+ if (!response.ok) {
145
+ const payload = (await response.json().catch(() => ({})));
146
+ throw new Error(payload.error?.message || `External llama.cpp unload failed (${response.status})`);
147
+ }
148
+ }
149
+ else {
150
+ await stopManagedLlamaServer();
151
+ }
152
+ return {
153
+ backend: 'llama.cpp',
154
+ runtime: {
155
+ state: config.llamaCpp.baseUrl ? 'external' : 'stopped',
156
+ detail: config.llamaCpp.baseUrl
157
+ ? 'Unload request accepted by the existing local server.'
158
+ : 'The managed model was unloaded from memory. GGUF files were not deleted.',
159
+ },
160
+ };
161
+ });
162
+ res.json(result);
163
+ }
164
+ catch (error) {
165
+ errorResponse(res, error);
166
+ }
167
+ });
168
+ router.get('/api/generation/models', requireLoopback, async (req, res) => {
169
+ try {
170
+ const provider = parseProvider(req.query.provider);
171
+ const sort = String(req.query.sort || 'default');
172
+ if (!openRouterSorts.has(sort)) {
173
+ throw new ValidationError('Invalid model sort', { field: 'sort' });
174
+ }
175
+ const models = await getGenerationProvider(provider).listModels({
176
+ sort: sort,
177
+ freeOnly: req.query.free === '1',
178
+ });
179
+ let runtime;
180
+ if (provider === 'llama-cpp') {
181
+ const config = await getGenerationConfig();
182
+ runtime = config.llamaCpp.baseUrl
183
+ ? {
184
+ state: 'external',
185
+ model: models.find((model) => model.loaded)?.id,
186
+ detail: `Using an existing local server at ${config.llamaCpp.baseUrl}.`,
187
+ }
188
+ : getManagedLlamaStatus();
189
+ }
190
+ else {
191
+ runtime = {
192
+ state: 'remote',
193
+ detail: 'Models run remotely through OpenRouter and are not loaded by ThreadShelf.',
194
+ };
195
+ }
196
+ res.json({ provider, models, runtime });
197
+ }
198
+ catch (error) {
199
+ errorResponse(res, error);
200
+ }
201
+ });
202
+ router.get('/api/generation/prompts', requireLoopback, async (_req, res) => {
203
+ try {
204
+ res.json(await listMasterPrompts());
205
+ }
206
+ catch (error) {
207
+ errorResponse(res, error);
208
+ }
209
+ });
210
+ router.post('/api/generation/prompts', requireLoopback, async (req, res) => {
211
+ try {
212
+ res.status(201).json(await createMasterPrompt(req.body ?? {}));
213
+ }
214
+ catch (error) {
215
+ errorResponse(res, error);
216
+ }
217
+ });
218
+ // Registered before '/:id' so the literal segment is never read as an id.
219
+ router.put('/api/generation/prompts/active', requireLoopback, async (req, res) => {
220
+ try {
221
+ res.json(await setActiveMasterPrompt(req.body?.id ?? ''));
222
+ }
223
+ catch (error) {
224
+ errorResponse(res, error);
225
+ }
226
+ });
227
+ router.patch('/api/generation/prompts/:id', requireLoopback, async (req, res) => {
228
+ try {
229
+ res.json(await updateMasterPrompt(req.params.id, req.body ?? {}));
230
+ }
231
+ catch (error) {
232
+ errorResponse(res, error);
233
+ }
234
+ });
235
+ router.delete('/api/generation/prompts/:id', requireLoopback, async (req, res) => {
236
+ try {
237
+ res.json(await deleteMasterPrompt(req.params.id));
238
+ }
239
+ catch (error) {
240
+ errorResponse(res, error);
241
+ }
242
+ });
243
+ router.get('/api/generation/threads', requireLoopback, async (_req, res) => {
244
+ try {
245
+ res.json({ threads: await listThreadShelfChats() });
246
+ }
247
+ catch (error) {
248
+ errorResponse(res, error);
249
+ }
250
+ });
251
+ router.post('/api/generation/threads', requireLoopback, async (req, res) => {
252
+ try {
253
+ const title = normalizeOptionalString(req.body?.title, { field: 'title', maxLength: 200 });
254
+ const turns = req.body?.turns === undefined ? [] : validateTurns(req.body.turns);
255
+ res.status(201).json(await createThreadShelfChat(title, turns));
256
+ }
257
+ catch (error) {
258
+ errorResponse(res, error);
259
+ }
260
+ });
261
+ router.patch('/api/generation/threads/:id', requireLoopback, async (req, res) => {
262
+ try {
263
+ const title = normalizeQuery(req.body?.title, { field: 'title', maxLength: 200 });
264
+ res.json(await renameThreadShelfChat(req.params.id, title));
265
+ }
266
+ catch (error) {
267
+ errorResponse(res, error);
268
+ }
269
+ });
270
+ router.delete('/api/generation/threads/:id', requireLoopback, async (req, res) => {
271
+ try {
272
+ await deleteThreadShelfChat(req.params.id);
273
+ res.json({ ok: true });
274
+ }
275
+ catch (error) {
276
+ errorResponse(res, error);
277
+ }
278
+ });
279
+ router.get('/api/generation/threads/:id', requireLoopback, async (req, res) => {
280
+ try {
281
+ res.json(await getThreadShelfChat(req.params.id));
282
+ }
283
+ catch (error) {
284
+ errorResponse(res, error);
285
+ }
286
+ });
287
+ const turnMessages = (turns) => turns.flatMap((turn) => {
288
+ if (typeof turn.user === 'string')
289
+ return [{ role: 'user', content: turn.user }];
290
+ if (typeof turn.ai === 'string')
291
+ return [{ role: 'assistant', content: turn.ai }];
292
+ // Deliberately exclude archived hidden reasoning from provider context.
293
+ return [];
294
+ });
295
+ const parseContinuation = (value) => {
296
+ if (value === undefined)
297
+ return [];
298
+ if (!Array.isArray(value) || value.length > 100) {
299
+ throw new ValidationError('Invalid continuation', { field: 'continuation' });
300
+ }
301
+ return value.map((entry, index) => {
302
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
303
+ throw new ValidationError(`Invalid continuation[${index}]`, { field: 'continuation' });
304
+ }
305
+ const message = entry;
306
+ if (!['user', 'assistant'].includes(String(message.role))) {
307
+ throw new ValidationError(`Invalid continuation[${index}].role`, { field: 'continuation' });
308
+ }
309
+ const content = normalizeQuery(message.content, {
310
+ field: `continuation[${index}].content`,
311
+ maxLength: 100_000,
312
+ });
313
+ return { role: message.role, content };
314
+ });
315
+ };
316
+ // The user's master prompt. It is prepended as a system message to every
317
+ // request but is never persisted with the conversation, so re-reading a stored
318
+ // thread never replays a prompt the user has since changed or turned off.
319
+ const parseSystemPrompt = (value) => {
320
+ const content = normalizeOptionalString(value, { field: 'systemPrompt', maxLength: 20_000 });
321
+ return content ? [{ role: 'system', content }] : [];
322
+ };
323
+ const prepareChat = async (body) => {
324
+ const provider = parseProvider(body?.provider);
325
+ const model = normalizeQuery(body?.model, { field: 'model', maxLength: 4096 });
326
+ const prompt = normalizeQuery(body?.prompt, { field: 'prompt', maxLength: 100_000 });
327
+ const system = parseSystemPrompt(body?.systemPrompt);
328
+ if (body?.ephemeral === true) {
329
+ const continuation = parseContinuation(body?.continuation);
330
+ return {
331
+ provider,
332
+ prompt,
333
+ ephemeral: true,
334
+ request: {
335
+ provider,
336
+ model,
337
+ messages: [...system, ...continuation, { role: 'user', content: prompt }],
338
+ temperature: body?.temperature,
339
+ maxTokens: body?.maxTokens,
340
+ openRouterZdr: body?.openRouterZdr,
341
+ persistDiagnostics: false,
342
+ },
343
+ };
344
+ }
345
+ if (body?.threadId !== undefined) {
346
+ if (body.continuation !== undefined) {
347
+ throw new ValidationError('ThreadShelf chats use their stored history', {
348
+ field: 'continuation',
349
+ });
350
+ }
351
+ const chat = await getThreadShelfChat(body.threadId);
352
+ return {
353
+ provider,
354
+ threadId: chat.id,
355
+ prompt,
356
+ request: {
357
+ provider,
358
+ model,
359
+ messages: [...system, ...turnMessages(chat.turns), { role: 'user', content: prompt }],
360
+ temperature: body?.temperature,
361
+ maxTokens: body?.maxTokens,
362
+ openRouterZdr: body?.openRouterZdr,
363
+ },
364
+ };
365
+ }
366
+ const collection = normalizeCollectionSelector(body?.collection);
367
+ const sourceFile = normalizeQuery(body?.sourceFile, {
368
+ field: 'sourceFile',
369
+ maxLength: 4096,
370
+ });
371
+ const conversationKey = body?.conversationKey === undefined
372
+ ? undefined
373
+ : normalizeQuery(body.conversationKey, { field: 'conversationKey', maxLength: 4096 });
374
+ const thread = await resolveStoredThreadGenerationTarget(collection, sourceFile, conversationKey);
375
+ if (!thread) {
376
+ throw new BadRequestError('This archived thread predates persistent thread storage. Re-index its collection before continuing it.');
377
+ }
378
+ const continuation = parseContinuation(body?.continuation);
379
+ return {
380
+ provider,
381
+ prompt,
382
+ storedThread: thread,
383
+ request: {
384
+ provider,
385
+ model,
386
+ messages: [
387
+ ...system,
388
+ ...turnMessages(thread.turns),
389
+ ...continuation,
390
+ { role: 'user', content: prompt },
391
+ ],
392
+ temperature: body?.temperature,
393
+ maxTokens: body?.maxTokens,
394
+ openRouterZdr: body?.openRouterZdr,
395
+ },
396
+ };
397
+ };
398
+ const prepareChatWithLease = async (body) => {
399
+ if (body.threadId !== undefined) {
400
+ const release = acquireThreadShelfChat(body.threadId);
401
+ try {
402
+ return { prepared: await prepareChat(body), release };
403
+ }
404
+ catch (error) {
405
+ release();
406
+ throw error;
407
+ }
408
+ }
409
+ const initial = await prepareChat(body);
410
+ if (!initial.storedThread)
411
+ return { prepared: initial };
412
+ const release = acquireStoredThreadGeneration(initial.storedThread);
413
+ try {
414
+ // Re-read under the per-conversation lease so provider context cannot be
415
+ // assembled from history that another generation is about to replace.
416
+ return { prepared: await prepareChat(body), release };
417
+ }
418
+ catch (error) {
419
+ release();
420
+ throw error;
421
+ }
422
+ };
423
+ router.post('/api/generation/chat', requireLoopback, async (req, res) => {
424
+ const controller = new AbortController();
425
+ res.once('close', () => {
426
+ if (!res.writableEnded)
427
+ controller.abort();
428
+ });
429
+ let releaseChat;
430
+ try {
431
+ const leased = await prepareChatWithLease(req.body);
432
+ const prepared = leased.prepared;
433
+ releaseChat = leased.release;
434
+ const response = await generateChat(prepared.request, controller.signal);
435
+ let persistence;
436
+ if (prepared.threadId) {
437
+ persistence = (await appendThreadShelfChatExchange(prepared.threadId, prepared.prompt, response)).persistence;
438
+ }
439
+ else if (prepared.storedThread) {
440
+ persistence = await appendStoredThreadExchange(prepared.storedThread, prepared.prompt, response);
441
+ }
442
+ res.json({ ...response, persistence });
443
+ }
444
+ catch (error) {
445
+ errorResponse(res, error);
446
+ }
447
+ finally {
448
+ releaseChat?.();
449
+ }
450
+ });
451
+ router.post('/api/generation/chat/stream', requireLoopback, async (req, res) => {
452
+ const controller = new AbortController();
453
+ res.once('close', () => {
454
+ if (!res.writableEnded)
455
+ controller.abort();
456
+ });
457
+ res.status(200);
458
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
459
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
460
+ res.setHeader('X-Accel-Buffering', 'no');
461
+ res.flushHeaders();
462
+ const send = (event) => {
463
+ if (!res.destroyed)
464
+ res.write(`${JSON.stringify(event)}\n`);
465
+ };
466
+ let releaseChat;
467
+ try {
468
+ send({
469
+ type: 'status',
470
+ phase: 'preparing',
471
+ message: req.body?.ephemeral === true
472
+ ? 'Preparing a private in-memory chat…'
473
+ : req.body?.threadId
474
+ ? 'Loading the locally saved ThreadShelf chat…'
475
+ : 'Loading the archived conversation…',
476
+ });
477
+ const leased = await prepareChatWithLease(req.body);
478
+ const prepared = leased.prepared;
479
+ releaseChat = leased.release;
480
+ send({
481
+ type: 'status',
482
+ phase: prepared.provider === 'llama-cpp' ? 'loading-model' : 'connecting',
483
+ message: prepared.provider === 'llama-cpp'
484
+ ? 'Starting llama.cpp and loading the selected GGUF model. First load can take a while…'
485
+ : 'Connecting to OpenRouter and waiting for the first token…',
486
+ provider: prepared.provider,
487
+ model: prepared.request.model,
488
+ });
489
+ let firstDelta = true;
490
+ const response = await generateChatStream(prepared.request, (delta) => {
491
+ if (firstDelta) {
492
+ firstDelta = false;
493
+ send({
494
+ type: 'status',
495
+ phase: 'generating',
496
+ message: 'Generating response…',
497
+ provider: prepared.provider,
498
+ model: delta.model || prepared.request.model,
499
+ });
500
+ }
501
+ send({ type: 'delta', ...delta });
502
+ }, controller.signal);
503
+ if (!prepared.ephemeral) {
504
+ send({
505
+ type: 'status',
506
+ phase: 'saving',
507
+ message: 'Saving and indexing the completed exchange locally…',
508
+ provider: prepared.provider,
509
+ model: response.model,
510
+ });
511
+ }
512
+ let persistence;
513
+ if (prepared.threadId) {
514
+ persistence = (await appendThreadShelfChatExchange(prepared.threadId, prepared.prompt, response)).persistence;
515
+ }
516
+ else if (prepared.storedThread) {
517
+ persistence = await appendStoredThreadExchange(prepared.storedThread, prepared.prompt, response);
518
+ }
519
+ send({ type: 'done', response: { ...response, persistence } });
520
+ res.end();
521
+ }
522
+ catch (error) {
523
+ if (!controller.signal.aborted) {
524
+ console.error('[/api/generation/chat/stream]', error);
525
+ send({
526
+ type: 'error',
527
+ error: error instanceof Error ? error.message : 'Generation failed',
528
+ });
529
+ res.end();
530
+ }
531
+ }
532
+ finally {
533
+ releaseChat?.();
534
+ }
535
+ });
536
+ export default router;
@@ -0,0 +1,6 @@
1
+ import { Router } from 'express';
2
+ const router = Router();
3
+ router.get('/api/health', (_req, res) => {
4
+ res.json({ ok: true });
5
+ });
6
+ export default router;
@@ -0,0 +1,21 @@
1
+ import { Router } from 'express';
2
+ import healthRouter from './health.js';
3
+ import collectionsRouter from './collections.js';
4
+ import filesRouter from './files.js';
5
+ import searchRouter from './search.js';
6
+ import threadRouter from './thread.js';
7
+ import ingestRouter from './ingest.js';
8
+ import insightsRouter from './insights.js';
9
+ import generationRouter from './generation.js';
10
+ import modelCatalogRouter from './model-catalog.js';
11
+ const router = Router();
12
+ router.use(healthRouter);
13
+ router.use(collectionsRouter);
14
+ router.use(filesRouter);
15
+ router.use(searchRouter);
16
+ router.use(threadRouter);
17
+ router.use(ingestRouter);
18
+ router.use(insightsRouter);
19
+ router.use(generationRouter);
20
+ router.use(modelCatalogRouter);
21
+ export default router;