@mono-agent/web 0.13.0 → 0.15.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 (43) hide show
  1. package/README.md +136 -12
  2. package/dist/contracts.d.ts +14 -0
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/notification-client.d.ts +19 -0
  11. package/dist/notification-client.d.ts.map +1 -0
  12. package/dist/notification-client.js +136 -0
  13. package/dist/notification-client.js.map +1 -0
  14. package/dist/notification-ingress.d.ts +17 -0
  15. package/dist/notification-ingress.d.ts.map +1 -0
  16. package/dist/notification-ingress.js +170 -0
  17. package/dist/notification-ingress.js.map +1 -0
  18. package/dist/operator-client.d.ts +19 -1
  19. package/dist/operator-client.d.ts.map +1 -1
  20. package/dist/operator-client.js +58 -0
  21. package/dist/operator-client.js.map +1 -1
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +50 -18
  24. package/dist/server.js.map +1 -1
  25. package/dist/service.d.ts +24 -1
  26. package/dist/service.d.ts.map +1 -1
  27. package/dist/service.js +203 -7
  28. package/dist/service.js.map +1 -1
  29. package/dist/state-paths.d.ts +1 -0
  30. package/dist/state-paths.d.ts.map +1 -1
  31. package/dist/state-paths.js +12 -0
  32. package/dist/state-paths.js.map +1 -1
  33. package/dist/store.d.ts +47 -2
  34. package/dist/store.d.ts.map +1 -1
  35. package/dist/store.js +496 -11
  36. package/dist/store.js.map +1 -1
  37. package/package.json +5 -5
  38. package/webapp/dist/assets/index-D3Tzt8AW.css +1 -0
  39. package/webapp/dist/assets/index-DlEfCKkw.js +51 -0
  40. package/webapp/dist/index.html +2 -2
  41. package/webapp/dist/sw.js +1 -1
  42. package/webapp/dist/assets/index-1UedhLBk.css +0 -1
  43. package/webapp/dist/assets/index-D-X2B4kU.js +0 -47
package/dist/store.js CHANGED
@@ -1,8 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmod, lstat, readdir, unlink } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
- import { WEB_MAX_FILES_PER_TURN, WEB_MAX_TURN_ATTACHMENT_BYTES, } from "./contracts.js";
5
+ import { AGENT_LIVE_INPUT_MAX_CHARACTERS, } from "@mono-agent/agent-contracts";
6
+ import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
6
7
  import { WebConsoleError } from "./errors.js";
7
8
  import { prepareWebStatePaths } from "./state-paths.js";
8
9
  export class WebStore {
@@ -38,6 +39,7 @@ export class WebStore {
38
39
  chmod(`${paths.database}-shm`, 0o600).catch(ignoreMissing),
39
40
  ]);
40
41
  store.recoverInterruptedTurns();
42
+ store.recoverLiveInputs();
41
43
  return store;
42
44
  }
43
45
  catch (error) {
@@ -98,6 +100,89 @@ export class WebStore {
98
100
  throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
99
101
  return agent;
100
102
  }
103
+ reserveNotification(input) {
104
+ if (this.getAgent(input.sourceId) === undefined) {
105
+ throw new WebConsoleError("agent_not_found", "The notification agent is no longer available.", 404);
106
+ }
107
+ if (input.deliveryKey.length === 0 || input.deliveryKey.length > 1_024) {
108
+ throw new WebConsoleError("invalid_notification", "Notification deliveryKey must contain 1 to 1024 characters.", 400);
109
+ }
110
+ if (input.text.trim().length === 0) {
111
+ throw new WebConsoleError("invalid_notification", "Notification text cannot be empty.", 400);
112
+ }
113
+ const threadId = notificationThreadId(input.sourceId, input.deliveryKey);
114
+ const payloadSha256 = notificationPayloadSha256(input.triggerKind, input.text);
115
+ const existing = this.database.prepare(`
116
+ SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
117
+ `).get(input.sourceId, input.deliveryKey);
118
+ if (existing !== undefined) {
119
+ if (existing.thread_id !== threadId
120
+ || existing.trigger_kind !== input.triggerKind
121
+ || existing.payload_sha256 !== payloadSha256) {
122
+ throw new WebConsoleError("notification_idempotency_conflict", "The notification delivery key was already used with different content.", 409);
123
+ }
124
+ if (existing.completed_at !== null && this.getThread(existing.thread_id) === undefined) {
125
+ throw new WebConsoleError("storage_corrupt", "A completed notification is missing its conversation.", 500);
126
+ }
127
+ return { ...input, threadId, payloadSha256, duplicate: existing.completed_at !== null };
128
+ }
129
+ const now = this.now();
130
+ this.database.prepare(`
131
+ INSERT INTO notification_deliveries (
132
+ source_id, delivery_key, thread_id, trigger_kind, payload_sha256, created_at, completed_at
133
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL)
134
+ `).run(input.sourceId, input.deliveryKey, threadId, input.triggerKind, payloadSha256, now);
135
+ return { ...input, threadId, payloadSha256, duplicate: false };
136
+ }
137
+ completeNotification(reservation) {
138
+ const existing = this.database.prepare(`
139
+ SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
140
+ `).get(reservation.sourceId, reservation.deliveryKey);
141
+ if (existing === undefined
142
+ || existing.thread_id !== reservation.threadId
143
+ || existing.trigger_kind !== reservation.triggerKind
144
+ || existing.payload_sha256 !== reservation.payloadSha256) {
145
+ throw new WebConsoleError("notification_reservation_lost", "The notification reservation is no longer valid.", 409);
146
+ }
147
+ if (existing.completed_at !== null) {
148
+ return { thread: this.requireThread(existing.thread_id), duplicate: true };
149
+ }
150
+ const now = this.now();
151
+ const turnId = randomUUID();
152
+ const assistantMessageId = randomUUID();
153
+ const title = reservation.triggerKind === "cron" ? "Cron notification" : "Webhook notification";
154
+ this.transaction(() => {
155
+ if (this.getThread(reservation.threadId) !== undefined) {
156
+ throw new WebConsoleError("notification_idempotency_conflict", "The notification conversation already exists.", 409);
157
+ }
158
+ this.database.prepare(`
159
+ INSERT INTO threads (
160
+ id, source_id, conversation_id, title, title_manual, trigger_kind, archived_at,
161
+ created_at, updated_at, revision
162
+ ) VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?, 1)
163
+ `).run(reservation.threadId, reservation.sourceId, `web:${reservation.threadId}`, title, reservation.triggerKind, now, now);
164
+ this.database.prepare(`
165
+ INSERT INTO turns (
166
+ id, thread_id, status, text, model, effort, assistant_message_id,
167
+ started_at, finished_at, error_code, error_message
168
+ ) VALUES (?, ?, 'complete', '', NULL, NULL, ?, ?, ?, NULL, NULL)
169
+ `).run(turnId, reservation.threadId, assistantMessageId, now, now);
170
+ this.database.prepare(`
171
+ INSERT INTO messages (
172
+ id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
173
+ ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
174
+ `).run(assistantMessageId, reservation.threadId, turnId, JSON.stringify([{ type: "text", text: reservation.text }]), now, now);
175
+ this.database.prepare(`
176
+ INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at)
177
+ VALUES ('thread', ?, 1, 'notification_created', ?)
178
+ `).run(reservation.threadId, now);
179
+ this.database.prepare(`
180
+ UPDATE notification_deliveries SET completed_at = ?
181
+ WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
182
+ `).run(now, reservation.sourceId, reservation.deliveryKey);
183
+ });
184
+ return { thread: this.requireThread(reservation.threadId), duplicate: false };
185
+ }
101
186
  createThread(sourceId) {
102
187
  const agent = this.getAgent(sourceId);
103
188
  if (agent === undefined) {
@@ -130,7 +215,17 @@ export class WebStore {
130
215
  const thread = this.getThread(id);
131
216
  if (thread === undefined)
132
217
  return undefined;
133
- const rows = this.database.prepare("SELECT * FROM messages WHERE thread_id = ? ORDER BY created_at, rowid").all(id);
218
+ const rows = this.database.prepare(`
219
+ SELECT m.* FROM messages m
220
+ LEFT JOIN turns t ON t.id = m.turn_id
221
+ WHERE m.thread_id = ?
222
+ ORDER BY COALESCE(t.started_at, m.created_at),
223
+ CASE WHEN m.turn_id IS NOT NULL AND m.role = 'user' THEN 0
224
+ WHEN m.turn_id IS NOT NULL AND m.role = 'system' THEN 1
225
+ WHEN m.turn_id IS NOT NULL THEN 2
226
+ ELSE 3 END,
227
+ m.created_at, m.rowid
228
+ `).all(id);
134
229
  return { thread, messages: rows.map((row) => this.mapMessage(row)) };
135
230
  }
136
231
  currentThreadId() {
@@ -168,6 +263,28 @@ export class WebStore {
168
263
  });
169
264
  return { ...this.requireThread(id), sourceId: current.sourceId };
170
265
  }
266
+ async deleteArchivedThread(id) {
267
+ const thread = this.requireThread(id);
268
+ if (thread.archivedAt === null) {
269
+ throw new WebConsoleError("thread_not_archived", "Archive the conversation before deleting it.", 409);
270
+ }
271
+ const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
272
+ .all(id);
273
+ this.transaction(() => {
274
+ this.database.prepare("DELETE FROM notification_deliveries WHERE thread_id = ?").run(id);
275
+ this.database.prepare("DELETE FROM revisions WHERE entity_kind = 'thread' AND entity_id = ?").run(id);
276
+ this.database.prepare("DELETE FROM threads WHERE id = ?").run(id);
277
+ this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id' AND value = ?").run(id);
278
+ });
279
+ let orphanedFiles = 0;
280
+ for (const row of attachments) {
281
+ await unlink(this.attachmentPath(mapStoredAttachment(row))).catch((error) => {
282
+ if (error.code !== "ENOENT")
283
+ orphanedFiles += 1;
284
+ });
285
+ }
286
+ return { orphanedFiles };
287
+ }
171
288
  createUpload(input) {
172
289
  const id = randomUUID();
173
290
  const now = this.now();
@@ -252,6 +369,23 @@ export class WebStore {
252
369
  }
253
370
  return removed;
254
371
  }
372
+ async purgeUnreferencedAttachmentFiles() {
373
+ const referenced = new Set(this.database.prepare("SELECT storage_name FROM attachments").all()
374
+ .map((row) => row.storage_name));
375
+ const entries = await readdir(this.paths.uploads, { withFileTypes: true });
376
+ let removed = 0;
377
+ for (const entry of entries) {
378
+ if (!/^[0-9a-f-]{36}\.bin$/iu.test(entry.name) || referenced.has(entry.name))
379
+ continue;
380
+ const path = resolve(this.paths.uploads, entry.name);
381
+ const info = await lstat(path);
382
+ if (!info.isFile() || info.isSymbolicLink())
383
+ continue;
384
+ await unlink(path);
385
+ removed += 1;
386
+ }
387
+ return removed;
388
+ }
255
389
  beginTurn(input) {
256
390
  const thread = this.requireThread(input.threadId);
257
391
  if (thread.archivedAt !== null) {
@@ -347,6 +481,181 @@ export class WebStore {
347
481
  thread: this.requireThread(input.threadId),
348
482
  };
349
483
  }
484
+ reserveLiveInput(threadId, text) {
485
+ const thread = this.requireThread(threadId);
486
+ if (thread.archivedAt !== null) {
487
+ throw new WebConsoleError("thread_archived", "Unarchive this conversation before sending another message.", 409);
488
+ }
489
+ if (!thread.canSend) {
490
+ throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
491
+ }
492
+ if (text.trim().length === 0) {
493
+ throw new WebConsoleError("empty_turn", "Enter a message.", 400);
494
+ }
495
+ if (text.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
496
+ throw new WebConsoleError("turn_text_too_large", `A live follow-up may contain at most ${AGENT_LIVE_INPUT_MAX_CHARACTERS} characters.`, 413);
497
+ }
498
+ const usage = this.database.prepare("SELECT COUNT(*) AS count FROM live_inputs WHERE thread_id = ?").get(threadId);
499
+ if (usage.count >= WEB_MAX_LIVE_INPUTS_PER_THREAD) {
500
+ throw new WebConsoleError("live_input_queue_full", "Too many follow-up messages are waiting.", 429);
501
+ }
502
+ const active = this.database.prepare("SELECT id, model, effort FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
503
+ const id = randomUUID();
504
+ const messageId = randomUUID();
505
+ const now = this.now();
506
+ const status = active === undefined ? "queued" : "offered";
507
+ const parts = [
508
+ liveInputTelemetry(status === "offered" ? "pending" : "queued"),
509
+ { type: "text", text },
510
+ ];
511
+ this.transaction(() => {
512
+ this.database.prepare(`
513
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
514
+ VALUES (?, ?, ?, 'user', ?, ?, ?, 'complete')
515
+ `).run(messageId, threadId, active?.id ?? null, JSON.stringify(parts), now, now);
516
+ this.database.prepare(`
517
+ INSERT INTO live_inputs (
518
+ id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
519
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
520
+ `).run(id, threadId, messageId, active?.id ?? null, text, active?.model ?? null, active?.effort ?? null, status, now, now);
521
+ const title = deriveAutomaticTitle(text, []);
522
+ this.database.prepare(`
523
+ UPDATE threads
524
+ SET title = CASE WHEN title_manual = 0 AND title = 'New conversation' THEN ? ELSE title END,
525
+ updated_at = ?, revision = revision + 1
526
+ WHERE id = ?
527
+ `).run(title, now, threadId);
528
+ this.recordThreadRevision(threadId, "live_input_received", now);
529
+ this.setSetting("current_thread_id", threadId);
530
+ });
531
+ const stored = this.requireLiveInput(id);
532
+ return {
533
+ input: mapLiveInput(stored),
534
+ message: this.requireMessage(messageId),
535
+ thread: this.requireThread(threadId),
536
+ offered: status === "offered",
537
+ };
538
+ }
539
+ markLiveInputApplied(id) {
540
+ const row = this.getLiveInput(id);
541
+ if (row === undefined)
542
+ return undefined;
543
+ const message = this.requireMessage(row.message_id);
544
+ const now = this.now();
545
+ this.transaction(() => {
546
+ this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
547
+ .run(JSON.stringify(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
548
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
549
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
550
+ .run(now, row.thread_id);
551
+ this.recordThreadRevision(row.thread_id, "live_input_applied", now);
552
+ });
553
+ return this.requireMessage(row.message_id);
554
+ }
555
+ queueLiveInput(id) {
556
+ const row = this.getLiveInput(id);
557
+ if (row === undefined)
558
+ return undefined;
559
+ const message = this.requireMessage(row.message_id);
560
+ const now = this.now();
561
+ this.transaction(() => {
562
+ this.database.prepare(`
563
+ UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
564
+ `).run(now, id);
565
+ this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
566
+ .run(JSON.stringify(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
567
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
568
+ .run(now, row.thread_id);
569
+ this.recordThreadRevision(row.thread_id, "live_input_queued", now);
570
+ });
571
+ return this.requireMessage(row.message_id);
572
+ }
573
+ cancelLiveInput(id) {
574
+ const row = this.getLiveInput(id);
575
+ if (row === undefined)
576
+ return undefined;
577
+ const message = this.requireMessage(row.message_id);
578
+ const now = this.now();
579
+ this.transaction(() => {
580
+ this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
581
+ .run(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
582
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
583
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
584
+ .run(now, row.thread_id);
585
+ this.recordThreadRevision(row.thread_id, "live_input_cancelled", now);
586
+ });
587
+ return this.requireMessage(row.message_id);
588
+ }
589
+ cancelLiveInputs(threadId) {
590
+ const rows = this.database.prepare("SELECT * FROM live_inputs WHERE thread_id = ? ORDER BY created_at, rowid").all(threadId);
591
+ if (rows.length === 0)
592
+ return [];
593
+ const now = this.now();
594
+ this.transaction(() => {
595
+ const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
596
+ for (const row of rows) {
597
+ const message = this.requireMessage(row.message_id);
598
+ update.run(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
599
+ }
600
+ this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
601
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
602
+ .run(now, threadId);
603
+ this.recordThreadRevision(threadId, "live_inputs_cancelled", now);
604
+ });
605
+ return rows.map((row) => this.requireMessage(row.message_id));
606
+ }
607
+ queuedLiveInputThreadIds() {
608
+ return this.database.prepare(`
609
+ SELECT thread_id FROM live_inputs WHERE status = 'queued'
610
+ GROUP BY thread_id ORDER BY MIN(created_at), thread_id
611
+ `).all().map((row) => row.thread_id);
612
+ }
613
+ promoteNextQueuedLiveInput(threadId) {
614
+ const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
615
+ if (active !== undefined)
616
+ return undefined;
617
+ const row = this.database.prepare(`
618
+ SELECT * FROM live_inputs
619
+ WHERE thread_id = ? AND status = 'queued'
620
+ ORDER BY created_at, rowid LIMIT 1
621
+ `).get(threadId);
622
+ if (row === undefined)
623
+ return undefined;
624
+ const thread = this.requireThread(threadId);
625
+ if (!thread.canSend || thread.archivedAt !== null)
626
+ return undefined;
627
+ const turnId = randomUUID();
628
+ const assistantMessageId = randomUUID();
629
+ const now = this.now();
630
+ const userMessage = this.requireMessage(row.message_id);
631
+ this.transaction(() => {
632
+ this.database.prepare(`
633
+ INSERT INTO turns (
634
+ id, thread_id, status, text, model, effort, assistant_message_id,
635
+ started_at, finished_at, error_code, error_message
636
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
637
+ `).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
638
+ this.database.prepare("UPDATE messages SET turn_id = ?, parts_json = ?, updated_at = ? WHERE id = ?")
639
+ .run(turnId, JSON.stringify(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
640
+ this.database.prepare(`
641
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
642
+ VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
643
+ `).run(assistantMessageId, threadId, turnId, now, now);
644
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(row.id);
645
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
646
+ .run(now, threadId);
647
+ this.recordThreadRevision(threadId, "turn_started", now);
648
+ });
649
+ return {
650
+ turnId,
651
+ conversationId: `web:${threadId}`,
652
+ text: row.text,
653
+ userMessageId: row.message_id,
654
+ assistantMessageId,
655
+ attachments: [],
656
+ thread: this.requireThread(threadId),
657
+ };
658
+ }
350
659
  applyStreamFrame(turnId, frame) {
351
660
  return this.applyStreamFrames(turnId, [frame]);
352
661
  }
@@ -419,13 +728,17 @@ export class WebStore {
419
728
  try {
420
729
  this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
421
730
  const versionRow = this.database.prepare("PRAGMA user_version").get();
422
- if (versionRow.user_version > 1) {
423
- throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema 1.`, 500);
731
+ if (versionRow.user_version > 3) {
732
+ throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema 3.`, 500);
424
733
  }
425
734
  if (versionRow.user_version < 0) {
426
735
  throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
427
736
  }
428
- this.database.exec(`
737
+ const migrating = versionRow.user_version < 3;
738
+ if (migrating)
739
+ this.database.exec("BEGIN IMMEDIATE");
740
+ try {
741
+ this.database.exec(`
429
742
  CREATE TABLE IF NOT EXISTS agents (
430
743
  source_id TEXT PRIMARY KEY,
431
744
  label TEXT NOT NULL,
@@ -445,6 +758,7 @@ export class WebStore {
445
758
  conversation_id TEXT NOT NULL UNIQUE,
446
759
  title TEXT NOT NULL,
447
760
  title_manual INTEGER NOT NULL DEFAULT 0,
761
+ trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook')),
448
762
  archived_at TEXT,
449
763
  created_at TEXT NOT NULL,
450
764
  updated_at TEXT NOT NULL,
@@ -476,6 +790,20 @@ export class WebStore {
476
790
  status TEXT NOT NULL
477
791
  );
478
792
  CREATE INDEX IF NOT EXISTS messages_by_thread ON messages(thread_id, created_at);
793
+ CREATE TABLE IF NOT EXISTS live_inputs (
794
+ id TEXT PRIMARY KEY,
795
+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
796
+ message_id TEXT NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE,
797
+ active_turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
798
+ text TEXT NOT NULL,
799
+ model TEXT,
800
+ effort TEXT,
801
+ status TEXT NOT NULL CHECK (status IN ('offered', 'queued')),
802
+ created_at TEXT NOT NULL,
803
+ updated_at TEXT NOT NULL
804
+ );
805
+ CREATE INDEX IF NOT EXISTS live_inputs_by_thread
806
+ ON live_inputs(thread_id, status, created_at);
479
807
  CREATE TABLE IF NOT EXISTS attachments (
480
808
  id TEXT PRIMARY KEY,
481
809
  thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
@@ -504,9 +832,31 @@ export class WebStore {
504
832
  key TEXT PRIMARY KEY,
505
833
  value TEXT NOT NULL
506
834
  );
835
+ CREATE TABLE IF NOT EXISTS notification_deliveries (
836
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
837
+ delivery_key TEXT NOT NULL,
838
+ thread_id TEXT NOT NULL UNIQUE,
839
+ trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('cron', 'webhook')),
840
+ payload_sha256 TEXT NOT NULL,
841
+ created_at TEXT NOT NULL,
842
+ completed_at TEXT,
843
+ PRIMARY KEY (source_id, delivery_key)
844
+ );
507
845
  `);
508
- if (versionRow.user_version === 0)
509
- this.database.exec("PRAGMA user_version = 1");
846
+ if (versionRow.user_version === 1) {
847
+ const columns = this.database.prepare("PRAGMA table_info(threads)").all();
848
+ if (!columns.some((column) => column.name === "trigger_kind")) {
849
+ this.database.exec("ALTER TABLE threads ADD COLUMN trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook'))");
850
+ }
851
+ }
852
+ if (migrating)
853
+ this.database.exec("PRAGMA user_version = 3; COMMIT");
854
+ }
855
+ catch (error) {
856
+ if (this.database.isTransaction)
857
+ this.database.exec("ROLLBACK");
858
+ throw error;
859
+ }
510
860
  this.validateStorage();
511
861
  }
512
862
  catch (error) {
@@ -520,7 +870,17 @@ export class WebStore {
520
870
  if (check === undefined || !Object.values(check).includes("ok")) {
521
871
  throw new WebConsoleError("storage_corrupt", "Web state failed SQLite integrity validation.", 500);
522
872
  }
523
- const requiredTables = new Set(["agents", "threads", "turns", "messages", "attachments", "revisions", "settings"]);
873
+ const requiredTables = new Set([
874
+ "agents",
875
+ "threads",
876
+ "turns",
877
+ "messages",
878
+ "live_inputs",
879
+ "attachments",
880
+ "revisions",
881
+ "settings",
882
+ "notification_deliveries",
883
+ ]);
524
884
  const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
525
885
  for (const table of tables)
526
886
  requiredTables.delete(table.name);
@@ -543,6 +903,33 @@ export class WebStore {
543
903
  this.interruptTurn(turnId, "The web service restarted before this turn completed.");
544
904
  }
545
905
  }
906
+ recoverLiveInputs() {
907
+ const rows = this.database.prepare("SELECT * FROM live_inputs WHERE status = 'offered' ORDER BY created_at, rowid").all();
908
+ if (rows.length === 0)
909
+ return;
910
+ const now = this.now();
911
+ const threadIds = new Set(rows.map((row) => row.thread_id));
912
+ this.transaction(() => {
913
+ const updateInput = this.database.prepare(`
914
+ UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
915
+ `);
916
+ const updateMessage = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
917
+ for (const row of rows) {
918
+ const persisted = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
919
+ .get(row.message_id);
920
+ if (persisted === undefined) {
921
+ throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
922
+ }
923
+ updateInput.run(now, row.id);
924
+ updateMessage.run(JSON.stringify(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
925
+ }
926
+ for (const threadId of threadIds) {
927
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
928
+ .run(now, threadId);
929
+ this.recordThreadRevision(threadId, "live_inputs_recovered", now);
930
+ }
931
+ });
932
+ }
546
933
  finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
547
934
  const turn = this.requireTurn(turnId);
548
935
  const existing = this.requireMessage(turn.assistant_message_id);
@@ -582,6 +969,9 @@ export class WebStore {
582
969
  createdAt: row.created_at,
583
970
  updatedAt: row.updated_at,
584
971
  revision: row.revision,
972
+ ...(row.trigger_kind === "cron" || row.trigger_kind === "webhook"
973
+ ? { trigger: { kind: row.trigger_kind } }
974
+ : {}),
585
975
  ...(preview === undefined ? {} : { lastMessagePreview: preview }),
586
976
  messageCount: row.message_count,
587
977
  runState,
@@ -594,17 +984,20 @@ export class WebStore {
594
984
  .all(row.id);
595
985
  const storedParts = parseParts(row.parts_json);
596
986
  const quote = quoteFromParts(storedParts);
987
+ const liveInputStatus = liveInputStatusFromParts(storedParts);
597
988
  return {
598
989
  id: row.id,
599
990
  threadId: row.thread_id,
600
991
  ...(row.turn_id === null ? {} : { turnId: row.turn_id }),
601
992
  role: normalizeRole(row.role),
602
993
  ...(quote === undefined ? {} : { quote }),
603
- parts: storedParts.filter((part) => part.type !== "telemetry" || part.event !== QUOTE_TELEMETRY_EVENT),
994
+ parts: storedParts.filter((part) => part.type !== "telemetry"
995
+ || (part.event !== QUOTE_TELEMETRY_EVENT && part.event !== LIVE_INPUT_TELEMETRY_EVENT)),
604
996
  attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
605
997
  createdAt: row.created_at,
606
998
  updatedAt: row.updated_at,
607
999
  status: normalizeMessageStatus(row.status),
1000
+ ...(liveInputStatus === undefined ? {} : { liveInputStatus }),
608
1001
  };
609
1002
  }
610
1003
  latestRunState(threadId) {
@@ -662,6 +1055,16 @@ export class WebStore {
662
1055
  throw new WebConsoleError("message_not_found", "Message not found.", 404);
663
1056
  return this.mapMessage(row);
664
1057
  }
1058
+ getLiveInput(id) {
1059
+ return this.database.prepare("SELECT * FROM live_inputs WHERE id = ?")
1060
+ .get(id);
1061
+ }
1062
+ requireLiveInput(id) {
1063
+ const row = this.getLiveInput(id);
1064
+ if (row === undefined)
1065
+ throw new WebConsoleError("live_input_not_found", "Live input not found.", 404);
1066
+ return row;
1067
+ }
665
1068
  requireStoredAttachment(id) {
666
1069
  const attachment = this.getStoredAttachment(id);
667
1070
  if (attachment === undefined)
@@ -695,7 +1098,7 @@ export class WebStore {
695
1098
  }
696
1099
  function threadSelectSql(suffix) {
697
1100
  return `
698
- SELECT t.id, t.source_id, t.title, t.archived_at, t.created_at, t.updated_at, t.revision,
1101
+ SELECT t.id, t.source_id, t.title, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
699
1102
  CASE WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
700
1103
  CASE WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
701
1104
  (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
@@ -717,6 +1120,22 @@ function agentSelectSql(suffix) {
717
1120
  function agentPinSettingKey(sourceId) {
718
1121
  return `agent_pin:${sourceId}`;
719
1122
  }
1123
+ function notificationThreadId(sourceId, deliveryKey) {
1124
+ const digest = createHash("sha256")
1125
+ .update(sourceId)
1126
+ .update("\0")
1127
+ .update(deliveryKey)
1128
+ .digest("hex")
1129
+ .slice(0, 32);
1130
+ return `notification-${digest}`;
1131
+ }
1132
+ function notificationPayloadSha256(kind, text) {
1133
+ return createHash("sha256")
1134
+ .update(kind)
1135
+ .update("\0")
1136
+ .update(text)
1137
+ .digest("hex");
1138
+ }
720
1139
  function mapAgent(row) {
721
1140
  const models = parseStringArray(row.models_json);
722
1141
  const efforts = parseStringArray(row.efforts_json);
@@ -752,6 +1171,16 @@ function mapStoredAttachment(row) {
752
1171
  updatedAt: row.updated_at,
753
1172
  };
754
1173
  }
1174
+ function mapLiveInput(row) {
1175
+ return {
1176
+ id: row.id,
1177
+ threadId: row.thread_id,
1178
+ messageId: row.message_id,
1179
+ text: row.text,
1180
+ status: row.status,
1181
+ createdAt: row.created_at,
1182
+ };
1183
+ }
755
1184
  export function toWebAttachment(attachment) {
756
1185
  return {
757
1186
  id: attachment.id,
@@ -801,8 +1230,37 @@ function applyEvent(parts, event) {
801
1230
  });
802
1231
  return;
803
1232
  }
1233
+ if (event.type === "runtime_telemetry" && event.kind === "context_compaction") {
1234
+ upsertContextCompaction(parts, event);
1235
+ return;
1236
+ }
804
1237
  parts.push({ type: "telemetry", event: event.type, data: event });
805
1238
  }
1239
+ function contextCompactionOperationId(value) {
1240
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1241
+ return undefined;
1242
+ const event = value;
1243
+ if (event.type !== "runtime_telemetry" || event.kind !== "context_compaction")
1244
+ return undefined;
1245
+ const data = event.data;
1246
+ if (data === null || typeof data !== "object" || Array.isArray(data))
1247
+ return undefined;
1248
+ const operationId = data.operationId;
1249
+ return typeof operationId === "string" && operationId.length > 0 ? operationId : undefined;
1250
+ }
1251
+ function upsertContextCompaction(parts, event) {
1252
+ const operationId = contextCompactionOperationId(event);
1253
+ const next = { type: "telemetry", event: event.type, data: event };
1254
+ if (operationId === undefined) {
1255
+ parts.push(next);
1256
+ return;
1257
+ }
1258
+ const index = parts.findIndex((part) => part.type === "telemetry" && contextCompactionOperationId(part.data) === operationId);
1259
+ if (index < 0)
1260
+ parts.push(next);
1261
+ else
1262
+ parts[index] = next;
1263
+ }
806
1264
  function existingToolName(parts, id) {
807
1265
  const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
808
1266
  return existing?.type === "tool-call" ? existing.toolName : undefined;
@@ -897,6 +1355,33 @@ function parseParts(value) {
897
1355
  return parsed;
898
1356
  }
899
1357
  const QUOTE_TELEMETRY_EVENT = "quote";
1358
+ const LIVE_INPUT_TELEMETRY_EVENT = "live_input";
1359
+ function liveInputTelemetry(status) {
1360
+ return { type: "telemetry", event: LIVE_INPUT_TELEMETRY_EVENT, data: { status } };
1361
+ }
1362
+ function withoutLiveInputTelemetry(parts) {
1363
+ return parts.filter((part) => part.type !== "telemetry" || part.event !== LIVE_INPUT_TELEMETRY_EVENT);
1364
+ }
1365
+ function withLiveInputStatus(parts, status) {
1366
+ return [liveInputTelemetry(status), ...withoutLiveInputTelemetry(parts)];
1367
+ }
1368
+ function liveInputStatusFromParts(parts) {
1369
+ const markers = parts.filter((part) => part.type === "telemetry" && part.event === LIVE_INPUT_TELEMETRY_EVENT);
1370
+ if (markers.length === 0)
1371
+ return undefined;
1372
+ if (markers.length !== 1) {
1373
+ throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is duplicated.", 500);
1374
+ }
1375
+ const data = markers[0]?.data;
1376
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
1377
+ throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is invalid.", 500);
1378
+ }
1379
+ const status = data.status;
1380
+ if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled") {
1381
+ throw new WebConsoleError("storage_corrupt", "Persisted live-input status is invalid.", 500);
1382
+ }
1383
+ return status;
1384
+ }
900
1385
  function quoteFromParts(parts) {
901
1386
  const markers = parts.filter((part) => part.type === "telemetry" && part.event === QUOTE_TELEMETRY_EVENT);
902
1387
  if (markers.length === 0)