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,119 @@
1
+ import { scope, ownerOf } from './scope.js';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ // Scheduled automations and their runs.
5
+ // One area of the Store; see index.js.
6
+
7
+ function automationView(row) {
8
+ return {
9
+ id: row.id, userId: row.user_id, chatId: row.chat_id, chatTitle: row.chat_title,
10
+ name: row.name, prompt: row.prompt, cron: row.cron, timezone: row.timezone,
11
+ enabled: Boolean(row.enabled), nextRunAt: row.next_run_at, lastRunAt: row.last_run_at,
12
+ lastStatus: row.last_status, lastResult: row.last_result, source: row.source,
13
+ createdAt: row.created_at, updatedAt: row.updated_at
14
+ };
15
+ }
16
+
17
+ export class AutomationStore {
18
+ constructor(db, deps = {}) {
19
+ this.db = db;
20
+ Object.assign(this, deps);
21
+ }
22
+
23
+ list(userId) {
24
+ const s = scope(userId, 'a.user_id');
25
+ return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
26
+ WHERE ${s.sql} ORDER BY a.updated_at DESC`).all(...s.params).map(automationView);
27
+ }
28
+
29
+ listAll() {
30
+ return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
31
+ ORDER BY a.updated_at DESC`).all().map(automationView);
32
+ }
33
+
34
+ get(id, userId) {
35
+ const s = scope(userId, 'a.user_id');
36
+ const row = this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
37
+ WHERE a.id=? AND ${s.sql}`).get(id, ...s.params);
38
+ return row ? automationView(row) : null;
39
+ }
40
+
41
+ create(data, userId) {
42
+ scope(userId);
43
+ const id = randomBytes(12).toString('hex');
44
+ const now = Date.now();
45
+ this.db.prepare(`INSERT INTO automations
46
+ (id,user_id,chat_id,name,prompt,cron,timezone,enabled,next_run_at,source,created_at,updated_at)
47
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(id,ownerOf(userId),data.chatId,data.name,data.prompt,data.cron,data.timezone,
48
+ data.enabled === false ? 0 : 1,data.nextRunAt,data.source || 'user',now,now);
49
+ return this.get(id,userId);
50
+ }
51
+
52
+ update(id, patch, userId) {
53
+ const current = this.get(id,userId);
54
+ if (!current) return null;
55
+ const fields = { chatId:'chat_id', name:'name', prompt:'prompt', cron:'cron', timezone:'timezone', enabled:'enabled', nextRunAt:'next_run_at', lastRunAt:'last_run_at', lastStatus:'last_status', lastResult:'last_result' };
56
+ const sets = ['updated_at=?'];
57
+ const values = [Date.now()];
58
+ for (const [key,col] of Object.entries(fields)) if (patch[key] !== undefined) {
59
+ sets.push(`${col}=?`); values.push(key === 'enabled' ? (patch[key] ? 1 : 0) : patch[key]);
60
+ }
61
+ values.push(id);
62
+ this.db.prepare(`UPDATE automations SET ${sets.join(',')} WHERE id=?`).run(...values);
63
+ return this.get(id,userId);
64
+ }
65
+
66
+ delete(id, userId) {
67
+ const s = scope(userId);
68
+ return this.db.prepare(`DELETE FROM automations WHERE id=? AND ${s.sql}`).run(id, ...s.params).changes > 0;
69
+ }
70
+
71
+ /** Drops every automation that posts into a chat (the chat is going). */
72
+ deleteForChat(chatId) {
73
+ this.db.prepare('DELETE FROM automations WHERE chat_id = ?').run(chatId);
74
+ }
75
+
76
+ /** When the next enabled automation is due, or null when none is. */
77
+ nextDue() {
78
+ return this.db.prepare('SELECT MIN(next_run_at) AS due FROM automations WHERE enabled=1').get()?.due ?? null;
79
+ }
80
+
81
+ due(now = Date.now()) {
82
+ return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
83
+ WHERE a.enabled=1 AND a.next_run_at<=? ORDER BY a.next_run_at`).all(now).map(automationView);
84
+ }
85
+
86
+ addRun(automationId, scheduledAt, status = 'queued', triggerType = 'schedule') {
87
+ const id = randomBytes(12).toString('hex');
88
+ this.db.prepare(`INSERT INTO automation_runs(id,automation_id,scheduled_at,status,trigger_type) VALUES(?,?,?,?,?)`)
89
+ .run(id,automationId,scheduledAt,status,triggerType);
90
+ return id;
91
+ }
92
+
93
+ updateRun(id, patch) {
94
+ const fields = { status:'status', startedAt:'started_at', finishedAt:'finished_at', result:'result', error:'error' };
95
+ const sets = []; const values = [];
96
+ for (const [key,col] of Object.entries(fields)) if (patch[key] !== undefined) { sets.push(`${col}=?`); values.push(patch[key]); }
97
+ if (sets.length) this.db.prepare(`UPDATE automation_runs SET ${sets.join(',')} WHERE id=?`).run(...values,id);
98
+ const row = this.db.prepare('SELECT * FROM automation_runs WHERE id=?').get(id);
99
+ const owner = this.db.prepare('SELECT user_id FROM automations WHERE id=?').get(row?.automation_id)?.user_id ?? null;
100
+ if (row) this.update(row.automation_id, {
101
+ lastRunAt: row.started_at || row.scheduled_at, lastStatus: row.status,
102
+ ...(row.result !== null ? { lastResult: row.result.slice(0, 4000) } : {})
103
+ }, owner);
104
+ }
105
+
106
+ listRuns(automationId, userId, limit = 10) {
107
+ const owned = this.get(automationId,userId);
108
+ if (!owned) return null;
109
+ return this.db.prepare('SELECT id,scheduled_at,started_at,finished_at,status,trigger_type,result,error FROM automation_runs WHERE automation_id=? ORDER BY scheduled_at DESC LIMIT ?')
110
+ .all(automationId,Math.min(50,Math.max(1,limit)));
111
+ }
112
+
113
+ recoverRuns(now = Date.now()) {
114
+ const rows = this.db.prepare("SELECT id FROM automation_runs WHERE status IN ('queued','running')").all();
115
+ for (const row of rows) this.updateRun(row.id, {
116
+ status: 'failed', finishedAt: now, error: 'Server restarted during this run.'
117
+ });
118
+ }
119
+ }
@@ -0,0 +1,254 @@
1
+ import { ALL_USERS, scope, ownerOf } from './scope.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { CORPUS } from './embeddings.js';
4
+
5
+ // Chats and what hangs off one: tasks, folders, queued input, ask_user questions, rewind and delete.
6
+ // One area of the Store; see index.js.
7
+
8
+ 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(`
33
+ SELECT id, title, status, created_at, updated_at FROM tasks t
34
+ WHERE chat_id = ? AND (status != 'completed'
35
+ OR COALESCE((SELECT MAX(seq) FROM task_changes c WHERE c.task_id = t.id), t.created_seq)
36
+ >= COALESCE((SELECT MAX(seq) FROM messages m WHERE m.chat_id = t.chat_id AND m.role = 'user'), -1))
37
+ 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
+ }
254
+ }
@@ -0,0 +1,256 @@
1
+ import { scope, ftsQuery } from './scope.js';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { CORPUS } from './embeddings.js';
4
+
5
+ // Attached documents, split into passages for read_document (BM25 and dense retrieval).
6
+ // One area of the Store; see index.js.
7
+
8
+ /** Default passage split: characters per passage, and how much neighbours overlap. */
9
+ export const PASSAGE_SIZE = 1800;
10
+ export const PASSAGE_OVERLAP = 200;
11
+
12
+ /**
13
+ * Splits text into overlapping passages -- the unit read_document returns.
14
+ * Character-based on purpose: passages are for reading and BM25, and the
15
+ * embedding side cuts its own model-sized chunks out of them. Breaks are
16
+ * nudged onto a paragraph or line boundary when one is nearby, so a
17
+ * passage doesn't open or close mid-sentence more than it has to.
18
+ */
19
+ export function splitPassages(text, size = PASSAGE_SIZE, overlap = PASSAGE_OVERLAP) {
20
+ const passages = [];
21
+ let start = 0;
22
+ while (start < text.length) {
23
+ let end = Math.min(start + size, text.length);
24
+ if (end < text.length) {
25
+ const para = text.lastIndexOf('\n\n', end);
26
+ const line = text.lastIndexOf('\n', end);
27
+ const boundary = para > start + size * 0.5 ? para : (line > start + size * 0.5 ? line : -1);
28
+ if (boundary !== -1) end = boundary;
29
+ }
30
+ passages.push({ start, text: text.slice(start, end) });
31
+ if (end >= text.length) break;
32
+ start = Math.max(end - overlap, start + 1);
33
+ }
34
+ return passages;
35
+ }
36
+
37
+ const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
38
+
39
+ function insertPassages(db, docId, chatId, content, size, overlap) {
40
+ const passages = splitPassages(content, size, overlap);
41
+ const insertPassage = db.prepare('INSERT INTO document_passages (body) VALUES (?)');
42
+ const insertMap = db.prepare(`
43
+ INSERT INTO document_passage_map (passage_rowid, doc_id, chat_id, passage_idx, char_start)
44
+ VALUES (?, ?, ?, ?, ?)
45
+ `);
46
+ passages.forEach((passage, idx) => {
47
+ const { lastInsertRowid } = insertPassage.run(passage.text);
48
+ insertMap.run(lastInsertRowid, docId, chatId, idx, passage.start);
49
+ });
50
+ return passages.length;
51
+ }
52
+
53
+ export class DocumentStore {
54
+ constructor(db, deps = {}) {
55
+ this.db = db;
56
+ Object.assign(this, deps);
57
+ }
58
+
59
+ /**
60
+ * `original`: the uploaded bytes, kept (once per distinct file) so the file
61
+ * itself can be opened later. `md5` names them when the caller already has
62
+ * the fingerprint, e.g. copying a document into another chat.
63
+ */
64
+ add(chatId, { filename, mime, content, original = null, md5 = null }, { passageSize = PASSAGE_SIZE, passageOverlap = PASSAGE_OVERLAP } = {}) {
65
+ const id = randomBytes(12).toString('hex');
66
+ const createdAt = Date.now();
67
+ const hash = original ? md5Of(original) : md5;
68
+ this.db.exec('BEGIN');
69
+ try {
70
+ if (original) this.db.prepare('INSERT OR IGNORE INTO document_blobs (md5, data) VALUES (?, ?)').run(hash, original);
71
+ this.db.prepare(`
72
+ INSERT INTO documents (id, chat_id, filename, mime, char_len, created_at, content, passage_size, passage_overlap, md5)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
+ `).run(id, chatId, filename, mime ?? null, content.length, createdAt, content, passageSize, passageOverlap, hash);
75
+ const passages = insertPassages(this.db, id, chatId, content, passageSize, passageOverlap);
76
+ this.db.exec('COMMIT');
77
+ return { id, filename, mime: mime ?? null, char_len: content.length, created_at: createdAt, passages };
78
+ } catch (err) { this.db.exec('ROLLBACK'); throw err; }
79
+ }
80
+
81
+ /** The chat's document with these bytes, if it already has one -- a file is attached to a chat once. */
82
+ findInChat(chatId, original) {
83
+ return this.#inChat(chatId, md5Of(original));
84
+ }
85
+
86
+ #inChat(chatId, md5) {
87
+ return this.db.prepare('SELECT id, filename, mime, char_len, created_at FROM documents WHERE chat_id = ? AND md5 = ?')
88
+ .get(chatId, md5) || null;
89
+ }
90
+
91
+ /** The uploaded bytes as a Buffer, or null (no such document, not yours, or stored before originals were kept). */
92
+ original(id, userId) {
93
+ const doc = this.get(id, userId);
94
+ if (!doc?.md5) return null;
95
+ const row = this.db.prepare('SELECT data FROM document_blobs WHERE md5 = ?').get(doc.md5);
96
+ return row ? Buffer.from(row.data) : null;
97
+ }
98
+
99
+ /**
100
+ * What the user has attached before, across all their chats, newest first --
101
+ * for picking a file again without uploading it. One entry per distinct file:
102
+ * the same bytes in ten chats is one line, and it names the latest chat.
103
+ */
104
+ library(userId, limit = 200) {
105
+ const s = scope(userId, 'c.user_id');
106
+ const inner = scope(userId, 'c2.user_id'); // the "latest" must be the user's own copy, not someone else's
107
+ return this.db.prepare(`
108
+ SELECT d.id, d.filename, d.mime, d.char_len, d.created_at, c.title AS chatTitle
109
+ FROM documents d JOIN chats c ON c.id = d.chat_id
110
+ WHERE ${s.sql}
111
+ AND d.id = (
112
+ SELECT d2.id FROM documents d2 JOIN chats c2 ON c2.id = d2.chat_id
113
+ WHERE ${inner.sql} AND COALESCE(d2.md5, d2.id) = COALESCE(d.md5, d.id)
114
+ ORDER BY d2.created_at DESC, d2.rowid DESC LIMIT 1
115
+ )
116
+ ORDER BY d.created_at DESC
117
+ LIMIT ?
118
+ `).all(...s.params, ...inner.params, limit);
119
+ }
120
+
121
+ /**
122
+ * Puts a copy of one of the user's documents into another chat of theirs.
123
+ * Documents are chat-scoped (the chat's deletion takes them, its passages
124
+ * carry its id), so this is a new row: same text, same file blob, its own
125
+ * passages. Null when the source is not theirs; the chat's existing copy
126
+ * when it already has this file.
127
+ */
128
+ copyToChat(sourceId, chatId, userId, passageSettings) {
129
+ const src = this.get(sourceId, userId);
130
+ if (!src) return null;
131
+ if (src.md5) {
132
+ const have = this.#inChat(chatId, src.md5);
133
+ if (have) return { ...have, existing: true };
134
+ }
135
+ return this.add(chatId, { filename: src.filename, mime: src.mime, content: src.content, md5: src.md5 }, passageSettings);
136
+ }
137
+
138
+ /** Documents split with other passage settings than these (NULL = the original 1800/200). */
139
+ withOtherPassages(size, overlap) {
140
+ return this.db.prepare(`
141
+ SELECT id FROM documents
142
+ WHERE COALESCE(passage_size, ${PASSAGE_SIZE}) != ? OR COALESCE(passage_overlap, ${PASSAGE_OVERLAP}) != ?
143
+ `).all(size, overlap).map((r) => r.id);
144
+ }
145
+
146
+ /** Re-splits a stored document with new passage settings; its old vectors go with the old passages. */
147
+ repassage(docId, size, overlap) {
148
+ const doc = this.db.prepare('SELECT id, chat_id, content FROM documents WHERE id = ?').get(docId);
149
+ if (!doc) return 0;
150
+ this.db.exec('BEGIN');
151
+ try {
152
+ this.#deletePassages(docId);
153
+ const n = insertPassages(this.db, doc.id, doc.chat_id, doc.content, size, overlap);
154
+ this.db.prepare('UPDATE documents SET passage_size = ?, passage_overlap = ? WHERE id = ?').run(size, overlap, docId);
155
+ this.db.exec('COMMIT');
156
+ return n;
157
+ } catch (err) { this.db.exec('ROLLBACK'); throw err; }
158
+ }
159
+
160
+ /** Scoped through the owning chat: a document is its chat's user's. */
161
+ get(id, userId) {
162
+ const s = scope(userId, 'c.user_id');
163
+ return this.db.prepare(
164
+ `SELECT d.* FROM documents d JOIN chats c ON c.id = d.chat_id WHERE d.id = ? AND ${s.sql}`
165
+ ).get(id, ...s.params) || null;
166
+ }
167
+
168
+ list(chatId) {
169
+ return this.db.prepare(`
170
+ SELECT id, filename, mime, char_len, created_at
171
+ FROM documents WHERE chat_id = ? ORDER BY created_at ASC
172
+ `).all(chatId);
173
+ }
174
+
175
+ /** Drops one document, its passages and their chunk vectors. The rest of the chat is untouched. */
176
+ delete(id, userId) {
177
+ if (!this.get(id, userId)) return false;
178
+ this.#deletePassages(id);
179
+ const gone = this.db.prepare('DELETE FROM documents WHERE id = ?').run(id).changes > 0;
180
+ this.#dropOrphanBlobs();
181
+ return gone;
182
+ }
183
+
184
+ /** Drops every document of a chat, with its passages and vectors (the chat is going). */
185
+ deleteForChat(chatId) {
186
+ for (const { id } of this.db.prepare('SELECT id FROM documents WHERE chat_id = ?').all(chatId)) this.#deletePassages(id);
187
+ this.db.prepare('DELETE FROM documents WHERE chat_id = ?').run(chatId);
188
+ this.#dropOrphanBlobs();
189
+ }
190
+
191
+ /** A stored file goes when the last document naming it does. */
192
+ #dropOrphanBlobs() {
193
+ this.db.exec('DELETE FROM document_blobs WHERE md5 NOT IN (SELECT md5 FROM documents WHERE md5 IS NOT NULL)');
194
+ }
195
+
196
+ // FTS5 has no foreign keys of its own, so passage rows are dropped by rowid
197
+ // before the map that names them.
198
+ #deletePassages(docId) {
199
+ this.db.prepare(`
200
+ DELETE FROM document_passages WHERE rowid IN (
201
+ SELECT passage_rowid FROM document_passage_map WHERE doc_id = ?
202
+ )
203
+ `).run(docId);
204
+ this.embeddings.deleteOwner(CORPUS.DOCUMENTS, docId);
205
+ this.db.prepare('DELETE FROM document_passage_map WHERE doc_id = ?').run(docId);
206
+ }
207
+
208
+ /** Ranked passage search within one document, via FTS5 bm25() (lexical mode). */
209
+ searchPassages(docId, query, limit = 5) {
210
+ const q = ftsQuery(query);
211
+ if (!q) return [];
212
+ return this.db.prepare(`
213
+ SELECT m.passage_idx AS passageIdx, m.char_start AS charStart,
214
+ document_passages.body AS body, bm25(document_passages) AS rank
215
+ FROM document_passages
216
+ JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
217
+ WHERE document_passages MATCH ? AND m.doc_id = ?
218
+ ORDER BY rank
219
+ LIMIT ?
220
+ `).all(q, docId, limit);
221
+ }
222
+
223
+ /** Every passage of a document, in order -- the candidate set for dense and hybrid ranking. */
224
+ passages(docId) {
225
+ return this.db.prepare(`
226
+ SELECT m.passage_rowid AS rowid, m.passage_idx AS passageIdx, m.char_start AS charStart, document_passages.body AS body
227
+ FROM document_passage_map m JOIN document_passages ON document_passages.rowid = m.passage_rowid
228
+ WHERE m.doc_id = ? ORDER BY m.passage_idx
229
+ `).all(docId);
230
+ }
231
+
232
+ /**
233
+ * BM25 scores per passage, higher is better. `any`: any query word may
234
+ * match (the hybrid's lexical side, so partial matches still rank above
235
+ * none -- dense ranking handles the rest); otherwise all of them must.
236
+ */
237
+ lexicalScores(docId, query, { any = true } = {}) {
238
+ const q = ftsQuery(query, { any });
239
+ if (!q) return new Map();
240
+ const rows = this.db.prepare(`
241
+ SELECT m.passage_rowid AS rowid, bm25(document_passages) AS rank
242
+ FROM document_passages JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
243
+ WHERE document_passages MATCH ? AND m.doc_id = ?
244
+ `).all(q, docId);
245
+ return new Map(rows.map((r) => [r.rowid, -r.rank]));
246
+ }
247
+
248
+ /** Documents with passages not yet embedded under this model (for backfill). */
249
+ missingVectors(modelKey) {
250
+ return this.db.prepare(`
251
+ SELECT DISTINCT m.doc_id AS id FROM document_passage_map m
252
+ LEFT JOIN embeddings e ON e.corpus = ? AND e.unit_id = m.passage_rowid AND e.model_key = ? AND e.chunk = 0
253
+ WHERE e.unit_id IS NULL
254
+ `).all(CORPUS.DOCUMENTS, modelKey).map((r) => r.id);
255
+ }
256
+ }