tinywebui 0.1.3 → 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 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  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 +83 -20
  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 -138
  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 -451
  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 +98 -66
  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 -100
  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 +428 -392
  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,254 +1,229 @@
1
1
  import { ALL_USERS, scope, ownerOf } from './scope.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { CORPUS } from './embeddings.js';
4
-
5
4
  // Chats and what hangs off one: tasks, folders, queued input, ask_user questions, rewind and delete.
6
5
  // One area of the Store; see index.js.
7
-
8
6
  export class ChatStore {
9
- constructor(db, deps = {}) {
10
- this.db = db;
11
- Object.assign(this, deps);
12
- }
13
-
14
- listTasks(chatId) {
15
- return this.db.prepare('SELECT id, title, status, created_at, updated_at FROM tasks WHERE chat_id = ? ORDER BY created_at, rowid').all(chatId);
16
- }
17
-
18
- /**
19
- * Task writes are stamped with the seq of the latest saved message: the
20
- * assistant message that made the call, which is saved before its tools run.
21
- * That seq lies inside the turn, after its question, so rewinding to the
22
- * question (or retrying its answer) undoes the write -- and it holds even if
23
- * the turn dies before saving anything more. See undoTasksFrom.
24
- */
25
- /**
26
- * The checklist as the user sees it: a task finished before the latest
27
- * question is done with and drops out, the rest stay. A finish is stamped
28
- * inside the turn that made it, so it compares against the question's seq
29
- * the same way a rewind does; a rewind that reopens a task shows it again.
30
- */
31
- visibleTasks(chatId) {
32
- return this.db.prepare(`
7
+ constructor(db, deps) {
8
+ this.db = db;
9
+ Object.assign(this, deps);
10
+ }
11
+ listTasks(chatId) {
12
+ return this.db.prepare('SELECT id, title, status, created_at, updated_at FROM tasks WHERE chat_id = ? ORDER BY created_at, rowid').all(chatId);
13
+ }
14
+ /**
15
+ * Task writes are stamped with the seq of the latest saved message: the
16
+ * assistant message that made the call, which is saved before its tools run.
17
+ * That seq lies inside the turn, after its question, so rewinding to the
18
+ * question (or retrying its answer) undoes the write -- and it holds even if
19
+ * the turn dies before saving anything more. See undoTasksFrom.
20
+ */
21
+ /**
22
+ * The checklist as the user sees it: a task finished before the latest
23
+ * question is done with and drops out, the rest stay. A finish is stamped
24
+ * inside the turn that made it, so it compares against the question's seq
25
+ * the same way a rewind does; a rewind that reopens a task shows it again.
26
+ */
27
+ visibleTasks(chatId) {
28
+ return this.db.prepare(`
33
29
  SELECT id, title, status, created_at, updated_at FROM tasks t
34
30
  WHERE chat_id = ? AND (status != 'completed'
35
31
  OR COALESCE((SELECT MAX(seq) FROM task_changes c WHERE c.task_id = t.id), t.created_seq)
36
32
  >= COALESCE((SELECT MAX(seq) FROM messages m WHERE m.chat_id = t.chat_id AND m.role = 'user'), -1))
37
33
  ORDER BY created_at, rowid`).all(chatId);
38
- }
39
-
40
- addTask(chatId, title) {
41
- const id = randomUUID();
42
- const now = Date.now();
43
- this.db.prepare('INSERT INTO tasks (id, chat_id, title, created_at, updated_at, created_seq) VALUES (?, ?, ?, ?, ?, ?)')
44
- .run(id, chatId, title, now, now, this.#stamp(chatId));
45
- return this.listTasks(chatId).find((task) => task.id === id);
46
- }
47
-
48
- updateTask(chatId, id, status) {
49
- const prev = this.db.prepare('SELECT status FROM tasks WHERE chat_id = ? AND id = ?').get(chatId, id);
50
- if (!prev) return null;
51
- this.db.prepare('INSERT INTO task_changes (chat_id, task_id, seq, prev_status) VALUES (?, ?, ?, ?)')
52
- .run(chatId, id, this.#stamp(chatId), prev.status);
53
- this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?')
54
- .run(status, Date.now(), chatId, id);
55
- return this.listTasks(chatId).find((task) => task.id === id);
56
- }
57
-
58
- #stamp(chatId) {
59
- return this.messages.nextSeq(chatId) - 1;
60
- }
61
-
62
- /** Undoes the checklist writes made from `seq` on: changes newest first, then the tasks added. */
63
- undoTasksFrom(chatId, seq) {
64
- const changes = this.db.prepare(
65
- 'SELECT rowid, task_id, prev_status FROM task_changes WHERE chat_id = ? AND seq >= ? ORDER BY rowid DESC'
66
- ).all(chatId, seq);
67
- const restore = this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?');
68
- for (const c of changes) restore.run(c.prev_status, Date.now(), chatId, c.task_id);
69
- this.db.prepare('DELETE FROM task_changes WHERE chat_id = ? AND seq >= ?').run(chatId, seq);
70
- this.db.prepare('DELETE FROM tasks WHERE chat_id = ? AND created_seq >= ?').run(chatId, seq);
71
- }
72
-
73
- create({ id, title = 'New chat', createdAt = Date.now() } = {}, userId = ALL_USERS) {
74
- const chatId = id || randomUUID();
75
- this.db.prepare(
76
- 'INSERT INTO chats (id, title, created_at, updated_at, user_id) VALUES (?, ?, ?, ?, ?)'
77
- ).run(chatId, title, createdAt, createdAt, ownerOf(userId));
78
- return this.byId(chatId);
79
- }
80
-
81
- /** Scoped lookup for anything reached from a request. See scope(). */
82
- get(id, userId) {
83
- const s = scope(userId);
84
- return this.db.prepare(`SELECT * FROM chats WHERE id = ? AND ${s.sql}`).get(id, ...s.params) || null;
85
- }
86
-
87
- /** Unscoped lookup for internal plumbing that already holds a chat id it
88
- * got from a scoped path (the chat loop, compaction, automation runs). */
89
- byId(id) {
90
- return this.db.prepare('SELECT * FROM chats WHERE id = ?').get(id) || null;
91
- }
92
-
93
- list(limit, userId) {
94
- const s = scope(userId);
95
- return this.db.prepare(
96
- `SELECT id, title, updated_at, epoch, boundary_seq, folder FROM chats WHERE ${s.sql} ORDER BY updated_at DESC LIMIT ?`
97
- ).all(...s.params, limit);
98
- }
99
-
100
- organize(id, { folder = null, tags } = {}, userId) {
101
- if (!this.get(id, userId)) return null;
102
- const clean = (items, max) => [...new Set((Array.isArray(items) ? items : [])
103
- .map((v) => String(v).trim().slice(0, max)).filter(Boolean))].slice(0, 10);
104
- const cleanFolder = folder == null ? null : String(folder).trim().slice(0, 40) || null;
105
- const tagsJson = tags === undefined ? null : JSON.stringify(clean(tags, 32));
106
- const result = this.db.prepare('UPDATE chats SET folder = ?, tags_json = COALESCE(?, tags_json) WHERE id = ?')
107
- .run(cleanFolder, tagsJson, id);
108
- return result.changes ? this.byId(id) : null;
109
- }
110
-
111
- /** Names only, sorted -- the create-then-move-chats-into-it workflow needs
112
- * to list folders even when nothing has been organized into them yet. */
113
- listFolders(userId) {
114
- const s = scope(userId);
115
- return this.db.prepare(`SELECT name FROM folders WHERE ${s.sql}`).all(...s.params).map((r) => r.name).sort((a, b) => a.localeCompare(b));
116
- }
117
-
118
- createFolder(name, userId) {
119
- const clean = String(name || '').trim().slice(0, 40);
120
- if (!clean) return null;
121
- const owner = ownerOf(userId);
122
- const s = scope(owner);
123
- const exists = this.db.prepare(`SELECT 1 FROM folders WHERE name = ? AND ${s.sql}`).get(clean, ...s.params);
124
- if (!exists) {
125
- this.db.prepare('INSERT INTO folders (name, user_id, created_at) VALUES (?, ?, ?)')
126
- .run(clean, owner, Date.now());
127
- }
128
- return clean;
129
- }
130
-
131
- /**
132
- * Rewinds a conversation, dropping everything from `seq` onward.
133
- *
134
- * This is the one place that breaks the append-only rule on purpose. Editing
135
- * a question means the answers that followed it were answers to a question
136
- * that no longer exists, so they go with it -- and the prompt cache from that
137
- * point on goes too, which is correct rather than unfortunate: the model must
138
- * genuinely reconsider everything after the edit.
139
- *
140
- * Artifacts are left behind. They are addressed by id from a message that no
141
- * longer exists, so nothing can reach them, and deleting them would be the
142
- * one way to lose tool output that the transcript promises to keep.
143
- */
144
- /**
145
- * Queues input for a running turn. Returns false when that id is already
146
- * queued (a retry), true when it was added.
147
- */
148
- addQueued(chatId, { id, kind, content }) {
149
- return this.db.prepare(
150
- 'INSERT OR IGNORE INTO queued_messages (id, chat_id, kind, content, created_at) VALUES (?, ?, ?, ?, ?)'
151
- ).run(String(id), chatId, kind, String(content), Date.now()).changes > 0;
152
- }
153
-
154
- /** A chat's queue, oldest first. */
155
- listQueued(chatId) {
156
- return this.db.prepare(
157
- 'SELECT id, kind, content, created_at FROM queued_messages WHERE chat_id = ? ORDER BY pos'
158
- ).all(chatId);
159
- }
160
-
161
- /**
162
- * Removes and returns queued items for delivery, oldest first: every item
163
- * of `kind`, or with `first` only the oldest item of any kind.
164
- */
165
- takeQueued(chatId, { kind = null, first = false } = {}) {
166
- const rows = this.db.prepare(
167
- `SELECT pos, id, kind, content FROM queued_messages WHERE chat_id = ?${kind ? ' AND kind = ?' : ''} ORDER BY pos${first ? ' LIMIT 1' : ''}`
168
- ).all(...(kind ? [chatId, kind] : [chatId]));
169
- const drop = this.db.prepare('DELETE FROM queued_messages WHERE pos = ?');
170
- for (const r of rows) drop.run(r.pos);
171
- return rows.map(({ id, kind: k, content }) => ({ id, kind: k, content }));
172
- }
173
-
174
- /** Withdraws one queued item before it is delivered. */
175
- deleteQueued(chatId, id) {
176
- return this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ? AND id = ?').run(chatId, String(id)).changes > 0;
177
- }
178
-
179
- /** Records a question as pending. `deadline` is epoch ms, or null for no timeout. */
180
- addQuestion(chatId, { id, question, choices = [], allowFreeText = true, deadline = null }) {
181
- this.db.prepare(
182
- 'INSERT INTO questions (id, chat_id, question, choices_json, allow_free_text, created_at, deadline) VALUES (?, ?, ?, ?, ?, ?, ?)'
183
- ).run(String(id), chatId, question, JSON.stringify(choices), allowFreeText ? 1 : 0, Date.now(), deadline);
184
- }
185
-
186
- getQuestion(id) {
187
- const row = this.db.prepare('SELECT * FROM questions WHERE id = ?').get(String(id));
188
- return row && {
189
- id: row.id, chatId: row.chat_id, question: row.question, choices: JSON.parse(row.choices_json),
190
- allowFreeText: Boolean(row.allow_free_text), status: row.status, answer: row.answer,
191
- createdAt: row.created_at, deadline: row.deadline, settledAt: row.settled_at
192
- };
193
- }
194
-
195
- /**
196
- * Settles a pending question. True for the one caller that settled it;
197
- * false when it was already settled (or never existed).
198
- */
199
- settleQuestion(id, status, answer = null) {
200
- return this.db.prepare(
201
- "UPDATE questions SET status = ?, answer = ?, settled_at = ? WHERE id = ? AND status = 'pending'"
202
- ).run(status, answer, Date.now(), String(id)).changes > 0;
203
- }
204
-
205
- /** Expires every pending question: at startup no run is left to answer one. */
206
- expireQuestions() {
207
- return this.db.prepare(
208
- "UPDATE questions SET status = 'expired', settled_at = ? WHERE status = 'pending'"
209
- ).run(Date.now()).changes;
210
- }
211
-
212
- truncateFrom(chatId, seq) {
213
- // One transaction: a crash midway must not leave messages cut but their
214
- // task writes (or cache markers) still standing.
215
- this.db.exec('BEGIN');
216
- try {
217
- const removed = this.messages.deleteFrom(chatId, seq);
218
- this.undoTasksFrom(chatId, seq);
219
- // A frozen compaction boundary inside the cut no longer describes anything,
220
- // so the pinned cache breakpoint it drives has to go with it.
221
- const chat = this.byId(chatId);
222
- if (chat && chat.boundary_seq >= seq) this.touch(chatId, { boundary_seq: -1 });
223
- // Same for the hard window: a cut at or past the rewind point would hide
224
- // the very question being asked again. A checkpoint summarises what that
225
- // window cut off, so it goes too.
226
- if (chat && chat.window_seq >= seq) this.touch(chatId, { window_seq: -1, checkpoint_json: null });
227
- this.db.exec('COMMIT');
228
- return removed;
229
- } catch (err) { this.db.exec('ROLLBACK'); throw err; }
230
- }
231
-
232
- delete(id, userId) {
233
- if (!this.get(id, userId)) return false;
234
- // Each area drops its own part of the chat; tasks go by foreign key.
235
- this.automations.deleteForChat(id);
236
- this.messages.deleteForChat(id);
237
- this.documents.deleteForChat(id);
238
- this.embeddings.deleteOwner(CORPUS.CHATS, id);
239
- this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ?').run(id);
240
- this.db.prepare('DELETE FROM questions WHERE chat_id = ?').run(id);
241
- this.db.prepare('DELETE FROM chats WHERE id = ?').run(id);
242
- return true;
243
- }
244
-
245
- touch(id, patch = {}) {
246
- const sets = ['updated_at = ?'];
247
- const vals = [Date.now()];
248
- for (const key of ['title', 'epoch', 'boundary_seq', 'window_seq', 'checkpoint_json']) {
249
- if (patch[key] !== undefined) { sets.push(`${key} = ?`); vals.push(patch[key]); }
250
- }
251
- vals.push(id);
252
- this.db.prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
253
- }
34
+ }
35
+ addTask(chatId, title) {
36
+ const id = randomUUID();
37
+ const now = Date.now();
38
+ this.db.prepare('INSERT INTO tasks (id, chat_id, title, created_at, updated_at, created_seq) VALUES (?, ?, ?, ?, ?, ?)')
39
+ .run(id, chatId, title, now, now, this.#stamp(chatId));
40
+ return this.listTasks(chatId).find((task) => task.id === id);
41
+ }
42
+ updateTask(chatId, id, status) {
43
+ const prev = this.db.prepare('SELECT status FROM tasks WHERE chat_id = ? AND id = ?').get(chatId, id);
44
+ if (!prev)
45
+ return null;
46
+ this.db.prepare('INSERT INTO task_changes (chat_id, task_id, seq, prev_status) VALUES (?, ?, ?, ?)')
47
+ .run(chatId, id, this.#stamp(chatId), prev.status);
48
+ this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?')
49
+ .run(status, Date.now(), chatId, id);
50
+ return this.listTasks(chatId).find((task) => task.id === id);
51
+ }
52
+ #stamp(chatId) {
53
+ return this.messages.nextSeq(chatId) - 1;
54
+ }
55
+ /** Undoes the checklist writes made from `seq` on: changes newest first, then the tasks added. */
56
+ undoTasksFrom(chatId, seq) {
57
+ const changes = this.db.prepare('SELECT rowid, task_id, prev_status FROM task_changes WHERE chat_id = ? AND seq >= ? ORDER BY rowid DESC').all(chatId, seq);
58
+ const restore = this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?');
59
+ for (const c of changes)
60
+ restore.run(c.prev_status, Date.now(), chatId, c.task_id);
61
+ this.db.prepare('DELETE FROM task_changes WHERE chat_id = ? AND seq >= ?').run(chatId, seq);
62
+ this.db.prepare('DELETE FROM tasks WHERE chat_id = ? AND created_seq >= ?').run(chatId, seq);
63
+ }
64
+ create({ id, title = 'New chat', createdAt = Date.now() } = {}, userId = ALL_USERS) {
65
+ const chatId = id || randomUUID();
66
+ this.db.prepare('INSERT INTO chats (id, title, created_at, updated_at, user_id) VALUES (?, ?, ?, ?, ?)').run(chatId, title, createdAt, createdAt, ownerOf(userId));
67
+ return this.byId(chatId);
68
+ }
69
+ claimUnowned(userId) {
70
+ this.db.prepare('UPDATE chats SET user_id = ? WHERE user_id IS NULL').run(userId);
71
+ this.db.prepare('UPDATE folders SET user_id = ? WHERE user_id IS NULL').run(userId);
72
+ }
73
+ /** Scoped lookup for anything reached from a request. See scope(). */
74
+ get(id, userId) {
75
+ const s = scope(userId);
76
+ return this.db.prepare(`SELECT * FROM chats WHERE id = ? AND ${s.sql}`).get(id, ...s.params) || null;
77
+ }
78
+ /** Unscoped lookup for internal plumbing that already holds a chat id it
79
+ * got from a scoped path (the chat loop, compaction, automation runs). */
80
+ byId(id) {
81
+ return this.db.prepare('SELECT * FROM chats WHERE id = ?').get(id) || null;
82
+ }
83
+ list(limit, userId) {
84
+ const s = scope(userId);
85
+ return this.db.prepare(`SELECT id, title, updated_at, epoch, boundary_seq, folder FROM chats WHERE ${s.sql} ORDER BY updated_at DESC LIMIT ?`).all(...s.params, limit);
86
+ }
87
+ organize(id, { folder = null, tags } = {}, userId) {
88
+ if (!this.get(id, userId))
89
+ return null;
90
+ const clean = (items, max) => [...new Set((Array.isArray(items) ? items : [])
91
+ .map((v) => String(v).trim().slice(0, max)).filter(Boolean))].slice(0, 10);
92
+ const cleanFolder = folder == null ? null : String(folder).trim().slice(0, 40) || null;
93
+ const tagsJson = tags === undefined ? null : JSON.stringify(clean(tags, 32));
94
+ const result = this.db.prepare('UPDATE chats SET folder = ?, tags_json = COALESCE(?, tags_json) WHERE id = ?')
95
+ .run(cleanFolder, tagsJson, id);
96
+ return result.changes ? this.byId(id) : null;
97
+ }
98
+ /** Names only, sorted -- the create-then-move-chats-into-it workflow needs
99
+ * to list folders even when nothing has been organized into them yet. */
100
+ listFolders(userId) {
101
+ const s = scope(userId);
102
+ return this.db.prepare(`SELECT name FROM folders WHERE ${s.sql}`).all(...s.params).map((r) => r.name).sort((a, b) => a.localeCompare(b));
103
+ }
104
+ createFolder(name, userId) {
105
+ const clean = String(name || '').trim().slice(0, 40);
106
+ if (!clean)
107
+ return null;
108
+ const owner = ownerOf(userId);
109
+ const s = scope(owner);
110
+ const exists = this.db.prepare(`SELECT 1 FROM folders WHERE name = ? AND ${s.sql}`).get(clean, ...s.params);
111
+ if (!exists) {
112
+ this.db.prepare('INSERT INTO folders (name, user_id, created_at) VALUES (?, ?, ?)')
113
+ .run(clean, owner, Date.now());
114
+ }
115
+ return clean;
116
+ }
117
+ /**
118
+ * Rewinds a conversation, dropping everything from `seq` onward.
119
+ *
120
+ * This is the one place that breaks the append-only rule on purpose. Editing
121
+ * a question means the answers that followed it were answers to a question
122
+ * that no longer exists, so they go with it -- and the prompt cache from that
123
+ * point on goes too, which is correct rather than unfortunate: the model must
124
+ * genuinely reconsider everything after the edit.
125
+ *
126
+ * Artifacts are left behind. They are addressed by id from a message that no
127
+ * longer exists, so nothing can reach them, and deleting them would be the
128
+ * one way to lose tool output that the transcript promises to keep.
129
+ */
130
+ /**
131
+ * Queues input for a running turn. Returns false when that id is already
132
+ * queued (a retry), true when it was added.
133
+ */
134
+ addQueued(chatId, { id, kind, content }) {
135
+ return this.db.prepare('INSERT OR IGNORE INTO queued_messages (id, chat_id, kind, content, created_at) VALUES (?, ?, ?, ?, ?)').run(String(id), chatId, kind, String(content), Date.now()).changes > 0;
136
+ }
137
+ /** A chat's queue, oldest first. */
138
+ listQueued(chatId) {
139
+ return this.db.prepare('SELECT id, kind, content, created_at FROM queued_messages WHERE chat_id = ? ORDER BY pos').all(chatId);
140
+ }
141
+ /**
142
+ * Removes and returns queued items for delivery, oldest first: every item
143
+ * of `kind`, or with `first` only the oldest item of any kind.
144
+ */
145
+ takeQueued(chatId, { kind = null, first = false } = {}) {
146
+ const rows = this.db.prepare(`SELECT pos, id, kind, content FROM queued_messages WHERE chat_id = ?${kind ? ' AND kind = ?' : ''} ORDER BY pos${first ? ' LIMIT 1' : ''}`).all(...(kind ? [chatId, kind] : [chatId]));
147
+ const drop = this.db.prepare('DELETE FROM queued_messages WHERE pos = ?');
148
+ for (const r of rows)
149
+ drop.run(r.pos);
150
+ return rows.map(({ id, kind: k, content }) => ({ id, kind: k, content }));
151
+ }
152
+ /** Withdraws one queued item before it is delivered. */
153
+ deleteQueued(chatId, id) {
154
+ return this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ? AND id = ?').run(chatId, String(id)).changes > 0;
155
+ }
156
+ /** Records a question as pending. `deadline` is epoch ms, or null for no timeout. */
157
+ addQuestion(chatId, { id, question, choices = [], allowFreeText = true, deadline = null }) {
158
+ this.db.prepare('INSERT INTO questions (id, chat_id, question, choices_json, allow_free_text, created_at, deadline) VALUES (?, ?, ?, ?, ?, ?, ?)').run(String(id), chatId, question, JSON.stringify(choices), allowFreeText ? 1 : 0, Date.now(), deadline);
159
+ }
160
+ getQuestion(id) {
161
+ const row = this.db.prepare('SELECT * FROM questions WHERE id = ?').get(String(id));
162
+ return row && {
163
+ id: row.id, chatId: row.chat_id, question: row.question, choices: JSON.parse(row.choices_json),
164
+ allowFreeText: Boolean(row.allow_free_text), status: row.status, answer: row.answer,
165
+ createdAt: row.created_at, deadline: row.deadline, settledAt: row.settled_at
166
+ };
167
+ }
168
+ /**
169
+ * Settles a pending question. True for the one caller that settled it;
170
+ * false when it was already settled (or never existed).
171
+ */
172
+ settleQuestion(id, status, answer = null) {
173
+ return this.db.prepare("UPDATE questions SET status = ?, answer = ?, settled_at = ? WHERE id = ? AND status = 'pending'").run(status, answer, Date.now(), String(id)).changes > 0;
174
+ }
175
+ /** Expires every pending question: at startup no run is left to answer one. */
176
+ expireQuestions() {
177
+ return this.db.prepare("UPDATE questions SET status = 'expired', settled_at = ? WHERE status = 'pending'").run(Date.now()).changes;
178
+ }
179
+ truncateFrom(chatId, seq) {
180
+ // One transaction: a crash midway must not leave messages cut but their
181
+ // task writes (or cache markers) still standing.
182
+ this.db.exec('BEGIN');
183
+ try {
184
+ const removed = this.messages.deleteFrom(chatId, seq);
185
+ this.undoTasksFrom(chatId, seq);
186
+ // A frozen compaction boundary inside the cut no longer describes anything,
187
+ // so the pinned cache breakpoint it drives has to go with it.
188
+ const chat = this.byId(chatId);
189
+ if (chat && chat.boundary_seq >= seq)
190
+ this.touch(chatId, { boundary_seq: -1 });
191
+ // Same for the hard window: a cut at or past the rewind point would hide
192
+ // the very question being asked again. A checkpoint summarises what that
193
+ // window cut off, so it goes too.
194
+ if (chat && chat.window_seq >= seq)
195
+ this.touch(chatId, { window_seq: -1, checkpoint_json: null });
196
+ this.db.exec('COMMIT');
197
+ return removed;
198
+ }
199
+ catch (err) {
200
+ this.db.exec('ROLLBACK');
201
+ throw err;
202
+ }
203
+ }
204
+ delete(id, userId) {
205
+ if (!this.get(id, userId))
206
+ return false;
207
+ // Each area drops its own part of the chat; tasks go by foreign key.
208
+ this.automations.deleteForChat(id);
209
+ this.messages.deleteForChat(id);
210
+ this.documents.deleteForChat(id);
211
+ this.embeddings.deleteOwner(CORPUS.CHATS, id);
212
+ this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ?').run(id);
213
+ this.db.prepare('DELETE FROM questions WHERE chat_id = ?').run(id);
214
+ this.db.prepare('DELETE FROM chats WHERE id = ?').run(id);
215
+ return true;
216
+ }
217
+ touch(id, patch = {}) {
218
+ const sets = ['updated_at = ?'];
219
+ const vals = [Date.now()];
220
+ for (const key of ['title', 'epoch', 'boundary_seq', 'window_seq', 'checkpoint_json']) {
221
+ if (patch[key] !== undefined) {
222
+ sets.push(`${key} = ?`);
223
+ vals.push(patch[key]);
224
+ }
225
+ }
226
+ vals.push(id);
227
+ this.db.prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
228
+ }
254
229
  }