tinywebui 0.1.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 (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,448 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { attributeRequest, completeAttribution, markFinal } from '../harness/attribution.js';
3
+ import { toWire, AUX_KIND } from '../store/index.js';
4
+ import { toolExecutor } from './tool_executor.js';
5
+ import { ASK_USER } from '../tools/ask_tool.js';
6
+ import { openTasksNote } from '../tools/task_tool.js';
7
+ import { runAgentLoop, runHooks } from '../harness/agent.js';
8
+ import { repairToolHistory } from '../harness/history.js';
9
+ import { checkpointSource, checkpointSection, summarize } from '../harness/checkpoint.js';
10
+ import { harnessBlock, budgetFooter } from '../harness/prompt.js';
11
+ import { isOpenRouter, post, streamChunks, retryDelay, sleep, STREAM_RETRIES, StreamError } from '../harness/provider.js';
12
+ import { EVENT } from '../../public/shared/events.js';
13
+ import {
14
+ digest, planEpoch, applyEpoch, planWindow, windowRows, estimateTokens, estimateToolTokens
15
+ } from './compact.js';
16
+
17
+ /**
18
+ * How much of the next prompt is conversation history -- the only part
19
+ * compaction can shrink, and so the only part the threshold should measure.
20
+ *
21
+ * The provider's own count of the last request is the best number we have, but
22
+ * it needs two corrections. It includes the system prompt and the tool block,
23
+ * which on a setup with a few MCP servers can be tens of thousands of tokens by
24
+ * itself; counting that against the threshold starts an epoch (a full cache
25
+ * miss) on nearly every turn while saving next to nothing. And it counts
26
+ * results that were stubbed on arrival at full size, because the turn that
27
+ * fetched them sent them whole -- the next request sends the stub instead.
28
+ */
29
+ function historyTokens(rows, cfg, tools) {
30
+ const last = rows.findLastIndex((r) => r.usage_json);
31
+ const reported = last >= 0 ? JSON.parse(rows[last].usage_json).prompt_tokens : 0;
32
+ // Providers disagree about reporting usage and some report none at all, so
33
+ // fall back to a size estimate rather than never compacting.
34
+ if (!reported) return estimateTokens(rows.map(toWire));
35
+
36
+ const overhead = estimateTokens([{ content: cfg.systemPrompt }]) + estimateToolTokens(tools);
37
+ let turnStart = last;
38
+ while (turnStart > 0 && rows[turnStart].role !== 'user') turnStart--;
39
+ let shrunk = 0;
40
+ for (let i = turnStart; i < last; i++) {
41
+ const r = rows[i];
42
+ if (r.role === 'tool' && r.stub_text) shrunk += (r.content?.length ?? 0) - r.stub_text.length;
43
+ }
44
+ return Math.max(0, reported - overhead - Math.ceil(shrunk / 4));
45
+ }
46
+
47
+ /**
48
+ * Whether the previous turn left a result that was sent whole then but goes
49
+ * out as its stub from now on (the `maxInlineChars` case). Results past
50
+ * `maxTurnChars` were already sent as stubs, so they change nothing.
51
+ */
52
+ export function swapsStubThisTurn(rows, cfg) {
53
+ const users = rows.map((r, i) => (r.role === 'user' ? i : -1)).filter((i) => i >= 0);
54
+ if (users.length < 2) return false;
55
+ const turnCap = cfg.maxTurnChars || 0;
56
+ return rows.slice(users[users.length - 2], users[users.length - 1]).some((r) =>
57
+ r.role === 'tool' && r.stub_text && !(turnCap > 0 && (r.content?.length ?? 0) > turnCap));
58
+ }
59
+
60
+ /**
61
+ * One chat turn: decides compaction and the hard window up front, then drives
62
+ * the generic loop in agent.js with TinyWebUI's runtime -- the provider stream,
63
+ * the tool policy, and persistence. `emit(event)` is called for every UI event.
64
+ * Returns the messages appended to the conversation.
65
+ */
66
+ export async function runChat({
67
+ cfg, chatId, store, tools, hub, emit, signal, historyFromSeq = null, unattended = false,
68
+ // (call) => Promise<'allow' | 'always' | 'deny'>. Asked for each call the
69
+ // approval policy stops; without it such calls are declined, never run.
70
+ approve = null,
71
+ // Extra lifecycle hooks (see agent.js), run after the built-in ones.
72
+ hooks = {},
73
+ // () => string[]: steering input queued while this run works, taken at each
74
+ // safe boundary. Persisted and shown as ordinary user messages.
75
+ takeInput = null,
76
+ // ({ question, choices, allowFreeText }) => Promise<{ answered, answer? , reason? }>:
77
+ // puts an ask_user question to the user. Absent on unattended runs.
78
+ askUser = null
79
+ }) {
80
+ const maxRounds = Math.max(1, cfg.maxToolRounds || 12);
81
+ const operatorPrompt = cfg.systemPrompt;
82
+
83
+ // Built once per turn, so every round of it sends the same system bytes.
84
+ // Server-level MCP guidance (call order, when to prefer one tool over
85
+ // another) rides along after the harness block.
86
+ const instructions = hub?.instructionsBlock?.();
87
+ const harness = harnessBlock({
88
+ maxRounds, hasTools: tools.length > 0, timeZone: cfg.timezone,
89
+ canAsk: tools.some((t) => t.function?.name === ASK_USER)
90
+ });
91
+ const owner = (name) => hub?.routes?.get(name)?.server || 'built-ins';
92
+
93
+ // Optional request fields this endpoint has already refused, learned once.
94
+ const disabled = new Set();
95
+
96
+ // Automation executions remain ordinary, visible chat turns, but do not
97
+ // inherit the conversation that happened before the automation was created.
98
+ // The boundary is captured at launch so subsequent rounds still include all
99
+ // messages generated by this execution.
100
+ // One loader for every (re)read, so a reload after compaction can't drop the
101
+ // automation boundary or the hard window.
102
+ const scopeStart = Number.isSafeInteger(historyFromSeq) ? historyFromSeq : null;
103
+ const scoped = () => {
104
+ const all = store.messages.list(chatId);
105
+ return scopeStart == null ? all : all.filter((row) => row.seq >= scopeStart);
106
+ };
107
+ // A checkpoint written under another history boundary (an automation run
108
+ // in the same chat, or the chat itself) summarises messages this run must
109
+ // not see, so it only counts when the boundary matches.
110
+ const checkpoint = () => {
111
+ const raw = store.chats.byId(chatId).checkpoint_json;
112
+ const cp = raw ? JSON.parse(raw) : null;
113
+ return cp && cp.scopeStart === scopeStart ? cp : null;
114
+ };
115
+ const load = () => windowRows(scoped(), store.chats.byId(chatId).window_seq, { summarized: Boolean(checkpoint()) });
116
+ let rows = load();
117
+ const chat = store.chats.byId(chatId);
118
+ const keepTurns = Math.max(1, cfg.keepTurns || 2);
119
+
120
+ // Compaction is decided once, here, before the first request of the turn --
121
+ // never between rounds. Within a turn the prefix may only grow, so a round
122
+ // can always read back what the previous round wrote to the cache.
123
+ // A result stubbed for later turns only flips to its stub now, so this
124
+ // request is cold from that message on whatever we do. An epoch opened now
125
+ // rides on that miss for free instead of costing its own later.
126
+ const stubSwap = swapsStubThisTurn(rows, cfg);
127
+ const threshold = cfg.compactThreshold || 0;
128
+ if (threshold > 0) {
129
+ const plan = planEpoch(rows, {
130
+ threshold,
131
+ keepTurns,
132
+ promptTokens: stubSwap ? Infinity : historyTokens(rows, cfg, tools)
133
+ });
134
+ const done = plan && applyEpoch(store, chat, plan, { minSaved: cfg.compactMinSaved || 0 });
135
+ if (done) {
136
+ rows = load();
137
+ emit({
138
+ type: EVENT.NOTICE,
139
+ text: `Context compacted (epoch ${done.epoch}): ${done.saved.toLocaleString('en-US')} chars of earlier tool output and images moved out of the window. Use expand_context to read any of it.`
140
+ });
141
+ emit({ type: EVENT.COMPACTED, epoch: done.epoch, boundarySeq: done.boundarySeq, saved: done.saved });
142
+ }
143
+ }
144
+
145
+ // The hard window, for when stubbing is not enough: a long conversation of
146
+ // plain text has nothing for an epoch to shrink. Checked after the epoch so
147
+ // it only ever moves when compaction has already done what it can.
148
+ const maxHistory = cfg.maxHistoryTokens || 0;
149
+ const cut = planWindow(rows, {
150
+ maxTokens: maxHistory, targetTokens: Math.floor(maxHistory / 2), keepTurns, toWire
151
+ });
152
+ if (cut != null) {
153
+ const before = rows.length;
154
+ const summary = cfg.llmCompaction
155
+ ? await summarizeDropped({ cfg, store, chatId, emit, signal, previous: checkpoint(),
156
+ dropped: scoped().filter((r) => r.seq >= (rows[0]?.seq ?? 0) && r.seq < cut) })
157
+ : null;
158
+ // One statement, so a checkpoint is never stored against the wrong window.
159
+ store.chats.touch(chatId, summary
160
+ ? { window_seq: cut, checkpoint_json: JSON.stringify({ summary, throughSeq: cut - 1, scopeStart, model: cfg.model }) }
161
+ : { window_seq: cut });
162
+ rows = load();
163
+ emit({
164
+ type: EVENT.NOTICE,
165
+ text: `Context window full: the ${before - rows.length} oldest messages are no longer sent to the model (they stay in the transcript)`
166
+ + (summary ? ' and are summarised for it instead.' : '.')
167
+ });
168
+ }
169
+
170
+ // Built once per turn, after the window, so every round of it sends the same
171
+ // system bytes and a checkpoint only changes them when the window moves --
172
+ // a cold request anyway. Kept apart so Statistics can say which part of the
173
+ // system prompt costs what.
174
+ const cp = checkpoint();
175
+ const systemParts = [
176
+ { category: 'operator', text: operatorPrompt },
177
+ { category: 'harness', text: harness },
178
+ { category: 'mcp_instructions', text: instructions },
179
+ { category: 'checkpoint', text: cp ? checkpointSection(cp.summary) : '' }
180
+ ];
181
+ cfg = { ...cfg, systemPrompt: systemParts.map((p) => p.text).filter(Boolean).join('\n\n') };
182
+
183
+ // Index of the last message inside the frozen prefix, for the pinned
184
+ // breakpoint. -1 before the first epoch, when there is nothing frozen yet.
185
+ const boundarySeq = store.chats.byId(chatId).boundary_seq;
186
+ // Reasoning behind the frozen boundary goes too. Providers only need it
187
+ // echoed within the turn that made it, and the boundary moves only at an
188
+ // epoch, which is a cold request anyway -- so dropping it never costs cache.
189
+ const seqOf = new Map();
190
+ const working = repairToolHistory(rows.map((r) => {
191
+ const w = toWire(r);
192
+ if (boundarySeq >= 0 && r.seq < boundarySeq) { delete w.reasoning_details; delete w.reasoning; }
193
+ seqOf.set(w, r.seq);
194
+ return w;
195
+ }));
196
+ // Found by seq, not row index: a repair can add or drop messages before it.
197
+ const epochIndex = boundarySeq >= 0
198
+ ? working.findIndex((w) => seqOf.get(w) >= boundarySeq) - 1
199
+ : -1;
200
+
201
+ const tools_ = toolExecutor({ cfg, chatId, store, hub, emit, unattended, approve, askUser, footer: (round) => budgetFooter(round, maxRounds) + openTasksNote(store.chats.listTasks(chatId)), hooks, signal });
202
+ const saved = [];
203
+ const appended = await runAgentLoop({
204
+ messages: working,
205
+ maxRounds,
206
+ signal,
207
+ runtime: {
208
+ onEvent: emit,
209
+ streamTurn: ({ messages, lastCall }) => streamTurn({
210
+ cfg, chatId, store, emit, signal, disabled, tools, epochIndex, messages, lastCall, stubSwap,
211
+ attributionContext: { systemParts, owner },
212
+ onSaved: (assistant, seq) => saved.push({ assistant, seq })
213
+ }),
214
+ executeToolBatch: tools_.execute,
215
+ pendingInput: takeInput && (async () => (await takeInput()).map((content) => {
216
+ const msg = { role: 'user', content };
217
+ store.messages.add(chatId, msg);
218
+ emit({ type: EVENT.USER, content });
219
+ return { wire: msg, message: msg };
220
+ })),
221
+ shouldContinue: async ({ round, assistant, results }) => {
222
+ if (tools_.stopRequested()) return false;
223
+ try {
224
+ return !(await runHooks(hooks.afterTurn, { round, assistant, results, signal }, (o) => o.stop));
225
+ } catch (err) {
226
+ if (signal?.aborted) throw err;
227
+ // Stopping is the one outcome that cannot leave the transcript
228
+ // half-built: the batch is complete, and no model call is pending.
229
+ emit({ type: EVENT.NOTICE, text: `Turn hook failed (${err.message}) — stopping here.` });
230
+ return false;
231
+ }
232
+ }
233
+ }
234
+ });
235
+ // Hand the client the exact turns we appended -- including tool calls
236
+ // and results -- so the next request replays an identical prefix.
237
+ // The last reply is only known to be the answer once the run has ended.
238
+ const final = saved.at(-1);
239
+ if (final && !final.assistant.tool_calls?.length && !signal?.aborted) {
240
+ final.assistant.usage.attribution = markFinal(final.assistant.usage.attribution);
241
+ store.messages.updateUsage(chatId, final.seq, final.assistant.usage);
242
+ }
243
+ emit({ type: EVENT.DONE, messages: appended });
244
+ return appended;
245
+ }
246
+
247
+ /**
248
+ * One model request: posts it, streams the reply to the UI, and persists the
249
+ * assistant message it assembles. The provider-specific half of a round.
250
+ */
251
+ /**
252
+ * A fingerprint of what one request asked the provider to cache, so a miss can
253
+ * be pinned on us or on them. `chain[i]` hashes the tool block and messages
254
+ * 0..i cumulatively, so two requests agree up to exactly the first message
255
+ * whose bytes differ. `matched` is how many leading messages this request
256
+ * shares byte-for-byte with the previous one in the same chat: a miss where
257
+ * `matched` covers the whole previous request is the provider's, not ours.
258
+ */
259
+ const lastChains = new Map();
260
+ // Only the chats a server is actively serving matter; the oldest go first.
261
+ const MAX_CHAINS = 200;
262
+
263
+ function rememberChain(chatId, chain) {
264
+ lastChains.delete(chatId);
265
+ lastChains.set(chatId, chain);
266
+ if (lastChains.size > MAX_CHAINS) lastChains.delete(lastChains.keys().next().value);
267
+ }
268
+
269
+ export function prefixFingerprint(body, previous) {
270
+ const h = createHash('sha256');
271
+ h.update(JSON.stringify(body.tools || []));
272
+ const tools = h.copy().digest('hex').slice(0, 8);
273
+ const chain = body.messages.map((m) => {
274
+ h.update(JSON.stringify(m));
275
+ return h.copy().digest('hex').slice(0, 8);
276
+ });
277
+ let matched = 0;
278
+ if (previous) while (matched < chain.length && chain[matched] === previous[matched]) matched++;
279
+ return { tools, chain, matched, previous: previous ? previous.length : null };
280
+ }
281
+
282
+ async function streamTurn({ cfg, chatId, store, emit, signal, disabled, tools, epochIndex, messages, lastCall, stubSwap, attributionContext, onSaved }) {
283
+ let input;
284
+ let prefix;
285
+ const request = () => post(cfg, { turn: messages, tools, lastCall, epochIndex, chatId,
286
+ onRequest: (body) => {
287
+ input = attributeRequest(body, attributionContext);
288
+ prefix = prefixFingerprint(body, lastChains.get(chatId));
289
+ // Marks a break we chose (see swapsStubThisTurn), so Statistics doesn't
290
+ // report it as a prefix bug.
291
+ if (stubSwap && prefix.previous != null && prefix.matched < prefix.previous) prefix.stubSwap = true;
292
+ rememberChain(chatId, prefix.chain);
293
+ }
294
+ }, signal, disabled, emit);
295
+
296
+ let content;
297
+ let reasoning;
298
+ let reasoningDetails;
299
+ let toolCalls;
300
+ let usage;
301
+ // Which upstream actually served this round. A gateway that routes one
302
+ // model across several providers gives each its own cache, so a round that
303
+ // lands somewhere new misses in full however stable the prefix was --
304
+ // indistinguishable from a prefix bug unless the provider is on the record.
305
+ let provider;
306
+ let finishReason;
307
+
308
+ // post() retries until the first byte. This covers what happens after it:
309
+ // a stream that breaks or reports an upstream error part-way. The round is
310
+ // repeated only while no answer text has reached the user -- a half-sent
311
+ // tool call has run nothing and is simply discarded -- and never stored.
312
+ for (let attempt = 0; ; attempt++) {
313
+ content = ''; reasoning = ''; reasoningDetails = []; toolCalls = [];
314
+ usage = null; provider = null; finishReason = null;
315
+ // post() has already spent its own retries on anything before the stream.
316
+ const res = await request();
317
+ try {
318
+ for await (const chunk of streamChunks(res)) {
319
+ if (chunk.usage) usage = chunk.usage;
320
+ if (chunk.provider) provider = chunk.provider;
321
+ // A failure after the 200 arrives in-band: an `error` object, or a choice
322
+ // that finishes with reason "error". Either way the round did not happen.
323
+ const finish = chunk.choices?.[0]?.finish_reason;
324
+ if (chunk.error || finish === 'error') {
325
+ throw new StreamError(chunk.error?.message || 'upstream error mid-stream');
326
+ }
327
+ if (finish) finishReason = finish;
328
+ const delta = chunk.choices?.[0]?.delta;
329
+ if (!delta) continue;
330
+ // Reasoning is spelled differently per provider: `reasoning` on OpenRouter,
331
+ // `reasoning_content` on DeepSeek/Qwen. Both are plain text deltas.
332
+ const think = delta.reasoning ?? delta.reasoning_content;
333
+ if (think) {
334
+ reasoning += think;
335
+ emit({ type: EVENT.REASONING, delta: think });
336
+ }
337
+ // Structured form, which has to be echoed back verbatim or providers reject
338
+ // the follow-up request that carries tool results.
339
+ for (const d of delta.reasoning_details || []) {
340
+ const slot = (reasoningDetails[d.index ?? reasoningDetails.length] ||= { ...d, text: '' });
341
+ if (d.text) slot.text += d.text;
342
+ if (d.summary) slot.summary = (slot.summary || '') + d.summary;
343
+ }
344
+ if (delta.content) {
345
+ content += delta.content;
346
+ emit({ type: EVENT.TEXT, delta: delta.content });
347
+ }
348
+ for (const tc of delta.tool_calls || []) {
349
+ const slot = (toolCalls[tc.index] ||= { id: '', type: 'function', function: { name: '', arguments: '' } });
350
+ if (tc.id) slot.id = tc.id;
351
+ if (tc.function?.name) slot.function.name += tc.function.name;
352
+ if (tc.function?.arguments) slot.function.arguments += tc.function.arguments;
353
+ }
354
+ }
355
+ break;
356
+ } catch (err) {
357
+ if (signal?.aborted) throw err;
358
+ if (content || attempt >= STREAM_RETRIES) {
359
+ throw new Error(`The model's reply broke off part-way (${err.message}).`
360
+ + (content ? ' Retry to run this round again.' : ''));
361
+ }
362
+ emit({ type: EVENT.NOTICE, text: `Reply stream failed (${err.message}) — retrying the round (${attempt + 1}/${STREAM_RETRIES}).` });
363
+ await sleep(retryDelay(null, attempt + 1), signal);
364
+ }
365
+ }
366
+
367
+ // Cut off by the output cap: say so, or a truncated tool call just looks
368
+ // like the model writing bad arguments.
369
+ if (finishReason === 'length') {
370
+ emit({ type: EVENT.NOTICE, text: 'The reply hit the output token limit and was cut off (raise maxTokens if this recurs).' });
371
+ }
372
+
373
+ // Dropped before anything is stored: nothing will produce their results.
374
+ // Attribute generated output before dropping refused calls or inserting UI notices.
375
+ const attribution = completeAttribution(input, usage, { content, reasoning,
376
+ toolCalls: toolCalls.filter(Boolean), details: reasoningDetails.filter(Boolean) });
377
+ if (lastCall) toolCalls.length = 0;
378
+
379
+ // A reply with no text and no calls ends the turn with nothing to show --
380
+ // most often the final pass on a provider that ignored tool_choice and
381
+ // only tried to call more tools. Say so in the transcript rather than
382
+ // leaving a blank bubble; stored like any answer, so the next turn's model
383
+ // sees it too and knows the question went unanswered.
384
+ if (!content.trim() && !toolCalls.some(Boolean)) {
385
+ content = lastCall
386
+ ? '(No answer: the tool budget ran out and the model replied without text. Ask again'
387
+ + ' to have it answer from what it gathered, or raise maxToolRounds.)'
388
+ : '(The model returned an empty reply.)';
389
+ emit({ type: EVENT.TEXT, delta: content });
390
+ }
391
+
392
+ const assistant = { role: 'assistant', content: content || null };
393
+ // Keep both: `reasoning_details` is what the provider needs echoed back,
394
+ // `reasoning` is the plain text the transcript replays from. Storing only
395
+ // the former loses every thinking block when a saved chat is reopened.
396
+ if (reasoningDetails.length) {
397
+ const details = reasoningDetails.filter(Boolean);
398
+ assistant.reasoning_details = details;
399
+ if (!reasoning) reasoning = details.map((d) => d.text || d.summary || '').join('');
400
+ }
401
+ if (reasoning) assistant.reasoning = reasoning;
402
+ if (toolCalls.length) assistant.tool_calls = toolCalls.filter(Boolean);
403
+ // Usage rides on the message rather than living only in a transient event,
404
+ // so a reopened chat can still show what each round cost and how much of it
405
+ // was a cache read. Without that there is no way to tell whether any of the
406
+ // caching or compaction work here is actually paying off.
407
+ assistant.usage = {
408
+ ...(usage || {}),
409
+ attribution,
410
+ ...(prefix ? { prefix } : {}),
411
+ ...(provider ? { provider } : {}),
412
+ ...(usage?.cost != null && isOpenRouter(cfg) && usage.cost_currency == null ? { cost_currency: 'USD' } : {})
413
+ };
414
+ // Snapshotted per-round rather than read back from config later -- the
415
+ // model can change between chats (or mid-session), and usage history
416
+ // should report what actually served the round, not whatever is current.
417
+ assistant.model = cfg.model;
418
+ const seq = store.messages.add(chatId, assistant);
419
+ onSaved?.(assistant, seq);
420
+ if (usage) emit({ type: EVENT.USAGE, usage: assistant.usage });
421
+ return assistant;
422
+ }
423
+
424
+ /**
425
+ * Summarises the rows a window move is about to drop, merged with the
426
+ * previous checkpoint. Returns the summary, or null after a notice when it
427
+ * could not be made: the window then moves as it always has, dropping them
428
+ * unsummarised, rather than failing the user's turn.
429
+ */
430
+ async function summarizeDropped({ cfg, store, chatId, emit, signal, previous, dropped }) {
431
+ try {
432
+ const { text } = await summarize({
433
+ cfg, signal, emit,
434
+ // Raw rows, not the wire form: full tool text (cut down in the source)
435
+ // and the artifact ids a stub would hide.
436
+ source: checkpointSource({ previous: previous?.summary, rows: dropped.map((r) => ({
437
+ role: r.role, content: r.content, artifact_id: r.artifact_id,
438
+ tool_calls: r.tool_calls_json ? JSON.parse(r.tool_calls_json) : undefined
439
+ })) }),
440
+ onUsage: (usage) => usage && store.usage.addAuxiliary(chatId, { kind: AUX_KIND.CHECKPOINT, model: cfg.model, usage })
441
+ });
442
+ return text;
443
+ } catch (err) {
444
+ if (signal?.aborted) throw err;
445
+ emit({ type: EVENT.NOTICE, text: `Could not summarise the earlier conversation (${err.message}); it is dropped without a summary.` });
446
+ return null;
447
+ }
448
+ }
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { runChat } from './llm.js';
3
+ import { effectiveConfig } from '../config/models.js';
4
+ import { logger } from '../log.js';
5
+ import { EVENT } from '../../public/shared/events.js';
6
+ import { CORPUS } from '../store/index.js';
7
+
8
+ const log = logger('retrieval');
9
+
10
+ const RETAIN_MS = 5 * 60 * 1000;
11
+
12
+ /**
13
+ * Turns in flight, by chat id.
14
+ *
15
+ * A turn used to live inside its HTTP response: the request's `close` event
16
+ * aborted it, so closing the tab halfway through a twelve-round research run
17
+ * threw the whole thing away, tokens already spent and all. A run is owned by
18
+ * the server instead. The response is only a viewer -- it can come and go,
19
+ * and more than one can watch at once.
20
+ *
21
+ * Every event is kept as well as broadcast, so a viewer that arrives late (a
22
+ * reload, a second tab) replays what it missed from `from` and then follows
23
+ * the rest live. The buffer is per-turn and dropped a few minutes after the
24
+ * turn ends; the transcript itself lives in the store, as it always did.
25
+ *
26
+ * `app` supplies the live cfg and hub, the store and retrieval, and
27
+ * `onRunIdle(chatId)`, called once a chat has nothing running.
28
+ */
29
+ export function createRuns(app) {
30
+ const { store } = app;
31
+ const runs = new Map();
32
+
33
+ const get = (chatId) => runs.get(chatId);
34
+ const isRunning = (chatId) => {
35
+ const run = runs.get(chatId);
36
+ return Boolean(run && !run.done);
37
+ };
38
+
39
+ /** Tells everyone watching a chat's run what is queued now. */
40
+ const announceQueue = (chatId) => runs.get(chatId)?.emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chatId) });
41
+
42
+ /**
43
+ * `lead` is a queued follow-up that starts this run: stored after baseCount
44
+ * and sent as an event, so a live view and a reload each show it once.
45
+ */
46
+ function start({ chat, tools, onFinish, historyFromSeq = null, unattended = false, model = null, lead = null }) {
47
+ const run = {
48
+ events: [],
49
+ subs: new Set(),
50
+ ac: new AbortController(),
51
+ done: false,
52
+ // Where the store stood when the turn began, including the user message
53
+ // that started it. A client reopening mid-turn replays up to here and
54
+ // plays the events over the top.
55
+ baseCount: store.messages.list(chat.id).length,
56
+ // Tool calls waiting on the user, by call id -> { name, resolve }.
57
+ approvals: new Map(),
58
+ // The ask_user question waiting on the user: { id, settle }, or null.
59
+ question: null,
60
+ unattended
61
+ };
62
+ runs.set(chat.id, run);
63
+ // A stop settles every open question as "no", so the loop can unwind
64
+ // instead of waiting forever on a prompt nobody will answer now.
65
+ run.ac.signal.addEventListener('abort', () => {
66
+ for (const { resolve } of run.approvals.values()) resolve('deny');
67
+ run.approvals.clear();
68
+ // Stop ends the run; the question is closed so no late answer revives it.
69
+ run.question?.settle('cancelled');
70
+ }, { once: true });
71
+ // Unattended runs get no asker: the loop refuses gated calls itself.
72
+ const approve = unattended ? null : ({ id, name }) =>
73
+ new Promise((resolve) => run.approvals.set(id, { name, resolve }));
74
+
75
+ /**
76
+ * Puts one ask_user question to the user and waits: for an answer, a
77
+ * skip, the timeout, or a stop. Persisted first, so the row decides who
78
+ * settled it; the timer is read from the live config at ask time.
79
+ */
80
+ const askUser = unattended ? null : ({ question, choices, allowFreeText }) => {
81
+ if (run.ac.signal.aborted) return Promise.resolve({ answered: false, reason: 'cancelled' });
82
+ // Calls are sequential, so a second one can only arrive after the first
83
+ // settled; this is the guard should that ever change.
84
+ if (run.question) return Promise.resolve({ answered: false, reason: 'busy' });
85
+ const id = randomUUID();
86
+ const seconds = Math.max(0, Number(app.cfg.askUserTimeoutSeconds) || 0);
87
+ const deadline = seconds ? Date.now() + seconds * 1000 : null;
88
+ store.chats.addQuestion(chat.id, { id, question, choices, allowFreeText, deadline });
89
+ return new Promise((resolve) => {
90
+ let timer = null;
91
+ const settle = (status, answer = null) => {
92
+ if (!store.chats.settleQuestion(id, status, answer)) return false;
93
+ clearTimeout(timer);
94
+ if (run.question?.id === id) run.question = null;
95
+ emit({ type: EVENT.QUESTION_DONE, id, status, answer });
96
+ resolve(status === 'answered' ? { answered: true, answer } : { answered: false, reason: status });
97
+ return true;
98
+ };
99
+ run.question = { id, choices, allowFreeText, settle };
100
+ if (deadline) timer = setTimeout(() => settle('timeout'), seconds * 1000);
101
+ emit({ type: EVENT.QUESTION, id, question, choices, allowFreeText, deadline, timeoutSeconds: seconds });
102
+ });
103
+ };
104
+
105
+ const emit = (event) => {
106
+ run.events.push(event);
107
+ for (const sub of run.subs) {
108
+ try { sub.write(`data: ${JSON.stringify(event)}\n\n`); } catch { run.subs.delete(sub); }
109
+ }
110
+ };
111
+
112
+ run.emit = emit;
113
+ emit({ type: EVENT.CHAT, id: chat.id, title: chat.title });
114
+ if (lead != null) {
115
+ store.messages.add(chat.id, { role: 'user', content: lead });
116
+ emit({ type: EVENT.USER, content: lead });
117
+ }
118
+ emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
119
+
120
+ // Steering is interactive input: an unattended run never takes any, so
121
+ // it cannot swallow what someone typed into a chat an automation shares.
122
+ const takeInput = unattended ? null : () => {
123
+ const items = store.chats.takeQueued(chat.id, { kind: 'steer' });
124
+ if (items.length) emit({ type: EVENT.QUEUE, items: store.chats.listQueued(chat.id) });
125
+ return items.map((i) => i.content);
126
+ };
127
+
128
+ run.promise = (async () => {
129
+ try {
130
+ await runChat({ cfg: effectiveConfig(app.cfg, model), chatId: chat.id, store, tools, hub: app.hub, emit, signal: run.ac.signal, historyFromSeq, unattended, approve, takeInput, askUser });
131
+ } catch (err) {
132
+ emit({ type: EVENT.ERROR, error: run.ac.signal.aborted ? 'Stopped.' : err.message });
133
+ } finally {
134
+ store.chats.touch(chat.id);
135
+ run.done = true;
136
+ app.retrieval.ingest(CORPUS.CHATS, chat.id).catch((err) => log.error(`embedding chat ${chat.id} failed: ${err.message}`));
137
+ // The run would go idle here, so the oldest queued item starts the
138
+ // next one -- in this same synchronous block, so nothing queued before
139
+ // `done` flipped can be missed. A stop or failure delivers nothing:
140
+ // the queue stays put and the client hands it back to the composer.
141
+ const ok = !run.events.some((event) => event.type === EVENT.ERROR) && !run.ac.signal.aborted;
142
+ const next = ok && !unattended ? store.chats.takeQueued(chat.id, { first: true })[0] : null;
143
+ if (next) {
144
+ emit({ type: EVENT.NEXT_RUN });
145
+ start({ chat, tools, model, lead: next.content });
146
+ }
147
+ try { onFinish?.({
148
+ ok,
149
+ error: run.events.find((event) => event.type === EVENT.ERROR)?.error || (run.ac.signal.aborted ? 'Stopped.' : null),
150
+ result: store.messages.list(chat.id).slice(run.baseCount).filter((m) => m.role === 'assistant').at(-1)?.content || ''
151
+ }); } catch { /* a run observer cannot disrupt chat teardown */ }
152
+ for (const sub of run.subs) { try { sub.end(); } catch { /* already gone */ } }
153
+ run.subs.clear();
154
+ // Held briefly so a client reconnecting a second later still gets the
155
+ // tail of the turn rather than a 404.
156
+ setTimeout(() => { if (runs.get(chat.id) === run) runs.delete(chat.id); }, RETAIN_MS).unref();
157
+ queueMicrotask(() => app.onRunIdle(chat.id));
158
+ }
159
+ })();
160
+
161
+ return run;
162
+ }
163
+
164
+ /** Points one response at a run: the backlog from `from`, then the live rest. */
165
+ function attach(run, res, from) {
166
+ res.writeHead(200, {
167
+ 'content-type': 'text/event-stream',
168
+ 'cache-control': 'no-cache',
169
+ connection: 'keep-alive'
170
+ });
171
+ for (const ev of run.events.slice(from)) res.write(`data: ${JSON.stringify(ev)}\n\n`);
172
+ if (run.done) return res.end();
173
+ run.subs.add(res);
174
+ // A viewer leaving is just a viewer leaving. The run keeps going.
175
+ res.on('close', () => run.subs.delete(res));
176
+ return undefined;
177
+ }
178
+
179
+ return { get, isRunning, start, attach, announceQueue };
180
+ }