tinywebui 0.1.4 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -3,21 +3,20 @@ import { approvalFor } from '../config/approval.js';
3
3
  import { runHooks } from '../harness/agent.js';
4
4
  import { digest } from './compact.js';
5
5
  import { EVENT } from '../../public/shared/events.js';
6
-
7
6
  /**
8
7
  * Key for spotting a repeated call. Keys are sorted at every depth: a flat
9
8
  * replacer list would also act as a whitelist on nested objects and collapse
10
9
  * {q:{text:"a"}} and {q:{text:"b"}} into the same key.
11
10
  */
12
11
  function stableKey(value) {
13
- if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`;
14
- if (value && typeof value === 'object') {
15
- return `{${Object.keys(value).sort()
16
- .map((k) => `${JSON.stringify(k)}:${stableKey(value[k])}`).join(',')}}`;
17
- }
18
- return JSON.stringify(value) ?? 'undefined';
12
+ if (Array.isArray(value))
13
+ return `[${value.map(stableKey).join(',')}]`;
14
+ if (value && typeof value === 'object') {
15
+ return `{${Object.keys(value).sort()
16
+ .map((k) => `${JSON.stringify(k)}:${stableKey(value[k])}`).join(',')}}`;
17
+ }
18
+ return JSON.stringify(value) ?? 'undefined';
19
19
  }
20
-
21
20
  /**
22
21
  * TinyWebUI's tool policy, as the loop's `executeToolBatch`: argument
23
22
  * validation, duplicate suppression, approvals, MCP dispatch, artifacts and
@@ -25,215 +24,227 @@ function stableKey(value) {
25
24
  * the closure, so one executor serves exactly one turn.
26
25
  */
27
26
  export function toolExecutor({ cfg, chatId, store, hub, emit, unattended, approve, askUser, footer: roundFooter, hooks, signal }) {
28
- const toolCtx = { store, chatId, budget: cfg.expandCharBudget || 8000, unattended, askUser: unattended ? null : askUser };
29
- // Same tool name + same args, seen earlier in this turn, on a tool that has
30
- // declared its result depends only on its arguments: re-running it only burns
31
- // a round. Tracked by round so the model can be told exactly when it already
32
- // did this. Anything else -- a browser snapshot after a click, a list after a
33
- // create -- is run again, because its answer may well have changed.
34
- const seenCalls = new Map();
35
- // "Always allow" is saved by the server, but this turn's cfg is a snapshot
36
- // taken before it; remembered here so the next call in the turn doesn't ask.
37
- const allowedNow = new Set();
38
-
39
- // Set by an afterToolCall hook; honoured once the batch is complete.
40
- let stop = false;
41
-
42
- /** Resolves one call against the approval policy: 'allow', 'deny' or 'unattended'. */
43
- const gate = async (name, args, id) => {
44
- if (allowedNow.has(name) || approvalFor(cfg, hub, name, args) === 'auto') return 'allow';
45
- if (unattended) return 'unattended';
46
- if (!approve) return 'deny';
47
- emit({ type: EVENT.APPROVAL, id, name, args });
48
- const decision = await approve({ id, name, args });
49
- emit({ type: EVENT.APPROVAL_DONE, id, name, decision });
50
- if (decision === 'always') allowedNow.add(name);
51
- return decision === 'always' || decision === 'allow' ? 'allow' : 'deny';
52
- };
53
-
54
- const STOPPED = 'Error: the run was stopped before this call finished.';
55
-
56
- /** Approval, as the first beforeToolCall hook. */
57
- const approval = async ({ name, args, call }) => {
58
- const decision = await gate(name, args, call.id);
59
- if (decision === 'unattended') {
60
- return {
61
- block: `Error: "${name}" needs the user's approval before it runs, and this is an`
62
- + ' unattended scheduled run with no one to ask, so it was not called. Finish what'
63
- + ' you can without it and say in your result that it still needs doing.'
64
- };
65
- }
66
- if (decision === 'deny') {
67
- return {
68
- block: `The user declined this call to "${name}", so it was not run. Do not retry it`
69
- + ' unless they ask; continue without it, or ask them how they would like to proceed.'
70
- };
71
- }
72
- return undefined;
73
- };
74
- const before = [approval, ...(hooks.beforeToolCall || [])];
75
-
76
- /** Runs the afterToolCall chain; a failing hook leaves the result as it stood. */
77
- const after = async (ctx) => {
78
- try {
79
- await runHooks(hooks.afterToolCall, ctx, (o) => {
80
- if (o.result !== undefined) ctx.result = o.result;
81
- if (o.stop) stop = true;
82
- return false;
83
- });
84
- } catch (err) {
85
- if (signal?.aborted) throw err;
86
- emit({ type: EVENT.NOTICE, text: `Tool result hook failed for "${ctx.name}" (${err.message}); result kept as returned.` });
87
- }
88
- return ctx.result;
89
- };
90
-
91
- /**
92
- * Everything that has to happen before a call may run: argument checks,
93
- * duplicate suppression and the beforeToolCall chain (approval included).
94
- * Returns `{ args, result }` for a call that will not run, `{ args, run }`
95
- * for one that will.
96
- */
97
- const prepare = async (call, round) => {
98
- const name = call.function.name;
99
- let args = {};
100
- let badArgs = null;
101
- try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; }
102
- catch (err) { badArgs = err.message; }
103
- if (!badArgs && (args === null || typeof args !== 'object' || Array.isArray(args))) {
104
- badArgs = 'arguments must be a JSON object';
105
- }
106
- emit({ type: EVENT.TOOL_CALL, id: call.id, name, args: badArgs ? {} : args });
107
-
108
- if (badArgs) {
109
- // Never run a tool on arguments the model did not write. Falling back
110
- // to {} quietly runs it on its defaults, and the model then reasons as
111
- // if its own arguments had been used.
112
- return {
113
- args: {},
114
- result: `Error: the arguments for "${name}" were not valid JSON (${badArgs}), so the`
115
- + ' tool was not called. Retry with a well-formed JSON object.'
116
- };
117
- }
118
- const dupKey = `${name}:${stableKey(args)}`;
119
- const seenRound = hub.isIdempotent?.(name) ? seenCalls.get(dupKey) : undefined;
120
- if (seenRound !== undefined) {
121
- return {
122
- args,
123
- result: `Skipped: this is an identical call to "${name}" with the same arguments`
124
- + ` already made in round ${seenRound + 1} of this turn, and this tool returns the same`
125
- + ' result for the same arguments. Reuse what you got back then.'
126
- };
127
- }
128
- const ctx = { name, args, call, round, signal };
129
- let blocked = null;
130
- try {
131
- await runHooks(before, ctx, (o) => {
132
- if (o.block === undefined) return false;
133
- blocked = String(o.block);
134
- return true;
135
- });
136
- } catch (err) {
137
- if (signal?.aborted) throw err;
138
- // A policy check that cannot decide must not let the call through.
139
- emit({ type: EVENT.NOTICE, text: `Tool policy hook failed for "${name}" (${err.message}); call not run.` });
140
- blocked = `Error: a policy check for "${name}" failed (${err.message}), so the tool was not`
141
- + ' called. Continue without it.';
142
- }
143
- if (blocked !== null) return { args, result: blocked };
144
- seenCalls.set(dupKey, round);
145
- return { args, run: ctx };
146
- };
147
-
148
- /**
149
- * Runs one prepared call and its afterToolCall chain. A stop abandons it
150
- * rather than waiting on a tool that may never answer; the call still gets
151
- * a result, so the transcript stays valid.
152
- */
153
- const run = async (ctx) => {
154
- if (signal?.aborted) return STOPPED;
155
- let onAbort;
156
- const stopped = new Promise((r) => {
157
- onAbort = () => r(STOPPED);
158
- signal?.addEventListener('abort', onAbort, { once: true });
159
- });
160
- try {
161
- const result = await Promise.race([hub.call(ctx.name, ctx.args, toolCtx), stopped]);
162
- if (result === STOPPED) return result;
163
- return await after({ ...ctx, result });
164
- } catch (err) {
165
- if (signal?.aborted) return STOPPED;
166
- throw err;
167
- } finally {
168
- signal?.removeEventListener('abort', onAbort);
169
- }
170
- };
171
-
172
- const execute = async (calls, { round }) => {
173
- // Every call is checked and approved before any of them runs, so no
174
- // write can start while a later call in the batch still waits on the user.
175
- const prepared = [];
176
- // The assistant turn holding these calls is already stored, so a Stop that
177
- // lands while one waits for approval must still answer all of them.
178
- for (const call of calls) {
179
- try {
180
- prepared.push(signal?.aborted ? { args: {}, result: STOPPED } : await prepare(call, round));
181
- } catch (err) {
182
- if (!signal?.aborted) throw err;
183
- prepared.push({ args: {}, result: STOPPED });
184
- }
185
- }
186
-
187
- // One sequential call makes the whole batch sequential: deterministic, and
188
- // no guessing at which calls could safely overlap it.
189
- const parallel = prepared.every((p) => !p.run || hub.executionMode?.(p.run.name) === 'parallel');
190
- const finish = async (p, call) => {
191
- if (p.run) p.result = await run(p.run);
192
- emit({ type: EVENT.TOOL_RESULT, id: call.id, name: call.function.name, result: p.result });
27
+ const toolCtx = { store, chatId, budget: cfg.expandCharBudget || 8000, unattended, askUser: unattended ? null : askUser };
28
+ // Same tool name + same args, seen earlier in this turn, on a tool that has
29
+ // declared its result depends only on its arguments: re-running it only burns
30
+ // a round. Tracked by round so the model can be told exactly when it already
31
+ // did this. Anything else -- a browser snapshot after a click, a list after a
32
+ // create -- is run again, because its answer may well have changed.
33
+ const seenCalls = new Map();
34
+ // "Always allow" is saved by the server, but this turn's cfg is a snapshot
35
+ // taken before it; remembered here so the next call in the turn doesn't ask.
36
+ const allowedNow = new Set();
37
+ // Set by an afterToolCall hook; honoured once the batch is complete.
38
+ let stop = false;
39
+ /** Resolves one call against the approval policy: 'allow', 'deny' or 'unattended'. */
40
+ const gate = async (name, args, id) => {
41
+ if (allowedNow.has(name) || approvalFor(cfg, hub, name, args) === 'auto')
42
+ return 'allow';
43
+ if (unattended)
44
+ return 'unattended';
45
+ if (!approve)
46
+ return 'deny';
47
+ emit({ type: EVENT.APPROVAL, id, name, args });
48
+ const decision = await approve({ id, name, args });
49
+ emit({ type: EVENT.APPROVAL_DONE, id, name, decision });
50
+ if (decision === 'always')
51
+ allowedNow.add(name);
52
+ return decision === 'always' || decision === 'allow' ? 'allow' : 'deny';
193
53
  };
194
- if (parallel) await Promise.all(prepared.map((p, i) => finish(p, calls[i])));
195
- else for (const [i, p] of prepared.entries()) await finish(p, calls[i]);
196
-
197
- // Persisted in the order the model made the calls, whatever order they finished in.
198
- const out = [];
199
- for (const [i, call] of calls.entries()) {
200
- const name = call.function.name;
201
- const { args, result } = prepared[i];
202
-
203
- const text = String(result);
204
- // Every tool result becomes an artifact, whatever tool produced it. This
205
- // is the one thing that makes compaction possible later: content can only
206
- // leave the window safely if the stub left behind can bring it back. The
207
- // artifact keeps the raw output; the budget footer is harness chatter.
208
- const artifactId = store.messages.addArtifact(chatId, { toolName: name, args, content: text });
209
- const footer = i === calls.length - 1 ? roundFooter(round) : '';
210
- const msg = { role: 'tool', tool_call_id: call.id, content: text + footer, artifact_id: artifactId };
211
-
212
- // Two caps. Past `maxInlineChars` a result is stubbed for every LATER
213
- // turn: the round that asked for it still reads it whole, since a head and
214
- // a tail leave no way to aim a grep at the middle and blind paging costs
215
- // more rounds than the inline text ever cost in tokens. Past
216
- // `maxTurnChars` it is stubbed even for this turn, because a result that
217
- // size would be resent in full on every remaining round and can blow the
218
- // window on its own. The turn cap is an append and costs no cache. The
219
- // inline cap is not: the next turn swaps full text for the stub, which
220
- // is a rewrite -- runChat opens any due epoch on that same turn so the
221
- // conversation pays for one cold request, not two.
222
- const inlineCap = cfg.maxInlineChars || 0;
223
- const turnCap = cfg.maxTurnChars || 0;
224
- const overTurn = turnCap > 0 && text.length > turnCap;
225
- if (overTurn || (inlineCap > 0 && text.length > inlineCap)) {
226
- msg.stub_text = digest({ id: artifactId, tool_name: name, content: text }) + (overTurn ? footer : '');
227
- }
228
-
229
- store.messages.add(chatId, msg);
230
- out.push({
231
- wire: toWire(overTurn ? msg : { ...msg, stub_text: null }),
232
- message: msg.stub_text ? { ...msg, compacted: true } : msg
233
- });
234
- }
235
- return out;
236
- };
237
-
238
- return { execute, stopRequested: () => stop };
54
+ const STOPPED = 'Error: the run was stopped before this call finished.';
55
+ /** Approval, as the first beforeToolCall hook. */
56
+ const approval = async ({ name, args, call }) => {
57
+ const decision = await gate(name, args, call.id);
58
+ if (decision === 'unattended') {
59
+ return {
60
+ block: `Error: "${name}" needs the user's approval before it runs, and this is an`
61
+ + ' unattended scheduled run with no one to ask, so it was not called. Finish what'
62
+ + ' you can without it and say in your result that it still needs doing.'
63
+ };
64
+ }
65
+ if (decision === 'deny') {
66
+ return {
67
+ block: `The user declined this call to "${name}", so it was not run. Do not retry it`
68
+ + ' unless they ask; continue without it, or ask them how they would like to proceed.'
69
+ };
70
+ }
71
+ return undefined;
72
+ };
73
+ const before = [approval, ...(hooks.beforeToolCall || [])];
74
+ /** Runs the afterToolCall chain; a failing hook leaves the result as it stood. */
75
+ const after = async (ctx) => {
76
+ try {
77
+ await runHooks(hooks.afterToolCall, ctx, (o) => {
78
+ if (o.result !== undefined)
79
+ ctx.result = o.result;
80
+ if (o.stop)
81
+ stop = true;
82
+ return false;
83
+ });
84
+ }
85
+ catch (err) {
86
+ if (signal?.aborted)
87
+ throw err;
88
+ emit({ type: EVENT.NOTICE, text: `Tool result hook failed for "${ctx.name}" (${err.message}); result kept as returned.` });
89
+ }
90
+ return ctx.result;
91
+ };
92
+ /**
93
+ * Everything that has to happen before a call may run: argument checks,
94
+ * duplicate suppression and the beforeToolCall chain (approval included).
95
+ * Returns `{ args, result }` for a call that will not run, `{ args, run }`
96
+ * for one that will.
97
+ */
98
+ const prepare = async (call, round) => {
99
+ const name = call.function.name;
100
+ let args = {};
101
+ let badArgs = null;
102
+ try {
103
+ args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
104
+ }
105
+ catch (err) {
106
+ badArgs = err.message;
107
+ }
108
+ if (!badArgs && (args === null || typeof args !== 'object' || Array.isArray(args))) {
109
+ badArgs = 'arguments must be a JSON object';
110
+ }
111
+ emit({ type: EVENT.TOOL_CALL, id: call.id, name, args: badArgs ? {} : args });
112
+ if (badArgs) {
113
+ // Never run a tool on arguments the model did not write. Falling back
114
+ // to {} quietly runs it on its defaults, and the model then reasons as
115
+ // if its own arguments had been used.
116
+ return {
117
+ args: {},
118
+ result: `Error: the arguments for "${name}" were not valid JSON (${badArgs}), so the`
119
+ + ' tool was not called. Retry with a well-formed JSON object.'
120
+ };
121
+ }
122
+ const dupKey = `${name}:${stableKey(args)}`;
123
+ const seenRound = hub.isIdempotent?.(name) ? seenCalls.get(dupKey) : undefined;
124
+ if (seenRound !== undefined) {
125
+ return {
126
+ args,
127
+ result: `Skipped: this is an identical call to "${name}" with the same arguments`
128
+ + ` already made in round ${seenRound + 1} of this turn, and this tool returns the same`
129
+ + ' result for the same arguments. Reuse what you got back then.'
130
+ };
131
+ }
132
+ const ctx = { name, args, call, round, signal };
133
+ let blocked = null;
134
+ try {
135
+ await runHooks(before, ctx, (o) => {
136
+ if (o.block === undefined)
137
+ return false;
138
+ blocked = String(o.block);
139
+ return true;
140
+ });
141
+ }
142
+ catch (err) {
143
+ if (signal?.aborted)
144
+ throw err;
145
+ // A policy check that cannot decide must not let the call through.
146
+ emit({ type: EVENT.NOTICE, text: `Tool policy hook failed for "${name}" (${err.message}); call not run.` });
147
+ blocked = `Error: a policy check for "${name}" failed (${err.message}), so the tool was not`
148
+ + ' called. Continue without it.';
149
+ }
150
+ if (blocked !== null)
151
+ return { args, result: blocked };
152
+ seenCalls.set(dupKey, round);
153
+ return { args, run: ctx };
154
+ };
155
+ /**
156
+ * Runs one prepared call and its afterToolCall chain. A stop abandons it
157
+ * rather than waiting on a tool that may never answer; the call still gets
158
+ * a result, so the transcript stays valid.
159
+ */
160
+ const run = async (ctx) => {
161
+ if (signal?.aborted)
162
+ return STOPPED;
163
+ let onAbort;
164
+ const stopped = new Promise((r) => {
165
+ onAbort = () => r(STOPPED);
166
+ signal?.addEventListener('abort', onAbort, { once: true });
167
+ });
168
+ try {
169
+ const result = await Promise.race([hub.call(ctx.name, ctx.args, toolCtx), stopped]);
170
+ if (result === STOPPED)
171
+ return result;
172
+ return await after({ ...ctx, result });
173
+ }
174
+ catch (err) {
175
+ if (signal?.aborted)
176
+ return STOPPED;
177
+ throw err;
178
+ }
179
+ finally {
180
+ signal?.removeEventListener('abort', onAbort);
181
+ }
182
+ };
183
+ const execute = async (calls, { round }) => {
184
+ // Every call is checked and approved before any of them runs, so no
185
+ // write can start while a later call in the batch still waits on the user.
186
+ const prepared = [];
187
+ // The assistant turn holding these calls is already stored, so a Stop that
188
+ // lands while one waits for approval must still answer all of them.
189
+ for (const call of calls) {
190
+ try {
191
+ prepared.push(signal?.aborted ? { args: {}, result: STOPPED } : await prepare(call, round));
192
+ }
193
+ catch (err) {
194
+ if (!signal?.aborted)
195
+ throw err;
196
+ prepared.push({ args: {}, result: STOPPED });
197
+ }
198
+ }
199
+ // One sequential call makes the whole batch sequential: deterministic, and
200
+ // no guessing at which calls could safely overlap it.
201
+ const parallel = prepared.every((p) => !p.run || hub.executionMode?.(p.run.name) === 'parallel');
202
+ const finish = async (p, call) => {
203
+ if (p.run)
204
+ p.result = await run(p.run);
205
+ emit({ type: EVENT.TOOL_RESULT, id: call.id, name: call.function.name, result: p.result });
206
+ };
207
+ if (parallel)
208
+ await Promise.all(prepared.map((p, i) => finish(p, calls[i])));
209
+ else
210
+ for (const [i, p] of prepared.entries())
211
+ await finish(p, calls[i]);
212
+ // Persisted in the order the model made the calls, whatever order they finished in.
213
+ const out = [];
214
+ for (const [i, call] of calls.entries()) {
215
+ const name = call.function.name;
216
+ const { args, result } = prepared[i];
217
+ const text = String(result);
218
+ // Every tool result becomes an artifact, whatever tool produced it. This
219
+ // is the one thing that makes compaction possible later: content can only
220
+ // leave the window safely if the stub left behind can bring it back. The
221
+ // artifact keeps the raw output; the budget footer is harness chatter.
222
+ const artifactId = store.messages.addArtifact(chatId, { toolName: name, args, content: text });
223
+ const footer = i === calls.length - 1 ? roundFooter(round) : '';
224
+ const msg = { role: 'tool', tool_call_id: call.id, content: text + footer, artifact_id: artifactId };
225
+ // Two caps. Past `maxInlineChars` a result is stubbed for every LATER
226
+ // turn: the round that asked for it still reads it whole, since a head and
227
+ // a tail leave no way to aim a grep at the middle and blind paging costs
228
+ // more rounds than the inline text ever cost in tokens. Past
229
+ // `maxTurnChars` it is stubbed even for this turn, because a result that
230
+ // size would be resent in full on every remaining round and can blow the
231
+ // window on its own. The turn cap is an append and costs no cache. The
232
+ // inline cap is not: the next turn swaps full text for the stub, which
233
+ // is a rewrite -- runChat opens any due epoch on that same turn so the
234
+ // conversation pays for one cold request, not two.
235
+ const inlineCap = cfg.maxInlineChars || 0;
236
+ const turnCap = cfg.maxTurnChars || 0;
237
+ const overTurn = turnCap > 0 && text.length > turnCap;
238
+ if (overTurn || (inlineCap > 0 && text.length > inlineCap)) {
239
+ msg.stub_text = digest({ id: artifactId, tool_name: name, content: text }) + (overTurn ? footer : '');
240
+ }
241
+ store.messages.add(chatId, msg);
242
+ out.push({
243
+ wire: toWire(overTurn ? msg : { ...msg, stub_text: null }),
244
+ message: msg.stub_text ? { ...msg, compacted: true } : msg
245
+ });
246
+ }
247
+ return out;
248
+ };
249
+ return { execute, stopRequested: () => stop };
239
250
  }