getbotons 0.0.0-stage → 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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,3087 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { currentName } from '../../shared/legacy-names.mjs';
3
+ import { chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { DatabaseSync, type StatementSync } from 'node:sqlite';
6
+ import type {
7
+ AccessLevel,
8
+ AttentionRun,
9
+ BackgroundActivity,
10
+ BackgroundItem,
11
+ Bot,
12
+ CallbackDelivery,
13
+ BotStatus,
14
+ CreateBotInput,
15
+ CreateRoutineInput,
16
+ DuplicateBotInput,
17
+ Memory,
18
+ Message,
19
+ MessageMeta,
20
+ MessageReaction,
21
+ MessageRole,
22
+ BotonLook,
23
+ NotifyDelivery,
24
+ NotifyPolicy,
25
+ ProviderHandoff,
26
+ ReactionActor,
27
+ Routine,
28
+ RoutineRun,
29
+ RoutineSession,
30
+ RunMeta,
31
+ RunsAttention,
32
+ RunPayload,
33
+ RunStatus,
34
+ RunTrigger,
35
+ ServerEvent,
36
+ Settings,
37
+ SidebarImportResult,
38
+ SidebarLayout,
39
+ SidebarTransfer,
40
+ SidebarSectionKey,
41
+ TeamMember,
42
+ TokenUsage,
43
+ TurnOutcome,
44
+ TurnStep,
45
+ TurnSteps,
46
+ TurnTrigger,
47
+ UpdateBotInput,
48
+ UpdateRoutineInput,
49
+ UpdateSettingsInput,
50
+ WebhookFilter,
51
+ WebhookSource,
52
+ UsageReport,
53
+ UsageRow,
54
+ } from '../../shared/types.ts';
55
+ import type { TranscriptCard } from '../../shared/cards.ts';
56
+ import type { BrowserHandoff } from '../../shared/handoff.ts';
57
+ import { lookFor, lookFromTater, parseLook, randomLook } from '../../shared/look.ts';
58
+ import { parseTater, taterFromCritter } from '../../shared/look-tater.ts';
59
+ import { critterFromLook24, isLook24, parseCritter } from '../../shared/look-critter.ts';
60
+ import { effortProblem, type ModelEfforts, providerEffort } from '../../shared/effort.ts';
61
+ import { INBOX_CLEANUP_CHOICES, INBOX_CLEANUP_DEFAULT_DAYS, INBOX_PINS_MAX } from '../../shared/files.ts';
62
+ import { DEFAULT_REACTION_FOLLOW_UPS, REACTION_FOLLOW_UP_MAX, reactionEmoji } from '../../shared/reactions.ts';
63
+ import { isAutomaticWake, mayBeQuiet, parseQuiet, routineLabel, TURN_ACTIVITY, wakeLabel } from '../../shared/wake.ts';
64
+ import { copyName, durationText, isPastOneOff, isValidTimezone, MIN_TIME_LIMIT_MS, totalTokens } from '../../shared/util.ts';
65
+ import { actingTurn } from './acting.ts';
66
+ import type { Config } from './config.ts';
67
+ import type { EventHub } from './events.ts';
68
+ import { askFirstList } from './ask-rules.ts';
69
+ import { log } from './log.ts';
70
+ import type { NtfyConfig } from './notify.ts';
71
+ import { backup } from './backup.ts';
72
+ import { PIN_LIMIT, REPLAY_PAGE, type Replayable, WEBHOOK_HISTORY_MAX } from './replay.ts';
73
+ import { CHAT_MESSAGE, MESSAGE_META, openSchema, PINNED_MESSAGE, RESET_EVENT, ROUTINE_MESSAGE, SCHEMA_VERSION, sidebarGroupKey } from './schema.ts';
74
+ import { describeSchedule, nextRun, parseSchedule, toSpec } from './schedule.ts';
75
+ import { newId, newSecret, nowIso, parseJson, type Row, strings, truncate } from './util.ts';
76
+ import { type HookSecrets, isWebhookSource, parseFilter, WEBHOOK_SOURCES } from './webhooks.ts';
77
+
78
+ export class HttpError extends Error {
79
+ status: number;
80
+ headers: Record<string, string> | undefined;
81
+ constructor(status: number, message: string, headers?: Record<string, string>) {
82
+ super(message);
83
+ this.status = status;
84
+ this.headers = headers;
85
+ }
86
+ }
87
+
88
+ const RUN_HISTORY = 20;
89
+ /** At most this many of each kind in the runs-needing-attention overview. */
90
+ const ATTENTION_LIMIT = 50;
91
+ export const TURN_RETENTION_MS = 90 * 86_400_000;
92
+ export const MAX_STEPS = 200;
93
+ export const MAX_STEP_DETAIL = 2000;
94
+ /**
95
+ * At most this many results of one routine waiting to reach its callback URL. Each waits through its retries (about
96
+ * three minutes); a routine finishing runs faster than its receiver answers gets the rest recorded as not sent, in
97
+ * Runs to check, rather than an ever-growing backlog. Pending rows are kept apart from RUN_HISTORY too.
98
+ */
99
+ export const PENDING_CALLBACK_LIMIT = 20;
100
+ /** Kept apart from RUN_HISTORY so skipped slots can't push real runs out. */
101
+ const SKIP_HISTORY = 10;
102
+ export const MAX_ROUTINES_PER_BOT = 50;
103
+ /** The Boton as a turn read it: a session it starts or carries on is kept only while the Boton is still so. */
104
+ export interface TurnStamp {
105
+ contextSince: string | null;
106
+ provider: string;
107
+ generation: number;
108
+ }
109
+ /** The latest provider switch of the Boton `bot` (a bound parameter by default): see Store.switchGeneration. */
110
+ const generationSql = (bot = '?') =>
111
+ `coalesce((select g.seq from messages g where g.bot_id = ${bot} and g.role = 'event' and json_extract(case when json_valid(g.meta) then g.meta end, '$.kind') = 'provider.switched' order by g.seq desc limit 1), 0)`;
112
+ const GENERATION_SQL = generationSql();
113
+ /** Each repeats the condition of its partial index (schema.ts) word for word, so SQLite reads that index. */
114
+ const PINNED_SQL = PINNED_MESSAGE;
115
+ const NOT_STALE_SQL = `coalesce(json_extract(${MESSAGE_META}, '$.staleContext'), 0) = 0`;
116
+ export const NOTIFY_POLICIES: NotifyPolicy[] = ['auto', 'always', 'failures', 'never'];
117
+ const NTFY_TOPIC = /^[-_A-Za-z0-9]{1,64}$/;
118
+
119
+ /** `fallback` when it is left out. */
120
+ export function flag(v: unknown, field: string, fallback?: boolean): boolean {
121
+ if (v === undefined && fallback !== undefined) return fallback;
122
+ if (typeof v !== 'boolean') throw new HttpError(400, `${field} must be true or false`);
123
+ return v;
124
+ }
125
+
126
+ /** The owner's follow-up reactions: emoji only, each once, at most REACTION_FOLLOW_UP_MAX. */
127
+ export function followUpsInput(v: unknown): string[] {
128
+ if (!Array.isArray(v)) throw new HttpError(400, 'reactionFollowUps must be a list of emoji ([] for none)');
129
+ const out: string[] = [];
130
+ for (const x of v) {
131
+ const e = reactionEmoji(x);
132
+ if (!e) throw new HttpError(400, `reactionFollowUps: ${JSON.stringify(String(x)).slice(0, 40)} is not one emoji`);
133
+ if (!out.includes(e)) out.push(e);
134
+ }
135
+ if (out.length > REACTION_FOLLOW_UP_MAX) throw new HttpError(400, `reactionFollowUps: at most ${REACTION_FOLLOW_UP_MAX} emoji`);
136
+ return out;
137
+ }
138
+
139
+ function webhookHistory(v: unknown): number {
140
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > WEBHOOK_HISTORY_MAX) {
141
+ throw new HttpError(400, `webhookHistory must be a whole number of messages from 0 to ${WEBHOOK_HISTORY_MAX}`);
142
+ }
143
+ return v;
144
+ }
145
+
146
+ function routineSession(v: unknown): RoutineSession {
147
+ if (v !== 'chat' && v !== 'own') throw new HttpError(400, 'session must be "chat" or "own"');
148
+ return v;
149
+ }
150
+
151
+ /** A routine's own time limit, in ms: null keeps the server's; never longer than the server's (BOTONS_TURN_TIMEOUT_MS). */
152
+ function timeLimitMs(v: unknown, max: number): number | null {
153
+ if (v === null) return null;
154
+ if (typeof v !== 'number' || !Number.isInteger(v)) throw new HttpError(400, 'timeLimitMs must be a whole number of milliseconds, or null for the server\'s limit');
155
+ if (v < MIN_TIME_LIMIT_MS || v > max) {
156
+ throw new HttpError(400, `timeLimitMs must be between ${durationText(MIN_TIME_LIMIT_MS)} and ${durationText(max)} (the server's limit, BOTONS_TURN_TIMEOUT_MS); got ${v}`);
157
+ }
158
+ return v;
159
+ }
160
+
161
+ function routineModel(v: unknown): string | null {
162
+ if (v === null) return null;
163
+ return clean(v, 'model', 120) || null;
164
+ }
165
+
166
+ /**
167
+ * A Boton's effort for `provider` and its model: "" is the provider's default; a level the provider doesn't have, or
168
+ * one the model's own list (`known`) leaves out, is refused with the ones it has.
169
+ */
170
+ function botEffort(v: unknown, provider: string, known: ModelEfforts | null): string {
171
+ if (typeof v !== 'string') throw new HttpError(400, 'effort must be a level name, or "" for the provider\'s default');
172
+ const effort = v.trim();
173
+ const problem = effortProblem(provider, effort, known);
174
+ if (problem) throw new HttpError(400, problem);
175
+ return effort;
176
+ }
177
+
178
+ /** A routine's own effort, checked like a Boton's against the model its runs use: null (or "") is the Boton's. */
179
+ function routineEffort(v: unknown, provider: string, known: ModelEfforts | null): string | null {
180
+ if (v === null) return null;
181
+ if (typeof v !== 'string') throw new HttpError(400, 'effort must be a level name, or null for the Boton\'s effort');
182
+ const effort = v.trim();
183
+ if (!effort) return null;
184
+ const problem = effortProblem(provider, effort, known);
185
+ if (problem) throw new HttpError(400, problem.replace(/, or "" for its default.*$/, ', or leave it out for the Boton\'s effort'));
186
+ return effort;
187
+ }
188
+
189
+ /**
190
+ * Why a routine with its own model can't take its Boton's effort: the model's own list leaves that level out. null
191
+ * when it can, or when nothing is known about the model (the CLI is then asked for the level as it is).
192
+ */
193
+ export function inheritedEffortProblem(provider: string, botonEffort: string, known: ModelEfforts | null): string | null {
194
+ if (!botonEffort || !known || known.levels.includes(botonEffort)) return null;
195
+ const name = providerEffort(provider)?.name ?? provider;
196
+ const usable = (providerEffort(provider)?.levels ?? []).filter((l) => known.levels.includes(l.id)).map((l) => l.id);
197
+ return `${name}'s model list has no effort level "${botonEffort}" (the Boton's) for ${known.model}: give the routine its own effort, one of ${usable.join(', ')}`;
198
+ }
199
+
200
+ function notifyPolicy(v: unknown): NotifyPolicy {
201
+ if (!NOTIFY_POLICIES.includes(v as NotifyPolicy)) throw new HttpError(400, `notify must be one of ${NOTIFY_POLICIES.map((p) => `"${p}"`).join(', ')}`);
202
+ return v as NotifyPolicy;
203
+ }
204
+
205
+ /** What an ntfy token is bound to: the server's origin ("" for none or one that isn't a URL). */
206
+ export function ntfyOrigin(server: string): string {
207
+ try {
208
+ return server ? new URL(server).origin : '';
209
+ } catch {
210
+ return '';
211
+ }
212
+ }
213
+
214
+ type StoredNtfy = { enabled: boolean; server: string; topic: string; token: string | null; preview: boolean };
215
+ const NTFY_DEFAULTS: StoredNtfy = { enabled: false, server: '', topic: '', token: null, preview: false };
216
+
217
+ type HeldFor = 'retry' | 'usage_limit';
218
+
219
+ export interface RunRecord extends RoutineRun {
220
+ payload: string | null;
221
+ }
222
+
223
+ const PAYLOAD_PREVIEW_BYTES = 2048;
224
+ const DELIVERY_TTL_MS = 24 * 3_600_000;
225
+
226
+ /** Not the payload, which can be a megabyte and goes out with every run.upserted: only its start and size. */
227
+ const RUN_COLUMNS = `seq, id, routine_id, bot_id, trigger, status, error, reply, quiet, meta, started_at, finished_at, attempts, last_error, retry_at, held_for, headers,
228
+ substr(payload, 1, ${PAYLOAD_PREVIEW_BYTES}) as payload_preview, coalesce(length(cast(payload as blob)), 0) as payload_bytes`;
229
+
230
+ function utf8Prefix(text: string, max: number): string {
231
+ const bytes = Buffer.from(text, 'utf8');
232
+ if (bytes.length <= max) return text;
233
+ let end = max;
234
+ // Back up to the start of a character (continuation bytes are 10xxxxxx).
235
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
236
+ return bytes.subarray(0, end).toString('utf8');
237
+ }
238
+
239
+ interface TurnStart {
240
+ botId: string;
241
+ trigger: TurnTrigger;
242
+ routineId?: string | null;
243
+ runId?: string | null;
244
+ provider: string;
245
+ requestedModel: string;
246
+ }
247
+
248
+ interface TurnEnd {
249
+ outcome: Exclude<TurnOutcome, 'running'>;
250
+ errorKind?: string | null;
251
+ durationMs: number;
252
+ usage?: (TokenUsage & { numTurns?: number }) | null;
253
+ model?: string | null;
254
+ replayedTranscript: boolean;
255
+ }
256
+
257
+ /** A test run of a starter's routine, with what it ran on (starter-gate.ts); `parts` null: it changed during the run. */
258
+ export interface StarterTestRecord {
259
+ runId: string;
260
+ routineId: string;
261
+ at: string;
262
+ parts: Record<string, string> | null;
263
+ }
264
+
265
+ /** What the server keeps for a Boton installed from a starter (starterKey in settings). */
266
+ export interface StarterRecord {
267
+ templateId: string;
268
+ templateName: string;
269
+ version: number;
270
+ slots: Record<string, string | null>;
271
+ installedAt: string;
272
+ pending: string[];
273
+ tests: StarterTestRecord[];
274
+ }
275
+
276
+ export const starterKey = (botId: string) => `starter:${botId}`;
277
+
278
+ export interface BotRecord extends Bot {
279
+ sessionId: string | null;
280
+ sessionKey: string | null;
281
+ contextSince: string | null;
282
+ }
283
+
284
+ const slug = (s: string) =>
285
+ s
286
+ .toLowerCase()
287
+ .replace(/[^a-z0-9]+/g, '-')
288
+ .replace(/^-|-$/g, '')
289
+ .slice(0, 32) || 'boton';
290
+
291
+ /** A Boton row with its sidebar group's name. */
292
+ const BOT_SELECT = 'select b.*, g.name as group_name from bots b left join sidebar_groups g on g.id = b.group_id';
293
+
294
+ const SIDEBAR_GROUP_NAME_MAX = 40;
295
+ const SIDEBAR_GROUPS_MAX = 100;
296
+ const SIDEBAR_COLLAPSED: Record<SidebarSectionKey, string> = { pinned: 'sidebarPinnedCollapsed', ungrouped: 'sidebarUngroupedCollapsed' };
297
+
298
+ /** A place in a list of `length` (once the item is in it): missing or past the end is last. */
299
+ function sidebarIndex(v: unknown, length: number): number {
300
+ if (v === undefined || v === null) return length;
301
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) throw new HttpError(400, 'index must be a whole number, 0 or more');
302
+ return Math.min(v, length);
303
+ }
304
+
305
+ const SIDEBAR_FORMAT = 'botons-sidebar';
306
+ const SIDEBAR_IDS_MAX = 10_000;
307
+
308
+ /** A layout file checked whole (see SidebarTransfer), or a 400 saying what is wrong with it. */
309
+ export function sidebarTransfer(v: unknown): SidebarTransfer {
310
+ const bad = (why: string): never => {
311
+ throw new HttpError(400, `Not a sidebar layout this server can import: ${why}`);
312
+ };
313
+ const object = (x: unknown, what: string): Record<string, unknown> => (x && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : bad(`${what} must be an object`));
314
+ const f = object(v, 'the file');
315
+ // A layout exported under the format's deprecated name reads the same (shared/legacy-names.mjs).
316
+ if (currentName(String(f.format)) !== SIDEBAR_FORMAT) bad(`format must be "${SIDEBAR_FORMAT}"`);
317
+ if (f.version !== 1) bad('version must be 1');
318
+ const ids = (x: unknown, what: string): string[] => {
319
+ if (!Array.isArray(x)) return bad(`${what}.botIds must be a list`);
320
+ if (x.length > SIDEBAR_IDS_MAX) bad(`${what}.botIds has more than ${SIDEBAR_IDS_MAX} Botons`);
321
+ for (const id of x) if (typeof id !== 'string' || !id || id.length > 100) bad(`${what}.botIds must be Boton ids`);
322
+ return x as string[];
323
+ };
324
+ const section = (x: unknown, what: string) => {
325
+ const o = object(x, what);
326
+ if (typeof o.collapsed !== 'boolean') bad(`${what}.collapsed must be true or false`);
327
+ return { collapsed: o.collapsed as boolean, botIds: ids(o.botIds, what) };
328
+ };
329
+ if (!Array.isArray(f.groups)) bad('groups must be a list');
330
+ const list = f.groups as unknown[];
331
+ if (list.length > SIDEBAR_GROUPS_MAX) bad(`it has more than ${SIDEBAR_GROUPS_MAX} groups`);
332
+ const names = new Set<string>();
333
+ const groups = list.map((g, i) => {
334
+ const what = `groups[${i}]`;
335
+ const name = (() => {
336
+ try {
337
+ return clean(object(g, what).name, `${what}.name`, SIDEBAR_GROUP_NAME_MAX, true);
338
+ } catch (e) {
339
+ return bad((e as Error).message);
340
+ }
341
+ })();
342
+ if (names.has(sidebarGroupKey(name))) bad(`two groups are called ${name}`);
343
+ names.add(sidebarGroupKey(name));
344
+ return { name, ...section(g, what) };
345
+ });
346
+ const pinned = section(f.pinned, 'pinned');
347
+ const ungrouped = section(f.ungrouped, 'ungrouped');
348
+ const seen = new Set<string>();
349
+ for (const id of [...groups.flatMap((g) => g.botIds), ...ungrouped.botIds]) {
350
+ if (seen.has(id)) bad(`${id} is in more than one place`);
351
+ seen.add(id);
352
+ }
353
+ if (new Set(pinned.botIds).size !== pinned.botIds.length) bad('a Boton is pinned twice');
354
+ return { format: SIDEBAR_FORMAT, version: 1, pinned, groups, ungrouped };
355
+ }
356
+
357
+ export function clean(s: unknown, field: string, max: number, required = false): string {
358
+ if (s === undefined || s === null) {
359
+ if (required) throw new HttpError(400, `${field} is required`);
360
+ return '';
361
+ }
362
+ if (typeof s !== 'string') throw new HttpError(400, `${field} must be a string`);
363
+ const t = s.trim();
364
+ if (required && !t) throw new HttpError(400, `${field} is required`);
365
+ if (t.length > max) throw new HttpError(400, `${field} is too long (max ${max} characters)`);
366
+ return t;
367
+ }
368
+
369
+ /** The data folder and the database hold every chat and the webhook keys: they are the user's alone. */
370
+ function secureDataFiles(cfg: Config) {
371
+ try {
372
+ chmodSync(cfg.home, 0o700);
373
+ for (const f of [cfg.dbPath, `${cfg.dbPath}-wal`, `${cfg.dbPath}-shm`]) if (existsSync(f)) chmodSync(f, 0o600);
374
+ } catch (err) {
375
+ log.warn(`could not make ${cfg.home} private (mode 0700, database 0600): ${(err as Error).message}`);
376
+ }
377
+ }
378
+
379
+ export class Store {
380
+ db: DatabaseSync;
381
+ #hub: EventHub;
382
+ #cfg: Config;
383
+ #statusOf: (botId: string) => BotStatus;
384
+ #tx: { events: (ServerEvent | (() => void))[]; dirs: string[] } | null = null;
385
+ /** What a provider's own model list says a model takes (ProviderRegistry.modelEfforts); the app sets it. */
386
+ effortCaps: (provider: string, model: string) => ModelEfforts | null = () => null;
387
+
388
+ constructor(cfg: Config, hub: EventHub, statusOf: (botId: string) => BotStatus) {
389
+ this.#cfg = cfg;
390
+ this.#hub = hub;
391
+ this.#statusOf = statusOf;
392
+ this.db = new DatabaseSync(cfg.dbPath);
393
+ try {
394
+ this.db.exec('pragma busy_timeout = 5000; pragma journal_mode = wal; pragma foreign_keys = on;');
395
+ // A full backup before any upgrade in place: going back is the old release and this backup.
396
+ openSchema(this.db, {
397
+ beforeUpgrade: (version) => {
398
+ const done = backup({ home: cfg.home });
399
+ // Going back needs the workspaces too: a backup of the database alone is no way back, so no upgrade.
400
+ if (done.manifest.workspacesError) throw new Error(`the workspaces were not backed up (${done.manifest.workspacesError}); the database alone is in ${done.dir}`);
401
+ log.info('backed up the database before upgrading its schema', { from: version, to: SCHEMA_VERSION, backup: done.dir, workspaces: done.manifest.workspaces, ...(done.manifest.tarWarning ? { tarWarning: done.manifest.tarWarning } : {}) });
402
+ },
403
+ });
404
+ secureDataFiles(cfg);
405
+ } catch (err) {
406
+ this.db.close();
407
+ throw err;
408
+ }
409
+ }
410
+
411
+ #all(sql: string, ...params: any[]): Row[] {
412
+ return this.db.prepare(sql).all(...params) as Row[];
413
+ }
414
+ #get(sql: string, ...params: any[]): Row | undefined {
415
+ return this.db.prepare(sql).get(...params) as Row | undefined;
416
+ }
417
+ #run(sql: string, ...params: any[]) {
418
+ return this.db.prepare(sql).run(...params);
419
+ }
420
+
421
+ #publish(event: ServerEvent): void {
422
+ if (this.#tx) this.#tx.events.push(event);
423
+ else this.#hub.publish(event);
424
+ }
425
+
426
+ /**
427
+ * One transaction: its events, and what afterCommit() was given, go out after the commit, in order; a rollback
428
+ * drops them and removes the workspace folders it made. Every component publishes through here (publish()), so
429
+ * nothing a rolled-back transaction did is ever seen.
430
+ */
431
+ atomically<T>(fn: () => T): T {
432
+ if (this.#tx) return fn();
433
+ const tx: { events: (ServerEvent | (() => void))[]; dirs: string[] } = { events: [], dirs: [] };
434
+ this.db.exec('begin immediate');
435
+ this.#tx = tx;
436
+ let out: T;
437
+ try {
438
+ out = fn();
439
+ this.db.exec('commit');
440
+ } catch (err) {
441
+ if (this.db.isTransaction) this.db.exec('rollback');
442
+ for (const dir of tx.dirs) rmSync(dir, { recursive: true, force: true });
443
+ throw err;
444
+ } finally {
445
+ this.#tx = null;
446
+ }
447
+ for (const event of tx.events) {
448
+ if (typeof event !== 'function') this.#hub.publish(event);
449
+ else {
450
+ try {
451
+ event();
452
+ } catch (err) {
453
+ log.error('a step after a commit failed', { err });
454
+ }
455
+ }
456
+ }
457
+ return out;
458
+ }
459
+
460
+ /** `fn` once the transaction it is in commits (at once outside one); never if it rolls back. For in-memory effects. */
461
+ afterCommit(fn: () => void): void {
462
+ if (this.#tx) this.#tx.events.push(fn);
463
+ else fn();
464
+ }
465
+
466
+ #makeWorkspace(name: string): void {
467
+ const dir = join(this.#cfg.workspacesDir, name);
468
+ const existed = existsSync(dir);
469
+ mkdirSync(dir, { recursive: true });
470
+ if (!existed) this.#tx?.dirs.push(dir);
471
+ }
472
+
473
+ // ---------------------------------------------------------------- settings
474
+
475
+ getSettings(): Settings {
476
+ const rows = this.#all('select key, value from settings');
477
+ const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
478
+ const { token, ...ntfy } = this.#storedNtfy();
479
+ const primary = map.primaryBotId && this.hasBot(map.primaryBotId) ? (map.primaryBotId as string) : null;
480
+ return {
481
+ ownerName: map.ownerName ?? defaultOwner(),
482
+ timezone: map.timezone ?? this.#cfg.defaultTimezone,
483
+ primaryBotId: primary,
484
+ // Never the token: it is write-only.
485
+ ntfy: { ...ntfy, hasToken: !!token },
486
+ paused: map.paused === '1',
487
+ reactionFollowUps: map.reactionFollowUps === undefined ? [...DEFAULT_REACTION_FOLLOW_UPS] : parseJson<string[]>(map.reactionFollowUps, []),
488
+ inboxCleanupDays: map.inboxCleanupDays === undefined ? INBOX_CLEANUP_DEFAULT_DAYS : map.inboxCleanupDays === 'off' ? null : Number(map.inboxCleanupDays) || INBOX_CLEANUP_DEFAULT_DAYS,
489
+ };
490
+ }
491
+
492
+ // The inbox files the owner keeps (trash.ts): names directly in the Boton's inbox. Never set by a Boton.
493
+ inboxPins(botId: string): string[] {
494
+ return parseJson<string[]>(this.#get('select value from settings where key = ?', `inboxPins:${botId}`)?.value, []);
495
+ }
496
+
497
+ /** Every pin is kept: a new one past INBOX_PINS_MAX is refused, never made room for. */
498
+ setInboxPin(botId: string, name: string, pinned: boolean): void {
499
+ const was = this.inboxPins(botId);
500
+ if (pinned && was.includes(name)) return;
501
+ const now = was.filter((n) => n !== name);
502
+ if (pinned) {
503
+ if (now.length >= INBOX_PINS_MAX) throw new HttpError(409, `At most ${INBOX_PINS_MAX} files can be kept in one inbox; stop keeping some first`);
504
+ now.push(name);
505
+ }
506
+ this.#writeInboxPins(botId, now);
507
+ }
508
+
509
+ /** Pins only for names still in the inbox (`present`): one a Boton deleted or renamed is no longer kept. */
510
+ pruneInboxPins(botId: string, present: Set<string>): void {
511
+ const was = this.inboxPins(botId);
512
+ const now = was.filter((n) => present.has(n));
513
+ if (now.length !== was.length) this.#writeInboxPins(botId, now);
514
+ }
515
+
516
+ #writeInboxPins(botId: string, names: string[]) {
517
+ if (names.length) this.#setSetting(`inboxPins:${botId}`, JSON.stringify(names));
518
+ else this.#run('delete from settings where key = ?', `inboxPins:${botId}`);
519
+ }
520
+
521
+ /** The storage cleanup's own bookkeeping (when it last ran). */
522
+ storageState(key: string): string | null {
523
+ return (this.#get('select value from settings where key = ?', key)?.value as string | undefined) ?? null;
524
+ }
525
+
526
+ setStorageState(key: string, value: string): void {
527
+ this.#setSetting(key, value);
528
+ }
529
+
530
+ #storedNtfy(): StoredNtfy {
531
+ const raw = this.#get("select value from settings where key = 'ntfy'")?.value;
532
+ return { ...NTFY_DEFAULTS, ...parseJson<Partial<StoredNtfy>>(raw, {}) };
533
+ }
534
+
535
+ #setSetting(key: string, value: string) {
536
+ this.#run('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value', key, value);
537
+ }
538
+
539
+ ntfyConfig(): NtfyConfig | null {
540
+ const n = this.#storedNtfy();
541
+ return n.server && n.topic ? n : null;
542
+ }
543
+
544
+ updateSettings(patch: UpdateSettingsInput): Settings {
545
+ // Check everything before saving anything.
546
+ if ((patch as { paused?: unknown }).paused !== undefined) throw new HttpError(400, 'Pause or resume every Boton with POST /api/pause or POST /api/resume');
547
+ const ownerName = patch.ownerName !== undefined ? clean(patch.ownerName, 'ownerName', 80, true) : undefined;
548
+ if (patch.timezone !== undefined && !isValidTimezone(patch.timezone)) throw new HttpError(400, `Unknown timezone ${patch.timezone}`);
549
+ const ntfy = patch.ntfy !== undefined ? this.#nextNtfy(patch.ntfy) : undefined;
550
+ const primary = patch.primaryBotId !== undefined ? this.#primaryInput(patch.primaryBotId) : undefined;
551
+ const followUps = patch.reactionFollowUps !== undefined ? followUpsInput(patch.reactionFollowUps) : undefined;
552
+ const cleanup = patch.inboxCleanupDays;
553
+ if (cleanup !== undefined && cleanup !== null && !INBOX_CLEANUP_CHOICES.includes(cleanup as number)) {
554
+ throw new HttpError(400, `inboxCleanupDays must be one of ${INBOX_CLEANUP_CHOICES.join(', ')}, or null for off`);
555
+ }
556
+ if (ownerName !== undefined) this.#setSetting('ownerName', ownerName);
557
+ if (primary === null) this.#run("delete from settings where key = 'primaryBotId'");
558
+ else if (primary !== undefined) this.#setSetting('primaryBotId', primary);
559
+ if (patch.timezone !== undefined) this.#setSetting('timezone', patch.timezone);
560
+ if (ntfy) this.#setSetting('ntfy', JSON.stringify(ntfy));
561
+ if (followUps) this.#setSetting('reactionFollowUps', JSON.stringify(followUps));
562
+ if (cleanup !== undefined) this.#setSetting('inboxCleanupDays', cleanup === null ? 'off' : String(cleanup));
563
+ const settings = this.getSettings();
564
+ this.#publish({ type: 'settings.updated', settings });
565
+ return settings;
566
+ }
567
+
568
+ #primaryInput(v: unknown): string | null {
569
+ if (v === null || v === '') return null;
570
+ if (typeof v !== 'string') throw new HttpError(400, 'primaryBotId must be a Boton id or null');
571
+ if (!this.hasBot(v)) throw new HttpError(400, `No Boton with id ${v}`);
572
+ return v;
573
+ }
574
+
575
+ #nextNtfy(input: unknown): StoredNtfy {
576
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) throw new HttpError(400, 'ntfy must be an object');
577
+ const p = input as NonNullable<UpdateSettingsInput['ntfy']>;
578
+ const next = this.#storedNtfy();
579
+ next.enabled = flag(p.enabled, 'ntfy.enabled', next.enabled);
580
+ next.preview = flag(p.preview, 'ntfy.preview', next.preview);
581
+ if (p.server !== undefined) {
582
+ const server = clean(p.server, 'ntfy.server', 500).replace(/\/+$/, '');
583
+ if (server) {
584
+ let url: URL;
585
+ try {
586
+ url = new URL(server);
587
+ } catch {
588
+ throw new HttpError(400, 'ntfy.server must be a URL such as https://ntfy.sh');
589
+ }
590
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new HttpError(400, 'ntfy.server must be an http:// or https:// URL');
591
+ if (url.username || url.password || url.search || url.hash) throw new HttpError(400, 'ntfy.server must be a plain URL (put an access token in ntfy.token)');
592
+ }
593
+ next.server = server;
594
+ }
595
+ if (p.topic !== undefined) {
596
+ const topic = clean(p.topic, 'ntfy.topic', 64);
597
+ if (topic && !NTFY_TOPIC.test(topic)) throw new HttpError(400, 'ntfy.topic may use only letters, digits, - and _ (at most 64)');
598
+ next.topic = topic;
599
+ }
600
+ // The token is the credential of one ntfy server: a different server (scheme, host or port) never gets it.
601
+ // Without a token of its own in the same change, the saved one is removed.
602
+ if (p.token === undefined && next.token && ntfyOrigin(next.server) !== ntfyOrigin(this.#storedNtfy().server)) next.token = null;
603
+ if (p.token !== undefined) {
604
+ if (p.token === null || p.token === '') next.token = null;
605
+ else if (typeof p.token !== 'string' || !/^[\x21-\x7e]{1,200}$/.test(p.token.trim())) throw new HttpError(400, 'ntfy.token must be a token such as tk_…');
606
+ else next.token = p.token.trim();
607
+ }
608
+ if (next.enabled && (!next.server || !next.topic)) throw new HttpError(400, 'Set the ntfy server and topic before turning push notifications on');
609
+ return next;
610
+ }
611
+
612
+ setPausedAll(paused: boolean): Settings {
613
+ const changed = this.getSettings().paused !== paused;
614
+ this.#setSetting('paused', paused ? '1' : '0');
615
+ const settings = this.getSettings();
616
+ if (changed) this.#publish({ type: 'settings.updated', settings });
617
+ return settings;
618
+ }
619
+
620
+ pausedReason(botId: string): string | null {
621
+ const r = this.#get("select b.paused, b.name, (select value from settings where key = 'paused') as all_paused from bots b where b.id = ?", botId);
622
+ if (!r) return null;
623
+ if (r.all_paused === '1') return 'All Botons are paused';
624
+ if (r.paused) return `${r.name} is paused`;
625
+ return null;
626
+ }
627
+
628
+ // -------------------------------------------------------------------- bots
629
+
630
+ #toBot(r: Row): BotRecord {
631
+ const last = this.#get(
632
+ // A wake-up whose turn ended [quiet] isn't in the transcript, so it isn't the chat's latest line either.
633
+ `select role, text, created_at, json_extract(${MESSAGE_META}, '$.fromBotName') as from_name from messages where bot_id = ? and role != 'event'
634
+ and coalesce(json_extract(${MESSAGE_META}, '$.quiet'), 0) = 0 order by seq desc limit 1`,
635
+ r.id,
636
+ );
637
+ const unread = this.#get("select count(*) as n from messages where bot_id = ? and seq > ? and role in ('bot', 'error')", r.id, r.read_seq)!;
638
+ return {
639
+ id: r.id,
640
+ name: r.name,
641
+ title: r.title,
642
+ instructions: r.instructions,
643
+ look: lookFor(parseJson<unknown>(r.look, null), r.id),
644
+ provider: r.provider,
645
+ model: r.model,
646
+ effort: r.effort ?? '',
647
+ access: r.access as AccessLevel,
648
+ groupId: r.group_id ?? null,
649
+ group: r.group_name ?? '',
650
+ pinned: !!r.pinned,
651
+ timezone: r.timezone,
652
+ // Stored relative to the workspaces folder (absolute values resolve as they are).
653
+ workspace: resolve(this.#cfg.workspacesDir, r.workspace),
654
+ createdAt: r.created_at,
655
+ updatedAt: r.updated_at,
656
+ lastMessage: last
657
+ ? { role: last.role as MessageRole, text: last.text.slice(0, 200), createdAt: last.created_at, ...(last.from_name ? { from: String(last.from_name) } : {}) }
658
+ : null,
659
+ unread: Number(unread.n),
660
+ status: this.#statusOf(r.id),
661
+ paused: !!r.paused,
662
+ connections: parseList(r.connections),
663
+ skills: r.skills === null ? null : parseList(r.skills),
664
+ alwaysAllow: parseList(r.always_allow),
665
+ askFirst: parseList(r.ask_first),
666
+ sessionId: r.session_id ?? null,
667
+ sessionKey: r.session_key ?? null,
668
+ contextSince: r.context_since ?? null,
669
+ };
670
+ }
671
+
672
+ publicBot(b: BotRecord): Bot {
673
+ const { sessionId: _s, sessionKey: _k, contextSince: _c, ...bot } = b;
674
+ return bot;
675
+ }
676
+
677
+ listBots(): Bot[] {
678
+ return this.#all(`${BOT_SELECT} order by b.last_activity_at desc`).map((r) => this.publicBot(this.#toBot(r)));
679
+ }
680
+
681
+ getBot(id: string): BotRecord {
682
+ const r = this.#get(`${BOT_SELECT} where b.id = ?`, id);
683
+ if (!r) throw new HttpError(404, `No Boton with id ${id}`);
684
+ return this.#toBot(r);
685
+ }
686
+
687
+ hasBot(id: string): boolean {
688
+ return !!this.#get('select 1 from bots where id = ?', id);
689
+ }
690
+
691
+ #validateBotFields(input: UpdateBotInput, providers: Set<string>) {
692
+ if (input.access !== undefined && input.access !== 'workspace' && input.access !== 'host') {
693
+ throw new HttpError(400, 'access must be "workspace" or "host"');
694
+ }
695
+ if (input.provider !== undefined && !providers.has(input.provider)) {
696
+ throw new HttpError(400, `Unknown provider ${input.provider}`);
697
+ }
698
+ if (input.timezone !== undefined && !isValidTimezone(input.timezone)) {
699
+ throw new HttpError(400, `Unknown timezone ${input.timezone}`);
700
+ }
701
+ if (input.look !== undefined) lookInput(input.look, '');
702
+ if (input.connections !== undefined && (!Array.isArray(input.connections) || !input.connections.every((c) => typeof c === 'string'))) {
703
+ throw new HttpError(400, 'connections must be a list of connector ids');
704
+ }
705
+ if (input.skills !== undefined && input.skills !== null && (!Array.isArray(input.skills) || !input.skills.every((c) => typeof c === 'string'))) {
706
+ throw new HttpError(400, 'skills must be a list of skill names, or null for every skill');
707
+ }
708
+ if (input.alwaysAllow !== undefined) alwaysAllowList(input.alwaysAllow);
709
+ if (input.askFirst !== undefined) askFirstList(input.askFirst);
710
+ }
711
+
712
+ createBot(input: CreateBotInput, providers: Set<string>, defaultProvider: string): Bot {
713
+ this.#validateBotFields(input, providers);
714
+ const id = newId('bot');
715
+ const name = clean(input.name, 'name', 60, true);
716
+ const now = nowIso();
717
+ const workspace = `${slug(name)}-${id.slice(4)}`;
718
+ const provider = input.provider ?? defaultProvider;
719
+ const model = clean(input.model, 'model', 120);
720
+ const row = [
721
+ id,
722
+ name,
723
+ clean(input.title, 'title', 80),
724
+ clean(input.instructions, 'instructions', 20_000),
725
+ JSON.stringify(input.look !== undefined ? lookInput(input.look, id) : randomLook()),
726
+ provider,
727
+ model,
728
+ input.effort === undefined ? '' : botEffort(input.effort, provider, this.effortCaps(provider, model)),
729
+ input.access ?? 'workspace',
730
+ input.pinned ? 1 : 0,
731
+ input.timezone ?? this.getSettings().timezone,
732
+ workspace,
733
+ JSON.stringify(input.connections ?? []),
734
+ input.skills ? JSON.stringify(input.skills) : null,
735
+ now,
736
+ now,
737
+ now,
738
+ ];
739
+ return this.atomically(() => {
740
+ const groupId = this.#groupInput(input) ?? null;
741
+ this.#makeWorkspace(workspace);
742
+ this.#run(
743
+ `insert into bots (id, name, title, instructions, look, provider, model, effort, access, pinned, timezone, workspace, connections, skills, created_at, updated_at, last_activity_at, group_id, position, pin_position)
744
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
745
+ ...row,
746
+ groupId,
747
+ this.#nextPosition(groupId),
748
+ input.pinned ? this.#nextPinPosition() : 0,
749
+ );
750
+ this.#publishSidebar();
751
+ return this.emitBot(id);
752
+ });
753
+ }
754
+
755
+ updateBot(id: string, input: UpdateBotInput, providers: Set<string>): Bot {
756
+ const current = this.getBot(id);
757
+ this.#validateBotFields(input, providers);
758
+ const provider = input.provider ?? current.provider;
759
+ const model = input.model !== undefined ? clean(input.model, 'model', 120) : current.model;
760
+ const known = this.effortCaps(provider, model);
761
+ // A level the new provider or model doesn't have goes back to the default, unless this update sets one (the engine
762
+ // says so in the chat); a level it has stays.
763
+ const moved = provider !== current.provider || model !== current.model;
764
+ const effort =
765
+ input.effort !== undefined ? botEffort(input.effort, provider, known) : moved && effortProblem(provider, current.effort, known) ? '' : undefined;
766
+ const sets: string[] = [];
767
+ const vals: any[] = [];
768
+ const set = (col: string, v: any) => {
769
+ sets.push(`${col} = ?`);
770
+ vals.push(v);
771
+ };
772
+ if (input.name !== undefined) set('name', clean(input.name, 'name', 60, true));
773
+ if (input.title !== undefined) set('title', clean(input.title, 'title', 80));
774
+ if (input.instructions !== undefined) set('instructions', clean(input.instructions, 'instructions', 20_000));
775
+ if (input.look !== undefined) set('look', JSON.stringify(lookInput(input.look, id)));
776
+ if (input.provider !== undefined) set('provider', input.provider);
777
+ if (input.model !== undefined) set('model', model);
778
+ if (effort !== undefined) set('effort', effort);
779
+ if (input.access !== undefined) set('access', input.access);
780
+ if (input.pinned !== undefined) flag(input.pinned, 'pinned');
781
+ if (input.timezone !== undefined) set('timezone', input.timezone);
782
+ if (input.connections !== undefined) set('connections', JSON.stringify(input.connections));
783
+ if (input.skills !== undefined) set('skills', input.skills === null ? null : JSON.stringify(input.skills));
784
+ if (input.alwaysAllow !== undefined) set('always_allow', JSON.stringify(alwaysAllowList(input.alwaysAllow)));
785
+ if (input.askFirst !== undefined) set('ask_first', JSON.stringify(askFirstList(input.askFirst)));
786
+ return this.atomically(() => {
787
+ // Its place in the sidebar: a new group or a pin goes last there; the same one keeps its place.
788
+ const groupId = this.#groupInput(input);
789
+ let moved = false;
790
+ if (groupId !== undefined && groupId !== current.groupId) {
791
+ set('group_id', groupId);
792
+ set('position', this.#nextPosition(groupId));
793
+ moved = true;
794
+ }
795
+ if (input.pinned !== undefined && input.pinned !== current.pinned) {
796
+ set('pinned', input.pinned ? 1 : 0);
797
+ set('pin_position', input.pinned ? this.#nextPinPosition() : 0);
798
+ moved = true;
799
+ }
800
+ if (sets.length) {
801
+ set('updated_at', nowIso());
802
+ this.#run(`update bots set ${sets.join(', ')} where id = ?`, ...vals, id);
803
+ }
804
+ if (moved) this.#publishSidebar();
805
+ return this.emitBot(id);
806
+ });
807
+ }
808
+
809
+ duplicateBot(id: string, input: DuplicateBotInput): Bot {
810
+ const src = this.getBot(id);
811
+ const withRoutines = flag(input.withRoutines, 'withRoutines', true);
812
+ const withMemories = flag(input.withMemories, 'withMemories', false);
813
+ const given = input.name === undefined || input.name === null ? '' : clean(input.name, 'name', 60);
814
+ const name = given || copyName(src.name, new Set(this.#all('select name from bots').map((r) => String(r.name).toLowerCase())));
815
+ const routines = withRoutines ? this.#all('select * from routines where bot_id = ? order by created_at, id', id) : [];
816
+ const memories = withMemories ? this.#all('select * from memories where bot_id = ? order by created_at, id', id) : [];
817
+ const newBotId = newId('bot');
818
+ const workspace = `${slug(name)}-${newBotId.slice(4)}`;
819
+ const now = nowIso();
820
+ const routineIds: string[] = [];
821
+ const copiedIds = new Map<string, string>();
822
+ const memoryIds: string[] = [];
823
+ const bot = this.atomically(() => {
824
+ this.#makeWorkspace(workspace);
825
+ this.#run(
826
+ `insert into bots (id, name, title, instructions, look, provider, model, effort, access, group_id, position, pinned, timezone, workspace, connections, skills, ask_first, created_at, updated_at, last_activity_at)
827
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`,
828
+ newBotId,
829
+ name,
830
+ src.title,
831
+ src.instructions,
832
+ JSON.stringify(src.look),
833
+ src.provider,
834
+ src.model,
835
+ src.effort,
836
+ src.access,
837
+ src.groupId,
838
+ this.#nextPosition(src.groupId),
839
+ src.timezone,
840
+ workspace,
841
+ JSON.stringify(src.connections),
842
+ src.skills === null ? null : JSON.stringify(src.skills),
843
+ // Not always_allow: what it may do without asking stays with the original.
844
+ JSON.stringify(src.askFirst),
845
+ now,
846
+ now,
847
+ now,
848
+ );
849
+ for (const r of routines) {
850
+ const row: Row = {
851
+ ...r,
852
+ id: newId('rt'),
853
+ bot_id: newBotId,
854
+ enabled: 0,
855
+ next_run_at: null,
856
+ last_run_at: null,
857
+ last_status: null,
858
+ session_id: null,
859
+ session_key: null,
860
+ created_at: now,
861
+ updated_at: now,
862
+ };
863
+ row.webhook_key = r.webhook_key ? newSecret() : null;
864
+ // Otherwise the copy's results would reach the original's receiver, signed with another key.
865
+ row.webhook_secret = null;
866
+ row.callback_url = null;
867
+ const cols = Object.keys(row);
868
+ this.#run(`insert into routines (${cols.map((c) => `"${c}"`).join(', ')}) values (${cols.map(() => '?').join(', ')})`, ...cols.map((c) => row[c]));
869
+ routineIds.push(row.id);
870
+ copiedIds.set(String(r.id), row.id);
871
+ }
872
+ // A starter's routines still waiting on the owner wait on them in the copy too, with no test run of their own yet.
873
+ const starter = this.starterRecord(id);
874
+ if (starter) {
875
+ const pending = starter.pending.flatMap((x) => (copiedIds.has(x) ? [copiedIds.get(x)!] : []));
876
+ if (pending.length) this.saveStarterRecord(newBotId, { ...starter, installedAt: now, pending, tests: [] });
877
+ }
878
+ for (const m of memories) {
879
+ const memId = newId('mem');
880
+ this.#run('insert into memories (id, bot_id, text, created_at) values (?, ?, ?, ?)', memId, newBotId, m.text, m.created_at);
881
+ memoryIds.push(memId);
882
+ }
883
+ this.#publishSidebar();
884
+ const made = this.emitBot(newBotId);
885
+ for (const rid of routineIds) this.#emitRoutine(rid);
886
+ for (const memory of this.listMemories(newBotId)) this.#publish({ type: 'memory.upserted', memory });
887
+ this.addMessage(newBotId, 'event', `Duplicated from ${src.name}`, {
888
+ kind: 'bot.duplicated',
889
+ sourceBotId: src.id,
890
+ sourceName: src.name,
891
+ copiedRoutines: routineIds.length,
892
+ copiedMemories: memoryIds.length,
893
+ });
894
+ return made;
895
+ });
896
+ log.info('Boton duplicated', { botId: newBotId, sourceBotId: src.id, routines: routineIds.length, memories: memoryIds.length });
897
+ return bot;
898
+ }
899
+
900
+ /** Takes `name` off every Boton's list of connectors or skills. */
901
+ dropFromBots(column: 'connections' | 'skills', name: string): void {
902
+ for (const r of this.#all(`select id, ${column} as list from bots where ${column} is not null`)) {
903
+ const list = parseList(r.list);
904
+ if (!list.includes(name)) continue;
905
+ this.#run(`update bots set ${column} = ? where id = ?`, JSON.stringify(list.filter((x) => x !== name)), r.id);
906
+ this.emitBot(r.id);
907
+ }
908
+ }
909
+
910
+ /** A Boton's skills: null for every skill. */
911
+ setSkills(id: string, skills: string[] | null): Bot {
912
+ this.getBot(id);
913
+ this.#run('update bots set skills = ?, updated_at = ? where id = ?', skills === null ? null : JSON.stringify(skills), nowIso(), id);
914
+ return this.emitBot(id);
915
+ }
916
+
917
+ /** Published when the transaction it is in commits (at once outside one). */
918
+ publish(event: ServerEvent): void {
919
+ this.#publish(event);
920
+ }
921
+
922
+ setConnections(id: string, connections: string[]): Bot {
923
+ this.getBot(id);
924
+ this.#run('update bots set connections = ?, updated_at = ? where id = ?', JSON.stringify(connections), nowIso(), id);
925
+ return this.emitBot(id);
926
+ }
927
+
928
+ setBotPaused(id: string, paused: boolean): Bot {
929
+ this.getBot(id);
930
+ this.#run('update bots set paused = ? where id = ?', paused ? 1 : 0, id);
931
+ return this.emitBot(id);
932
+ }
933
+
934
+ addAlwaysAllow(id: string, action: string): Bot {
935
+ const bot = this.getBot(id);
936
+ if (bot.alwaysAllow.includes(action)) return this.publicBot(bot);
937
+ this.#run('update bots set always_allow = ?, updated_at = ? where id = ?', JSON.stringify(alwaysAllowList([...bot.alwaysAllow, action])), nowIso(), id);
938
+ return this.emitBot(id);
939
+ }
940
+
941
+ deleteBot(id: string): void {
942
+ this.getBot(id);
943
+ this.#failActiveRuns('bot_id', id, 'Boton was deleted');
944
+ this.#run('delete from bots where id = ?', id);
945
+ this.#run('delete from settings where key = ?', starterKey(id));
946
+ this.#run('delete from settings where key = ?', `inboxPins:${id}`);
947
+ this.#publish({ type: 'bot.deleted', botId: id });
948
+ this.#publishSidebar();
949
+ if (Number(this.#run("delete from settings where key = 'primaryBotId' and value = ?", id).changes)) {
950
+ this.#publish({ type: 'settings.updated', settings: this.getSettings() });
951
+ }
952
+ }
953
+
954
+ // ----------------------------------------------------------------- sidebar
955
+ // One layout for the owner, on every device: groups in order, each Boton in one group or ungrouped at its
956
+ // position there, and the pinned ones in their own order. Every change publishes the whole layout.
957
+
958
+ sidebarLayout(): SidebarLayout {
959
+ const settings = Object.fromEntries(this.#all(`select key, value from settings where key in ('${SIDEBAR_COLLAPSED.pinned}', '${SIDEBAR_COLLAPSED.ungrouped}')`).map((r) => [r.key, r.value]));
960
+ const bots = this.#all('select id, group_id, pinned from bots order by position, created_at, id');
961
+ const groups = this.#all('select id, name, collapsed from sidebar_groups order by position, created_at, id').map((g) => ({
962
+ id: String(g.id),
963
+ name: String(g.name),
964
+ collapsed: !!g.collapsed,
965
+ botIds: bots.filter((b) => b.group_id === g.id).map((b) => String(b.id)),
966
+ }));
967
+ return {
968
+ pinned: {
969
+ collapsed: settings[SIDEBAR_COLLAPSED.pinned] === '1',
970
+ botIds: this.#all('select id from bots where pinned = 1 order by pin_position, created_at, id').map((r) => String(r.id)),
971
+ },
972
+ groups,
973
+ ungrouped: { collapsed: settings[SIDEBAR_COLLAPSED.ungrouped] === '1', botIds: bots.filter((b) => b.group_id === null).map((b) => String(b.id)) },
974
+ };
975
+ }
976
+
977
+ #publishSidebar(): void {
978
+ this.#publish({ type: 'sidebar.updated', layout: this.sidebarLayout() });
979
+ }
980
+
981
+ /** Changes the layout in one transaction, then publishes it; returns it. */
982
+ #sidebarChange(fn: () => void): SidebarLayout {
983
+ return this.atomically(() => {
984
+ fn();
985
+ const layout = this.sidebarLayout();
986
+ this.#publish({ type: 'sidebar.updated', layout });
987
+ return layout;
988
+ });
989
+ }
990
+
991
+ #group(id: unknown): Row {
992
+ const g = typeof id === 'string' ? this.#get('select * from sidebar_groups where id = ?', id) : undefined;
993
+ if (!g) throw new HttpError(404, `No sidebar group with id ${String(id)}`);
994
+ return g;
995
+ }
996
+
997
+ #groupName(name: unknown, except: string | null = null): string {
998
+ const n = clean(name, 'name', SIDEBAR_GROUP_NAME_MAX, true);
999
+ const taken = this.#get('select name from sidebar_groups where name_key = ? and id is not ?', sidebarGroupKey(n), except);
1000
+ if (taken) throw new HttpError(409, `There is already a group called ${taken.name}`);
1001
+ return n;
1002
+ }
1003
+
1004
+ #addGroup(name: string, index?: number): string {
1005
+ if (Number(this.#get('select count(*) as n from sidebar_groups')!.n) >= SIDEBAR_GROUPS_MAX) {
1006
+ throw new HttpError(400, `The sidebar can have at most ${SIDEBAR_GROUPS_MAX} groups`);
1007
+ }
1008
+ const id = newId('grp');
1009
+ const now = nowIso();
1010
+ this.#run(
1011
+ 'insert into sidebar_groups (id, name, name_key, position, collapsed, created_at, updated_at) values (?, ?, ?, ?, 0, ?, ?)',
1012
+ id,
1013
+ name,
1014
+ sidebarGroupKey(name),
1015
+ this.#nextGroupPosition(),
1016
+ now,
1017
+ now,
1018
+ );
1019
+ if (index !== undefined) this.#reorder('sidebar_groups', this.#groupIds(), id, index);
1020
+ return id;
1021
+ }
1022
+
1023
+ /**
1024
+ * The group a Boton's input names: by id (null or "": ungrouped), or by name in any case, made last if there is
1025
+ * none ("": ungrouped). undefined: it names none.
1026
+ */
1027
+ #groupInput(input: { groupId?: unknown; group?: unknown }): string | null | undefined {
1028
+ if (input.groupId !== undefined && input.group !== undefined) throw new HttpError(400, 'Give a group by groupId or by name (group), not both');
1029
+ if (input.groupId !== undefined) return input.groupId === null || input.groupId === '' ? null : String(this.#group(input.groupId).id);
1030
+ if (input.group === undefined) return undefined;
1031
+ const name = clean(input.group, 'group', SIDEBAR_GROUP_NAME_MAX);
1032
+ if (!name) return null;
1033
+ const found = this.#get('select id from sidebar_groups where name_key = ?', sidebarGroupKey(name));
1034
+ return found ? String(found.id) : this.#addGroup(name);
1035
+ }
1036
+
1037
+ #nextPosition(groupId: string | null): number {
1038
+ return Number(this.#get('select coalesce(max(position) + 1, 0) as n from bots where group_id is ?', groupId)!.n);
1039
+ }
1040
+
1041
+ #nextPinPosition(): number {
1042
+ return Number(this.#get('select coalesce(max(pin_position) + 1, 0) as n from bots where pinned = 1')!.n);
1043
+ }
1044
+
1045
+ #nextGroupPosition(): number {
1046
+ return Number(this.#get('select coalesce(max(position) + 1, 0) as n from sidebar_groups')!.n);
1047
+ }
1048
+
1049
+ #groupIds(): string[] {
1050
+ return this.#all('select id from sidebar_groups order by position, created_at, id').map((r) => String(r.id));
1051
+ }
1052
+
1053
+ /** Puts `id` at `index` in `ids` (the list it is in or moves into) and numbers the list again from 0. */
1054
+ #reorder(table: 'sidebar_groups' | 'bots', ids: string[], id: string, index: unknown, column: 'position' | 'pin_position' = 'position'): void {
1055
+ const list = ids.filter((x) => x !== id);
1056
+ list.splice(sidebarIndex(index, list.length), 0, id);
1057
+ this.#renumber(table, list, column);
1058
+ }
1059
+
1060
+ #renumber(table: 'sidebar_groups' | 'bots', ids: string[], column: 'position' | 'pin_position' = 'position'): void {
1061
+ const save = this.db.prepare(`update ${table} set ${column} = ? where id = ?`);
1062
+ ids.forEach((x, i) => save.run(i, x));
1063
+ }
1064
+
1065
+ createSidebarGroup(input: { name?: unknown; index?: unknown }): { id: string; layout: SidebarLayout } {
1066
+ let id = '';
1067
+ const layout = this.#sidebarChange(() => {
1068
+ const name = this.#groupName(input?.name);
1069
+ id = this.#addGroup(name, input.index === undefined ? undefined : sidebarIndex(input.index, Number.MAX_SAFE_INTEGER));
1070
+ });
1071
+ return { id, layout };
1072
+ }
1073
+
1074
+ updateSidebarGroup(id: string, input: { name?: unknown; collapsed?: unknown }): SidebarLayout {
1075
+ return this.#sidebarChange(() => {
1076
+ this.#group(id);
1077
+ const name = input?.name !== undefined ? this.#groupName(input.name, id) : undefined;
1078
+ const collapsed = input?.collapsed !== undefined ? flag(input.collapsed, 'collapsed') : undefined;
1079
+ if (name !== undefined) this.#run('update sidebar_groups set name = ?, name_key = ?, updated_at = ? where id = ?', name, sidebarGroupKey(name), nowIso(), id);
1080
+ if (collapsed !== undefined) this.#run('update sidebar_groups set collapsed = ?, updated_at = ? where id = ?', collapsed ? 1 : 0, nowIso(), id);
1081
+ // A Boton shows its group's name: a rename changes how each of them reads.
1082
+ if (name !== undefined) for (const r of this.#all('select id from bots where group_id = ?', id)) this.emitBot(String(r.id));
1083
+ });
1084
+ }
1085
+
1086
+ /** Deleting a group deletes no Boton: its Botons go last among the ungrouped, in their order; pins stay. */
1087
+ deleteSidebarGroup(id: string): SidebarLayout {
1088
+ return this.#sidebarChange(() => {
1089
+ this.#group(id);
1090
+ const members = this.#all('select id from bots where group_id = ? order by position, created_at, id', id).map((r) => String(r.id));
1091
+ let next = this.#nextPosition(null);
1092
+ for (const m of members) this.#run('update bots set group_id = null, position = ? where id = ?', next++, m);
1093
+ this.#run('delete from sidebar_groups where id = ?', id);
1094
+ this.#renumber('sidebar_groups', this.#groupIds());
1095
+ for (const m of members) this.emitBot(m);
1096
+ });
1097
+ }
1098
+
1099
+ moveSidebarGroup(id: string, input: { index?: unknown }): SidebarLayout {
1100
+ return this.#sidebarChange(() => {
1101
+ this.#group(id);
1102
+ this.#reorder('sidebar_groups', this.#groupIds(), id, input?.index);
1103
+ });
1104
+ }
1105
+
1106
+ /** Into a group (or ungrouped, groupId null) at `index` there: within its own group, a reorder. */
1107
+ moveSidebarBot(botId: string, input: { groupId?: unknown; index?: unknown }): SidebarLayout {
1108
+ return this.#sidebarChange(() => {
1109
+ const bot = this.getBot(botId);
1110
+ if (input?.groupId === undefined) throw new HttpError(400, 'groupId is required: a group\'s id, or null for ungrouped');
1111
+ const groupId = this.#groupInput({ groupId: input.groupId })!;
1112
+ const members = this.#all('select id from bots where group_id is ? order by position, created_at, id', groupId).map((r) => String(r.id));
1113
+ if (groupId !== bot.groupId) this.#run('update bots set group_id = ?, updated_at = ? where id = ?', groupId, nowIso(), botId);
1114
+ this.#reorder('bots', members, botId, input.index);
1115
+ if (groupId !== bot.groupId) this.emitBot(botId);
1116
+ });
1117
+ }
1118
+
1119
+ /** Pins it at `index` among the pinned (pinned again: a reorder), or unpins it; its group stays. */
1120
+ pinSidebarBot(botId: string, input: { pinned?: unknown; index?: unknown }): SidebarLayout {
1121
+ return this.#sidebarChange(() => {
1122
+ const bot = this.getBot(botId);
1123
+ const pinned = flag(input?.pinned, 'pinned');
1124
+ if (pinned) {
1125
+ const pins = this.#all('select id from bots where pinned = 1 order by pin_position, created_at, id').map((r) => String(r.id));
1126
+ this.#run('update bots set pinned = 1 where id = ?', botId);
1127
+ this.#reorder('bots', pins, botId, input.index, 'pin_position');
1128
+ } else {
1129
+ if (input.index !== undefined) throw new HttpError(400, 'index is only for pinning');
1130
+ this.#run('update bots set pinned = 0, pin_position = 0 where id = ?', botId);
1131
+ }
1132
+ if (pinned !== bot.pinned) {
1133
+ this.#run('update bots set updated_at = ? where id = ?', nowIso(), botId);
1134
+ this.emitBot(botId);
1135
+ }
1136
+ });
1137
+ }
1138
+
1139
+ setSidebarSection(key: string, input: { collapsed?: unknown }): SidebarLayout {
1140
+ if (key !== 'pinned' && key !== 'ungrouped') throw new HttpError(404, 'The sections are pinned and ungrouped; a group is changed by its id');
1141
+ const collapsed = flag(input?.collapsed, 'collapsed');
1142
+ return this.#sidebarChange(() => this.#setSetting(SIDEBAR_COLLAPSED[key as SidebarSectionKey], collapsed ? '1' : '0'));
1143
+ }
1144
+
1145
+ exportSidebar(): SidebarTransfer {
1146
+ const l = this.sidebarLayout();
1147
+ return {
1148
+ format: SIDEBAR_FORMAT,
1149
+ version: 1,
1150
+ exportedAt: nowIso(),
1151
+ pinned: l.pinned,
1152
+ groups: l.groups.map((g) => ({ name: g.name, collapsed: g.collapsed, botIds: g.botIds })),
1153
+ ungrouped: l.ungrouped,
1154
+ };
1155
+ }
1156
+
1157
+ /** See SidebarTransfer: the whole file is checked before anything changes, and it changes in one transaction. */
1158
+ importSidebar(input: unknown): SidebarImportResult {
1159
+ const file = sidebarTransfer(input);
1160
+ const skipped = new Set<string>();
1161
+ const layout = this.#sidebarChange(() => {
1162
+ const before = new Map(this.#all('select id, group_id, pinned from bots').map((r) => [String(r.id), r]));
1163
+ const known = (ids: string[]) => {
1164
+ for (const id of ids) if (!before.has(id)) skipped.add(id);
1165
+ return ids.filter((id) => before.has(id));
1166
+ };
1167
+
1168
+ // Groups: the same name keeps its id, the rest are made; the ones the file lacks go, ungrouping their Botons.
1169
+ const existing = new Map(this.#all('select id, name_key from sidebar_groups').map((r) => [String(r.name_key), String(r.id)]));
1170
+ const now = nowIso();
1171
+ const groupIds = file.groups.map((g, i) => {
1172
+ const id = existing.get(sidebarGroupKey(g.name));
1173
+ if (id) {
1174
+ existing.delete(sidebarGroupKey(g.name));
1175
+ this.#run('update sidebar_groups set name = ?, name_key = ?, position = ?, collapsed = ?, updated_at = ? where id = ?', g.name, sidebarGroupKey(g.name), i, g.collapsed ? 1 : 0, now, id);
1176
+ return id;
1177
+ }
1178
+ const made = newId('grp');
1179
+ this.#run(
1180
+ 'insert into sidebar_groups (id, name, name_key, position, collapsed, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)',
1181
+ made,
1182
+ g.name,
1183
+ sidebarGroupKey(g.name),
1184
+ i,
1185
+ g.collapsed ? 1 : 0,
1186
+ now,
1187
+ now,
1188
+ );
1189
+ return made;
1190
+ });
1191
+ for (const gone of existing.values()) {
1192
+ this.#run('update bots set group_id = null where group_id = ?', gone);
1193
+ this.#run('delete from sidebar_groups where id = ?', gone);
1194
+ }
1195
+
1196
+ // Botons: where the file puts them; the ones it doesn't mention after the ungrouped it lists, in their order.
1197
+ const placed = new Set<string>();
1198
+ const place = this.db.prepare('update bots set group_id = ?, position = ? where id = ?');
1199
+ file.groups.forEach((g, i) =>
1200
+ known(g.botIds).forEach((id, n) => {
1201
+ place.run(groupIds[i], n, id);
1202
+ placed.add(id);
1203
+ }),
1204
+ );
1205
+ const ungrouped = known(file.ungrouped.botIds);
1206
+ for (const id of ungrouped) placed.add(id);
1207
+ const rest = this.#all('select id from bots order by position, created_at, id')
1208
+ .map((r) => String(r.id))
1209
+ .filter((id) => !placed.has(id));
1210
+ [...ungrouped, ...rest].forEach((id, n) => place.run(null, n, id));
1211
+
1212
+ // Pins: the file's, in its order; a Boton it doesn't mention keeps its pin, after them.
1213
+ const mentioned = new Set([...placed, ...file.pinned.botIds]);
1214
+ const pins = known(file.pinned.botIds);
1215
+ const kept = this.#all('select id from bots where pinned = 1 order by pin_position, created_at, id')
1216
+ .map((r) => String(r.id))
1217
+ .filter((id) => !mentioned.has(id));
1218
+ this.db.prepare('update bots set pinned = 0, pin_position = 0').run();
1219
+ const pin = this.db.prepare('update bots set pinned = 1, pin_position = ? where id = ?');
1220
+ [...pins, ...kept].forEach((id, n) => pin.run(n, id));
1221
+
1222
+ this.#setSetting(SIDEBAR_COLLAPSED.pinned, file.pinned.collapsed ? '1' : '0');
1223
+ this.#setSetting(SIDEBAR_COLLAPSED.ungrouped, file.ungrouped.collapsed ? '1' : '0');
1224
+ // A Boton whose group or pin changed reads differently, and so may one in a group the file spells anew.
1225
+ for (const [id, was] of before) {
1226
+ const b = this.getBot(id);
1227
+ const moved = b.groupId !== (was.group_id ?? null) || b.pinned !== !!was.pinned;
1228
+ if (moved) this.#run('update bots set updated_at = ? where id = ?', now, id);
1229
+ if (moved || b.groupId) this.emitBot(id);
1230
+ }
1231
+ });
1232
+ return { layout, skipped: [...skipped] };
1233
+ }
1234
+
1235
+ team(botId: string): TeamMember[] {
1236
+ this.getBot(botId);
1237
+ const primary = this.getSettings().primaryBotId;
1238
+ return this.#all('select id, name, title, paused from bots order by name collate nocase, id').map((r) => ({
1239
+ id: r.id,
1240
+ name: r.name,
1241
+ title: r.title,
1242
+ state: this.#statusOf(r.id).state,
1243
+ paused: !!r.paused,
1244
+ primary: r.id === primary,
1245
+ self: r.id === botId,
1246
+ }));
1247
+ }
1248
+
1249
+ emitBot(id: string): Bot {
1250
+ const bot = this.publicBot(this.getBot(id));
1251
+ this.#publish({ type: 'bot.upserted', bot });
1252
+ return bot;
1253
+ }
1254
+
1255
+ /** The marker never moves back, so an older device catching up can't make read messages unread. */
1256
+ markRead(botId: string, upTo?: string): Bot {
1257
+ this.getBot(botId);
1258
+ let seq: number;
1259
+ if (upTo === undefined) {
1260
+ seq = Number(this.#get('select coalesce(max(seq), 0) as seq from messages where bot_id = ?', botId)!.seq);
1261
+ } else {
1262
+ const row = this.#get('select seq from messages where id = ? and bot_id = ?', upTo, botId);
1263
+ if (!row) throw new HttpError(400, `upTo must be a message in this chat; ${upTo} is not`);
1264
+ seq = Number(row.seq);
1265
+ }
1266
+ const moved = Number(this.#run('update bots set read_seq = ? where id = ? and read_seq < ?', seq, botId, seq).changes);
1267
+ return moved ? this.emitBot(botId) : this.publicBot(this.getBot(botId));
1268
+ }
1269
+
1270
+ /**
1271
+ * Kept only while the Boton is as the turn read it: the same conversation (no reset since), the same provider, and
1272
+ * no provider switch since (`switchGeneration`), so a move away and back can't hand the old turn's session back.
1273
+ */
1274
+ setSession(botId: string, sessionId: string | null, sessionKey: string | null, at: TurnStamp): boolean {
1275
+ const r = this.#run(
1276
+ `update bots set session_id = ?, session_key = ? where id = ? and context_since is ? and provider = ? and ${GENERATION_SQL} = ?`,
1277
+ sessionId,
1278
+ sessionKey,
1279
+ botId,
1280
+ at.contextSince,
1281
+ at.provider,
1282
+ botId,
1283
+ at.generation,
1284
+ );
1285
+ return Number(r.changes) > 0;
1286
+ }
1287
+
1288
+ /** Ends the chat's saved session: the next chat turn starts a new one. */
1289
+ dropSession(botId: string): void {
1290
+ this.#run('update bots set session_id = null, session_key = null where id = ?', botId);
1291
+ }
1292
+
1293
+ /**
1294
+ * The Boton's provider switches so far, as the seq of the latest one's event (0 before any): seqs are never reused,
1295
+ * so each switch gives a new value, for good, across restarts. A turn reads it with the Boton.
1296
+ */
1297
+ switchGeneration(botId: string): number {
1298
+ return Number(this.#get(`select ${GENERATION_SQL} as g`, botId)?.g ?? 0);
1299
+ }
1300
+
1301
+ contextSince(botId: string): string | null {
1302
+ const r = this.#get('select context_since from bots where id = ?', botId);
1303
+ if (!r) throw new HttpError(404, `No Boton with id ${botId}`);
1304
+ return r.context_since ?? null;
1305
+ }
1306
+
1307
+ /**
1308
+ * Starts the chat fresh: no session, and nothing from before is given to a new one. The pins go with the rest, in
1309
+ * the same transaction, and every device is told, so no message still shows a pin no session will be given.
1310
+ */
1311
+ resetContext(botId: string): Message {
1312
+ this.getBot(botId);
1313
+ return this.atomically(() => {
1314
+ this.#run('update bots set session_id = null, session_key = null, context_since = ? where id = ?', nowIso(), botId);
1315
+ this.#run('update routines set session_id = null, session_key = null where bot_id = ?', botId);
1316
+ const pins = this.#all(`select * from messages where bot_id = ? and ${PINNED_SQL}`, botId);
1317
+ for (const r of pins) this.#publish({ type: 'message.updated', message: this.#unpin(r) });
1318
+ log.info('conversation reset: the next turn starts a new session', { botId, unpinned: pins.length });
1319
+ return this.addMessage(botId, 'event', 'Started a fresh conversation', { kind: 'session.reset' });
1320
+ });
1321
+ }
1322
+
1323
+ /** Takes the pin off a message's meta, keeping the rest of it. */
1324
+ #unpin(r: Row): Message {
1325
+ const { pinned: _p, ...meta } = parseJson<MessageMeta>(r.meta, {});
1326
+ this.#run('update messages set meta = ? where id = ?', Object.keys(meta).length ? JSON.stringify(meta) : null, r.id);
1327
+ return this.#toMessage(this.#get('select * from messages where id = ?', r.id)!);
1328
+ }
1329
+
1330
+ // ---------------------------------------------------------------- messages
1331
+
1332
+ #reactionsQuery: StatementSync | null = null;
1333
+
1334
+ /** Every read of a message has its reactions, so an event that replaces one on a client never drops them. */
1335
+ #toMessage(r: Row): Message {
1336
+ const reactions = this.reactionsOf(r.id);
1337
+ return {
1338
+ id: r.id,
1339
+ botId: r.bot_id,
1340
+ role: r.role as MessageRole,
1341
+ text: r.text,
1342
+ // A meta that isn't JSON reads as none, as it does in SQL (MESSAGE_META): the message still shows.
1343
+ meta: r.meta ? parseJson<MessageMeta | null>(r.meta, null) : null,
1344
+ createdAt: r.created_at,
1345
+ ...(reactions.length ? { reactions } : {}),
1346
+ };
1347
+ }
1348
+
1349
+ /** A message's reactions, oldest first (on the table's primary key). */
1350
+ reactionsOf(messageId: string): MessageReaction[] {
1351
+ // Prepared once: every message read runs it.
1352
+ this.#reactionsQuery ??= this.db.prepare('select actor, emoji, created_at from message_reactions where message_id = ? order by created_at, rowid');
1353
+ return (this.#reactionsQuery.all(messageId) as Row[]).map((x) => ({
1354
+ emoji: x.emoji,
1355
+ by: x.actor as ReactionActor,
1356
+ at: x.created_at,
1357
+ }));
1358
+ }
1359
+
1360
+ /**
1361
+ * Oldest first: the newest `limit`, the `limit` just before `before`, or the `limit` just after `after`
1362
+ * (a chat that let its newest pages go reads its way back to them). An `after` not in this chat is a 404.
1363
+ */
1364
+ listMessages(botId: string, opts: { before?: string; after?: string; limit?: number } = {}): Message[] {
1365
+ this.getBot(botId);
1366
+ const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
1367
+ if (opts.before && opts.after) throw new HttpError(400, 'Pass before or after, not both');
1368
+ let rows: Row[];
1369
+ if (opts.after) {
1370
+ const ref = this.#get('select seq from messages where id = ? and bot_id = ?', opts.after, botId);
1371
+ if (!ref) throw new HttpError(404, `No message ${opts.after} in this chat`);
1372
+ return this.#withQuiet(botId, this.#all('select * from messages where bot_id = ? and seq > ? order by seq asc limit ?', botId, ref.seq, limit));
1373
+ }
1374
+ if (opts.before) {
1375
+ const ref = this.#get('select seq from messages where id = ?', opts.before);
1376
+ rows = this.#all('select * from messages where bot_id = ? and seq < ? order by seq desc limit ?', botId, ref?.seq ?? 0, limit);
1377
+ } else {
1378
+ rows = this.#all('select * from messages where bot_id = ? order by seq desc limit ?', botId, limit);
1379
+ }
1380
+ return this.#withQuiet(botId, rows.reverse());
1381
+ }
1382
+
1383
+ /** Rows of one chat, oldest first, as messages, with what the transcript leaves out marked quiet (see #quietOf). */
1384
+ #withQuiet(botId: string, rows: Row[]): Message[] {
1385
+ const messages = rows.map((r) => this.#toMessage(r));
1386
+ const quiet = this.#quietOf(botId, rows, messages);
1387
+ return quiet.size ? messages.map((m) => (quiet.has(m.id) && !m.meta?.quiet ? { ...m, meta: { ...m.meta, quiet: true, ...(quiet.get(m.id) ? { quietTurn: quiet.get(m.id)! } : {}) } } : m)) : messages;
1388
+ }
1389
+
1390
+ /**
1391
+ * Which of these messages (one chat's, oldest first) the transcript leaves out, each with its quiet turn when known.
1392
+ * Marked ones as they are. Chat history from before the server marked them reads the same from the turn records,
1393
+ * whatever page the messages come in (nothing is written):
1394
+ * - a wake-up a quiet turn took: the turn is linked to the first message it took (a turn that took one of the owner's
1395
+ * never ends quiet). The server marks exactly the ones it took, so a turn whose first message is marked is never
1396
+ * read from times. An unmarked one took that first message and some of those after it up to when it started: it
1397
+ * read them a little before it started (attachments, connectors), so one that came in between went to the next
1398
+ * turn. Later ones count only when the next chat turn is quiet too and starts after them; processed state
1399
+ * alone cannot prove a quiet reply, since preparation failures may not create a turn record;
1400
+ * - a posted [quiet] reply to wake-ups only, with the wake-ups it answered, and a routine run's with its run chip;
1401
+ * - what a quiet turn did in the chat (TURN_ACTIVITY): only by the turn it is tagged with. An untagged one (written
1402
+ * before events said which turn did them) always shows: the owner can do the same (save a memory, change a
1403
+ * routine) while a turn runs, and nothing recorded tells the two apart, so it is never guessed from its time.
1404
+ * Only messages mayBeQuiet allows: never the owner's, an error, an approval or a card.
1405
+ */
1406
+ #quietOf(botId: string, rows: Row[], messages: Message[]): Map<string, string | null> {
1407
+ const out = new Map<string, string | null>();
1408
+ if (!rows.length) return out;
1409
+ const seqOf = new Map(rows.map((r, i) => [messages[i].id, Number(r.seq)]));
1410
+ const minAt = rows[0].created_at as string;
1411
+ const maxSeq = Number(rows.at(-1)!.seq);
1412
+ for (const m of messages) if (m.meta?.quiet && mayBeQuiet(m)) out.set(m.id, m.meta.quietTurn ?? null);
1413
+ const open = messages.filter((m) => !out.has(m.id) && mayBeQuiet(m));
1414
+ if (!open.length) return out;
1415
+
1416
+ // Quiet chat turns that could have taken one of these: started from a little before the page (a turn's longest
1417
+ // run) on, with their first message at or before the page's end (a turn may start long after its messages: a
1418
+ // paused Boton).
1419
+ const slack = new Date(Date.parse(minAt) - this.#cfg.turnTimeoutMs - 60_000).toISOString();
1420
+ const chatTurns = this.#all(
1421
+ `select t.rowid as n, t.id, t.started_at, a.id as a_id, a.seq as a_seq, a.meta as a_meta from turns t join messages a on a.id = t.message_id
1422
+ where t.bot_id = ? and t.started_at >= ? and a.seq <= ? and a.role = 'user'
1423
+ and exists (select 1 from turn_steps s where s.turn_id = t.id and s.label = 'Quiet reply')`,
1424
+ botId,
1425
+ slack,
1426
+ maxSeq,
1427
+ ).filter((t) => {
1428
+ const meta = t.a_meta ? parseJson<MessageMeta | null>(t.a_meta, null) : null;
1429
+ // Marked by the server: exactly what it took is marked, nothing to read from times.
1430
+ return isAutomaticWake({ role: 'user', meta }) && !meta?.quiet;
1431
+ });
1432
+ // The chat turn after each (by when it started): the quiet one's anchor, if it is quiet.
1433
+ const nextOf = new Map<string, { anchorSeq: number | null } | null>();
1434
+ const nextTurn = (t: Row) => {
1435
+ const id = String(t.id);
1436
+ if (!nextOf.has(id)) {
1437
+ const n = this.#get(
1438
+ `select t.id, case when exists (select 1 from turn_steps s where s.turn_id = t.id and s.label = 'Quiet reply') then a.seq end as a_seq
1439
+ from turns t left join messages a on a.id = t.message_id and a.role = 'user'
1440
+ where t.bot_id = ? and t.trigger = 'chat' and (t.started_at > ? or (t.started_at = ? and t.rowid > ?)) order by t.started_at, t.rowid limit 1`,
1441
+ botId,
1442
+ t.started_at,
1443
+ t.started_at,
1444
+ t.n,
1445
+ );
1446
+ nextOf.set(id, n ? { anchorSeq: n.a_seq == null ? null : Number(n.a_seq) } : null);
1447
+ }
1448
+ return nextOf.get(id)!;
1449
+ };
1450
+ for (const m of messages) {
1451
+ if (m.role !== 'user' || out.has(m.id) || !mayBeQuiet(m)) continue;
1452
+ const seq = seqOf.get(m.id)!;
1453
+ const t = chatTurns.find((t) => Number(t.a_seq) <= seq && m.createdAt <= t.started_at);
1454
+ if (!t) continue;
1455
+ if (t.a_id !== m.id) {
1456
+ const next = nextTurn(t);
1457
+ const taken = next !== null && next.anchorSeq !== null && next.anchorSeq > seq;
1458
+ if (!taken) continue;
1459
+ }
1460
+ out.set(m.id, String(t.id));
1461
+ }
1462
+
1463
+ // Posted [quiet] replies: in the page, and the reply after each wake-up left whose reply isn't (on another page).
1464
+ const replies = messages.filter((m) => m.role === 'bot' && !out.has(m.id) && parseQuiet(m.text) !== null);
1465
+ const seqs = new Map(seqOf);
1466
+ const looked = new Set<number>();
1467
+ // Wake-ups with a reply or error after them in the page need no look further.
1468
+ const answeredHere = new Set<string>();
1469
+ let replied = false;
1470
+ for (let i = messages.length - 1; i >= 0; i--) {
1471
+ if (messages[i].role === 'bot' || messages[i].role === 'error') replied = true;
1472
+ else if (replied) answeredHere.add(messages[i].id);
1473
+ }
1474
+ for (const m of open) {
1475
+ if (m.role !== 'user' || out.has(m.id) || answeredHere.has(m.id)) continue;
1476
+ const next = this.#get("select * from messages where bot_id = ? and seq > ? and role in ('bot', 'error') order by seq limit 1", botId, seqOf.get(m.id)!);
1477
+ if (!next || seqOf.has(next.id) || looked.has(Number(next.seq))) continue;
1478
+ looked.add(Number(next.seq));
1479
+ const reply = this.#toMessage(next);
1480
+ if (reply.role === 'bot' && parseQuiet(reply.text) !== null) {
1481
+ replies.push(reply);
1482
+ seqs.set(reply.id, Number(next.seq));
1483
+ }
1484
+ }
1485
+ for (const r of replies) {
1486
+ if (r.meta?.routineId) {
1487
+ out.set(r.id, r.meta.turnId ?? null);
1488
+ continue;
1489
+ }
1490
+ const seq = seqs.get(r.id)!;
1491
+ const answered = this.#all(
1492
+ `select * from messages where bot_id = ? and role = 'user' and seq < ?
1493
+ and seq > coalesce((select max(seq) from messages where bot_id = ? and seq < ? and role in ('bot', 'error')), 0)`,
1494
+ botId,
1495
+ seq,
1496
+ botId,
1497
+ seq,
1498
+ ).map((x) => this.#toMessage(x));
1499
+ if (!answered.length || !answered.every(isAutomaticWake)) continue;
1500
+ out.set(r.id, r.meta?.turnId ?? null);
1501
+ for (const w of answered) out.set(w.id, r.meta?.turnId ?? null);
1502
+ }
1503
+ // A routine run's chip goes with its run's quiet reply, in this page or another.
1504
+ const chips = messages.filter((m) => m.role === 'event' && m.meta?.kind === 'routine.run' && m.meta.runId && !out.has(m.id));
1505
+ if (chips.length) {
1506
+ const runs = new Set(
1507
+ this.#all(
1508
+ `select text, json_extract(${MESSAGE_META}, '$.runId') as run_id from messages where bot_id = ? and role = 'bot'
1509
+ and json_extract(${MESSAGE_META}, '$.runId') in (${chips.map(() => '?').join(', ')})`,
1510
+ botId,
1511
+ ...chips.map((c) => c.meta!.runId!),
1512
+ )
1513
+ .filter((x) => parseQuiet(String(x.text)) !== null)
1514
+ .map((x) => String(x.run_id)),
1515
+ );
1516
+ for (const c of chips) if (runs.has(c.meta!.runId!)) out.set(c.id, null);
1517
+ }
1518
+
1519
+ // What a quiet turn did in the chat.
1520
+ const activity = open.filter((m) => m.role === 'event' && TURN_ACTIVITY.has(m.meta?.kind ?? '') && !out.has(m.id));
1521
+ if (activity.length) {
1522
+ const tagged = activity.flatMap((m) => (m.meta?.byTurn ? [m.meta.byTurn] : []));
1523
+ const quietTagged = new Set(
1524
+ tagged.length
1525
+ ? this.#all(
1526
+ `select t.id from turns t where t.id in (${tagged.map(() => '?').join(', ')})
1527
+ and (exists (select 1 from turn_steps s where s.turn_id = t.id and s.label = 'Quiet reply') or exists (select 1 from routine_runs r where r.id = t.run_id and r.quiet = 1))`,
1528
+ ...tagged,
1529
+ ).map((x) => String(x.id))
1530
+ : [],
1531
+ );
1532
+ // Tagged as no turn's (the owner, the server), or untagged (no record of who did it): always shown.
1533
+ for (const m of activity) if (m.meta?.byTurn && quietTagged.has(m.meta.byTurn)) out.set(m.id, m.meta.byTurn);
1534
+ }
1535
+ return out;
1536
+ }
1537
+
1538
+ /**
1539
+ * A turn that ended [quiet]: the wake-ups it answered (the owner's messages never) and what it did in the chat are
1540
+ * marked, so the transcript leaves them out.
1541
+ */
1542
+ markQuiet(turnId: string | null, ids: string[]): void {
1543
+ const bots = new Set<string>();
1544
+ const mark = (r: Row) => {
1545
+ this.#run(
1546
+ `update messages set meta = json_set(coalesce(${MESSAGE_META}, '{}'), '$.quiet', json('true')${turnId ? ", '$.quietTurn', ?" : ''}) where id = ?`,
1547
+ ...(turnId ? [turnId] : []),
1548
+ r.id,
1549
+ );
1550
+ this.#publish({ type: 'message.updated', message: this.findMessage(r.id)! });
1551
+ bots.add(r.bot_id);
1552
+ };
1553
+ for (const id of ids) {
1554
+ const r = this.#get('select * from messages where id = ?', id);
1555
+ if (r && isAutomaticWake(this.#toMessage(r))) mark(r);
1556
+ }
1557
+ if (turnId) {
1558
+ for (const r of this.#all(`select * from messages where role = 'event' and json_extract(${MESSAGE_META}, '$.byTurn') = ?`, turnId)) {
1559
+ if (TURN_ACTIVITY.has(this.#toMessage(r).meta?.kind ?? '')) mark(r);
1560
+ }
1561
+ }
1562
+ for (const b of bots) this.emitBot(b);
1563
+ }
1564
+
1565
+ /**
1566
+ * What a Boton did in the background lately: its routine turns (scheduled, manual, webhook, test) and the wake-ups
1567
+ * that started a chat turn (connector updates, other Botons, follow-ups). The last hour's count and how many ended
1568
+ * quiet, when the latest of them all was, and the last day's newest 100 as a list (each with its quiet note or its
1569
+ * reply's turn), for the chat's background line and what it opens. At most 500 of each kind a day are counted.
1570
+ */
1571
+ backgroundActivity(botId: string, now = Date.now()): BackgroundActivity {
1572
+ this.getBot(botId);
1573
+ const hour = new Date(now - 3_600_000).toISOString();
1574
+ const day = new Date(now - 86_400_000).toISOString();
1575
+ const items: BackgroundItem[] = [];
1576
+ const note = (turnId: string | null): string =>
1577
+ turnId ? ((this.#get("select detail from turn_steps where turn_id = ? and label = 'Quiet reply' order by idx desc limit 1", turnId)?.detail as string | undefined) ?? '') : '';
1578
+ for (const t of this.#all(
1579
+ `select t.id, t.started_at, t.outcome, t.trigger, t.run_id, t.routine_id, t.step_count, r.quiet, r.reply, r.attempts, r.trigger as run_trigger, ro.name
1580
+ from turns t left join routine_runs r on r.id = t.run_id left join routines ro on ro.id = t.routine_id
1581
+ where t.bot_id = ? and t.started_at >= ? and t.trigger != 'chat' order by t.started_at desc, t.rowid desc limit 500`,
1582
+ botId,
1583
+ day,
1584
+ )) {
1585
+ const quiet = Number(t.quiet) === 1;
1586
+ const label = routineLabel({ routineName: (t.name as string | null) ?? undefined, trigger: (t.run_trigger ?? t.trigger) as RunTrigger, attempt: Number(t.attempts ?? 1) });
1587
+ items.push({
1588
+ at: t.started_at,
1589
+ label: label.text,
1590
+ detail: t.run_id ? `Run ${t.run_id}` : label.text,
1591
+ quiet,
1592
+ note: quiet ? ((t.reply as string | null) ?? note(String(t.id))) : null,
1593
+ outcome: t.outcome,
1594
+ stepsMessageId: null,
1595
+ steps: Number(t.step_count ?? 0),
1596
+ runId: t.run_id ?? null,
1597
+ routineId: t.routine_id ?? null,
1598
+ warn: t.outcome === 'error',
1599
+ });
1600
+ }
1601
+ const turns = items.length;
1602
+ // Wake-ups, newest first: the last day's, read as the chat reads them (quiet or not), and the latest of any day.
1603
+ const rows: Row[] = [];
1604
+ let lastWake: string | null = null;
1605
+ let looked = 0;
1606
+ for (const r of this.db.prepare(`select * from messages where bot_id = ? and role = 'user' order by seq desc`).iterate(botId) as Iterable<Row>) {
1607
+ // A long chat of the owner's: enough rows looked at.
1608
+ if (++looked > 5000) break;
1609
+ if (r.created_at < day && lastWake) break;
1610
+ const m = this.#toMessage(r);
1611
+ if (!isAutomaticWake(m)) continue;
1612
+ lastWake ??= r.created_at as string;
1613
+ if (r.created_at < day) break;
1614
+ if (rows.length < 500) rows.push(r);
1615
+ }
1616
+ rows.reverse();
1617
+ const quiet = this.#quietOf(botId, rows, rows.map((r) => this.#toMessage(r)));
1618
+ for (const r of rows) {
1619
+ const m = this.#toMessage(r);
1620
+ const label = wakeLabel(m);
1621
+ const isQuiet = quiet.has(m.id);
1622
+ let turnId = isQuiet ? quiet.get(m.id)! : null;
1623
+ let stepsMessageId: string | null = null;
1624
+ let outcome: string | null = null;
1625
+ let steps = 0;
1626
+ if (isQuiet) {
1627
+ const t = turnId ? this.#get('select message_id, step_count from turns where id = ?', turnId) : null;
1628
+ stepsMessageId = (t?.message_id as string | null | undefined) ?? null;
1629
+ steps = Number(t?.step_count ?? 0);
1630
+ outcome = 'ok';
1631
+ } else {
1632
+ // Its reply: the first reply or error after it.
1633
+ const reply = this.#get("select * from messages where bot_id = ? and seq > ? and role in ('bot', 'error') order by seq limit 1", botId, r.seq);
1634
+ const rm = reply ? this.#toMessage(reply) : null;
1635
+ turnId = rm?.meta?.turnId ?? null;
1636
+ stepsMessageId = rm?.meta?.turnId ? rm.id : null;
1637
+ steps = rm?.meta?.steps ?? 0;
1638
+ outcome = rm ? (rm.role === 'error' ? 'error' : 'ok') : r.pending ? 'running' : null;
1639
+ }
1640
+ items.push({
1641
+ at: m.createdAt,
1642
+ label: label.text,
1643
+ detail: label.detail,
1644
+ quiet: isQuiet,
1645
+ note: isQuiet ? note(turnId) : null,
1646
+ outcome,
1647
+ stepsMessageId,
1648
+ steps,
1649
+ runId: null,
1650
+ routineId: null,
1651
+ warn: label.warn || outcome === 'error',
1652
+ });
1653
+ }
1654
+ // Newest first; at the same millisecond, wake-ups before routine turns, each the later one first.
1655
+ const order = new Map(items.map((item, i) => [item, i < turns ? -i : i]));
1656
+ items.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : order.get(b)! - order.get(a)!));
1657
+ const recent = items.slice(0, 100);
1658
+ const lastHour = items.filter((i) => i.at >= hour);
1659
+ const lastRun = this.#get("select max(started_at) as at from turns where bot_id = ? and trigger != 'chat'", botId)?.at as string | null;
1660
+ const lastAt = [lastRun, lastWake].filter((x): x is string => !!x).sort().at(-1) ?? null;
1661
+ return { lastHour: lastHour.length, quietLastHour: lastHour.filter((i) => i.quiet).length, lastAt, recent };
1662
+ }
1663
+
1664
+ addMessage(botId: string, role: MessageRole, text: string, meta: MessageMeta | null = null, pending = false): Message {
1665
+ // What a Boton does in the chat says which turn did it (null: the owner or the server), so a quiet turn's goes with it.
1666
+ if (role === 'event' && meta?.kind && TURN_ACTIVITY.has(meta.kind) && meta.byTurn === undefined) meta = { ...meta, byTurn: actingTurn(botId) };
1667
+ const id = newId('msg', 10);
1668
+ const now = nowIso();
1669
+ this.#run(
1670
+ 'insert into messages (id, bot_id, role, text, meta, pending, created_at) values (?, ?, ?, ?, ?, ?, ?)',
1671
+ id,
1672
+ botId,
1673
+ role,
1674
+ text,
1675
+ meta ? JSON.stringify(meta) : null,
1676
+ pending ? 1 : 0,
1677
+ now,
1678
+ );
1679
+ this.#run('update bots set last_activity_at = ? where id = ?', now, botId);
1680
+ const message = this.#toMessage(this.#get('select * from messages where id = ?', id)!);
1681
+ this.#publish({ type: 'message.created', message });
1682
+ if (role !== 'event') this.emitBot(botId);
1683
+ return message;
1684
+ }
1685
+
1686
+ findMessage(id: string): Message | null {
1687
+ const r = this.#get('select * from messages where id = ?', id);
1688
+ return r ? this.#toMessage(r) : null;
1689
+ }
1690
+
1691
+ /**
1692
+ * A posted card's new state, only if it is still pending: the one place a card changes, so an answer counts once
1693
+ * however many arrive together. Returns the message as it is now, or null (it was not pending any more).
1694
+ */
1695
+ closeCard(id: string, card: TranscriptCard): Message | null {
1696
+ const changed = this.#run(
1697
+ `update messages set meta = json_set(meta, '$.card', json(?)) where id = ? and role = 'event'
1698
+ and json_extract(meta, '$.kind') = 'card.posted' and json_extract(meta, '$.card.status') = 'pending'`,
1699
+ JSON.stringify(card),
1700
+ id,
1701
+ ).changes;
1702
+ if (!Number(changed)) return null;
1703
+ const message = this.findMessage(id)!;
1704
+ this.#publish({ type: 'message.updated', message });
1705
+ return message;
1706
+ }
1707
+
1708
+ /**
1709
+ * A browser handoff's new record (shared/handoff.ts), only if it is still in state `from`: the one place a handoff
1710
+ * changes, so two takes or two hand-backs arriving together count once. Returns the message as it is now, or null.
1711
+ */
1712
+ setHandoff(id: string, from: BrowserHandoff['state'], next: BrowserHandoff): Message | null {
1713
+ const changed = this.#run(
1714
+ `update messages set meta = json_set(meta, '$.browserHandoff', json(?)) where id = ? and role = 'event'
1715
+ and json_extract(meta, '$.kind') = 'browser.handoff' and json_extract(meta, '$.browserHandoff.state') = ?`,
1716
+ JSON.stringify(next),
1717
+ id,
1718
+ from,
1719
+ ).changes;
1720
+ if (!Number(changed)) return null;
1721
+ const message = this.findMessage(id)!;
1722
+ this.#publish({ type: 'message.updated', message });
1723
+ return message;
1724
+ }
1725
+
1726
+ /** A Boton's browser handoffs still requested or taken, newest first (`botId` null: every Boton's). Requests past their expiry are closed as they are found, so this stays short. */
1727
+ openHandoffs(botId: string | null): Message[] {
1728
+ return this.#all(
1729
+ `select * from messages where (? is null or bot_id = ?) and role = 'event' and json_extract(meta, '$.kind') = 'browser.handoff'
1730
+ and json_extract(meta, '$.browserHandoff.state') in ('requested', 'taken') order by seq desc`,
1731
+ botId,
1732
+ botId,
1733
+ ).map((r) => this.#toMessage(r));
1734
+ }
1735
+
1736
+ /** A Boton's cards still pending and not yet past their expiry. */
1737
+ pendingCards(botId: string, now = nowIso()): number {
1738
+ return Number(
1739
+ this.#get(
1740
+ `select count(*) as n from messages where bot_id = ? and role = 'event' and json_extract(meta, '$.kind') = 'card.posted'
1741
+ and json_extract(meta, '$.card.status') = 'pending' and json_extract(meta, '$.card.expiresAt') > ?`,
1742
+ botId,
1743
+ now,
1744
+ )!.n,
1745
+ );
1746
+ }
1747
+
1748
+ /** A pending card of this Boton's with exactly this content, if there is one (a retry posts nothing new). */
1749
+ samePendingCard(botId: string, descriptor: string, now = nowIso()): Message | null {
1750
+ const r = this.#get(
1751
+ `select * from messages where bot_id = ? and role = 'event' and json_extract(meta, '$.kind') = 'card.posted'
1752
+ and json_extract(meta, '$.card.status') = 'pending' and json_extract(meta, '$.card.expiresAt') > ?
1753
+ and json(json_extract(meta, '$.card.descriptor')) = json(?) order by seq desc limit 1`,
1754
+ botId,
1755
+ now,
1756
+ descriptor,
1757
+ );
1758
+ return r ? this.#toMessage(r) : null;
1759
+ }
1760
+
1761
+ // A user message's `pending`: 1 waiting for a reply, 2 in flight (a 2 found at startup was cut off), 0 answered.
1762
+
1763
+ /**
1764
+ * The pending messages one chat turn answers: the oldest and those after it with the same audience, the one its reply
1765
+ * goes to (the owner, or the colleague whose ask it is). Never two audiences, since one reply is sent to each asker.
1766
+ */
1767
+ startChatTurn(botId: string): { messages: Message[]; interrupted: boolean; more: boolean } {
1768
+ const all = this.#all("select * from messages where bot_id = ? and role = 'user' and pending in (1, 2) order by seq", botId);
1769
+ const audience = (r: Row) => {
1770
+ const meta = r.meta ? parseJson<MessageMeta | null>(r.meta, null) : null;
1771
+ return meta?.kind === 'ask' ? `ask:${meta.fromBotId ?? ''}` : 'owner';
1772
+ };
1773
+ const first = all[0] ? audience(all[0]) : null;
1774
+ const end = all.findIndex((r) => audience(r) !== first);
1775
+ const rows = end === -1 ? all : all.slice(0, end);
1776
+ const interrupted = rows.some((r) => r.pending === 2);
1777
+ for (const r of rows) if (r.pending === 1) this.#run('update messages set pending = 2 where id = ?', r.id);
1778
+ return { messages: rows.map((r) => this.#toMessage(r)), interrupted, more: rows.length < all.length };
1779
+ }
1780
+
1781
+ /** One transaction, so a crash cannot leave the reply without its messages marked answered, or the reverse. */
1782
+ finishChatTurn(ids: string[], botId: string, text: string, meta: MessageMeta | null): Message {
1783
+ return this.atomically(() => {
1784
+ const reply = this.addMessage(botId, 'bot', text, meta);
1785
+ this.markProcessed(ids);
1786
+ return reply;
1787
+ });
1788
+ }
1789
+
1790
+ pendingChats(): { botId: string; since: string }[] {
1791
+ return this.#all("select bot_id, min(created_at) as since from messages where role = 'user' and pending in (1, 2) group by bot_id").map((r) => ({
1792
+ botId: r.bot_id as string,
1793
+ since: r.since as string,
1794
+ }));
1795
+ }
1796
+
1797
+ markProcessed(ids: string[]): void {
1798
+ for (const id of ids) this.#run('update messages set pending = 0 where id = ?', id);
1799
+ }
1800
+
1801
+ /** Messages of a turn that ended without answering them: waiting again (not in flight), for the next turn. */
1802
+ requeueChat(ids: string[]): void {
1803
+ for (const id of ids) this.#run("update messages set pending = 1 where id = ? and role = 'user' and pending = 2", id);
1804
+ }
1805
+
1806
+ hasPendingChat(botId: string): boolean {
1807
+ return !!this.#get("select 1 from messages where bot_id = ? and role = 'user' and pending in (1, 2) limit 1", botId);
1808
+ }
1809
+
1810
+ dropPendingChat(botId: string): number {
1811
+ return Number(this.#run("update messages set pending = 0 where bot_id = ? and role = 'user' and pending in (1, 2)", botId).changes);
1812
+ }
1813
+
1814
+ /**
1815
+ * What a new session may be given of the chat since the reset, newest first: the chat and, apart, routine messages
1816
+ * (so a run of busy routines can't push the owner's out), each read a page at a time only as far as sessionStart's
1817
+ * budget takes it, and the owner's pins (see setMessagePinned). Never a message still waiting for its reply: it is
1818
+ * the input of a turn, and one waiting for another audience's turn (the owner's, or another colleague's ask) is not
1819
+ * this turn's to see.
1820
+ */
1821
+ replaySource(botId: string, since: string | null, excludeIds: string[]): { chat: Iterable<Replayable>; routines: Iterable<Replayable>; pins: Replayable[] } {
1822
+ const waiting = this.#all("select id from messages where bot_id = ? and role = 'user' and pending in (1, 2)", botId).map((r) => r.id as string);
1823
+ const exclude = new Set([...excludeIds, ...waiting]);
1824
+ const store = this;
1825
+ const after = this.#resetFloor(botId, since);
1826
+ const newest = (where: string): Iterable<Replayable> => ({
1827
+ *[Symbol.iterator]() {
1828
+ let before = Number.MAX_SAFE_INTEGER;
1829
+ for (;;) {
1830
+ const rows = store.#replayRows(botId, since, after, where, before, REPLAY_PAGE);
1831
+ for (const r of rows) if (!exclude.has(r.id)) yield { message: store.#toMessage(r), seq: Number(r.seq) };
1832
+ if (rows.length < REPLAY_PAGE) return;
1833
+ before = Number(rows.at(-1)!.seq);
1834
+ }
1835
+ },
1836
+ });
1837
+ return {
1838
+ chat: newest(CHAT_MESSAGE),
1839
+ routines: newest(ROUTINE_MESSAGE),
1840
+ pins: this.#replayRows(botId, since, after, PINNED_SQL, Number.MAX_SAFE_INTEGER, PIN_LIMIT + exclude.size)
1841
+ .filter((r) => !exclude.has(r.id))
1842
+ .slice(0, PIN_LIMIT)
1843
+ .map((r) => ({ message: this.#toMessage(r), seq: Number(r.seq) })),
1844
+ };
1845
+ }
1846
+
1847
+ /**
1848
+ * The seq every message since the reset at `since` is after, found on messages_reset: resetContext adds its event
1849
+ * right after it sets the time, in one transaction, so every message before that event was written before it.
1850
+ * Reading only after it, the reads below stop at the reset instead of reading the whole chat before it to find
1851
+ * nothing newer. The `created_at > since` they keep decides as it always has; this only ends the read sooner. 0
1852
+ * (read from the start) with no reset, or none found for it.
1853
+ */
1854
+ #resetFloor(botId: string, since: string | null): number {
1855
+ if (!since) return 0;
1856
+ const r = this.#get(`select seq from messages where bot_id = ? and created_at >= ? and ${RESET_EVENT} order by created_at, seq limit 1`, botId, since);
1857
+ return r ? Number(r.seq) - 1 : 0;
1858
+ }
1859
+
1860
+ /**
1861
+ * One page of a replay's messages after `after` and older than `before`, newest first, on the index whose
1862
+ * condition `where` is (messages_chat, messages_routine or messages_pinned).
1863
+ */
1864
+ #replayRows(botId: string, since: string | null, after: number, where: string, before: number, n: number): Row[] {
1865
+ return this.#all(
1866
+ `select * from messages where bot_id = ? and seq < ? and seq > ? and created_at > ?
1867
+ and ${NOT_STALE_SQL}
1868
+ and ${where}
1869
+ order by seq desc limit ?`,
1870
+ botId,
1871
+ before,
1872
+ after,
1873
+ since ?? '',
1874
+ n,
1875
+ );
1876
+ }
1877
+
1878
+ /**
1879
+ * The replayable messages of this chat since the reset, oldest first: pins are only these. setMessagePinned keeps
1880
+ * them to PIN_LIMIT; one more is read, so a count past it would show rather than hide.
1881
+ */
1882
+ pinnedMessages(botId: string): Message[] {
1883
+ const since = this.contextSince(botId);
1884
+ return this.#replayRows(botId, since, this.#resetFloor(botId, since), PINNED_SQL, Number.MAX_SAFE_INTEGER, PIN_LIMIT + 1)
1885
+ .reverse()
1886
+ .map((r) => this.#toMessage(r));
1887
+ }
1888
+
1889
+ /**
1890
+ * Pins one of the owner's or the Boton's messages, so every new session is given it whatever its age (a long one
1891
+ * as an excerpt, its start and end), or unpins it. Only the owner's route calls this: the pin is the server's mark
1892
+ * in the message's meta, never anything a message's text or a Boton can set. A chat holds at most PIN_LIMIT pins
1893
+ * since its reset, which the replay budget always has room for: one more is refused, not silently dropped later.
1894
+ */
1895
+ setMessagePinned(botId: string, messageId: string, pinned: boolean): Message {
1896
+ return this.atomically(() => {
1897
+ const r = this.#get('select * from messages where id = ? and bot_id = ?', messageId, botId);
1898
+ if (!r) throw new HttpError(404, `No message ${messageId} in this chat`);
1899
+ const m = this.#toMessage(r);
1900
+ // Checked before the pin already there counts: a mark no session is given is not a pin to confirm.
1901
+ if (pinned) {
1902
+ if (m.role !== 'user' && m.role !== 'bot') throw new HttpError(400, 'Only messages can be pinned, not events or errors');
1903
+ const since = this.contextSince(botId);
1904
+ if (Number(r.seq) <= this.#resetFloor(botId, since) || m.createdAt <= (since ?? '') || m.meta?.staleContext) {
1905
+ throw new HttpError(400, 'This message is from before the chat was started fresh, so no session is given it: send it again to keep it');
1906
+ }
1907
+ }
1908
+ if (!!m.meta?.pinned === pinned) return m;
1909
+ if (pinned && this.pinnedMessages(botId).length >= PIN_LIMIT) {
1910
+ throw new HttpError(409, `${PIN_LIMIT} messages are pinned already, as many as a new session has room for: unpin one first`);
1911
+ }
1912
+ if (pinned) this.#run('update messages set meta = ? where id = ?', JSON.stringify({ ...m.meta, pinned: { at: nowIso() } }), messageId);
1913
+ const updated = pinned ? this.#toMessage(this.#get('select * from messages where id = ?', messageId)!) : this.#unpin(r);
1914
+ this.#publish({ type: 'message.updated', message: updated });
1915
+ return updated;
1916
+ });
1917
+ }
1918
+
1919
+
1920
+
1921
+ // ---------------------------------------------------------------- routines
1922
+
1923
+ #toRoutine(r: Row): Routine {
1924
+ return {
1925
+ id: r.id,
1926
+ botId: r.bot_id,
1927
+ name: r.name,
1928
+ instruction: r.instruction,
1929
+ schedule: r.schedule ?? null,
1930
+ scheduleText: r.schedule ? describeSchedule(r.schedule, r.timezone) : null,
1931
+ timezone: r.timezone,
1932
+ webhook: r.webhook_key
1933
+ ? {
1934
+ url: `${this.#cfg.hooks.publicUrl ?? this.#cfg.publicUrl}/hooks/routines/${r.id}`,
1935
+ key: r.webhook_key,
1936
+ source: r.webhook_source as WebhookSource,
1937
+ hasSecret: !!r.webhook_secret,
1938
+ filter: r.webhook_filter ? (JSON.parse(r.webhook_filter) as WebhookFilter) : null,
1939
+ callbackUrl: r.callback_url ?? null,
1940
+ }
1941
+ : null,
1942
+ enabled: !!r.enabled,
1943
+ nextRunAt: r.enabled ? (r.next_run_at ?? null) : null,
1944
+ lastRunAt: r.last_run_at ?? null,
1945
+ lastStatus: (r.last_status as RunStatus) ?? null,
1946
+ notify: r.notify as NotifyPolicy,
1947
+ webhookWorkspace: r.webhook_workspace !== 0,
1948
+ skill: r.skill ?? null,
1949
+ model: r.model ?? null,
1950
+ effort: r.effort ?? null,
1951
+ session: r.session === 'own' ? 'own' : 'chat',
1952
+ webhookHistory: Number(r.webhook_history ?? 0),
1953
+ timeLimitMs: r.time_limit_ms ?? null,
1954
+ createdAt: r.created_at,
1955
+ updatedAt: r.updated_at,
1956
+ };
1957
+ }
1958
+
1959
+ listRoutines(botId: string): Routine[] {
1960
+ this.getBot(botId);
1961
+ return this.#all('select * from routines where bot_id = ? order by created_at', botId).map((r) => this.#toRoutine(r));
1962
+ }
1963
+
1964
+ getRoutine(id: string): Routine {
1965
+ const r = this.#get('select * from routines where id = ?', id);
1966
+ if (!r) throw new HttpError(404, `No routine with id ${id}`);
1967
+ return this.#toRoutine(r);
1968
+ }
1969
+
1970
+ #emitRoutine(id: string): Routine {
1971
+ const routine = this.getRoutine(id);
1972
+ this.#publish({ type: 'routine.upserted', routine });
1973
+ return routine;
1974
+ }
1975
+
1976
+ #chip(routine: Routine, kind: MessageMeta['kind'], label: string) {
1977
+ this.addMessage(routine.botId, 'event', label, { kind, routineId: routine.id, routineName: routine.name });
1978
+ }
1979
+
1980
+ #normalizeSchedule(schedule: string | null | undefined, tz: string): string | null {
1981
+ if (schedule === undefined || schedule === null || schedule.trim() === '' || schedule.trim().toLowerCase() === 'none') return null;
1982
+ try {
1983
+ return toSpec(parseSchedule(schedule, tz));
1984
+ } catch (e) {
1985
+ throw new HttpError(400, (e as Error).message);
1986
+ }
1987
+ }
1988
+
1989
+ /** The API checks a callback URL's host (ssrf.ts) before this: that takes a DNS lookup. */
1990
+ #hookSettings(input: CreateRoutineInput | UpdateRoutineInput) {
1991
+ const out: { source?: WebhookSource; secret?: string | null; filter?: string | null; callbackUrl?: string | null } = {};
1992
+ if (input.callbackUrl !== undefined) {
1993
+ if (input.callbackUrl !== null && typeof input.callbackUrl !== 'string') throw new HttpError(400, 'callbackUrl must be a URL or null');
1994
+ const url = (input.callbackUrl ?? '').trim();
1995
+ if (url.length > 2000) throw new HttpError(400, 'callbackUrl is too long (max 2000 characters)');
1996
+ out.callbackUrl = url || null;
1997
+ }
1998
+ if (input.webhookSource !== undefined) {
1999
+ if (!isWebhookSource(input.webhookSource)) throw new HttpError(400, `webhookSource must be one of ${WEBHOOK_SOURCES.map((s) => `"${s}"`).join(', ')}`);
2000
+ out.source = input.webhookSource;
2001
+ }
2002
+ if (input.webhookSecret !== undefined) {
2003
+ if (input.webhookSecret !== null && typeof input.webhookSecret !== 'string') throw new HttpError(400, 'webhookSecret must be a string or null');
2004
+ const secret = (input.webhookSecret ?? '').trim();
2005
+ if (secret.length > 500) throw new HttpError(400, 'webhookSecret is too long (max 500 characters)');
2006
+ out.secret = secret || null;
2007
+ }
2008
+ if (input.webhookFilter !== undefined) {
2009
+ try {
2010
+ const filter = parseFilter(input.webhookFilter);
2011
+ out.filter = filter ? JSON.stringify(filter) : null;
2012
+ } catch (e) {
2013
+ throw new HttpError(400, (e as Error).message);
2014
+ }
2015
+ }
2016
+ return out;
2017
+ }
2018
+
2019
+ createRoutine(botId: string, input: CreateRoutineInput, opts: { chip?: boolean } = {}): Routine {
2020
+ const bot = this.getBot(botId);
2021
+ // Spent one-offs (their time has passed and they have no webhook) don't count.
2022
+ const count = this.#get(
2023
+ "select count(*) as n from routines where bot_id = ? and not (schedule is not null and schedule like 'once %' and substr(schedule, 6) <= ? and webhook_key is null)",
2024
+ botId,
2025
+ nowIso(),
2026
+ )!.n as number;
2027
+ if (count >= MAX_ROUTINES_PER_BOT) throw new HttpError(400, `A Boton can own at most ${MAX_ROUTINES_PER_BOT} routines`);
2028
+ const name = clean(input.name, 'name', 80, true);
2029
+ const instruction = clean(input.instruction, 'instruction', 20_000, true);
2030
+ const tz = input.timezone ?? bot.timezone;
2031
+ if (!isValidTimezone(tz)) throw new HttpError(400, `Unknown timezone ${tz}`);
2032
+ const schedule = this.#normalizeSchedule(input.schedule, tz);
2033
+ const notify = input.notify === undefined ? 'auto' : notifyPolicy(input.notify);
2034
+ const webhookWorkspace = flag(input.webhookWorkspace, 'webhookWorkspace', true);
2035
+ const hook = input.webhook ? this.#hookSettings(input) : {};
2036
+ if (!input.webhook && input.callbackUrl) throw new HttpError(400, 'A callbackUrl needs a webhook: its key signs the results');
2037
+ const skill = skillName(input.skill);
2038
+ const model = input.model === undefined ? null : routineModel(input.model);
2039
+ const effort = input.effort === undefined ? null : routineEffort(input.effort, bot.provider, this.effortCaps(bot.provider, model || bot.model));
2040
+ const inherited = effort === null && model ? inheritedEffortProblem(bot.provider, bot.effort, this.effortCaps(bot.provider, model)) : null;
2041
+ if (inherited) throw new HttpError(400, inherited);
2042
+ // A webhook's runs share the chat (what the owner said in it) only when asked to: their payloads come from outside.
2043
+ const session = input.session === undefined ? (input.webhook ? 'own' : 'chat') : routineSession(input.session);
2044
+ const history = input.webhookHistory === undefined ? 0 : webhookHistory(input.webhookHistory);
2045
+ const timeLimit = input.timeLimitMs === undefined ? null : timeLimitMs(input.timeLimitMs, this.#cfg.turnTimeoutMs);
2046
+ const id = newId('rt');
2047
+ const now = nowIso();
2048
+ const enabled = input.enabled === false ? 0 : 1;
2049
+ const next = schedule ? (nextRun(schedule, tz, new Date())?.toISOString() ?? null) : null;
2050
+ this.#run(
2051
+ `insert into routines (id, bot_id, name, instruction, schedule, timezone, webhook_key, enabled, next_run_at, notify, webhook_workspace,
2052
+ webhook_source, webhook_secret, webhook_filter, callback_url, skill, model, effort, session, webhook_history, time_limit_ms, created_at, updated_at)
2053
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2054
+ id,
2055
+ botId,
2056
+ name,
2057
+ instruction,
2058
+ schedule,
2059
+ tz,
2060
+ input.webhook ? newSecret() : null,
2061
+ enabled,
2062
+ next,
2063
+ notify,
2064
+ webhookWorkspace ? 1 : 0,
2065
+ hook.source ?? 'generic',
2066
+ hook.secret ?? null,
2067
+ hook.filter ?? null,
2068
+ hook.callbackUrl ?? null,
2069
+ skill,
2070
+ model,
2071
+ effort,
2072
+ session,
2073
+ history,
2074
+ timeLimit,
2075
+ now,
2076
+ now,
2077
+ );
2078
+ const routine = this.#emitRoutine(id);
2079
+ if (opts.chip !== false) this.#chip(routine, 'routine.created', 'Created routine');
2080
+ return routine;
2081
+ }
2082
+
2083
+ resetRoutineModels(botId: string): Routine[] {
2084
+ const had = this.listRoutines(botId).filter((r) => r.model);
2085
+ for (const r of had) {
2086
+ this.#run('update routines set model = null, updated_at = ? where id = ?', nowIso(), r.id);
2087
+ this.#emitRoutine(r.id);
2088
+ }
2089
+ return had;
2090
+ }
2091
+
2092
+ /**
2093
+ * Clears each routine's own effort that the Boton's provider, or the model the routine runs, doesn't have; returns
2094
+ * them as they were.
2095
+ */
2096
+ resetRoutineEfforts(botId: string): Routine[] {
2097
+ const { provider, model } = this.getBot(botId);
2098
+ const had = this.listRoutines(botId).filter((r) => r.effort && effortProblem(provider, r.effort, this.effortCaps(provider, r.model || model)));
2099
+ for (const r of had) {
2100
+ this.#run('update routines set effort = null, updated_at = ? where id = ?', nowIso(), r.id);
2101
+ this.#emitRoutine(r.id);
2102
+ }
2103
+ return had;
2104
+ }
2105
+
2106
+ /**
2107
+ * `opts.starterActivation` only from the owner's activation (starter-gate.ts), which has checked its test run or
2108
+ * been told to override: every other caller, whoever it is for, can't turn a starter's routine on.
2109
+ */
2110
+ updateRoutine(id: string, input: UpdateRoutineInput, opts: { starterActivation?: true } = {}): Routine {
2111
+ const before = this.getRoutine(id);
2112
+ const row = this.#get('select * from routines where id = ?', id)!;
2113
+ const sets: string[] = [];
2114
+ const vals: any[] = [];
2115
+ const set = (col: string, v: any) => {
2116
+ sets.push(`${col} = ?`);
2117
+ vals.push(v);
2118
+ };
2119
+ if (input.name !== undefined) set('name', clean(input.name, 'name', 80, true));
2120
+ if (input.instruction !== undefined) set('instruction', clean(input.instruction, 'instruction', 20_000, true));
2121
+ const tz = input.timezone ?? before.timezone;
2122
+ if (input.timezone !== undefined) {
2123
+ if (!isValidTimezone(tz)) throw new HttpError(400, `Unknown timezone ${tz}`);
2124
+ set('timezone', tz);
2125
+ }
2126
+ let schedule: string | null = row.schedule;
2127
+ if (input.schedule !== undefined) set('schedule', (schedule = this.#normalizeSchedule(input.schedule, tz)));
2128
+ // A retired one-off comes back with a new schedule, unless this update pauses it.
2129
+ const retired = !before.enabled && isPastOneOff(before.schedule);
2130
+ const enabled = input.enabled ?? (retired && input.schedule !== undefined ? true : before.enabled);
2131
+ const willHaveWebhook = input.webhook ?? !!row.webhook_key;
2132
+ if (enabled && !before.enabled && isPastOneOff(schedule) && !willHaveWebhook) {
2133
+ throw new HttpError(400, 'This one-off routine already ran (or its time passed). Give it a new time to run it again.');
2134
+ }
2135
+ // Any way it would come on (asked for, or a retired one-off given a new time) waits on the owner while it's a starter's.
2136
+ if (enabled && !before.enabled && !opts.starterActivation && this.starterPending(id)) {
2137
+ const owner = this.getSettings().ownerName;
2138
+ throw new HttpError(
2139
+ 409,
2140
+ `“${before.name}” came with a starter and waits on ${owner}: only ${owner} turns it on, from its setup check, after a test run of its current settings passes (or by overriding that).`,
2141
+ );
2142
+ }
2143
+ if (input.enabled !== undefined || enabled !== before.enabled) set('enabled', enabled ? 1 : 0);
2144
+ if (input.schedule !== undefined || input.timezone !== undefined || (enabled && !before.enabled)) {
2145
+ set('next_run_at', schedule ? (nextRun(schedule, tz, new Date())?.toISOString() ?? null) : null);
2146
+ }
2147
+ if (input.webhook === true && (!row.webhook_key || input.rotateKey)) set('webhook_key', newSecret());
2148
+ else if (input.webhook === undefined && input.rotateKey && row.webhook_key) set('webhook_key', newSecret());
2149
+ if (input.webhook === false) {
2150
+ set('webhook_key', null);
2151
+ set('webhook_source', 'generic');
2152
+ set('webhook_secret', null);
2153
+ set('webhook_filter', null);
2154
+ set('callback_url', null);
2155
+ } else {
2156
+ const hook = this.#hookSettings(input);
2157
+ if (hook.callbackUrl && !willHaveWebhook) throw new HttpError(400, 'A callbackUrl needs a webhook: its key signs the results');
2158
+ if (willHaveWebhook) {
2159
+ if (hook.source !== undefined) set('webhook_source', hook.source);
2160
+ if (hook.secret !== undefined) set('webhook_secret', hook.secret);
2161
+ if (hook.filter !== undefined) set('webhook_filter', hook.filter);
2162
+ if (hook.callbackUrl !== undefined) set('callback_url', hook.callbackUrl);
2163
+ }
2164
+ }
2165
+ if (input.notify !== undefined) set('notify', notifyPolicy(input.notify));
2166
+ if (input.webhookWorkspace !== undefined) set('webhook_workspace', flag(input.webhookWorkspace, 'webhookWorkspace') ? 1 : 0);
2167
+ if (input.skill !== undefined) set('skill', skillName(input.skill));
2168
+ if (input.model !== undefined || input.effort !== undefined) {
2169
+ const bot = this.getBot(before.botId);
2170
+ const model = input.model !== undefined ? routineModel(input.model) : before.model;
2171
+ let effort = input.effort !== undefined ? routineEffort(input.effort, bot.provider, null) : before.effort;
2172
+ // Checked against the model when either changes: the editor sends both back with every save, and a level that
2173
+ // stopped fitting after the Boton changed must not block other changes (its runs then ask for none; see turnEffort).
2174
+ if (model !== before.model || effort !== before.effort) {
2175
+ effort = routineEffort(effort, bot.provider, this.effortCaps(bot.provider, model || bot.model));
2176
+ const inherited = effort === null && model ? inheritedEffortProblem(bot.provider, bot.effort, this.effortCaps(bot.provider, model)) : null;
2177
+ if (inherited) throw new HttpError(400, inherited);
2178
+ }
2179
+ if (input.model !== undefined) set('model', model);
2180
+ if (input.effort !== undefined) set('effort', effort);
2181
+ }
2182
+ if (input.session !== undefined) {
2183
+ const session = routineSession(input.session);
2184
+ set('session', session);
2185
+ if (session !== before.session) {
2186
+ set('session_id', null);
2187
+ set('session_key', null);
2188
+ }
2189
+ }
2190
+ if (input.webhookHistory !== undefined) set('webhook_history', webhookHistory(input.webhookHistory));
2191
+ if (input.timeLimitMs !== undefined) set('time_limit_ms', timeLimitMs(input.timeLimitMs, this.#cfg.turnTimeoutMs));
2192
+ if (!sets.length) return before;
2193
+ set('updated_at', nowIso());
2194
+ this.#run(`update routines set ${sets.join(', ')} where id = ?`, ...vals, id);
2195
+ const routine = this.#emitRoutine(id);
2196
+
2197
+ const onlyToggle = Object.keys(input).every((k) => k === 'enabled');
2198
+ if (onlyToggle && input.enabled === false && before.enabled) this.#chip(routine, 'routine.paused', 'Paused routine');
2199
+ else if (onlyToggle && input.enabled === true && !before.enabled) this.#chip(routine, 'routine.resumed', 'Resumed routine');
2200
+ else if (!onlyToggle) this.#chip(routine, 'routine.updated', 'Updated routine');
2201
+ return routine;
2202
+ }
2203
+
2204
+ deleteRoutine(id: string): void {
2205
+ const routine = this.getRoutine(id);
2206
+ this.#failActiveRuns('routine_id', id, 'Routine was deleted');
2207
+ this.#run('delete from routines where id = ?', id);
2208
+ const starter = this.starterRecord(routine.botId);
2209
+ if (starter) this.saveStarterRecord(routine.botId, { ...starter, pending: starter.pending.filter((x) => x !== id), tests: starter.tests.filter((t) => t.routineId !== id) });
2210
+ this.#publish({ type: 'routine.deleted', routineId: id, botId: routine.botId });
2211
+ this.#chip(routine, 'routine.deleted', 'Deleted routine');
2212
+ }
2213
+
2214
+ // ----------------------------------------------------------- starters
2215
+ // A Boton installed from a starter: a settings row (starterKey), so it needs no table of its own. Written only by the
2216
+ // server (the install, the owner's activation, the engine noting a test run's settings), never from a request body.
2217
+
2218
+ /** What the server saved when `botId` was installed from a starter, or null. A row it can't read holds every routine. */
2219
+ starterRecord(botId: string): StarterRecord | null {
2220
+ const raw = this.#get('select value from settings where key = ?', starterKey(botId))?.value;
2221
+ if (raw === undefined) return null;
2222
+ const v = parseJson<Partial<StarterRecord> | null>(raw, null);
2223
+ const ok = v && typeof v.templateId === 'string' && Array.isArray(v.pending) && v.pending.every((x) => typeof x === 'string');
2224
+ if (!ok) {
2225
+ // Never on by accident: every routine of it waits on the owner, who can still override.
2226
+ const all = this.#all('select id from routines where bot_id = ?', botId).map((r) => String(r.id));
2227
+ return { templateId: typeof v?.templateId === 'string' ? v.templateId : '', templateName: '', version: 0, slots: {}, installedAt: '', pending: all, tests: [] };
2228
+ }
2229
+ return {
2230
+ templateId: v.templateId!,
2231
+ templateName: typeof v.templateName === 'string' ? v.templateName : v.templateId!,
2232
+ version: typeof v.version === 'number' ? v.version : 0,
2233
+ slots: v.slots && typeof v.slots === 'object' ? Object.fromEntries(Object.entries(v.slots).map(([k, x]) => [k, typeof x === 'string' ? x : null])) : {},
2234
+ installedAt: typeof v.installedAt === 'string' ? v.installedAt : '',
2235
+ pending: v.pending!,
2236
+ tests: Array.isArray(v.tests) ? v.tests.filter((t) => t && typeof t.runId === 'string' && typeof t.routineId === 'string') : [],
2237
+ };
2238
+ }
2239
+
2240
+ saveStarterRecord(botId: string, record: StarterRecord): void {
2241
+ this.#run('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value', starterKey(botId), JSON.stringify(record));
2242
+ }
2243
+
2244
+ /** Whether routine `id` came with a starter and still waits on the owner's decision to turn it on. */
2245
+ starterPending(id: string): boolean {
2246
+ const botId = this.routineOwner(id);
2247
+ return !!botId && !!this.starterRecord(botId)?.pending.includes(id);
2248
+ }
2249
+
2250
+ /**
2251
+ * The key the starter checks' hashes are made with (starter-gate.ts), made on first use and kept with the data: so a
2252
+ * hash a client sees says nothing about the secrets folded into it.
2253
+ */
2254
+ starterSecret(): string {
2255
+ const have = this.#get("select value from settings where key = 'starterSecret'")?.value;
2256
+ if (typeof have === 'string' && have) return have;
2257
+ this.#run("insert into settings(key, value) values ('starterSecret', ?) on conflict(key) do nothing", newSecret());
2258
+ return String(this.#get("select value from settings where key = 'starterSecret'")!.value);
2259
+ }
2260
+
2261
+ dueRoutines(now: Date): Routine[] {
2262
+ return this.#all(
2263
+ 'select * from routines where enabled = 1 and next_run_at is not null and next_run_at <= ? order by next_run_at',
2264
+ now.toISOString(),
2265
+ ).map((r) => this.#toRoutine(r));
2266
+ }
2267
+
2268
+ setNextRun(id: string, next: Date | null): void {
2269
+ this.#run('update routines set next_run_at = ? where id = ?', next?.toISOString() ?? null, id);
2270
+ this.#emitRoutine(id);
2271
+ }
2272
+
2273
+ retireOneOff(id: string): void {
2274
+ this.#run(
2275
+ `update routines set next_run_at = null,
2276
+ enabled = case when webhook_key is null then 0 else enabled end,
2277
+ schedule = case when webhook_key is null then schedule else null end
2278
+ where id = ?`,
2279
+ id,
2280
+ );
2281
+ this.#emitRoutine(id);
2282
+ }
2283
+
2284
+ findRoutineName(id: string): string | null {
2285
+ return (this.#get('select name from routines where id = ?', id)?.name as string | undefined) ?? null;
2286
+ }
2287
+
2288
+ routineOwner(id: string): string | null {
2289
+ return (this.#get('select bot_id from routines where id = ?', id)?.bot_id as string | undefined) ?? null;
2290
+ }
2291
+
2292
+ webhookSecrets(id: string): HookSecrets | null {
2293
+ const r = this.#get('select webhook_key, webhook_secret from routines where id = ?', id);
2294
+ return r?.webhook_key ? { key: r.webhook_key, secret: r.webhook_secret ?? null } : null;
2295
+ }
2296
+
2297
+ callbackTarget(routineId: string): { url: string; key: string } | null {
2298
+ const r = this.#get('select callback_url, webhook_key from routines where id = ?', routineId);
2299
+ return r?.callback_url && r.webhook_key ? { url: r.callback_url, key: r.webhook_key } : null;
2300
+ }
2301
+
2302
+ setRunCallback(id: string, delivery: CallbackDelivery): RoutineRun | null {
2303
+ const run = this.findRun(id);
2304
+ if (!run) return null;
2305
+ const meta: RunMeta = { ...run.meta, callback: delivery };
2306
+ this.#run('update routine_runs set meta = ? where id = ?', JSON.stringify(meta), id);
2307
+ return this.#emitRun(id);
2308
+ }
2309
+
2310
+ findDelivery(routineId: string, deliveryId: string): string | null {
2311
+ const since = new Date(Date.now() - DELIVERY_TTL_MS).toISOString();
2312
+ return (this.#get('select run_id from webhook_deliveries where routine_id = ? and delivery_id = ? and created_at > ?', routineId, deliveryId, since)?.run_id as string | undefined) ?? null;
2313
+ }
2314
+
2315
+ recordDelivery(routineId: string, deliveryId: string, runId: string): void {
2316
+ this.#run('delete from webhook_deliveries where created_at <= ?', new Date(Date.now() - DELIVERY_TTL_MS).toISOString());
2317
+ this.#run(
2318
+ 'insert into webhook_deliveries (routine_id, delivery_id, run_id, created_at) values (?, ?, ?, ?) on conflict(routine_id, delivery_id) do update set run_id = excluded.run_id, created_at = excluded.created_at',
2319
+ routineId,
2320
+ deliveryId,
2321
+ runId,
2322
+ nowIso(),
2323
+ );
2324
+ }
2325
+
2326
+ /** Its routines that keep a session of their own and have one now. */
2327
+ routineSessions(botId: string): { id: string; name: string; sessionKey: string | null }[] {
2328
+ return this.#all("select id, name, session_key from routines where bot_id = ? and session = 'own' and session_id is not null order by created_at, id", botId).map((r) => ({
2329
+ id: r.id,
2330
+ name: r.name,
2331
+ sessionKey: r.session_key ?? null,
2332
+ }));
2333
+ }
2334
+
2335
+ /** Its runs queued or running now, oldest first, and its scheduled runs due before `until`, soonest first. */
2336
+ activeRuns(botId: string, until: Date, limit: number): { runs: ProviderHandoff['runs']; dueSoon: ProviderHandoff['dueSoon'] } {
2337
+ const runs = this.#all(
2338
+ `select rr.routine_id, rr.status, r.name from routine_runs rr join routines r on r.id = rr.routine_id
2339
+ where rr.bot_id = ? and rr.status in ('queued', 'running') order by rr.started_at, rr.rowid limit ?`,
2340
+ botId,
2341
+ limit,
2342
+ ).map((r) => ({ routineId: r.routine_id, routineName: r.name, status: r.status as 'queued' | 'running' }));
2343
+ const dueSoon = this.#all(
2344
+ 'select id, name, next_run_at from routines where bot_id = ? and enabled = 1 and next_run_at is not null and next_run_at <= ? order by next_run_at, id limit ?',
2345
+ botId,
2346
+ until.toISOString(),
2347
+ limit,
2348
+ ).map((r) => ({ routineId: r.id, routineName: r.name, at: r.next_run_at }));
2349
+ return { runs, dueSoon };
2350
+ }
2351
+
2352
+ /**
2353
+ * The latest provider switch since the conversation was last reset, while no turn has run on the new provider's
2354
+ * chat session since: the first such turn starts with its handoff note.
2355
+ */
2356
+ pendingHandoff(botId: string, since: string | null): (Message & { meta: MessageMeta & { handoff: ProviderHandoff } }) | null {
2357
+ const r = this.#get(
2358
+ `select * from messages where bot_id = ? and created_at > ? and role = 'event' and json_extract(${MESSAGE_META}, '$.kind') = 'provider.switched'
2359
+ order by seq desc limit 1`,
2360
+ botId,
2361
+ since ?? '',
2362
+ );
2363
+ if (!r) return null;
2364
+ const m = this.#toMessage(r);
2365
+ return m.meta?.handoff && !m.meta.handoffDone ? (m as Message & { meta: MessageMeta & { handoff: ProviderHandoff } }) : null;
2366
+ }
2367
+
2368
+ /** The summary a handoff names (Message.meta.handoff.summary), if it is still the Boton's. */
2369
+ handoffSummary(botId: string, messageId: string): Message | null {
2370
+ const r = this.#get(`select * from messages where id = ? and bot_id = ? and role = 'bot' and json_extract(${MESSAGE_META}, '$.kind') = 'provider.summary'`, messageId, botId);
2371
+ return r ? this.#toMessage(r) : null;
2372
+ }
2373
+
2374
+ /**
2375
+ * The note is not given again (`pendingHandoff` no longer returns it). With `generation`, only while that switch is
2376
+ * still the Boton's latest: a turn from before a later switch can't mark that one's note done.
2377
+ */
2378
+ endHandoff(messageId: string, generation?: number): boolean {
2379
+ const r =
2380
+ generation === undefined
2381
+ ? this.#run("update messages set meta = json_set(meta, '$.handoffDone', json('true')) where id = ?", messageId)
2382
+ : this.#run(
2383
+ `update messages set meta = json_set(meta, '$.handoffDone', json('true')) where id = ? and seq = ? and ${generationSql('messages.bot_id')} = ?`,
2384
+ messageId,
2385
+ generation,
2386
+ generation,
2387
+ );
2388
+ return Number(r.changes) > 0;
2389
+ }
2390
+
2391
+ /** Ends the routine's own saved session: its next run starts a new one. */
2392
+ dropRoutineSession(id: string): void {
2393
+ this.#run('update routines set session_id = null, session_key = null where id = ?', id);
2394
+ }
2395
+
2396
+ routineSession(id: string): { sessionId: string | null; sessionKey: string | null } {
2397
+ const r = this.#get('select session_id, session_key from routines where id = ?', id);
2398
+ return { sessionId: r?.session_id ?? null, sessionKey: r?.session_key ?? null };
2399
+ }
2400
+
2401
+ /** Not saved if the routine went back to the chat's session since the run read the Boton, nor (as setSession) if the Boton changed since. */
2402
+ setRoutineSession(id: string, sessionId: string | null, sessionKey: string | null, at: TurnStamp): boolean {
2403
+ const bot = this.#get('select bot_id from routines where id = ?', id)?.bot_id ?? null;
2404
+ const r = this.#run(
2405
+ `update routines set session_id = ?, session_key = ?
2406
+ where id = ? and session = 'own' and bot_id = ?
2407
+ and (select context_since from bots where bots.id = routines.bot_id) is ?
2408
+ and (select provider from bots where bots.id = routines.bot_id) = ? and ${GENERATION_SQL} = ?`,
2409
+ sessionId,
2410
+ sessionKey,
2411
+ id,
2412
+ bot,
2413
+ at.contextSince,
2414
+ at.provider,
2415
+ bot,
2416
+ at.generation,
2417
+ );
2418
+ return Number(r.changes) > 0;
2419
+ }
2420
+
2421
+ /** Each routine's latest run first, so one that runs every few minutes can't push the others' results out. */
2422
+ ownSessionResults(botId: string, limit = 3): (RoutineRun & { routineName: string })[] {
2423
+ return this.#all(
2424
+ `select * from (
2425
+ select r.*, t.name as routine_name,
2426
+ substr(r.payload, 1, ${PAYLOAD_PREVIEW_BYTES}) as payload_preview, coalesce(length(cast(r.payload as blob)), 0) as payload_bytes,
2427
+ row_number() over (partition by r.routine_id order by r.finished_at desc, r.seq desc) as nth
2428
+ from routine_runs r join routines t on t.id = r.routine_id
2429
+ where t.bot_id = ? and t.session = 'own' and r.status in ('succeeded', 'failed') and r.finished_at is not null
2430
+ order by nth = 1 desc, r.finished_at desc, r.seq desc limit ?
2431
+ ) order by finished_at desc, seq desc`,
2432
+ botId,
2433
+ limit,
2434
+ ).map((r) => ({ ...this.#toRun(r), routineName: r.routine_name as string }));
2435
+ }
2436
+
2437
+ // -------------------------------------------------------------------- runs
2438
+
2439
+ #toRun(r: Row): RoutineRun {
2440
+ return {
2441
+ id: r.id,
2442
+ routineId: r.routine_id,
2443
+ botId: r.bot_id,
2444
+ trigger: r.trigger as RunTrigger,
2445
+ status: r.status as RunStatus,
2446
+ error: r.error ?? null,
2447
+ payloadPreview: r.payload_preview == null ? null : utf8Prefix(r.payload_preview, PAYLOAD_PREVIEW_BYTES),
2448
+ payloadBytes: Number(r.payload_bytes),
2449
+ headers: r.headers ? (JSON.parse(r.headers) as Record<string, string>) : null,
2450
+ reply: r.reply ?? null,
2451
+ quiet: !!r.quiet,
2452
+ meta: r.meta ? (JSON.parse(r.meta) as RunMeta) : null,
2453
+ startedAt: r.started_at,
2454
+ finishedAt: r.finished_at ?? null,
2455
+ attempts: Number(r.attempts),
2456
+ lastError: r.last_error ?? null,
2457
+ retryAt: r.retry_at ?? null,
2458
+ ...this.#runTurns(r.id),
2459
+ };
2460
+ }
2461
+
2462
+ #runTurns(runId: string): Pick<RoutineRun, 'usage' | 'model' | 'steps'> {
2463
+ const t = this.#get(
2464
+ `select count(*) as n, sum(input_tokens) as input, sum(output_tokens) as output, sum(cache_read_tokens) as cache_read,
2465
+ sum(cache_write_tokens) as cache_write, sum(cost_usd) as cost, sum(step_count) as steps,
2466
+ (select coalesce(model, nullif(requested_model, '')) from turns where run_id = ? order by started_at desc, rowid desc limit 1) as model
2467
+ from turns where run_id = ?`,
2468
+ runId,
2469
+ runId,
2470
+ )!;
2471
+ if (!Number(t.n)) return { usage: null, model: null, steps: 0 };
2472
+ const usage: TokenUsage = {
2473
+ inputTokens: Number(t.input ?? 0),
2474
+ outputTokens: Number(t.output ?? 0),
2475
+ cacheReadTokens: Number(t.cache_read ?? 0),
2476
+ cacheWriteTokens: Number(t.cache_write ?? 0),
2477
+ ...(t.cost ? { costUsd: Number(t.cost) } : {}),
2478
+ };
2479
+ return { usage: totalTokens(usage) ? usage : null, model: (t.model as string | null) ?? null, steps: Number(t.steps ?? 0) };
2480
+ }
2481
+
2482
+ createRun(routine: Routine, trigger: RunTrigger, payload: string | null, headers: Record<string, string> | null = null): RoutineRun {
2483
+ const id = newId('run');
2484
+ const now = nowIso();
2485
+ this.#run(
2486
+ `insert into routine_runs (id, routine_id, bot_id, trigger, status, payload, headers, started_at) values (?, ?, ?, ?, 'queued', ?, ?, ?)`,
2487
+ id,
2488
+ routine.id,
2489
+ routine.botId,
2490
+ trigger,
2491
+ payload,
2492
+ headers ? JSON.stringify(headers) : null,
2493
+ now,
2494
+ );
2495
+ if (trigger !== 'test') {
2496
+ this.#run("update routines set last_run_at = ?, last_status = 'queued' where id = ?", now, routine.id);
2497
+ this.#emitRoutine(routine.id);
2498
+ }
2499
+ return this.#emitRun(id);
2500
+ }
2501
+
2502
+ /** A new queued run with a failed one's trigger, payload and headers; each run is retried at most once. */
2503
+ createRetryRun(failed: RoutineRun): RoutineRun {
2504
+ if (failed.status !== 'failed') throw new HttpError(409, `Only a failed run can be retried; this one is ${failed.status}`);
2505
+ if (failed.meta?.retriedAs) throw new HttpError(409, `This run was already retried as ${failed.meta.retriedAs}`);
2506
+ const routine = this.getRoutine(failed.routineId);
2507
+ const id = newId('run');
2508
+ const now = nowIso();
2509
+ const meta: RunMeta = { retryOf: { runId: failed.id, error: failed.error } };
2510
+ return this.atomically(() => {
2511
+ // Claims the failed run first, so two retries at once start one run.
2512
+ const claimed = this.#run(
2513
+ "update routine_runs set meta = json_set(coalesce(meta, '{}'), '$.retriedAs', ?) where id = ? and status = 'failed' and json_extract(coalesce(meta, '{}'), '$.retriedAs') is null",
2514
+ id,
2515
+ failed.id,
2516
+ );
2517
+ if (!Number(claimed.changes)) throw new HttpError(409, 'This run was already retried');
2518
+ this.#run(
2519
+ `insert into routine_runs (id, routine_id, bot_id, trigger, status, payload, headers, meta, started_at)
2520
+ select ?, routine_id, bot_id, trigger, 'queued', payload, headers, ?, ? from routine_runs where id = ?`,
2521
+ id,
2522
+ JSON.stringify(meta),
2523
+ now,
2524
+ failed.id,
2525
+ );
2526
+ // Delivery lookup must survive pruning of the original run and of retry ancestors.
2527
+ // Preserve created_at so an owner retry does not extend the dedupe window.
2528
+ this.#run('update webhook_deliveries set run_id = ? where routine_id = ? and run_id = ?', id, routine.id, failed.id);
2529
+ if (failed.trigger !== 'test') {
2530
+ this.#run("update routines set last_run_at = ?, last_status = 'queued' where id = ?", now, routine.id);
2531
+ this.#emitRoutine(routine.id);
2532
+ }
2533
+ this.#emitRun(failed.id);
2534
+ return this.#emitRun(id);
2535
+ });
2536
+ }
2537
+
2538
+ /** The run a delivery ends at: a failed run's retry, and that one's, while they exist. */
2539
+ latestRetry(run: RoutineRun): RoutineRun {
2540
+ let at = run;
2541
+ for (let hops = 0; hops < RUN_HISTORY && at.meta?.retriedAs; hops++) {
2542
+ const next = this.findRun(at.meta.retriedAs);
2543
+ if (!next) break;
2544
+ at = next;
2545
+ }
2546
+ return at;
2547
+ }
2548
+
2549
+ recordSkippedRun(routine: Routine, trigger: RunTrigger, reason: string): RoutineRun {
2550
+ const id = newId('run');
2551
+ const now = nowIso();
2552
+ this.#run(
2553
+ `insert into routine_runs (id, routine_id, bot_id, trigger, status, error, started_at, finished_at) values (?, ?, ?, ?, 'skipped', ?, ?, ?)`,
2554
+ id,
2555
+ routine.id,
2556
+ routine.botId,
2557
+ trigger,
2558
+ reason,
2559
+ now,
2560
+ now,
2561
+ );
2562
+ this.#run(
2563
+ `delete from routine_runs
2564
+ where routine_id = ? and status = 'skipped'
2565
+ and seq not in (select seq from routine_runs where routine_id = ? and status = 'skipped' order by seq desc limit ?)`,
2566
+ routine.id,
2567
+ routine.id,
2568
+ SKIP_HISTORY,
2569
+ );
2570
+ return this.#emitRun(id);
2571
+ }
2572
+
2573
+ activeRunCount(routineId: string): number {
2574
+ return Number(this.#get("select count(*) as n from routine_runs where routine_id = ? and status in ('queued', 'running')", routineId)!.n);
2575
+ }
2576
+
2577
+ queuedRunCount(routineId: string): number {
2578
+ return Number(this.#get("select count(*) as n from routine_runs where routine_id = ? and status = 'queued'", routineId)!.n);
2579
+ }
2580
+
2581
+ findRun(id: string): RoutineRun | null {
2582
+ const r = this.#get(`select ${RUN_COLUMNS} from routine_runs where id = ?`, id);
2583
+ return r ? this.#toRun(r) : null;
2584
+ }
2585
+
2586
+ findRunRecord(id: string): RunRecord | null {
2587
+ const run = this.findRun(id);
2588
+ if (!run) return null;
2589
+ const payload = (this.#get('select payload from routine_runs where id = ?', id)?.payload as string | null | undefined) ?? null;
2590
+ return { ...run, payload };
2591
+ }
2592
+
2593
+ runPayload(id: string): RunPayload {
2594
+ const run = this.findRunRecord(id);
2595
+ if (!run) throw new HttpError(404, `No run with id ${id}`);
2596
+ return { runId: run.id, payload: run.payload, headers: run.headers, bytes: run.payloadBytes };
2597
+ }
2598
+
2599
+ runOwner(id: string): string | null {
2600
+ return (this.#get('select bot_id from routine_runs where id = ?', id)?.bot_id as string | undefined) ?? null;
2601
+ }
2602
+
2603
+ getRun(id: string): RoutineRun {
2604
+ const run = this.findRun(id);
2605
+ if (!run) throw new HttpError(404, `No run with id ${id}`);
2606
+ return run;
2607
+ }
2608
+
2609
+ #emitRun(id: string): RoutineRun {
2610
+ const run = this.getRun(id);
2611
+ this.#publish({ type: 'run.upserted', run });
2612
+ return run;
2613
+ }
2614
+
2615
+ /** A run that finishes also records its callback as pending, in the same transaction, so no crash can lose it. */
2616
+ updateRun(
2617
+ id: string,
2618
+ patch: { status: RunStatus; error?: string | null; reply?: string | null; quiet?: boolean; cancelled?: { started: boolean }; timedOut?: { limitMs: number } },
2619
+ ): RoutineRun {
2620
+ return this.atomically(() => {
2621
+ const finished = patch.status === 'succeeded' || patch.status === 'failed' ? nowIso() : null;
2622
+ this.#run(
2623
+ `update routine_runs set status = ?, error = coalesce(?, error), reply = coalesce(?, reply), quiet = coalesce(?, quiet), finished_at = ? where id = ?`,
2624
+ patch.status,
2625
+ patch.error ?? null,
2626
+ patch.reply ?? null,
2627
+ patch.quiet === undefined ? null : patch.quiet ? 1 : 0,
2628
+ finished,
2629
+ id,
2630
+ );
2631
+ // In the same write as the failure, so a restart never shows a cancelled run as one that needs the owner.
2632
+ if (patch.cancelled) {
2633
+ const cancelled: NonNullable<RunMeta['cancelled']> = { at: finished ?? nowIso(), started: patch.cancelled.started };
2634
+ this.#run("update routine_runs set meta = json_set(coalesce(meta, '{}'), '$.cancelled', json(?)) where id = ?", JSON.stringify(cancelled), id);
2635
+ }
2636
+ // In the same write too, so its callback says why it failed.
2637
+ if (patch.timedOut) {
2638
+ const timedOut: NonNullable<RunMeta['timedOut']> = { at: finished ?? nowIso(), limitMs: patch.timedOut.limitMs };
2639
+ this.#run("update routine_runs set meta = json_set(coalesce(meta, '{}'), '$.timedOut', json(?)) where id = ?", JSON.stringify(timedOut), id);
2640
+ }
2641
+ const run = this.getRun(id);
2642
+ if (finished) {
2643
+ this.#owePendingCallback(run);
2644
+ this.#pruneRuns(run.routineId, id);
2645
+ }
2646
+ this.#setLastStatus(run, patch.status);
2647
+ return this.#emitRun(id);
2648
+ });
2649
+ }
2650
+
2651
+ /**
2652
+ * Records a just-finished run's callback as owed: pending, which Callbacks sends (on this write's run.upserted, or at
2653
+ * the next start), or not sent when the routine already has PENDING_CALLBACK_LIMIT waiting. Not for test runs.
2654
+ */
2655
+ #owePendingCallback(run: RoutineRun): void {
2656
+ if (run.trigger === 'test' || run.meta?.callback) return;
2657
+ const r = this.#get('select callback_url, webhook_key from routines where id = ?', run.routineId);
2658
+ if (!r?.callback_url || !r.webhook_key) return;
2659
+ const waiting = this.pendingCallbackCount(run.routineId);
2660
+ const delivery: CallbackDelivery =
2661
+ waiting >= PENDING_CALLBACK_LIMIT
2662
+ ? { url: r.callback_url, ok: false, status: null, error: `Not sent: ${waiting} earlier results were still waiting for the callback URL`, attempts: 0, at: nowIso() }
2663
+ : { url: r.callback_url, ok: false, status: null, error: null, attempts: 0, at: nowIso(), pending: true };
2664
+ if (!delivery.pending) log.warn('callback not sent: too many results waiting for the callback URL', { runId: run.id, routineId: run.routineId, waiting });
2665
+ const meta: RunMeta = { ...run.meta, callback: delivery };
2666
+ this.#run('update routine_runs set meta = ? where id = ?', JSON.stringify(meta), run.id);
2667
+ }
2668
+
2669
+ pendingCallbackCount(routineId: string): number {
2670
+ return Number(this.#get("select count(*) as n from routine_runs where routine_id = ? and json_extract(meta, '$.callback.pending') = 1", routineId)!.n);
2671
+ }
2672
+
2673
+ #setLastStatus(run: RoutineRun, status: RunStatus) {
2674
+ if (run.trigger === 'test' || !this.#get('select 1 from routines where id = ?', run.routineId)) return;
2675
+ this.#run('update routines set last_status = ? where id = ?', status, run.routineId);
2676
+ this.#emitRoutine(run.routineId);
2677
+ }
2678
+
2679
+ addRunDelivery(id: string, delivery: NotifyDelivery): RoutineRun | null {
2680
+ const run = this.findRun(id);
2681
+ if (!run) return null;
2682
+ const meta: RunMeta = { ...run.meta, notifications: [...(run.meta?.notifications ?? []), delivery] };
2683
+ this.#run('update routine_runs set meta = ? where id = ?', JSON.stringify(meta), id);
2684
+ return this.#emitRun(id);
2685
+ }
2686
+
2687
+ startRun(id: string): RoutineRun {
2688
+ this.#run("update routine_runs set status = 'running', started_at = ?, attempts = attempts + 1, retry_at = null, held_for = null where id = ?", nowIso(), id);
2689
+ return this.#runChanged(id, 'running');
2690
+ }
2691
+
2692
+ holdRun(id: string, retryAt: Date, lastError: string, heldFor: HeldFor): RoutineRun {
2693
+ this.#run(
2694
+ "update routine_runs set status = 'queued', retry_at = ?, last_error = ?, held_for = ?, finished_at = null where id = ?",
2695
+ retryAt.toISOString(),
2696
+ lastError.slice(0, 2000),
2697
+ heldFor,
2698
+ id,
2699
+ );
2700
+ return this.#runChanged(id, 'queued');
2701
+ }
2702
+
2703
+ releaseUsageLimitHolds(botId: string): void {
2704
+ const ids = this.#all("select id from routine_runs where bot_id = ? and status = 'queued' and held_for = 'usage_limit' and retry_at is not null", botId).map((r) => r.id as string);
2705
+ for (const id of ids) {
2706
+ this.#run('update routine_runs set retry_at = null where id = ?', id);
2707
+ this.#emitRun(id);
2708
+ }
2709
+ }
2710
+
2711
+ runHeldFor(id: string): HeldFor | null {
2712
+ return (this.#get('select held_for from routine_runs where id = ?', id)?.held_for as HeldFor | null | undefined) ?? null;
2713
+ }
2714
+
2715
+ #runChanged(id: string, status: RunStatus): RoutineRun {
2716
+ this.#setLastStatus(this.getRun(id), status);
2717
+ return this.#emitRun(id);
2718
+ }
2719
+
2720
+ /**
2721
+ * Never queued or running runs: callers hold their ids (a webhook's 202 runId) and the engine still has to run them.
2722
+ * Nor runs whose callback is pending, which still have to be sent (at most PENDING_CALLBACK_LIMIT per routine); once
2723
+ * sent or failed, a later finish prunes them.
2724
+ */
2725
+ #pruneRuns(routineId: string, keepId: string) {
2726
+ this.#run(
2727
+ `delete from routine_runs
2728
+ where routine_id = ? and id != ? and status in ('succeeded', 'failed')
2729
+ and coalesce(json_extract(meta, '$.callback.pending'), 0) = 0
2730
+ and seq not in (select seq from routine_runs where routine_id = ? and status in ('succeeded', 'failed') order by seq desc limit ?)`,
2731
+ routineId,
2732
+ keepId,
2733
+ routineId,
2734
+ RUN_HISTORY,
2735
+ );
2736
+ }
2737
+
2738
+ /** Before a delete: webhook waiters and clients see each run end before the cascade removes its row. */
2739
+ #failActiveRuns(column: 'routine_id' | 'bot_id', id: string, error: string) {
2740
+ const rows = this.#all(`select id from routine_runs where ${column} = ? and status in ('queued', 'running')`, id);
2741
+ if (!rows.length) return;
2742
+ this.atomically(() => {
2743
+ this.#run(
2744
+ `update routine_runs set status = 'failed', error = ?, finished_at = ? where ${column} = ? and status in ('queued', 'running')`,
2745
+ error,
2746
+ nowIso(),
2747
+ id,
2748
+ );
2749
+ for (const r of rows) {
2750
+ this.#owePendingCallback(this.getRun(r.id));
2751
+ this.#emitRun(r.id);
2752
+ }
2753
+ });
2754
+ }
2755
+
2756
+ listRuns(routineId: string): RoutineRun[] {
2757
+ this.getRoutine(routineId);
2758
+ return this.#all(`select ${RUN_COLUMNS} from routine_runs where routine_id = ? order by seq desc`, routineId).map((r) => this.#toRun(r));
2759
+ }
2760
+
2761
+ /** Failed runs not yet retried (since `since`, not test runs nor runs the owner cancelled), and queued runs held to try again later. */
2762
+ attentionRuns(since: string, limit = ATTENTION_LIMIT): RunsAttention {
2763
+ const select = `select ${RUN_COLUMNS}, (select name from bots b where b.id = r.bot_id) as bot_name,
2764
+ (select name from routines t where t.id = r.routine_id) as routine_name from routine_runs r`;
2765
+ const named = (r: Row): AttentionRun => ({ ...this.#toRun(r), botName: r.bot_name as string, routineName: r.routine_name as string });
2766
+ const failed = this.#all(
2767
+ `${select} where status = 'failed' and trigger != 'test' and finished_at >= ?
2768
+ and json_extract(coalesce(meta, '{}'), '$.retriedAs') is null
2769
+ and json_extract(coalesce(meta, '{}'), '$.cancelled') is null
2770
+ order by finished_at desc, seq desc limit ?`,
2771
+ since,
2772
+ limit,
2773
+ ).map(named);
2774
+ const held = this.#all(`${select} where status = 'queued' and trigger != 'test' and retry_at is not null order by retry_at, seq limit ?`, limit).map(named);
2775
+ // Only while the routine still has a callback URL: without one there is nothing to resend.
2776
+ const callbacks = this.#all(
2777
+ `${select} where trigger != 'test' and finished_at >= ?
2778
+ and json_extract(meta, '$.callback.ok') = 0 and coalesce(json_extract(meta, '$.callback.pending'), 0) = 0
2779
+ and (select callback_url from routines t where t.id = r.routine_id) is not null
2780
+ order by finished_at desc, seq desc limit ?`,
2781
+ since,
2782
+ limit,
2783
+ ).map(named);
2784
+ return { since, failed, held, callbacks };
2785
+ }
2786
+
2787
+ /** Finished runs whose callback was being sent when the server last stopped. */
2788
+ pendingCallbackRuns(): RoutineRun[] {
2789
+ return this.#all(`select ${RUN_COLUMNS} from routine_runs where json_extract(meta, '$.callback.pending') = 1 order by seq`).map((r) => this.#toRun(r));
2790
+ }
2791
+
2792
+ failInterruptedRuns(): RoutineRun[] {
2793
+ const rows = this.#all(
2794
+ "select r.id, r.bot_id, r.routine_id, t.name from routine_runs r left join routines t on t.id = r.routine_id where r.status = 'running' order by r.seq",
2795
+ );
2796
+ return rows.map((r) => {
2797
+ const run = this.updateRun(r.id, { status: 'failed', error: 'Interrupted by a restart' });
2798
+ const name = (r.name as string | null) ?? 'routine';
2799
+ this.addMessage(r.bot_id, 'event', ...runChip(run, name));
2800
+ this.addMessage(r.bot_id, 'error', `Routine “${name}” was interrupted by a restart`, { routineId: r.routine_id, routineName: name, runId: r.id });
2801
+ return run;
2802
+ });
2803
+ }
2804
+
2805
+ queuedRuns(botId?: string): RoutineRun[] {
2806
+ const rows = botId
2807
+ ? this.#all(`select ${RUN_COLUMNS} from routine_runs where status = 'queued' and bot_id = ? order by seq`, botId)
2808
+ : this.#all(`select ${RUN_COLUMNS} from routine_runs where status = 'queued' order by seq`);
2809
+ return rows.map((r) => this.#toRun(r));
2810
+ }
2811
+
2812
+ cancelQueuedRuns(botId: string, error: string): number {
2813
+ const runs = this.queuedRuns(botId);
2814
+ // One held to try again had an earlier attempt run, so it may have partly run.
2815
+ for (const r of runs) this.updateRun(r.id, { status: 'failed', error, cancelled: { started: r.attempts > 0 } });
2816
+ return runs.length;
2817
+ }
2818
+
2819
+ // ------------------------------------------------------------------- turns
2820
+
2821
+ #prunedTurnsAt = 0;
2822
+
2823
+ startTurn(t: TurnStart): string {
2824
+ const id = newId('turn', 10);
2825
+ this.#run(
2826
+ `insert into turns (id, bot_id, routine_id, run_id, trigger, provider, requested_model, started_at, outcome) values (?, ?, ?, ?, ?, ?, ?, ?, 'running')`,
2827
+ id,
2828
+ t.botId,
2829
+ t.routineId ?? null,
2830
+ t.runId ?? null,
2831
+ t.trigger,
2832
+ t.provider,
2833
+ t.requestedModel,
2834
+ nowIso(),
2835
+ );
2836
+ if (Date.now() - this.#prunedTurnsAt > 3_600_000) this.pruneTurns();
2837
+ return id;
2838
+ }
2839
+
2840
+ finishTurn(id: string, end: TurnEnd): void {
2841
+ const u = end.usage;
2842
+ this.#run(
2843
+ `update turns set outcome = ?, error_kind = ?, duration_ms = ?, model = ?, input_tokens = ?, output_tokens = ?, cache_read_tokens = ?,
2844
+ cache_write_tokens = ?, cost_usd = ?, num_turns = ?, replayed_transcript = ? where id = ?`,
2845
+ end.outcome,
2846
+ end.errorKind ?? null,
2847
+ Math.round(end.durationMs),
2848
+ end.model ?? null,
2849
+ Math.round(u?.inputTokens ?? 0),
2850
+ Math.round(u?.outputTokens ?? 0),
2851
+ Math.round(u?.cacheReadTokens ?? 0),
2852
+ Math.round(u?.cacheWriteTokens ?? 0),
2853
+ u?.costUsd ?? null,
2854
+ u?.numTurns ?? null,
2855
+ end.replayedTranscript ? 1 : 0,
2856
+ id,
2857
+ );
2858
+ }
2859
+
2860
+ linkTurnMessage(turnId: string, messageId: string): void {
2861
+ this.#run('update turns set message_id = ? where id = ?', messageId, turnId);
2862
+ }
2863
+
2864
+ endInterruptedTurns(): { id: string; botId: string; steps: number }[] {
2865
+ const chat = this.#all("select id, bot_id, step_count from turns where outcome = 'running' and run_id is null and message_id is null and step_count > 0");
2866
+ this.#run("update turns set outcome = 'interrupted' where outcome = 'running'");
2867
+ return chat.map((r) => ({ id: String(r.id), botId: String(r.bot_id), steps: Number(r.step_count) }));
2868
+ }
2869
+
2870
+ /** Once per startup: every message still in flight was cut off once more. */
2871
+ countCutOffChats(limit: number): Map<string, Message[]> {
2872
+ this.#run("update messages set cut_off = cut_off + 1 where role = 'user' and pending = 2");
2873
+ const out = new Map<string, Message[]>();
2874
+ for (const r of this.#all("select * from messages where role = 'user' and pending = 2 and cut_off >= ? order by seq", limit)) {
2875
+ const list = out.get(String(r.bot_id)) ?? [];
2876
+ list.push(this.#toMessage(r));
2877
+ out.set(String(r.bot_id), list);
2878
+ }
2879
+ return out;
2880
+ }
2881
+
2882
+ pruneTurns(): number {
2883
+ this.#prunedTurnsAt = Date.now();
2884
+ return Number(this.#run('delete from turns where started_at < ?', new Date(this.#prunedTurnsAt - TURN_RETENTION_MS).toISOString()).changes);
2885
+ }
2886
+
2887
+ /** Saved as each step starts, so a turn that is stopped, fails or is cut off keeps what it did. Past MAX_STEPS a step is only counted. */
2888
+ addTurnStep(turnId: string, index: number, step: TurnStep): void {
2889
+ if (index < MAX_STEPS) {
2890
+ this.#run(
2891
+ 'insert or replace into turn_steps (turn_id, idx, kind, label, detail, at) values (?, ?, ?, ?, ?, ?)',
2892
+ turnId,
2893
+ index,
2894
+ step.kind,
2895
+ step.label.slice(0, 300),
2896
+ truncate(step.detail, MAX_STEP_DETAIL),
2897
+ step.at,
2898
+ );
2899
+ }
2900
+ this.#run('update turns set step_count = max(step_count, ?) where id = ?', index + 1, turnId);
2901
+ }
2902
+
2903
+ /** A quiet reply is history even when the tool-step budget is exhausted. */
2904
+ saveQuietTurnNote(turnId: string, index: number, note: string): void {
2905
+ this.#run('insert or replace into turn_steps (turn_id, idx, kind, label, detail, at) values (?, ?, ?, ?, ?, ?)',
2906
+ turnId, index, 'tool', 'Quiet reply', note, nowIso());
2907
+ this.#run('update turns set step_count = max(step_count, ?) where id = ?', index + 1, turnId);
2908
+ }
2909
+
2910
+ #turnSteps(t: Row): TurnSteps {
2911
+ const steps = this.#all('select kind, label, detail, at from turn_steps where turn_id = ? order by idx', t.id).map((r) => ({
2912
+ kind: r.kind,
2913
+ label: r.label,
2914
+ detail: r.detail,
2915
+ at: r.at,
2916
+ })) as TurnStep[];
2917
+ return { turnId: t.id, startedAt: t.started_at, outcome: t.outcome as TurnOutcome, total: Number(t.step_count), steps };
2918
+ }
2919
+
2920
+ messageSteps(messageId: string): TurnSteps | null {
2921
+ const t = this.#get('select id, started_at, outcome, step_count from turns where message_id = ?', messageId);
2922
+ return t ? this.#turnSteps(t) : null;
2923
+ }
2924
+
2925
+ runSteps(runId: string): TurnSteps[] {
2926
+ return this.#all('select id, started_at, outcome, step_count from turns where run_id = ? order by started_at, rowid', runId).map((t) => this.#turnSteps(t));
2927
+ }
2928
+
2929
+ turnRow(id: string): Row | undefined {
2930
+ return this.#get('select * from turns where id = ?', id);
2931
+ }
2932
+
2933
+ usage(opts: { since: Date; group: 'bot' | 'routine'; botId?: string }): UsageReport {
2934
+ const until = new Date();
2935
+ const byRoutine = opts.group === 'routine';
2936
+ const rows = this.#all(
2937
+ `select t.bot_id, b.name as bot_name, ${byRoutine ? 't.routine_id, r.name as routine_name,' : ''}
2938
+ count(*) as turns, sum(t.input_tokens) as input, sum(t.output_tokens) as output, sum(t.cache_read_tokens) as cache_read,
2939
+ sum(t.cache_write_tokens) as cache_write, sum(t.cost_usd) as cost
2940
+ from turns t join bots b on b.id = t.bot_id ${byRoutine ? 'left join routines r on r.id = t.routine_id' : ''}
2941
+ where t.started_at >= ? and t.started_at < ? ${opts.botId ? 'and t.bot_id = ?' : ''}
2942
+ group by t.bot_id${byRoutine ? ', t.routine_id' : ''}`,
2943
+ opts.since.toISOString(),
2944
+ until.toISOString(),
2945
+ ...(opts.botId ? [opts.botId] : []),
2946
+ );
2947
+ const out: UsageRow[] = rows.map((r) => ({
2948
+ botId: r.bot_id,
2949
+ botName: r.bot_name,
2950
+ ...(byRoutine ? { routineId: r.routine_id ?? null, routineName: r.routine_name ?? null } : {}),
2951
+ turns: Number(r.turns),
2952
+ inputTokens: Number(r.input ?? 0),
2953
+ outputTokens: Number(r.output ?? 0),
2954
+ cacheReadTokens: Number(r.cache_read ?? 0),
2955
+ cacheWriteTokens: Number(r.cache_write ?? 0),
2956
+ ...(r.cost ? { costUsd: Number(r.cost) } : {}),
2957
+ }));
2958
+ out.sort((a, b) => totalTokens(b) - totalTokens(a) || b.turns - a.turns);
2959
+ return { since: opts.since.toISOString(), until: until.toISOString(), group: opts.group, rows: out };
2960
+ }
2961
+
2962
+ // ----------------------------------------------------- provider cooldowns
2963
+
2964
+ cooldowns(): { provider: string; until: Date; reason: string }[] {
2965
+ return this.#all('select * from provider_cooldowns').map((r) => ({ provider: r.provider, until: new Date(r.until), reason: r.reason }));
2966
+ }
2967
+
2968
+ setCooldown(provider: string, until: Date, reason: string): void {
2969
+ this.#run(
2970
+ 'insert into provider_cooldowns (provider, until, reason) values (?, ?, ?) on conflict(provider) do update set until = excluded.until, reason = excluded.reason',
2971
+ provider,
2972
+ until.toISOString(),
2973
+ reason.slice(0, 2000),
2974
+ );
2975
+ }
2976
+
2977
+ clearCooldown(provider: string): void {
2978
+ this.#run('delete from provider_cooldowns where provider = ?', provider);
2979
+ }
2980
+
2981
+ // ---------------------------------------------------------------- memories
2982
+
2983
+ listMemories(botId: string): Memory[] {
2984
+ this.getBot(botId);
2985
+ return this.#all('select * from memories where bot_id = ? order by created_at', botId).map((r) => ({
2986
+ id: r.id,
2987
+ botId: r.bot_id,
2988
+ text: r.text,
2989
+ createdAt: r.created_at,
2990
+ }));
2991
+ }
2992
+
2993
+ addMemory(botId: string, text: string): Memory {
2994
+ this.getBot(botId);
2995
+ const t = clean(text, 'text', 2000, true);
2996
+ const memory: Memory = { id: newId('mem'), botId, text: t, createdAt: nowIso() };
2997
+ this.#run('insert into memories (id, bot_id, text, created_at) values (?, ?, ?, ?)', memory.id, botId, t, memory.createdAt);
2998
+ this.#publish({ type: 'memory.upserted', memory });
2999
+ this.addMessage(botId, 'event', 'Saved to memory', { kind: 'memory.saved', memoryText: t });
3000
+ return memory;
3001
+ }
3002
+
3003
+ memoryOwner(id: string): string | null {
3004
+ return (this.#get('select bot_id from memories where id = ?', id)?.bot_id as string | undefined) ?? null;
3005
+ }
3006
+
3007
+ deleteMemory(id: string): void {
3008
+ const r = this.#get('select * from memories where id = ?', id);
3009
+ if (!r) throw new HttpError(404, `No memory with id ${id}`);
3010
+ this.#run('delete from memories where id = ?', id);
3011
+ this.#publish({ type: 'memory.deleted', memoryId: id, botId: r.bot_id });
3012
+ this.addMessage(r.bot_id, 'event', 'Forgot a memory', { kind: 'memory.forgotten', memoryText: r.text });
3013
+ }
3014
+ }
3015
+
3016
+ /** The chip a run posts in its Boton's chat when it ends. */
3017
+ export function runChip(run: RoutineRun, routineName: string): [string, MessageMeta] {
3018
+ const retried = run.attempts > 1;
3019
+ return [
3020
+ retried ? `Retried routine (attempt ${run.attempts})` : 'Ran routine',
3021
+ { kind: 'routine.run', routineId: run.routineId, routineName, runId: run.id, trigger: run.trigger, ...(retried ? { attempt: run.attempts } : {}) },
3022
+ ];
3023
+ }
3024
+
3025
+ /** Whether the Boton may use it is checked by the caller (skills.ts). */
3026
+ function skillName(v: unknown): string | null {
3027
+ if (v === undefined || v === null || v === '') return null;
3028
+ if (typeof v !== 'string') throw new HttpError(400, 'skill must be a skill name or null');
3029
+ return v;
3030
+ }
3031
+
3032
+ const ALWAYS_ALLOW_MAX = 50;
3033
+
3034
+ export function actionKey(v: unknown, field = 'action'): string {
3035
+ if (typeof v !== 'string') throw new HttpError(400, `${field} must be a string`);
3036
+ const key = v.trim().replace(/\s+/g, ' ').toLowerCase();
3037
+ if (!key) throw new HttpError(400, `${field} is empty`);
3038
+ if (key.length > 80) throw new HttpError(400, `${field} is too long (max 80 characters)`);
3039
+ if (!/^[\p{L}\p{N} _.:/#@*()-]+$/u.test(key)) throw new HttpError(400, `${field} may use only letters, digits, spaces and - _ . : / # @ * ( )`);
3040
+ return key;
3041
+ }
3042
+
3043
+ export function alwaysAllowList(v: unknown): string[] {
3044
+ if (!Array.isArray(v)) throw new HttpError(400, 'alwaysAllow must be a list of kinds of action');
3045
+ const list = [...new Set(v.map((a) => actionKey(a, 'each alwaysAllow entry')))];
3046
+ if (list.length > ALWAYS_ALLOW_MAX) throw new HttpError(400, `alwaysAllow can hold at most ${ALWAYS_ALLOW_MAX} kinds of action`);
3047
+ return list;
3048
+ }
3049
+
3050
+ const parseList = (raw: unknown): string[] => strings(parseJson(raw, []));
3051
+
3052
+ let ownerCache: string | null = null;
3053
+ function defaultOwner(): string {
3054
+ if (ownerCache) return ownerCache;
3055
+ try {
3056
+ ownerCache = execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8', timeout: 2000 }).trim() || null;
3057
+ } catch {}
3058
+ ownerCache ??= process.env.USER ?? 'you';
3059
+ return ownerCache;
3060
+ }
3061
+
3062
+ /** A version-24 look with every field valid: what the capsule editor could send. */
3063
+ function validLook24(v: unknown): v is Record<string, unknown> {
3064
+ if (!isLook24(v)) return false;
3065
+ const hex = /^#[0-9a-fA-F]{6}$/;
3066
+ return typeof v.body === 'string' && hex.test(v.body) && typeof v.bg === 'string' && hex.test(v.bg) && (v.eyes === 1 || v.eyes === 2) &&
3067
+ ['none', 'sprout', 'spiky', 'combed', 'antenna'].includes(v.hair as string) && ['smile', 'grin', 'o', 'flat'].includes(v.mouth as string);
3068
+ }
3069
+
3070
+ /**
3071
+ * A look as stored from outside (a request, a saved template): a Boton, or a tater look, a valid Critter or a
3072
+ * version-24 look as its closest Boton; null for anything else.
3073
+ */
3074
+ export function storedLook(v: unknown, seed: string): BotonLook | null {
3075
+ const look = parseLook(v);
3076
+ if (look) return look;
3077
+ const critter = parseCritter(v) ?? (validLook24(v) ? critterFromLook24(v, seed) : null);
3078
+ const tater = parseTater(v) ?? (critter ? taterFromCritter(critter) : null);
3079
+ return tater ? lookFromTater(tater) : null;
3080
+ }
3081
+
3082
+ /** A look from a request, for the Boton `seed` names: as storedLook, or a 400. */
3083
+ export function lookInput(v: unknown, seed: string): BotonLook {
3084
+ const look = storedLook(v, seed);
3085
+ if (!look) throw new HttpError(400, 'look must have shape, shell, glow, eyes, brows, mouth, top, ears, visor, panel, arms, backdrop and cheeks (true or false), each one of its listed values');
3086
+ return look;
3087
+ }