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
@@ -1,5 +1,4 @@
1
1
  import { textMap, EXPAND_CONTEXT } from '../tools/context_tool.js';
2
-
3
2
  /**
4
3
  * Context compaction.
5
4
  *
@@ -25,168 +24,156 @@ import { textMap, EXPAND_CONTEXT } from '../tools/context_tool.js';
25
24
  * server. Nothing below may look at a tool's name or know its schema; the
26
25
  * only structure we infer is generic (JSON, XML-ish, or neither).
27
26
  */
28
-
29
27
  export const DIGEST_HEAD = 400;
30
28
  export const DIGEST_TAIL = 200;
31
-
32
29
  /**
33
30
  * A one-line shape hint, derived only from generic structure. Gives the model
34
31
  * enough to judge whether re-reading is worth an expand_context call, without
35
32
  * the stub pretending to summarise content it has not understood.
36
33
  */
37
34
  function outline(text) {
38
- const trimmed = text.trim();
39
-
40
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
41
- try {
42
- const parsed = JSON.parse(trimmed);
43
- if (Array.isArray(parsed)) return `json array, ${parsed.length} item(s)`;
44
- if (parsed && typeof parsed === 'object') {
45
- const keys = Object.keys(parsed);
46
- const shown = keys.slice(0, 12).join(', ');
47
- return `json object, keys: ${shown}${keys.length > 12 ? `, +${keys.length - 12} more` : ''}`;
48
- }
49
- } catch { /* not JSON after all; fall through */ }
50
- }
51
-
52
- // XML-ish covers a lot of MCP output without committing to a parser.
53
- const tags = trimmed.match(/<([a-zA-Z][\w:.-]*)(?=[\s/>])/g);
54
- if (tags && tags.length >= 2) {
55
- const counts = new Map();
56
- for (const t of tags) {
57
- const name = t.slice(1);
58
- counts.set(name, (counts.get(name) || 0) + 1);
35
+ const trimmed = text.trim();
36
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
37
+ try {
38
+ const parsed = JSON.parse(trimmed);
39
+ if (Array.isArray(parsed))
40
+ return `json array, ${parsed.length} item(s)`;
41
+ if (parsed && typeof parsed === 'object') {
42
+ const keys = Object.keys(parsed);
43
+ const shown = keys.slice(0, 12).join(', ');
44
+ return `json object, keys: ${shown}${keys.length > 12 ? `, +${keys.length - 12} more` : ''}`;
45
+ }
46
+ }
47
+ catch { /* not JSON after all; fall through */ }
59
48
  }
60
- // Sorted by count, then name, so the line is stable for identical input.
61
- const top = [...counts.entries()]
62
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
63
- .slice(0, 8)
64
- .map(([name, n]) => `${name}×${n}`);
65
- return `tags: ${top.join(', ')}`;
66
- }
67
-
68
- const lines = trimmed ? trimmed.split('\n').length : 0;
69
- return `plain text, ${lines} line(s)`;
49
+ // XML-ish covers a lot of MCP output without committing to a parser.
50
+ const tags = trimmed.match(/<([a-zA-Z][\w:.-]*)(?=[\s/>])/g);
51
+ if (tags && tags.length >= 2) {
52
+ const counts = new Map();
53
+ for (const t of tags) {
54
+ const name = t.slice(1);
55
+ counts.set(name, (counts.get(name) || 0) + 1);
56
+ }
57
+ // Sorted by count, then name, so the line is stable for identical input.
58
+ const top = [...counts.entries()]
59
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
60
+ .slice(0, 8)
61
+ .map(([name, n]) => `${name}×${n}`);
62
+ return `tags: ${top.join(', ')}`;
63
+ }
64
+ const lines = trimmed ? trimmed.split('\n').length : 0;
65
+ return `plain text, ${lines} line(s)`;
70
66
  }
71
-
72
67
  /**
73
68
  * Builds the stub that replaces a tool result on the wire. Head and tail are
74
69
  * kept verbatim because the ends of a tool result are where the useful framing
75
70
  * usually is, and because a verbatim excerpt cannot hallucinate.
76
71
  */
77
72
  export function digest(artifact, { head = DIGEST_HEAD, tail = DIGEST_TAIL } = {}) {
78
- const text = artifact.content ?? '';
79
- const len = text.length;
80
- const id = artifact.id;
81
- const name = artifact.tool_name || 'tool';
82
-
83
- if (len <= head + tail) {
84
- // Nothing to gain; keep it whole rather than emit a stub bigger than its source.
85
- return text;
86
- }
87
-
88
- const elided = len - head - tail;
89
- // Only for prose: a JSON or XML outline already names its own structure, and
90
- // the model greps those by key or tag rather than paging through them.
91
- const shape = outline(text);
92
- const map = shape.startsWith('plain text') ? textMap(text) : '';
93
- return [
94
- `[compacted: artifact ${id} · ${name} · ${len.toLocaleString('en-US')} chars · ${shape}]`,
95
- ...(map ? [map] : []),
96
- text.slice(0, head),
97
- `\n… ${elided.toLocaleString('en-US')} chars elided …\n`,
98
- text.slice(len - tail),
99
- `[Full output retained. Read it with ${EXPAND_CONTEXT}("${id}", grep=... or offset/limit).]`
100
- ].join('\n');
73
+ const text = artifact.content ?? '';
74
+ const len = text.length;
75
+ const id = artifact.id;
76
+ const name = artifact.tool_name || 'tool';
77
+ if (len <= head + tail) {
78
+ // Nothing to gain; keep it whole rather than emit a stub bigger than its source.
79
+ return text;
80
+ }
81
+ const elided = len - head - tail;
82
+ // Only for prose: a JSON or XML outline already names its own structure, and
83
+ // the model greps those by key or tag rather than paging through them.
84
+ const shape = outline(text);
85
+ const map = shape.startsWith('plain text') ? textMap(text) : '';
86
+ return [
87
+ `[compacted: artifact ${id} · ${name} · ${len.toLocaleString('en-US')} chars · ${shape}]`,
88
+ ...(map ? [map] : []),
89
+ text.slice(0, head),
90
+ `\n… ${elided.toLocaleString('en-US')} chars elided …\n`,
91
+ text.slice(len - tail),
92
+ `[Full output retained. Read it with ${EXPAND_CONTEXT}("${id}", grep=... or offset/limit).]`
93
+ ].join('\n');
101
94
  }
102
-
103
95
  /** What one image is counted as, in characters of text (about 1.5k tokens). */
104
96
  export const IMAGE_CHARS = 6000;
105
-
106
97
  /** Rough size of a tool block, so a large MCP toolset isn't mistaken for history. */
107
98
  export function estimateToolTokens(tools = []) {
108
- return tools.length ? Math.ceil(JSON.stringify(tools).length / 4) : 0;
99
+ return tools.length ? Math.ceil(JSON.stringify(tools).length / 4) : 0;
109
100
  }
110
-
111
101
  /** Rough size of the wire payload, for when a provider reports no usage at all. */
112
102
  export function estimateTokens(wireMessages) {
113
- let chars = 0;
114
- for (const m of wireMessages) {
115
- if (typeof m.content === 'string') chars += m.content.length;
116
- else if (Array.isArray(m.content)) {
117
- for (const p of m.content) {
118
- if (p.type === 'text') chars += p.text.length;
119
- // Base64 length says nothing about what an image costs; providers bill
120
- // a tile-based figure in the low thousands of tokens.
121
- else if (p.type === 'image_url') chars += IMAGE_CHARS;
122
- }
103
+ let chars = 0;
104
+ for (const m of wireMessages) {
105
+ if (typeof m.content === 'string')
106
+ chars += m.content.length;
107
+ else if (Array.isArray(m.content)) {
108
+ for (const p of m.content) {
109
+ if (p.type === 'text')
110
+ chars += p.text.length;
111
+ // Base64 length says nothing about what an image costs; providers bill
112
+ // a tile-based figure in the low thousands of tokens.
113
+ else if (p.type === 'image_url')
114
+ chars += IMAGE_CHARS;
115
+ }
116
+ }
117
+ if (m.tool_calls)
118
+ chars += JSON.stringify(m.tool_calls).length;
123
119
  }
124
- if (m.tool_calls) chars += JSON.stringify(m.tool_calls).length;
125
- }
126
- return Math.ceil(chars / 4);
120
+ return Math.ceil(chars / 4);
127
121
  }
128
-
129
- /**
130
- * Decides whether this turn should open a new epoch, and what to demote.
131
- *
132
- * Returns null when nothing should change -- which is the common case, and the
133
- * case that keeps the prefix cached. Called once at the start of a turn, never
134
- * mid-turn, so within a turn the prefix only ever grows.
135
- */
136
122
  export function planEpoch(rows, { threshold, keepTurns, promptTokens }) {
137
- if (!(promptTokens > threshold)) return null;
138
-
139
- // Everything from the Nth-from-last user message onward stays verbatim: the
140
- // model is most likely to still be working from the recent turns.
141
- const userSeqs = rows.filter((r) => r.role === 'user').map((r) => r.seq);
142
- if (userSeqs.length <= keepTurns) return null;
143
- const boundarySeq = userSeqs[userSeqs.length - keepTurns];
144
-
145
- // Old images go the same way as old tool output: they are resent in full on
146
- // every turn, and unlike text nothing ever shrinks them otherwise.
147
- const targets = rows.filter((r) => r.seq < boundarySeq && (
148
- (r.role === 'tool' && r.artifact_id && !r.stub_text)
149
- || (r.images_json && !r.images_dropped)
150
- ));
151
- // No candidates means bumping the epoch would cost a cache miss for nothing.
152
- if (!targets.length) return null;
153
-
154
- return { boundarySeq, targets };
123
+ if (!(promptTokens > threshold))
124
+ return null;
125
+ // Everything from the Nth-from-last user message onward stays verbatim: the
126
+ // model is most likely to still be working from the recent turns.
127
+ const userSeqs = rows.filter((r) => r.role === 'user').map((r) => r.seq);
128
+ if (userSeqs.length <= keepTurns)
129
+ return null;
130
+ const boundarySeq = userSeqs[userSeqs.length - keepTurns];
131
+ // Old images go the same way as old tool output: they are resent in full on
132
+ // every turn, and unlike text nothing ever shrinks them otherwise.
133
+ const targets = rows.filter((r) => r.seq < boundarySeq && ((r.role === 'tool' && r.artifact_id && !r.stub_text)
134
+ || (r.images_json && !r.images_dropped)));
135
+ // No candidates means bumping the epoch would cost a cache miss for nothing.
136
+ if (!targets.length)
137
+ return null;
138
+ return { boundarySeq, targets };
155
139
  }
156
-
157
140
  /**
158
141
  * Applies the plan: writes each stub back to the store and records the new
159
142
  * boundary. After this returns, the prefix up to `boundarySeq` is frozen --
160
143
  * stubs are persisted, so every later rebuild produces identical bytes.
161
144
  */
162
145
  export function applyEpoch(store, chat, plan, { minSaved = 0 } = {}) {
163
- // Stubs are worked out before anything is written: an epoch costs a full
164
- // cache miss, so one that would only shave a few hundred characters is not
165
- // worth opening, and the check has to happen before the store is touched.
166
- const stubs = [];
167
- let saved = 0;
168
- const drops = [];
169
- for (const row of plan.targets) {
170
- if (row.role !== 'tool') {
171
- drops.push(row.id);
172
- saved += JSON.parse(row.images_json).length * IMAGE_CHARS;
173
- continue;
146
+ // Stubs are worked out before anything is written: an epoch costs a full
147
+ // cache miss, so one that would only shave a few hundred characters is not
148
+ // worth opening, and the check has to happen before the store is touched.
149
+ const stubs = [];
150
+ let saved = 0;
151
+ const drops = [];
152
+ for (const row of plan.targets) {
153
+ if (row.role !== 'tool') {
154
+ drops.push(row.id);
155
+ saved += JSON.parse(row.images_json).length * IMAGE_CHARS;
156
+ continue;
157
+ }
158
+ const artifact = store.messages.getArtifact(row.artifact_id, chat.id);
159
+ if (!artifact)
160
+ continue;
161
+ const stub = digest(artifact);
162
+ if (stub.length >= (row.content?.length ?? 0))
163
+ continue;
164
+ stubs.push([row.id, stub]);
165
+ saved += (row.content?.length ?? 0) - stub.length;
174
166
  }
175
- const artifact = store.messages.getArtifact(row.artifact_id, chat.id);
176
- if (!artifact) continue;
177
- const stub = digest(artifact);
178
- if (stub.length >= (row.content?.length ?? 0)) continue;
179
- stubs.push([row.id, stub]);
180
- saved += (row.content?.length ?? 0) - stub.length;
181
- }
182
- if (!saved || saved < minSaved) return null;
183
- for (const [id, stub] of stubs) store.messages.setStub(id, stub);
184
- for (const id of drops) store.messages.dropImages(id);
185
- const epoch = chat.epoch + 1;
186
- store.chats.touch(chat.id, { epoch, boundary_seq: plan.boundarySeq });
187
- return { epoch, boundarySeq: plan.boundarySeq, saved };
167
+ if (!saved || saved < minSaved)
168
+ return null;
169
+ for (const [id, stub] of stubs)
170
+ store.messages.setStub(id, stub);
171
+ for (const id of drops)
172
+ store.messages.dropImages(id);
173
+ const epoch = chat.epoch + 1;
174
+ store.chats.touch(chat.id, { epoch, boundary_seq: plan.boundarySeq });
175
+ return { epoch, boundarySeq: plan.boundarySeq, saved };
188
176
  }
189
-
190
177
  /**
191
178
  * The hard window: what happens when stubbing is not enough.
192
179
  *
@@ -203,40 +190,46 @@ export function applyEpoch(store, chat, plan, { minSaved = 0 } = {}) {
203
190
  * `rows` must already be what the model would see (window applied, stubs in).
204
191
  */
205
192
  export function planWindow(rows, { maxTokens, targetTokens, keepTurns, toWire }) {
206
- if (!(maxTokens > 0)) return null;
207
- const sizes = rows.map((r) => estimateTokens([toWire(r)]));
208
- let total = sizes.reduce((a, b) => a + b, 0);
209
- if (total <= maxTokens) return null;
210
-
211
- const userIdx = rows.map((r, i) => (r.role === 'user' ? i : -1)).filter((i) => i >= 0);
212
- if (userIdx.length <= keepTurns) return null;
213
- const lastCut = userIdx[userIdx.length - keepTurns];
214
-
215
- let cut = 0;
216
- for (const i of userIdx) {
217
- if (i === 0) continue;
218
- if (i > lastCut) break;
219
- for (let k = cut; k < i; k++) total -= sizes[k];
220
- cut = i;
221
- if (total <= targetTokens) break;
222
- }
223
- return cut > 0 ? rows[cut].seq : null;
193
+ if (!(maxTokens > 0))
194
+ return null;
195
+ const sizes = rows.map((r) => estimateTokens([toWire(r)]));
196
+ let total = sizes.reduce((a, b) => a + b, 0);
197
+ if (total <= maxTokens)
198
+ return null;
199
+ const userIdx = rows.map((r, i) => (r.role === 'user' ? i : -1)).filter((i) => i >= 0);
200
+ if (userIdx.length <= keepTurns)
201
+ return null;
202
+ const lastCut = userIdx[userIdx.length - keepTurns];
203
+ let cut = 0;
204
+ for (const i of userIdx) {
205
+ if (i === 0)
206
+ continue;
207
+ if (i > lastCut)
208
+ break;
209
+ for (let k = cut; k < i; k++)
210
+ total -= sizes[k];
211
+ cut = i;
212
+ if (total <= targetTokens)
213
+ break;
214
+ }
215
+ return cut > 0 ? rows[cut].seq : null;
224
216
  }
225
-
226
217
  /**
227
218
  * Applies a window to stored rows: drops what is before it and marks the
228
219
  * first remaining message so the model knows the conversation did not start
229
220
  * there. Pure and deterministic, so every rebuild sends the same bytes.
230
221
  */
231
222
  export function windowRows(rows, windowSeq, { summarized = false } = {}) {
232
- if (!(windowSeq >= 0)) return rows;
233
- const kept = rows.filter((r) => r.seq >= windowSeq);
234
- const dropped = rows.length - kept.length;
235
- if (!dropped || !kept.length || kept[0].role !== 'user') return kept;
236
- const note = `[Earlier conversation omitted: ${dropped} message${dropped === 1 ? '' : 's'} before this`
237
- + ' point were removed to keep the conversation within the context window. '
238
- + (summarized
239
- ? 'They are summarised in the system prompt; if something you need is missing there, ask the user.]'
240
- : 'If something from before is needed and is not restated here, ask the user.]');
241
- return [{ ...kept[0], content: `${note}\n\n${kept[0].content ?? ''}` }, ...kept.slice(1)];
223
+ if (!(windowSeq >= 0))
224
+ return rows;
225
+ const kept = rows.filter((r) => r.seq >= windowSeq);
226
+ const dropped = rows.length - kept.length;
227
+ if (!dropped || !kept.length || kept[0].role !== 'user')
228
+ return kept;
229
+ const note = `[Earlier conversation omitted: ${dropped} message${dropped === 1 ? '' : 's'} before this`
230
+ + ' point were removed to keep the conversation within the context window. '
231
+ + (summarized
232
+ ? 'They are summarised in the system prompt; if something you need is missing there, ask the user.]'
233
+ : 'If something from before is needed and is not restated here, ask the user.]');
234
+ return [{ ...kept[0], content: `${note}\n\n${kept[0].content ?? ''}` }, ...kept.slice(1)];
242
235
  }