agent-mockingbird 0.0.1

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 (227) hide show
  1. package/.agents/skills/btca-cli/SKILL.md +64 -0
  2. package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
  3. package/.agents/skills/frontend-design/SKILL.md +42 -0
  4. package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
  5. package/.env.example +36 -0
  6. package/.githooks/pre-commit +33 -0
  7. package/.github/workflows/ci.yml +309 -0
  8. package/.opencode/bun.lock +18 -0
  9. package/.opencode/package.json +5 -0
  10. package/.opencode/tools/agent_type_manager.ts +100 -0
  11. package/.opencode/tools/config_manager.ts +87 -0
  12. package/.opencode/tools/cron_manager.ts +145 -0
  13. package/.opencode/tools/memory_get.ts +43 -0
  14. package/.opencode/tools/memory_remember.ts +53 -0
  15. package/.opencode/tools/memory_search.ts +48 -0
  16. package/AGENTS.md +126 -0
  17. package/MEMORY.md +2 -0
  18. package/README.md +451 -0
  19. package/THIRD_PARTY_NOTICES.md +11 -0
  20. package/agent-mockingbird.config.example.json +135 -0
  21. package/apps/server/package.json +32 -0
  22. package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
  23. package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
  24. package/apps/server/src/backend/agents/openclawImport.ts +797 -0
  25. package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
  26. package/apps/server/src/backend/agents/service.ts +10 -0
  27. package/apps/server/src/backend/config/example-config.test.ts +20 -0
  28. package/apps/server/src/backend/config/orchestration.ts +243 -0
  29. package/apps/server/src/backend/config/policy.ts +158 -0
  30. package/apps/server/src/backend/config/schema.test.ts +15 -0
  31. package/apps/server/src/backend/config/schema.ts +391 -0
  32. package/apps/server/src/backend/config/semantic.test.ts +34 -0
  33. package/apps/server/src/backend/config/semantic.ts +149 -0
  34. package/apps/server/src/backend/config/service.test.ts +75 -0
  35. package/apps/server/src/backend/config/service.ts +207 -0
  36. package/apps/server/src/backend/config/smoke.ts +77 -0
  37. package/apps/server/src/backend/config/store.test.ts +123 -0
  38. package/apps/server/src/backend/config/store.ts +581 -0
  39. package/apps/server/src/backend/config/testFixtures.ts +5 -0
  40. package/apps/server/src/backend/config/types.ts +56 -0
  41. package/apps/server/src/backend/contracts/events.ts +320 -0
  42. package/apps/server/src/backend/contracts/runtime.ts +111 -0
  43. package/apps/server/src/backend/cron/executor.ts +435 -0
  44. package/apps/server/src/backend/cron/repository.ts +170 -0
  45. package/apps/server/src/backend/cron/service.ts +660 -0
  46. package/apps/server/src/backend/cron/storage.ts +92 -0
  47. package/apps/server/src/backend/cron/types.ts +138 -0
  48. package/apps/server/src/backend/cron/utils.ts +351 -0
  49. package/apps/server/src/backend/db/client.ts +20 -0
  50. package/apps/server/src/backend/db/migrate.ts +40 -0
  51. package/apps/server/src/backend/db/repository.ts +1762 -0
  52. package/apps/server/src/backend/db/schema.ts +113 -0
  53. package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
  54. package/apps/server/src/backend/db/wipe.ts +13 -0
  55. package/apps/server/src/backend/defaults.ts +32 -0
  56. package/apps/server/src/backend/env.ts +48 -0
  57. package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
  58. package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
  59. package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
  60. package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
  61. package/apps/server/src/backend/heartbeat/service.ts +176 -0
  62. package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
  63. package/apps/server/src/backend/heartbeat/state.ts +167 -0
  64. package/apps/server/src/backend/heartbeat/types.ts +54 -0
  65. package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
  66. package/apps/server/src/backend/http/boundedQueue.ts +92 -0
  67. package/apps/server/src/backend/http/parsers.ts +40 -0
  68. package/apps/server/src/backend/http/router.ts +61 -0
  69. package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
  70. package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
  71. package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
  72. package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
  73. package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
  74. package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
  75. package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
  76. package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
  77. package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
  78. package/apps/server/src/backend/http/routes/index.ts +101 -0
  79. package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
  80. package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
  81. package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
  82. package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
  83. package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
  84. package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
  85. package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
  86. package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
  87. package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
  88. package/apps/server/src/backend/http/schemas.ts +64 -0
  89. package/apps/server/src/backend/http/sse.ts +144 -0
  90. package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
  91. package/apps/server/src/backend/logging/logger.ts +64 -0
  92. package/apps/server/src/backend/mcp/service.ts +326 -0
  93. package/apps/server/src/backend/memory/cli.ts +170 -0
  94. package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
  95. package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
  96. package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
  97. package/apps/server/src/backend/memory/qmdPort.ts +61 -0
  98. package/apps/server/src/backend/memory/records.test.ts +66 -0
  99. package/apps/server/src/backend/memory/records.ts +229 -0
  100. package/apps/server/src/backend/memory/service.ts +2012 -0
  101. package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
  102. package/apps/server/src/backend/memory/types.ts +104 -0
  103. package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
  104. package/apps/server/src/backend/opencode/client.ts +98 -0
  105. package/apps/server/src/backend/opencode/models.ts +41 -0
  106. package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
  107. package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
  108. package/apps/server/src/backend/paths.ts +57 -0
  109. package/apps/server/src/backend/prompts/service.ts +100 -0
  110. package/apps/server/src/backend/queue/queue.test.ts +189 -0
  111. package/apps/server/src/backend/queue/service.ts +177 -0
  112. package/apps/server/src/backend/queue/types.ts +39 -0
  113. package/apps/server/src/backend/run/service.ts +576 -0
  114. package/apps/server/src/backend/run/storage.ts +47 -0
  115. package/apps/server/src/backend/run/types.ts +44 -0
  116. package/apps/server/src/backend/runtime/errors.ts +61 -0
  117. package/apps/server/src/backend/runtime/index.ts +72 -0
  118. package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
  119. package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
  120. package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
  121. package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
  122. package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
  123. package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
  124. package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
  125. package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
  126. package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
  127. package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
  128. package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
  129. package/apps/server/src/backend/skills/service.ts +442 -0
  130. package/apps/server/src/backend/workspace/resolve.ts +27 -0
  131. package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
  132. package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
  133. package/apps/server/src/cli/runtime-assets.mjs +269 -0
  134. package/apps/server/src/cli/runtime-assets.test.ts +52 -0
  135. package/apps/server/src/cli/runtime-layout.mjs +75 -0
  136. package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
  137. package/apps/server/src/cli/standaloneBuild.ts +19 -0
  138. package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
  139. package/apps/server/src/index.ts +178 -0
  140. package/apps/server/tsconfig.json +12 -0
  141. package/backlog.md +5 -0
  142. package/bin/agent-mockingbird +2522 -0
  143. package/bin/runtime-layout.mjs +75 -0
  144. package/build-bin.ts +34 -0
  145. package/build-cli.mjs +37 -0
  146. package/build.ts +40 -0
  147. package/bun-env.d.ts +11 -0
  148. package/bun.lock +888 -0
  149. package/bunfig.toml +2 -0
  150. package/components.json +21 -0
  151. package/config.json +130 -0
  152. package/deploy/RELEASE_INSTALL.md +112 -0
  153. package/deploy/docker-compose.yml +42 -0
  154. package/deploy/systemd/README.md +46 -0
  155. package/deploy/systemd/agent-mockingbird.service +28 -0
  156. package/deploy/systemd/opencode.service +25 -0
  157. package/docs/legacy-config-ui-reference.md +51 -0
  158. package/docs/memory-e2e-trace-2026-03-04.md +63 -0
  159. package/docs/memory-ops.md +96 -0
  160. package/docs/memory-runtime-contract.md +42 -0
  161. package/docs/memory-tuning-remote-2026-03-04.md +59 -0
  162. package/docs/opencode-rebase-workflow-plan.md +614 -0
  163. package/docs/opencode-startup-sync-plan.md +94 -0
  164. package/docs/vendor-opencode.md +41 -0
  165. package/drizzle/0000_famous_turbo.sql +49 -0
  166. package/drizzle/0001_cron_memory_aux.sql +160 -0
  167. package/drizzle/0002_runtime_session_bindings.sql +28 -0
  168. package/drizzle/0003_background_runs.sql +27 -0
  169. package/drizzle/0004_memory_open_write.sql +63 -0
  170. package/drizzle/0005_signal_channel.sql +47 -0
  171. package/drizzle/0006_usage_event_dimensions.sql +7 -0
  172. package/drizzle/meta/0000_snapshot.json +341 -0
  173. package/drizzle/meta/_journal.json +55 -0
  174. package/drizzle.config.ts +14 -0
  175. package/eslint.config.mjs +77 -0
  176. package/knip.json +18 -0
  177. package/memory/2026-03-04.md +4 -0
  178. package/opencode.lock.json +16 -0
  179. package/package.json +67 -0
  180. package/packages/agent-mockingbird-installer/README.md +31 -0
  181. package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
  182. package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
  183. package/packages/agent-mockingbird-installer/package.json +23 -0
  184. package/packages/contracts/package.json +19 -0
  185. package/packages/contracts/src/agentTypes.ts +122 -0
  186. package/packages/contracts/src/cron.ts +146 -0
  187. package/packages/contracts/src/dashboard.ts +378 -0
  188. package/packages/contracts/src/index.ts +3 -0
  189. package/packages/contracts/tsconfig.json +4 -0
  190. package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
  191. package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
  192. package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
  193. package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
  194. package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
  195. package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
  196. package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
  197. package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
  198. package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
  199. package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
  200. package/runtime-assets/opencode-config/opencode.jsonc +25 -0
  201. package/runtime-assets/opencode-config/package.json +5 -0
  202. package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
  203. package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
  204. package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
  205. package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
  206. package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
  207. package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
  208. package/runtime-assets/workspace/AGENTS.md +56 -0
  209. package/runtime-assets/workspace/MEMORY.md +4 -0
  210. package/scripts/build-release-bundle.sh +66 -0
  211. package/scripts/check-ship.ts +383 -0
  212. package/scripts/dev-opencode.sh +17 -0
  213. package/scripts/dev-stack-opencode.sh +15 -0
  214. package/scripts/dev-stack.sh +61 -0
  215. package/scripts/install-systemd.sh +87 -0
  216. package/scripts/memory-e2e.sh +76 -0
  217. package/scripts/memory-trace-e2e.sh +141 -0
  218. package/scripts/migrate-opencode-env.ts +108 -0
  219. package/scripts/onboard/bootstrap.sh +32 -0
  220. package/scripts/opencode-swap.ts +78 -0
  221. package/scripts/opencode-sync.ts +715 -0
  222. package/scripts/runtime-assets-sync.mjs +83 -0
  223. package/scripts/setup-git-hooks.ts +39 -0
  224. package/tsconfig.json +45 -0
  225. package/tui.json +98 -0
  226. package/turbo.json +36 -0
  227. package/vendor/OPENCODE_VENDOR.md +13 -0
@@ -0,0 +1,1762 @@
1
+ import type {
2
+ ChatMessage,
3
+ ChatMessagePart,
4
+ DashboardBootstrap,
5
+ HeartbeatSnapshot,
6
+ MessageMemoryTrace,
7
+ SessionSummary,
8
+ UsageSnapshot,
9
+ } from "@agent-mockingbird/contracts/dashboard";
10
+ import type { SQLQueryBindings } from "bun:sqlite";
11
+
12
+ import { sqlite } from "./client";
13
+ import { toLegacySpecialistAgent } from "../agents/service";
14
+ import { getConfig as getManagedConfig } from "../config/service";
15
+ import { clearCronTables } from "../cron/storage";
16
+ import { DEFAULT_SESSIONS } from "../defaults";
17
+ import { ensureHeartbeatStateTable } from "../heartbeat/state";
18
+ import { clearRunTables } from "../run/storage";
19
+ import { listManagedSkillCatalog } from "../skills/service";
20
+
21
+ type RuntimeEventSource = "api" | "runtime" | "scheduler" | "system";
22
+
23
+ interface SessionRow {
24
+ id: string;
25
+ title: string;
26
+ model: string;
27
+ status: "active" | "idle";
28
+ message_count: number;
29
+ last_active_at: number;
30
+ }
31
+
32
+ interface MessageRow {
33
+ id: string;
34
+ session_id: string;
35
+ role: "user" | "assistant";
36
+ content: string;
37
+ created_at: number;
38
+ }
39
+
40
+ interface MessageMemoryTraceRow {
41
+ message_id: string;
42
+ trace_json: string;
43
+ }
44
+
45
+ interface MessagePartsRow {
46
+ message_id: string;
47
+ parts_json: string;
48
+ }
49
+
50
+ interface HeartbeatRow {
51
+ online: number;
52
+ created_at: number;
53
+ }
54
+
55
+ interface UsageAggregateRow {
56
+ request_count: number;
57
+ input_tokens: number;
58
+ output_tokens: number;
59
+ cost_micros: number;
60
+ }
61
+
62
+ interface UsageGroupedRow extends UsageAggregateRow {
63
+ provider_id: string | null;
64
+ model_id: string | null;
65
+ }
66
+
67
+ interface UsageRecentRow extends UsageGroupedRow {
68
+ id: string;
69
+ session_id: string | null;
70
+ created_at: number;
71
+ title: string | null;
72
+ }
73
+
74
+ interface RuntimeSessionBindingRow {
75
+ runtime: string;
76
+ session_id: string;
77
+ external_session_id: string;
78
+ updated_at: number;
79
+ }
80
+
81
+ interface ExistingMessageIdRow {
82
+ id: string;
83
+ content: string;
84
+ }
85
+
86
+ interface RuntimeSessionBindingRecord {
87
+ runtime: string;
88
+ sessionId: string;
89
+ externalSessionId: string;
90
+ updatedAt: string;
91
+ }
92
+
93
+ export type BackgroundRunStatus =
94
+ | "created"
95
+ | "running"
96
+ | "retrying"
97
+ | "idle"
98
+ | "completed"
99
+ | "failed"
100
+ | "aborted";
101
+
102
+ interface BackgroundRunRow {
103
+ id: string;
104
+ runtime: string;
105
+ parent_session_id: string;
106
+ parent_external_session_id: string;
107
+ child_external_session_id: string;
108
+ requested_by: string;
109
+ prompt: string;
110
+ status: BackgroundRunStatus;
111
+ result_summary: string | null;
112
+ error: string | null;
113
+ created_at: number;
114
+ updated_at: number;
115
+ started_at: number | null;
116
+ completed_at: number | null;
117
+ }
118
+
119
+ export interface BackgroundRunRecord {
120
+ id: string;
121
+ runtime: string;
122
+ parentSessionId: string;
123
+ parentExternalSessionId: string;
124
+ childExternalSessionId: string;
125
+ requestedBy: string;
126
+ prompt: string;
127
+ status: BackgroundRunStatus;
128
+ resultSummary: string | null;
129
+ error: string | null;
130
+ createdAt: string;
131
+ updatedAt: string;
132
+ startedAt: string | null;
133
+ completedAt: string | null;
134
+ }
135
+
136
+ interface UsageDashboardWindowSnapshot {
137
+ requestCount: number;
138
+ inputTokens: number;
139
+ outputTokens: number;
140
+ totalTokens: number;
141
+ estimatedCostUsd: number;
142
+ }
143
+
144
+ interface UsageDashboardGroupRecord extends UsageDashboardWindowSnapshot {
145
+ providerId: string;
146
+ modelId?: string;
147
+ }
148
+
149
+ interface UsageDashboardRecentRecord extends UsageDashboardWindowSnapshot {
150
+ id: string;
151
+ createdAt: string;
152
+ sessionId: string | null;
153
+ sessionTitle: string | null;
154
+ providerId: string | null;
155
+ modelId: string | null;
156
+ }
157
+
158
+ interface UsageDashboardSnapshot {
159
+ rangeStartAt: string | null;
160
+ rangeEndAtExclusive: string | null;
161
+ totals: UsageDashboardWindowSnapshot;
162
+ unattributedTotals: UsageDashboardWindowSnapshot;
163
+ providers: UsageDashboardGroupRecord[];
164
+ models: UsageDashboardGroupRecord[];
165
+ recent: UsageDashboardRecentRecord[];
166
+ forwardOnlyBreakdown: true;
167
+ }
168
+
169
+ interface SessionMessageImportInput {
170
+ id: string;
171
+ role: "user" | "assistant";
172
+ content: string;
173
+ createdAt: number;
174
+ parts?: ChatMessagePart[];
175
+ }
176
+
177
+ const nowMs = () => Date.now();
178
+ const toIso = (millis: number) => new Date(millis).toISOString();
179
+ const toMillisOrNull = (isoTimestamp: string | null) => {
180
+ if (!isoTimestamp) return null;
181
+ const parsed = Date.parse(isoTimestamp);
182
+ return Number.isFinite(parsed) ? parsed : null;
183
+ };
184
+ const sessionIdPrefix = "session";
185
+ function parseQualifiedModelRef(rawModel: string | null | undefined) {
186
+ const trimmed = rawModel?.trim() ?? "";
187
+ if (!trimmed) return { providerId: null, modelId: null };
188
+ const slash = trimmed.indexOf("/");
189
+ if (slash <= 0 || slash === trimmed.length - 1) {
190
+ return { providerId: null, modelId: null };
191
+ }
192
+ const providerId = trimmed.slice(0, slash).trim();
193
+ const modelId = trimmed.slice(slash + 1).trim();
194
+ if (!providerId || !modelId) {
195
+ return { providerId: null, modelId: null };
196
+ }
197
+ return { providerId, modelId };
198
+ }
199
+
200
+ function usageSnapshotFromAggregate(row: UsageAggregateRow): UsageDashboardWindowSnapshot {
201
+ return {
202
+ requestCount: row.request_count,
203
+ inputTokens: row.input_tokens,
204
+ outputTokens: row.output_tokens,
205
+ totalTokens: row.input_tokens + row.output_tokens,
206
+ estimatedCostUsd: row.cost_micros / 1_000_000,
207
+ };
208
+ }
209
+
210
+ interface UsageDashboardRange {
211
+ startAt: number | null;
212
+ endAtExclusive: number | null;
213
+ }
214
+
215
+ function normalizeUsageDashboardRange(input?: Partial<UsageDashboardRange> | null): UsageDashboardRange {
216
+ const startAt = Number.isFinite(input?.startAt) ? Math.trunc(input!.startAt as number) : null;
217
+ const endAtExclusive = Number.isFinite(input?.endAtExclusive)
218
+ ? Math.trunc(input!.endAtExclusive as number)
219
+ : null;
220
+
221
+ return {
222
+ startAt: startAt !== null && startAt >= 0 ? startAt : null,
223
+ endAtExclusive: endAtExclusive !== null && endAtExclusive >= 0 ? endAtExclusive : null,
224
+ };
225
+ }
226
+
227
+ function usageRangeFilter(range: UsageDashboardRange) {
228
+ const clauses: string[] = [];
229
+ const bindings: number[] = [];
230
+
231
+ if (range.startAt !== null) {
232
+ clauses.push(`usage_events.created_at >= ?${bindings.length + 1}`);
233
+ bindings.push(range.startAt);
234
+ }
235
+
236
+ if (range.endAtExclusive !== null) {
237
+ clauses.push(`usage_events.created_at < ?${bindings.length + 1}`);
238
+ bindings.push(range.endAtExclusive);
239
+ }
240
+
241
+ return {
242
+ whereClause: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "",
243
+ bindings,
244
+ };
245
+ }
246
+
247
+ function scalar<T>(query: string, ...bindings: SQLQueryBindings[]): T {
248
+ const row = sqlite.query(query).get(...bindings);
249
+ return row as T;
250
+ }
251
+
252
+ function allRows<T>(query: string, ...bindings: SQLQueryBindings[]): T[] {
253
+ return sqlite.query(query).all(...bindings) as T[];
254
+ }
255
+
256
+ function sessionRowToSummary(row: SessionRow): SessionSummary {
257
+ return {
258
+ id: row.id,
259
+ title: row.title,
260
+ model: row.model,
261
+ status: row.status,
262
+ lastActiveAt: toIso(row.last_active_at),
263
+ messageCount: row.message_count,
264
+ };
265
+ }
266
+
267
+ function messageRowToMessage(row: MessageRow): ChatMessage {
268
+ return {
269
+ id: row.id,
270
+ role: row.role,
271
+ content: row.content,
272
+ at: toIso(row.created_at),
273
+ };
274
+ }
275
+
276
+ function hydrateMessagesForSession(sessionId: string, messages: ChatMessage[]): ChatMessage[] {
277
+ if (!messages.length) return messages;
278
+
279
+ const messageIds = messages.map(message => message.id);
280
+ const placeholders = messageIds.map(() => "?").join(", ");
281
+ const traceRows = sqlite
282
+ .query(
283
+ `
284
+ SELECT message_id, trace_json
285
+ FROM message_memory_traces
286
+ WHERE session_id = ?1
287
+ AND message_id IN (${placeholders})
288
+ `,
289
+ )
290
+ .all(sessionId, ...messageIds) as MessageMemoryTraceRow[];
291
+ const traceMap = new Map<string, MessageMemoryTrace>();
292
+ for (const row of traceRows) {
293
+ try {
294
+ traceMap.set(row.message_id, JSON.parse(row.trace_json) as MessageMemoryTrace);
295
+ } catch {
296
+ // ignore malformed trace rows
297
+ }
298
+ }
299
+
300
+ const partRows = sqlite
301
+ .query(
302
+ `
303
+ SELECT message_id, parts_json
304
+ FROM message_parts
305
+ WHERE session_id = ?1
306
+ AND message_id IN (${placeholders})
307
+ `,
308
+ )
309
+ .all(sessionId, ...messageIds) as MessagePartsRow[];
310
+ const partMap = new Map<string, ChatMessagePart[]>();
311
+ for (const row of partRows) {
312
+ try {
313
+ const parsed = JSON.parse(row.parts_json) as unknown;
314
+ const parts = normalizeChatMessageParts(parsed);
315
+ if (parts.length > 0) {
316
+ partMap.set(row.message_id, parts);
317
+ }
318
+ } catch {
319
+ // ignore malformed part rows
320
+ }
321
+ }
322
+
323
+ return messages.map(message => ({
324
+ ...message,
325
+ memoryTrace: traceMap.get(message.id),
326
+ parts: partMap.get(message.id),
327
+ }));
328
+ }
329
+
330
+ function backgroundRunRowToRecord(row: BackgroundRunRow): BackgroundRunRecord {
331
+ return {
332
+ id: row.id,
333
+ runtime: row.runtime,
334
+ parentSessionId: row.parent_session_id,
335
+ parentExternalSessionId: row.parent_external_session_id,
336
+ childExternalSessionId: row.child_external_session_id,
337
+ requestedBy: row.requested_by,
338
+ prompt: row.prompt,
339
+ status: row.status,
340
+ resultSummary: row.result_summary,
341
+ error: row.error,
342
+ createdAt: toIso(row.created_at),
343
+ updatedAt: toIso(row.updated_at),
344
+ startedAt: row.started_at ? toIso(row.started_at) : null,
345
+ completedAt: row.completed_at ? toIso(row.completed_at) : null,
346
+ };
347
+ }
348
+
349
+ function runtimeSessionBindingRowToRecord(row: RuntimeSessionBindingRow): RuntimeSessionBindingRecord {
350
+ return {
351
+ runtime: row.runtime,
352
+ sessionId: row.session_id,
353
+ externalSessionId: row.external_session_id,
354
+ updatedAt: toIso(row.updated_at),
355
+ };
356
+ }
357
+
358
+ function isRecord(value: unknown): value is Record<string, unknown> {
359
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
360
+ }
361
+
362
+ function normalizeIsoTimestamp(value: unknown): string | undefined {
363
+ if (typeof value !== "string") return undefined;
364
+ const trimmed = value.trim();
365
+ if (!trimmed) return undefined;
366
+ const parsed = Date.parse(trimmed);
367
+ if (!Number.isFinite(parsed)) return undefined;
368
+ return new Date(parsed).toISOString();
369
+ }
370
+
371
+ function normalizeChatMessagePart(raw: unknown): ChatMessagePart | null {
372
+ if (!isRecord(raw)) return null;
373
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
374
+ const type = typeof raw.type === "string" ? raw.type : "";
375
+ if (!id || !type) return null;
376
+
377
+ if (type === "thinking") {
378
+ const text = typeof raw.text === "string" ? raw.text.trim() : "";
379
+ if (!text) return null;
380
+ return {
381
+ id,
382
+ type: "thinking",
383
+ text,
384
+ startedAt: normalizeIsoTimestamp(raw.startedAt),
385
+ endedAt: normalizeIsoTimestamp(raw.endedAt),
386
+ observedAt: normalizeIsoTimestamp(raw.observedAt),
387
+ };
388
+ }
389
+
390
+ if (type === "tool_call") {
391
+ const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId.trim() : "";
392
+ const tool = typeof raw.tool === "string" ? raw.tool.trim() : "";
393
+ const status = typeof raw.status === "string" ? raw.status : "";
394
+ if (!toolCallId || !tool || (status !== "pending" && status !== "running" && status !== "completed" && status !== "error")) {
395
+ return null;
396
+ }
397
+ const output = typeof raw.output === "string" ? raw.output : undefined;
398
+ const error = typeof raw.error === "string" ? raw.error : undefined;
399
+ return {
400
+ id,
401
+ type: "tool_call",
402
+ toolCallId,
403
+ tool,
404
+ status,
405
+ input: isRecord(raw.input) ? raw.input : undefined,
406
+ output,
407
+ error,
408
+ startedAt: normalizeIsoTimestamp(raw.startedAt),
409
+ endedAt: normalizeIsoTimestamp(raw.endedAt),
410
+ observedAt: normalizeIsoTimestamp(raw.observedAt),
411
+ };
412
+ }
413
+
414
+ return null;
415
+ }
416
+
417
+ function normalizeChatMessageParts(value: unknown): ChatMessagePart[] {
418
+ if (!Array.isArray(value)) return [];
419
+ return value
420
+ .map(part => normalizeChatMessagePart(part))
421
+ .filter((part): part is ChatMessagePart => Boolean(part));
422
+ }
423
+
424
+ function ensureAuxiliaryTables() {
425
+ sqlite.exec(`
426
+ CREATE TABLE IF NOT EXISTS message_memory_traces (
427
+ message_id TEXT PRIMARY KEY,
428
+ session_id TEXT NOT NULL,
429
+ trace_json TEXT NOT NULL,
430
+ created_at INTEGER NOT NULL
431
+ );
432
+
433
+ CREATE INDEX IF NOT EXISTS message_memory_traces_session_idx
434
+ ON message_memory_traces(session_id, created_at DESC);
435
+
436
+ CREATE TABLE IF NOT EXISTS message_parts (
437
+ message_id TEXT PRIMARY KEY,
438
+ session_id TEXT NOT NULL,
439
+ parts_json TEXT NOT NULL,
440
+ created_at INTEGER NOT NULL,
441
+ updated_at INTEGER NOT NULL
442
+ );
443
+
444
+ CREATE INDEX IF NOT EXISTS message_parts_session_updated_idx
445
+ ON message_parts(session_id, updated_at DESC);
446
+ `);
447
+ }
448
+
449
+ ensureAuxiliaryTables();
450
+ ensureHeartbeatStateTable();
451
+
452
+ function getDefaultSessionModel() {
453
+ try {
454
+ const runtimeConfig = getManagedConfig();
455
+ const provider = runtimeConfig.runtime.opencode.providerId.trim();
456
+ const model = runtimeConfig.runtime.opencode.modelId.trim();
457
+ if (provider && model) {
458
+ return `${provider}/${model}`;
459
+ }
460
+ } catch {
461
+ // db:wipe should remain operable even when runtime config is temporarily invalid.
462
+ }
463
+ return DEFAULT_SESSIONS[0]?.model ?? "default";
464
+ }
465
+
466
+ function createSessionRecord(input: {
467
+ id: string;
468
+ title: string;
469
+ model: string;
470
+ status?: SessionRow["status"];
471
+ messageCount?: number;
472
+ createdAt?: number;
473
+ }) {
474
+ const createdAt = input.createdAt ?? nowMs();
475
+ sqlite
476
+ .query(
477
+ `
478
+ INSERT INTO sessions (
479
+ id, title, model, status, message_count, created_at, updated_at, last_active_at
480
+ )
481
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6)
482
+ `,
483
+ )
484
+ .run(input.id, input.title, input.model, input.status ?? "idle", input.messageCount ?? 0, createdAt);
485
+ }
486
+
487
+ function allocateSessionId() {
488
+ let id = `${sessionIdPrefix}-${crypto.randomUUID().slice(0, 8)}`;
489
+ while (scalar<{ count: number }>("SELECT COUNT(*) as count FROM sessions WHERE id = ?1", id).count > 0) {
490
+ id = `${sessionIdPrefix}-${crypto.randomUUID().slice(0, 8)}`;
491
+ }
492
+ return id;
493
+ }
494
+
495
+ function seedDefaultState(createdAt: number) {
496
+ const defaultModel = getDefaultSessionModel();
497
+
498
+ for (const session of DEFAULT_SESSIONS) {
499
+ createSessionRecord({
500
+ id: session.id,
501
+ title: session.title,
502
+ model: defaultModel,
503
+ createdAt,
504
+ status: "idle",
505
+ messageCount: 0,
506
+ });
507
+ }
508
+
509
+ sqlite
510
+ .query(
511
+ `
512
+ INSERT INTO heartbeat_events (id, online, source, created_at)
513
+ VALUES (?1, 1, 'system', ?2)
514
+ `,
515
+ )
516
+ .run(crypto.randomUUID(), createdAt);
517
+ }
518
+
519
+ export function ensureSeedData() {
520
+ const seeded = scalar<{ count: number }>("SELECT COUNT(*) as count FROM sessions").count > 0;
521
+ if (seeded) return;
522
+
523
+ const seed = sqlite.transaction(() => {
524
+ seedDefaultState(nowMs());
525
+ });
526
+
527
+ seed();
528
+ }
529
+
530
+ export function createSession(input?: { title?: string; model?: string }): SessionSummary {
531
+ const inserted = sqlite.transaction(() => {
532
+ const totalSessions = scalar<{ count: number }>("SELECT COUNT(*) as count FROM sessions").count;
533
+ const title = input?.title?.trim() || `Session ${totalSessions + 1}`;
534
+ const model = input?.model?.trim() || getDefaultSessionModel();
535
+ const id = allocateSessionId();
536
+
537
+ createSessionRecord({
538
+ id,
539
+ title,
540
+ model,
541
+ status: "idle",
542
+ messageCount: 0,
543
+ });
544
+ return id;
545
+ });
546
+
547
+ const sessionId = inserted();
548
+ const session = getSessionById(sessionId);
549
+ if (!session) {
550
+ throw new Error(`Failed to load newly created session ${sessionId}`);
551
+ }
552
+ return session;
553
+ }
554
+
555
+ export function resetDatabaseToDefaults(): DashboardBootstrap {
556
+ const reset = sqlite.transaction(() => {
557
+ clearCronTables();
558
+ clearRunTables();
559
+ sqlite.query("DELETE FROM message_memory_traces").run();
560
+ sqlite.query("DELETE FROM message_parts").run();
561
+ sqlite.query("DELETE FROM messages").run();
562
+ sqlite.query("DELETE FROM usage_events").run();
563
+ sqlite.query("DELETE FROM heartbeat_events").run();
564
+ sqlite.query("DELETE FROM heartbeat_runtime_state").run();
565
+ sqlite.query("DELETE FROM runtime_config").run();
566
+ sqlite.query("DELETE FROM runtime_session_bindings").run();
567
+ sqlite.query("DELETE FROM background_runs").run();
568
+ sqlite.query("DELETE FROM sessions").run();
569
+ seedDefaultState(nowMs());
570
+ });
571
+ reset();
572
+ try {
573
+ return getDashboardBootstrap();
574
+ } catch {
575
+ // Keep reset operable even when config is temporarily invalid.
576
+ return {
577
+ sessions: listSessions(),
578
+ skills: [],
579
+ mcps: [],
580
+ agents: [],
581
+ usage: getUsageSnapshot(),
582
+ heartbeat: getHeartbeatSnapshot(),
583
+ };
584
+ }
585
+ }
586
+
587
+ export function listSessions(): SessionSummary[] {
588
+ const rows = allRows<SessionRow>(
589
+ `
590
+ SELECT id, title, model, status, message_count, last_active_at
591
+ FROM sessions
592
+ ORDER BY last_active_at DESC
593
+ `,
594
+ );
595
+ return rows.map(sessionRowToSummary);
596
+ }
597
+
598
+ export function getSessionById(sessionId: string): SessionSummary | null {
599
+ const row = scalar<SessionRow | null>(
600
+ `
601
+ SELECT id, title, model, status, message_count, last_active_at
602
+ FROM sessions
603
+ WHERE id = ?1
604
+ `,
605
+ sessionId,
606
+ );
607
+ return row ? sessionRowToSummary(row) : null;
608
+ }
609
+
610
+ export function setSessionModel(sessionId: string, model: string): SessionSummary | null {
611
+ const normalized = model.trim();
612
+ if (!normalized) return null;
613
+ const updatedAt = nowMs();
614
+ sqlite
615
+ .query(
616
+ `
617
+ UPDATE sessions
618
+ SET model = ?2, updated_at = ?3
619
+ WHERE id = ?1
620
+ `,
621
+ )
622
+ .run(sessionId, normalized, updatedAt);
623
+
624
+ return getSessionById(sessionId);
625
+ }
626
+
627
+ export function setSessionTitle(sessionId: string, title: string): SessionSummary | null {
628
+ const normalized = title.trim();
629
+ if (!normalized) return null;
630
+ const updatedAt = nowMs();
631
+ sqlite
632
+ .query(
633
+ `
634
+ UPDATE sessions
635
+ SET title = ?2, updated_at = ?3
636
+ WHERE id = ?1
637
+ `,
638
+ )
639
+ .run(sessionId, normalized, updatedAt);
640
+
641
+ return getSessionById(sessionId);
642
+ }
643
+
644
+ export function listMessagesForSession(sessionId: string): ChatMessage[] {
645
+ const rows = allRows<MessageRow>(
646
+ `
647
+ SELECT id, session_id, role, content, created_at
648
+ FROM messages
649
+ WHERE session_id = ?1
650
+ ORDER BY
651
+ created_at ASC,
652
+ CASE role
653
+ WHEN 'user' THEN 0
654
+ ELSE 1
655
+ END ASC,
656
+ id ASC
657
+ `,
658
+ sessionId,
659
+ );
660
+
661
+ return hydrateMessagesForSession(sessionId, rows.map(messageRowToMessage));
662
+ }
663
+
664
+ export function setMessageMemoryTrace(input: {
665
+ sessionId: string;
666
+ messageId: string;
667
+ trace: MessageMemoryTrace;
668
+ createdAt?: number;
669
+ }) {
670
+ ensureAuxiliaryTables();
671
+ const createdAt = input.createdAt ?? nowMs();
672
+ sqlite
673
+ .query(
674
+ `
675
+ INSERT INTO message_memory_traces (message_id, session_id, trace_json, created_at)
676
+ VALUES (?1, ?2, ?3, ?4)
677
+ ON CONFLICT(message_id) DO UPDATE SET
678
+ session_id = excluded.session_id,
679
+ trace_json = excluded.trace_json,
680
+ created_at = excluded.created_at
681
+ `,
682
+ )
683
+ .run(input.messageId, input.sessionId, JSON.stringify(input.trace), createdAt);
684
+ }
685
+
686
+ function setMessageParts(input: {
687
+ sessionId: string;
688
+ messageId: string;
689
+ parts: ChatMessagePart[];
690
+ createdAt?: number;
691
+ updatedAt?: number;
692
+ }) {
693
+ ensureAuxiliaryTables();
694
+ const createdAt = input.createdAt ?? nowMs();
695
+ const updatedAt = input.updatedAt ?? createdAt;
696
+ const parts = normalizeChatMessageParts(input.parts);
697
+ sqlite
698
+ .query(
699
+ `
700
+ INSERT INTO message_parts (message_id, session_id, parts_json, created_at, updated_at)
701
+ VALUES (?1, ?2, ?3, ?4, ?5)
702
+ ON CONFLICT(message_id) DO UPDATE SET
703
+ session_id = excluded.session_id,
704
+ parts_json = excluded.parts_json,
705
+ updated_at = excluded.updated_at
706
+ `,
707
+ )
708
+ .run(input.messageId, input.sessionId, JSON.stringify(parts), createdAt, updatedAt);
709
+ }
710
+
711
+ export function getUsageSnapshot(): UsageSnapshot {
712
+ const row = scalar<UsageAggregateRow>(
713
+ `
714
+ SELECT
715
+ COALESCE(SUM(request_count_delta), 0) AS request_count,
716
+ COALESCE(SUM(input_tokens_delta), 0) AS input_tokens,
717
+ COALESCE(SUM(output_tokens_delta), 0) AS output_tokens,
718
+ COALESCE(SUM(estimated_cost_usd_delta_micros), 0) AS cost_micros
719
+ FROM usage_events
720
+ `,
721
+ );
722
+
723
+ const snapshot = usageSnapshotFromAggregate(row);
724
+ return {
725
+ requestCount: snapshot.requestCount,
726
+ inputTokens: snapshot.inputTokens,
727
+ outputTokens: snapshot.outputTokens,
728
+ estimatedCostUsd: snapshot.estimatedCostUsd,
729
+ };
730
+ }
731
+
732
+ export function recordUsageDelta(input: {
733
+ id?: string;
734
+ sessionId?: string;
735
+ providerId?: string | null;
736
+ modelId?: string | null;
737
+ requestCountDelta: number;
738
+ inputTokensDelta: number;
739
+ outputTokensDelta: number;
740
+ estimatedCostUsdDelta: number;
741
+ source: RuntimeEventSource;
742
+ createdAt?: number;
743
+ }) {
744
+ const createdAt = input.createdAt ?? nowMs();
745
+ let providerId = input.providerId?.trim() || null;
746
+ let modelId = input.modelId?.trim() || null;
747
+
748
+ if ((!providerId || !modelId) && input.sessionId) {
749
+ const session = scalar<{ model: string } | null>(
750
+ `
751
+ SELECT model
752
+ FROM sessions
753
+ WHERE id = ?1
754
+ `,
755
+ input.sessionId,
756
+ );
757
+ if (session?.model) {
758
+ const parsed = parseQualifiedModelRef(session.model);
759
+ providerId ||= parsed.providerId;
760
+ modelId ||= parsed.modelId;
761
+ }
762
+ }
763
+
764
+ sqlite
765
+ .query(
766
+ `
767
+ INSERT OR IGNORE INTO usage_events (
768
+ id, session_id, provider_id, model_id, request_count_delta, input_tokens_delta,
769
+ output_tokens_delta, estimated_cost_usd_delta_micros, source, created_at
770
+ )
771
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
772
+ `,
773
+ )
774
+ .run(
775
+ input.id ?? crypto.randomUUID(),
776
+ input.sessionId ?? null,
777
+ providerId,
778
+ modelId,
779
+ input.requestCountDelta,
780
+ input.inputTokensDelta,
781
+ input.outputTokensDelta,
782
+ Math.round(input.estimatedCostUsdDelta * 1_000_000),
783
+ input.source,
784
+ createdAt,
785
+ );
786
+ }
787
+
788
+ export function getUsageDashboardSnapshot(input?: Partial<UsageDashboardRange> | null): UsageDashboardSnapshot {
789
+ const range = normalizeUsageDashboardRange(input);
790
+ const { whereClause: usageEventsWhereClause, bindings } = usageRangeFilter(range);
791
+
792
+ const totals = scalar<UsageAggregateRow>(
793
+ `
794
+ SELECT
795
+ COALESCE(SUM(request_count_delta), 0) AS request_count,
796
+ COALESCE(SUM(input_tokens_delta), 0) AS input_tokens,
797
+ COALESCE(SUM(output_tokens_delta), 0) AS output_tokens,
798
+ COALESCE(SUM(estimated_cost_usd_delta_micros), 0) AS cost_micros
799
+ FROM usage_events
800
+ ${usageEventsWhereClause}
801
+ `,
802
+ ...bindings,
803
+ );
804
+ const unattributedTotals = scalar<UsageAggregateRow>(
805
+ `
806
+ SELECT
807
+ COALESCE(SUM(request_count_delta), 0) AS request_count,
808
+ COALESCE(SUM(input_tokens_delta), 0) AS input_tokens,
809
+ COALESCE(SUM(output_tokens_delta), 0) AS output_tokens,
810
+ COALESCE(SUM(estimated_cost_usd_delta_micros), 0) AS cost_micros
811
+ FROM usage_events
812
+ ${usageEventsWhereClause ? `${usageEventsWhereClause} AND` : "WHERE"} (provider_id IS NULL OR model_id IS NULL)
813
+ `,
814
+ ...bindings,
815
+ );
816
+ const providers = allRows<UsageGroupedRow>(
817
+ `
818
+ SELECT
819
+ provider_id,
820
+ NULL AS model_id,
821
+ COALESCE(SUM(request_count_delta), 0) AS request_count,
822
+ COALESCE(SUM(input_tokens_delta), 0) AS input_tokens,
823
+ COALESCE(SUM(output_tokens_delta), 0) AS output_tokens,
824
+ COALESCE(SUM(estimated_cost_usd_delta_micros), 0) AS cost_micros
825
+ FROM usage_events
826
+ ${usageEventsWhereClause ? `${usageEventsWhereClause} AND provider_id IS NOT NULL` : "WHERE provider_id IS NOT NULL"}
827
+ GROUP BY provider_id
828
+ ORDER BY cost_micros DESC, output_tokens DESC, provider_id ASC
829
+ `,
830
+ ...bindings,
831
+ ).map(row => ({
832
+ providerId: row.provider_id ?? "unknown",
833
+ ...usageSnapshotFromAggregate(row),
834
+ }));
835
+ const models = allRows<UsageGroupedRow>(
836
+ `
837
+ SELECT
838
+ provider_id,
839
+ model_id,
840
+ COALESCE(SUM(request_count_delta), 0) AS request_count,
841
+ COALESCE(SUM(input_tokens_delta), 0) AS input_tokens,
842
+ COALESCE(SUM(output_tokens_delta), 0) AS output_tokens,
843
+ COALESCE(SUM(estimated_cost_usd_delta_micros), 0) AS cost_micros
844
+ FROM usage_events
845
+ ${usageEventsWhereClause
846
+ ? `${usageEventsWhereClause} AND provider_id IS NOT NULL AND model_id IS NOT NULL`
847
+ : "WHERE provider_id IS NOT NULL AND model_id IS NOT NULL"}
848
+ GROUP BY provider_id, model_id
849
+ ORDER BY cost_micros DESC, output_tokens DESC, provider_id ASC, model_id ASC
850
+ `,
851
+ ...bindings,
852
+ ).map(row => ({
853
+ providerId: row.provider_id ?? "unknown",
854
+ modelId: row.model_id ?? "unknown",
855
+ ...usageSnapshotFromAggregate(row),
856
+ }));
857
+ const recent = allRows<UsageRecentRow>(
858
+ `
859
+ SELECT
860
+ usage_events.id,
861
+ usage_events.session_id,
862
+ usage_events.provider_id,
863
+ usage_events.model_id,
864
+ usage_events.request_count_delta AS request_count,
865
+ usage_events.input_tokens_delta AS input_tokens,
866
+ usage_events.output_tokens_delta AS output_tokens,
867
+ usage_events.estimated_cost_usd_delta_micros AS cost_micros,
868
+ usage_events.created_at,
869
+ sessions.title
870
+ FROM usage_events
871
+ LEFT JOIN sessions ON sessions.id = usage_events.session_id
872
+ ${usageEventsWhereClause}
873
+ ORDER BY usage_events.created_at DESC, usage_events.id DESC
874
+ LIMIT 50
875
+ `,
876
+ ...bindings,
877
+ ).map(row => ({
878
+ id: row.id,
879
+ createdAt: toIso(row.created_at),
880
+ sessionId: row.session_id,
881
+ sessionTitle: row.title,
882
+ providerId: row.provider_id,
883
+ modelId: row.model_id,
884
+ ...usageSnapshotFromAggregate(row),
885
+ }));
886
+
887
+ return {
888
+ rangeStartAt: range.startAt === null ? null : toIso(range.startAt),
889
+ rangeEndAtExclusive: range.endAtExclusive === null ? null : toIso(range.endAtExclusive),
890
+ totals: usageSnapshotFromAggregate(totals),
891
+ unattributedTotals: usageSnapshotFromAggregate(unattributedTotals),
892
+ providers,
893
+ models,
894
+ recent,
895
+ forwardOnlyBreakdown: true,
896
+ };
897
+ }
898
+
899
+ export function getHeartbeatSnapshot(): HeartbeatSnapshot {
900
+ const row = scalar<HeartbeatRow | null>(
901
+ `
902
+ SELECT online, created_at
903
+ FROM heartbeat_events
904
+ ORDER BY created_at DESC
905
+ LIMIT 1
906
+ `,
907
+ );
908
+
909
+ if (!row) {
910
+ return { online: true, at: toIso(nowMs()) };
911
+ }
912
+
913
+ return {
914
+ online: row.online === 1,
915
+ at: toIso(row.created_at),
916
+ };
917
+ }
918
+
919
+ function recordHeartbeat(source: RuntimeEventSource, online = true, createdAt = nowMs()): HeartbeatSnapshot {
920
+ sqlite
921
+ .query(
922
+ `
923
+ INSERT INTO heartbeat_events (id, online, source, created_at)
924
+ VALUES (?1, ?2, ?3, ?4)
925
+ `,
926
+ )
927
+ .run(crypto.randomUUID(), online ? 1 : 0, source, createdAt);
928
+
929
+ return {
930
+ online,
931
+ at: toIso(createdAt),
932
+ };
933
+ }
934
+
935
+ function getConfig() {
936
+ const managedConfig = getManagedConfig();
937
+ const catalog = listManagedSkillCatalog(managedConfig.runtime.opencode.directory);
938
+ const agents =
939
+ managedConfig.ui.agents.length > 0
940
+ ? managedConfig.ui.agents
941
+ : managedConfig.ui.agentTypes.map(toLegacySpecialistAgent);
942
+ return {
943
+ skills: catalog.enabled,
944
+ mcps: managedConfig.ui.mcps,
945
+ agents,
946
+ };
947
+ }
948
+
949
+ export function getRuntimeSessionBinding(runtime: string, sessionId: string): string | null {
950
+ const normalizedRuntime = runtime.trim();
951
+ const normalizedSessionId = sessionId.trim();
952
+ if (!normalizedRuntime || !normalizedSessionId) return null;
953
+ const row = scalar<{ external_session_id: string } | null>(
954
+ `
955
+ SELECT external_session_id
956
+ FROM runtime_session_bindings
957
+ WHERE runtime = ?1
958
+ AND session_id = ?2
959
+ LIMIT 1
960
+ `,
961
+ normalizedRuntime,
962
+ normalizedSessionId,
963
+ );
964
+ return row?.external_session_id ?? null;
965
+ }
966
+
967
+ export function getLocalSessionIdByRuntimeBinding(runtime: string, externalSessionId: string): string | null {
968
+ const normalizedRuntime = runtime.trim();
969
+ const normalizedExternalSessionId = externalSessionId.trim();
970
+ if (!normalizedRuntime || !normalizedExternalSessionId) return null;
971
+ const row = scalar<{ session_id: string } | null>(
972
+ `
973
+ SELECT session_id
974
+ FROM runtime_session_bindings
975
+ WHERE runtime = ?1
976
+ AND external_session_id = ?2
977
+ LIMIT 1
978
+ `,
979
+ normalizedRuntime,
980
+ normalizedExternalSessionId,
981
+ );
982
+ return row?.session_id ?? null;
983
+ }
984
+
985
+ export function setRuntimeSessionBinding(runtime: string, sessionId: string, externalSessionId: string) {
986
+ const normalizedRuntime = runtime.trim();
987
+ const normalizedSessionId = sessionId.trim();
988
+ const normalizedExternalSessionId = externalSessionId.trim();
989
+ if (!normalizedRuntime || !normalizedSessionId || !normalizedExternalSessionId) {
990
+ return;
991
+ }
992
+ const updatedAt = nowMs();
993
+ sqlite
994
+ .query(
995
+ `
996
+ INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
997
+ VALUES (?1, ?2, ?3, ?4)
998
+ ON CONFLICT(runtime, session_id) DO UPDATE SET
999
+ external_session_id = excluded.external_session_id,
1000
+ updated_at = excluded.updated_at
1001
+ `,
1002
+ )
1003
+ .run(normalizedRuntime, normalizedSessionId, normalizedExternalSessionId, updatedAt);
1004
+ }
1005
+
1006
+ export function ensureSessionForRuntimeBinding(input: {
1007
+ runtime: string;
1008
+ externalSessionId: string;
1009
+ title?: string;
1010
+ model?: string;
1011
+ createdAt?: number;
1012
+ }): SessionSummary | null {
1013
+ const normalizedRuntime = input.runtime.trim();
1014
+ const normalizedExternalSessionId = input.externalSessionId.trim();
1015
+ if (!normalizedRuntime || !normalizedExternalSessionId) return null;
1016
+
1017
+ const tx = sqlite.transaction(() => {
1018
+ const existingSessionId = getLocalSessionIdByRuntimeBinding(normalizedRuntime, normalizedExternalSessionId);
1019
+ const normalizedTitle = input.title?.trim();
1020
+ const normalizedModel = input.model?.trim();
1021
+ if (existingSessionId) {
1022
+ if (normalizedTitle) {
1023
+ sqlite
1024
+ .query(
1025
+ `
1026
+ UPDATE sessions
1027
+ SET title = ?2, updated_at = ?3
1028
+ WHERE id = ?1
1029
+ `,
1030
+ )
1031
+ .run(existingSessionId, normalizedTitle, input.createdAt ?? nowMs());
1032
+ }
1033
+ if (normalizedModel) {
1034
+ sqlite
1035
+ .query(
1036
+ `
1037
+ UPDATE sessions
1038
+ SET model = ?2, updated_at = ?3
1039
+ WHERE id = ?1
1040
+ `,
1041
+ )
1042
+ .run(existingSessionId, normalizedModel, input.createdAt ?? nowMs());
1043
+ }
1044
+ return existingSessionId;
1045
+ }
1046
+
1047
+ const totalSessions = scalar<{ count: number }>("SELECT COUNT(*) as count FROM sessions").count;
1048
+ const title = normalizedTitle || `Session ${totalSessions + 1}`;
1049
+ const model = normalizedModel || getDefaultSessionModel();
1050
+ const id = allocateSessionId();
1051
+ const createdAt = input.createdAt ?? nowMs();
1052
+
1053
+ createSessionRecord({
1054
+ id,
1055
+ title,
1056
+ model,
1057
+ status: "idle",
1058
+ messageCount: 0,
1059
+ createdAt,
1060
+ });
1061
+ setRuntimeSessionBinding(normalizedRuntime, id, normalizedExternalSessionId);
1062
+ return id;
1063
+ });
1064
+
1065
+ const sessionId = tx();
1066
+ return sessionId ? getSessionById(sessionId) : null;
1067
+ }
1068
+
1069
+ export function listRuntimeSessionBindings(runtime: string, limit = 500): Array<RuntimeSessionBindingRecord> {
1070
+ const normalizedRuntime = runtime.trim();
1071
+ if (!normalizedRuntime) return [];
1072
+ const normalizedLimit = Math.max(1, Math.min(2_000, Math.floor(limit)));
1073
+ const rows = allRows<RuntimeSessionBindingRow>(
1074
+ `
1075
+ SELECT runtime, session_id, external_session_id, updated_at
1076
+ FROM runtime_session_bindings
1077
+ WHERE runtime = ?1
1078
+ ORDER BY updated_at DESC
1079
+ LIMIT ?2
1080
+ `,
1081
+ normalizedRuntime,
1082
+ normalizedLimit,
1083
+ );
1084
+ return rows.map(runtimeSessionBindingRowToRecord);
1085
+ }
1086
+
1087
+
1088
+ export function createBackgroundRun(input: {
1089
+ runtime: string;
1090
+ parentSessionId: string;
1091
+ parentExternalSessionId: string;
1092
+ childExternalSessionId: string;
1093
+ requestedBy?: string;
1094
+ prompt?: string;
1095
+ status?: BackgroundRunStatus;
1096
+ createdAt?: number;
1097
+ }): BackgroundRunRecord | null {
1098
+ const normalizedRuntime = input.runtime.trim();
1099
+ const normalizedParentSessionId = input.parentSessionId.trim();
1100
+ const normalizedParentExternalSessionId = input.parentExternalSessionId.trim();
1101
+ const normalizedChildExternalSessionId = input.childExternalSessionId.trim();
1102
+ if (
1103
+ !normalizedRuntime ||
1104
+ !normalizedParentSessionId ||
1105
+ !normalizedParentExternalSessionId ||
1106
+ !normalizedChildExternalSessionId
1107
+ ) {
1108
+ return null;
1109
+ }
1110
+
1111
+ const createdAt = input.createdAt ?? nowMs();
1112
+ const runId = `bg-${crypto.randomUUID().slice(0, 12)}`;
1113
+
1114
+ sqlite
1115
+ .query(
1116
+ `
1117
+ INSERT INTO background_runs (
1118
+ id, runtime, parent_session_id, parent_external_session_id,
1119
+ child_external_session_id, requested_by, prompt, status,
1120
+ result_summary, error, created_at, updated_at, started_at, completed_at
1121
+ )
1122
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, NULL, ?9, ?9, NULL, NULL)
1123
+ ON CONFLICT(runtime, child_external_session_id) DO UPDATE SET
1124
+ parent_session_id = excluded.parent_session_id,
1125
+ parent_external_session_id = excluded.parent_external_session_id,
1126
+ requested_by = excluded.requested_by,
1127
+ prompt = CASE
1128
+ WHEN trim(excluded.prompt) <> '' THEN excluded.prompt
1129
+ ELSE background_runs.prompt
1130
+ END,
1131
+ status = CASE
1132
+ WHEN background_runs.status IN ('completed', 'failed', 'aborted') THEN background_runs.status
1133
+ ELSE excluded.status
1134
+ END,
1135
+ updated_at = excluded.updated_at
1136
+ `,
1137
+ )
1138
+ .run(
1139
+ runId,
1140
+ normalizedRuntime,
1141
+ normalizedParentSessionId,
1142
+ normalizedParentExternalSessionId,
1143
+ normalizedChildExternalSessionId,
1144
+ input.requestedBy?.trim() || "system",
1145
+ input.prompt ?? "",
1146
+ input.status ?? "created",
1147
+ createdAt,
1148
+ );
1149
+
1150
+ return getBackgroundRunByChildExternalSessionId(normalizedRuntime, normalizedChildExternalSessionId);
1151
+ }
1152
+
1153
+ export function getBackgroundRunById(runId: string): BackgroundRunRecord | null {
1154
+ const normalizedRunId = runId.trim();
1155
+ if (!normalizedRunId) return null;
1156
+ const row = scalar<BackgroundRunRow | null>(
1157
+ `
1158
+ SELECT *
1159
+ FROM background_runs
1160
+ WHERE id = ?1
1161
+ LIMIT 1
1162
+ `,
1163
+ normalizedRunId,
1164
+ );
1165
+ return row ? backgroundRunRowToRecord(row) : null;
1166
+ }
1167
+
1168
+ export function getBackgroundRunByChildExternalSessionId(
1169
+ runtime: string,
1170
+ childExternalSessionId: string,
1171
+ ): BackgroundRunRecord | null {
1172
+ const normalizedRuntime = runtime.trim();
1173
+ const normalizedChildExternalSessionId = childExternalSessionId.trim();
1174
+ if (!normalizedRuntime || !normalizedChildExternalSessionId) return null;
1175
+ const row = scalar<BackgroundRunRow | null>(
1176
+ `
1177
+ SELECT *
1178
+ FROM background_runs
1179
+ WHERE runtime = ?1
1180
+ AND child_external_session_id = ?2
1181
+ LIMIT 1
1182
+ `,
1183
+ normalizedRuntime,
1184
+ normalizedChildExternalSessionId,
1185
+ );
1186
+ return row ? backgroundRunRowToRecord(row) : null;
1187
+ }
1188
+
1189
+ export function listBackgroundRunsForParentSession(sessionId: string, limit = 50): Array<BackgroundRunRecord> {
1190
+ const normalizedSessionId = sessionId.trim();
1191
+ if (!normalizedSessionId) return [];
1192
+ const normalizedLimit = Math.max(1, Math.min(200, Math.floor(limit)));
1193
+ const rows = allRows<BackgroundRunRow>(
1194
+ `
1195
+ SELECT *
1196
+ FROM background_runs
1197
+ WHERE parent_session_id = ?1
1198
+ ORDER BY created_at DESC
1199
+ LIMIT ?2
1200
+ `,
1201
+ normalizedSessionId,
1202
+ normalizedLimit,
1203
+ );
1204
+ return rows.map(backgroundRunRowToRecord);
1205
+ }
1206
+
1207
+ export function listInFlightBackgroundRuns(runtime: string, limit = 250): Array<BackgroundRunRecord> {
1208
+ const normalizedRuntime = runtime.trim();
1209
+ if (!normalizedRuntime) return [];
1210
+ const normalizedLimit = Math.max(1, Math.min(2_000, Math.floor(limit)));
1211
+ const rows = allRows<BackgroundRunRow>(
1212
+ `
1213
+ SELECT *
1214
+ FROM background_runs
1215
+ WHERE runtime = ?1
1216
+ AND status IN ('created', 'running', 'retrying', 'idle')
1217
+ ORDER BY updated_at ASC
1218
+ LIMIT ?2
1219
+ `,
1220
+ normalizedRuntime,
1221
+ normalizedLimit,
1222
+ );
1223
+ return rows.map(backgroundRunRowToRecord);
1224
+ }
1225
+
1226
+ export function listRecentBackgroundRuns(runtime: string, limit = 250): Array<BackgroundRunRecord> {
1227
+ const normalizedRuntime = runtime.trim();
1228
+ if (!normalizedRuntime) return [];
1229
+ const normalizedLimit = Math.max(1, Math.min(2_000, Math.floor(limit)));
1230
+ const rows = allRows<BackgroundRunRow>(
1231
+ `
1232
+ SELECT *
1233
+ FROM background_runs
1234
+ WHERE runtime = ?1
1235
+ ORDER BY created_at DESC
1236
+ LIMIT ?2
1237
+ `,
1238
+ normalizedRuntime,
1239
+ normalizedLimit,
1240
+ );
1241
+ return rows.map(backgroundRunRowToRecord);
1242
+ }
1243
+
1244
+ export function listBackgroundRunsPendingAnnouncement(runtime: string, limit = 250): Array<BackgroundRunRecord> {
1245
+ const normalizedRuntime = runtime.trim();
1246
+ if (!normalizedRuntime) return [];
1247
+ const normalizedLimit = Math.max(1, Math.min(2_000, Math.floor(limit)));
1248
+ const rows = allRows<BackgroundRunRow>(
1249
+ `
1250
+ SELECT *
1251
+ FROM background_runs
1252
+ WHERE runtime = ?1
1253
+ AND status = 'completed'
1254
+ AND (result_summary IS NULL OR trim(result_summary) = '')
1255
+ ORDER BY updated_at ASC
1256
+ LIMIT ?2
1257
+ `,
1258
+ normalizedRuntime,
1259
+ normalizedLimit,
1260
+ );
1261
+ return rows.map(backgroundRunRowToRecord);
1262
+ }
1263
+
1264
+ export function setBackgroundRunStatus(input: {
1265
+ runId: string;
1266
+ status: BackgroundRunStatus;
1267
+ updatedAt?: number;
1268
+ startedAt?: number | null;
1269
+ completedAt?: number | null;
1270
+ prompt?: string | null;
1271
+ resultSummary?: string | null;
1272
+ error?: string | null;
1273
+ }): BackgroundRunRecord | null {
1274
+ const existing = getBackgroundRunById(input.runId);
1275
+ if (!existing) return null;
1276
+
1277
+ const updatedAt = input.updatedAt ?? nowMs();
1278
+ const startedAt =
1279
+ typeof input.startedAt === "undefined"
1280
+ ? toMillisOrNull(existing.startedAt)
1281
+ : input.startedAt;
1282
+ const completedAt =
1283
+ typeof input.completedAt === "undefined"
1284
+ ? toMillisOrNull(existing.completedAt)
1285
+ : input.completedAt;
1286
+ const prompt = typeof input.prompt === "undefined" ? existing.prompt : input.prompt ?? "";
1287
+ const resultSummary =
1288
+ typeof input.resultSummary === "undefined" ? existing.resultSummary : input.resultSummary;
1289
+ const error = typeof input.error === "undefined" ? existing.error : input.error;
1290
+
1291
+ sqlite
1292
+ .query(
1293
+ `
1294
+ UPDATE background_runs
1295
+ SET
1296
+ status = ?2,
1297
+ prompt = ?3,
1298
+ result_summary = ?4,
1299
+ error = ?5,
1300
+ updated_at = ?6,
1301
+ started_at = ?7,
1302
+ completed_at = ?8
1303
+ WHERE id = ?1
1304
+ `,
1305
+ )
1306
+ .run(existing.id, input.status, prompt, resultSummary, error, updatedAt, startedAt, completedAt);
1307
+
1308
+ return getBackgroundRunById(existing.id);
1309
+ }
1310
+
1311
+ export function getDashboardBootstrap(): DashboardBootstrap {
1312
+ const config = getConfig();
1313
+ return {
1314
+ sessions: listSessions(),
1315
+ skills: config.skills,
1316
+ mcps: config.mcps,
1317
+ agents: config.agents,
1318
+ usage: getUsageSnapshot(),
1319
+ heartbeat: getHeartbeatSnapshot(),
1320
+ };
1321
+ }
1322
+
1323
+ export function appendChatExchange(input: {
1324
+ sessionId: string;
1325
+ userContent: string;
1326
+ assistantContent: string;
1327
+ assistantParts?: ChatMessagePart[];
1328
+ source: RuntimeEventSource;
1329
+ createdAt?: number;
1330
+ userMessageId?: string;
1331
+ assistantMessageId?: string;
1332
+ usage: {
1333
+ providerId?: string | null;
1334
+ modelId?: string | null;
1335
+ requestCountDelta: number;
1336
+ inputTokensDelta: number;
1337
+ outputTokensDelta: number;
1338
+ estimatedCostUsdDelta: number;
1339
+ };
1340
+ }): {
1341
+ session: SessionSummary;
1342
+ messages: ChatMessage[];
1343
+ usage: UsageSnapshot;
1344
+ heartbeat: HeartbeatSnapshot;
1345
+ } | null {
1346
+ const tx = sqlite.transaction(() => {
1347
+ const session = scalar<{ id: string } | null>("SELECT id FROM sessions WHERE id = ?1", input.sessionId);
1348
+ if (!session) return null;
1349
+
1350
+ const eventAt = input.createdAt ?? nowMs();
1351
+ const assistantParts = normalizeChatMessageParts(input.assistantParts);
1352
+ const userMessage: ChatMessage = {
1353
+ id: input.userMessageId ?? crypto.randomUUID(),
1354
+ role: "user",
1355
+ content: input.userContent,
1356
+ at: toIso(eventAt),
1357
+ };
1358
+ const assistantMessage: ChatMessage = {
1359
+ id: input.assistantMessageId ?? crypto.randomUUID(),
1360
+ role: "assistant",
1361
+ content: input.assistantContent,
1362
+ at: toIso(eventAt),
1363
+ parts: assistantParts.length > 0 ? assistantParts : undefined,
1364
+ };
1365
+
1366
+ const existingUser = scalar<MessageRow | null>(
1367
+ `
1368
+ SELECT id, session_id, role, content, created_at
1369
+ FROM messages
1370
+ WHERE id = ?1
1371
+ `,
1372
+ userMessage.id,
1373
+ );
1374
+
1375
+ const existingAssistant = scalar<MessageRow | null>(
1376
+ `
1377
+ SELECT id, session_id, role, content, created_at
1378
+ FROM messages
1379
+ WHERE id = ?1
1380
+ `,
1381
+ assistantMessage.id,
1382
+ );
1383
+
1384
+ if (existingUser && (existingUser.session_id !== input.sessionId || existingUser.role !== "user")) {
1385
+ throw new Error(`User message id collision for ${userMessage.id}`);
1386
+ }
1387
+ if (existingAssistant && (existingAssistant.session_id !== input.sessionId || existingAssistant.role !== "assistant")) {
1388
+ throw new Error(`Assistant message id collision for ${assistantMessage.id}`);
1389
+ }
1390
+
1391
+ let userCreatedAt = eventAt;
1392
+ let assistantCreatedAt = eventAt;
1393
+
1394
+ if (!existingUser && existingAssistant) {
1395
+ // If assistant was synced first, align user timestamp so conversation order stays stable.
1396
+ userCreatedAt = existingAssistant.created_at;
1397
+ }
1398
+ if (existingUser && !existingAssistant) {
1399
+ // Never backfill assistant before an existing user turn.
1400
+ assistantCreatedAt = Math.max(existingUser.created_at, eventAt);
1401
+ }
1402
+
1403
+ if (!existingUser) {
1404
+ sqlite
1405
+ .query(
1406
+ `
1407
+ INSERT INTO messages (id, session_id, role, content, created_at)
1408
+ VALUES (?1, ?2, ?3, ?4, ?5)
1409
+ `,
1410
+ )
1411
+ .run(userMessage.id, input.sessionId, userMessage.role, userMessage.content, userCreatedAt);
1412
+ } else if (!existingUser.content.trim() && userMessage.content.trim()) {
1413
+ sqlite
1414
+ .query(
1415
+ `
1416
+ UPDATE messages
1417
+ SET content = ?2
1418
+ WHERE id = ?1
1419
+ `,
1420
+ )
1421
+ .run(userMessage.id, userMessage.content);
1422
+ }
1423
+
1424
+ if (!existingAssistant) {
1425
+ sqlite
1426
+ .query(
1427
+ `
1428
+ INSERT INTO messages (id, session_id, role, content, created_at)
1429
+ VALUES (?1, ?2, ?3, ?4, ?5)
1430
+ `,
1431
+ )
1432
+ .run(assistantMessage.id, input.sessionId, assistantMessage.role, assistantMessage.content, assistantCreatedAt);
1433
+ } else if (!existingAssistant.content.trim() && assistantMessage.content.trim()) {
1434
+ sqlite
1435
+ .query(
1436
+ `
1437
+ UPDATE messages
1438
+ SET content = ?2
1439
+ WHERE id = ?1
1440
+ `,
1441
+ )
1442
+ .run(assistantMessage.id, assistantMessage.content);
1443
+ }
1444
+
1445
+ let persistedUser = scalar<MessageRow | null>(
1446
+ `
1447
+ SELECT id, session_id, role, content, created_at
1448
+ FROM messages
1449
+ WHERE id = ?1
1450
+ `,
1451
+ userMessage.id,
1452
+ );
1453
+ if (!persistedUser || persistedUser.role !== "user") {
1454
+ throw new Error(`Failed to persist user message ${userMessage.id}`);
1455
+ }
1456
+
1457
+ const persistedAssistant = scalar<MessageRow | null>(
1458
+ `
1459
+ SELECT id, session_id, role, content, created_at
1460
+ FROM messages
1461
+ WHERE id = ?1
1462
+ `,
1463
+ assistantMessage.id,
1464
+ );
1465
+ if (!persistedAssistant || persistedAssistant.role !== "assistant") {
1466
+ throw new Error(`Failed to persist assistant message ${assistantMessage.id}`);
1467
+ }
1468
+
1469
+ if (persistedUser.created_at > persistedAssistant.created_at) {
1470
+ sqlite
1471
+ .query(
1472
+ `
1473
+ UPDATE messages
1474
+ SET created_at = ?2
1475
+ WHERE id = ?1
1476
+ `,
1477
+ )
1478
+ .run(persistedUser.id, persistedAssistant.created_at);
1479
+ persistedUser = {
1480
+ ...persistedUser,
1481
+ created_at: persistedAssistant.created_at,
1482
+ };
1483
+ }
1484
+
1485
+ if (assistantMessage.parts && assistantMessage.parts.length > 0) {
1486
+ setMessageParts({
1487
+ sessionId: input.sessionId,
1488
+ messageId: persistedAssistant.id,
1489
+ parts: assistantMessage.parts,
1490
+ createdAt: persistedAssistant.created_at,
1491
+ updatedAt: eventAt,
1492
+ });
1493
+ }
1494
+
1495
+ sqlite
1496
+ .query(
1497
+ `
1498
+ UPDATE sessions
1499
+ SET
1500
+ status = 'active',
1501
+ message_count = (
1502
+ SELECT COUNT(*)
1503
+ FROM messages
1504
+ WHERE session_id = ?1
1505
+ ),
1506
+ updated_at = ?2,
1507
+ last_active_at = ?2
1508
+ WHERE id = ?1
1509
+ `,
1510
+ )
1511
+ .run(input.sessionId, eventAt);
1512
+
1513
+ recordUsageDelta({
1514
+ id: `assistant-message:${assistantMessage.id}`,
1515
+ sessionId: input.sessionId,
1516
+ providerId: input.usage.providerId,
1517
+ modelId: input.usage.modelId,
1518
+ requestCountDelta: input.usage.requestCountDelta,
1519
+ inputTokensDelta: input.usage.inputTokensDelta,
1520
+ outputTokensDelta: input.usage.outputTokensDelta,
1521
+ estimatedCostUsdDelta: input.usage.estimatedCostUsdDelta,
1522
+ source: input.source,
1523
+ createdAt: eventAt,
1524
+ });
1525
+
1526
+ const heartbeat = recordHeartbeat(input.source, true, eventAt);
1527
+ const sessionSummary = getSessionById(input.sessionId);
1528
+ if (!sessionSummary) return null;
1529
+
1530
+ return {
1531
+ session: sessionSummary,
1532
+ messages: [
1533
+ messageRowToMessage(persistedUser),
1534
+ {
1535
+ ...messageRowToMessage(persistedAssistant),
1536
+ parts: assistantMessage.parts,
1537
+ },
1538
+ ],
1539
+ usage: getUsageSnapshot(),
1540
+ heartbeat,
1541
+ };
1542
+ });
1543
+
1544
+ return tx();
1545
+ }
1546
+
1547
+ export function appendAssistantMessage(input: {
1548
+ sessionId: string;
1549
+ content: string;
1550
+ parts?: ChatMessagePart[];
1551
+ source: RuntimeEventSource;
1552
+ createdAt?: number;
1553
+ messageId?: string;
1554
+ }): {
1555
+ session: SessionSummary;
1556
+ message: ChatMessage;
1557
+ usage: UsageSnapshot;
1558
+ heartbeat: HeartbeatSnapshot;
1559
+ } | null {
1560
+ const tx = sqlite.transaction(() => {
1561
+ const session = scalar<{ id: string } | null>("SELECT id FROM sessions WHERE id = ?1", input.sessionId);
1562
+ if (!session) return null;
1563
+
1564
+ const createdAt = input.createdAt ?? nowMs();
1565
+ const messageParts = normalizeChatMessageParts(input.parts);
1566
+ const message: ChatMessage = {
1567
+ id: input.messageId ?? crypto.randomUUID(),
1568
+ role: "assistant",
1569
+ content: input.content,
1570
+ at: toIso(createdAt),
1571
+ parts: messageParts.length > 0 ? messageParts : undefined,
1572
+ };
1573
+
1574
+ sqlite
1575
+ .query(
1576
+ `
1577
+ INSERT INTO messages (id, session_id, role, content, created_at)
1578
+ VALUES (?1, ?2, ?3, ?4, ?5)
1579
+ `,
1580
+ )
1581
+ .run(message.id, input.sessionId, message.role, message.content, createdAt);
1582
+
1583
+ if (message.parts && message.parts.length > 0) {
1584
+ setMessageParts({
1585
+ sessionId: input.sessionId,
1586
+ messageId: message.id,
1587
+ parts: message.parts,
1588
+ createdAt,
1589
+ updatedAt: createdAt,
1590
+ });
1591
+ }
1592
+
1593
+ sqlite
1594
+ .query(
1595
+ `
1596
+ UPDATE sessions
1597
+ SET
1598
+ status = 'active',
1599
+ message_count = message_count + 1,
1600
+ updated_at = ?2,
1601
+ last_active_at = ?2
1602
+ WHERE id = ?1
1603
+ `,
1604
+ )
1605
+ .run(input.sessionId, createdAt);
1606
+
1607
+ const heartbeat = recordHeartbeat(input.source, true, createdAt);
1608
+ const sessionSummary = getSessionById(input.sessionId);
1609
+ if (!sessionSummary) return null;
1610
+
1611
+ return {
1612
+ session: sessionSummary,
1613
+ message,
1614
+ usage: getUsageSnapshot(),
1615
+ heartbeat,
1616
+ };
1617
+ });
1618
+
1619
+ return tx();
1620
+ }
1621
+
1622
+ export function upsertSessionMessages(input: {
1623
+ sessionId: string;
1624
+ messages: Array<SessionMessageImportInput>;
1625
+ touchedAt?: number;
1626
+ }): {
1627
+ session: SessionSummary;
1628
+ inserted: ChatMessage[];
1629
+ } | null {
1630
+ const sessionId = input.sessionId.trim();
1631
+ if (!sessionId) return null;
1632
+
1633
+ const tx = sqlite.transaction(() => {
1634
+ const sessionExists = scalar<{ id: string } | null>("SELECT id FROM sessions WHERE id = ?1", sessionId);
1635
+ if (!sessionExists) return null;
1636
+
1637
+ const deduped = new Map<string, SessionMessageImportInput>();
1638
+ for (const message of input.messages) {
1639
+ const id = message.id.trim();
1640
+ if (!id) continue;
1641
+ const role = message.role;
1642
+ if (role !== "user" && role !== "assistant") continue;
1643
+ const createdAt = Number.isFinite(message.createdAt) ? Math.floor(message.createdAt) : nowMs();
1644
+ deduped.set(id, {
1645
+ id,
1646
+ role,
1647
+ content: message.content,
1648
+ createdAt,
1649
+ parts: message.parts,
1650
+ });
1651
+ }
1652
+ const candidates = [...deduped.values()];
1653
+ if (!candidates.length) {
1654
+ const session = getSessionById(sessionId);
1655
+ return session ? { session, inserted: [] as ChatMessage[] } : null;
1656
+ }
1657
+
1658
+ const messageIds = candidates.map(message => message.id);
1659
+ const placeholders = messageIds.map(() => "?").join(", ");
1660
+ const existingRows = sqlite
1661
+ .query(
1662
+ `
1663
+ SELECT id, content
1664
+ FROM messages
1665
+ WHERE session_id = ?1
1666
+ AND id IN (${placeholders})
1667
+ `,
1668
+ )
1669
+ .all(sessionId, ...messageIds) as ExistingMessageIdRow[];
1670
+ const existingContentById = new Map(existingRows.map(row => [row.id, row.content] as const));
1671
+ const existingIds = new Set(existingContentById.keys());
1672
+
1673
+ const updatedInputs = candidates.filter(message => {
1674
+ const existingContent = existingContentById.get(message.id);
1675
+ if (typeof existingContent !== "string") return false;
1676
+ const nextContent = message.content.trim();
1677
+ if (!nextContent) return false;
1678
+ return existingContent.trim() !== nextContent;
1679
+ });
1680
+ for (const message of updatedInputs) {
1681
+ sqlite
1682
+ .query(
1683
+ `
1684
+ UPDATE messages
1685
+ SET content = ?3
1686
+ WHERE session_id = ?1
1687
+ AND id = ?2
1688
+ `,
1689
+ )
1690
+ .run(sessionId, message.id, message.content);
1691
+ }
1692
+
1693
+ const messagePartsToUpsert = candidates.filter(message => message.parts !== undefined);
1694
+ for (const message of messagePartsToUpsert) {
1695
+ setMessageParts({
1696
+ sessionId,
1697
+ messageId: message.id,
1698
+ parts: message.parts ?? [],
1699
+ createdAt: message.createdAt,
1700
+ updatedAt: nowMs(),
1701
+ });
1702
+ }
1703
+
1704
+ const insertedInputs = candidates
1705
+ .filter(message => !existingIds.has(message.id))
1706
+ .sort((left, right) => left.createdAt - right.createdAt);
1707
+ if (insertedInputs.length > 0) {
1708
+ for (const message of insertedInputs) {
1709
+ sqlite
1710
+ .query(
1711
+ `
1712
+ INSERT INTO messages (id, session_id, role, content, created_at)
1713
+ VALUES (?1, ?2, ?3, ?4, ?5)
1714
+ `,
1715
+ )
1716
+ .run(message.id, sessionId, message.role, message.content, message.createdAt);
1717
+ }
1718
+
1719
+ const touchedAt = Math.max(
1720
+ input.touchedAt ?? 0,
1721
+ insertedInputs[insertedInputs.length - 1]?.createdAt ?? 0,
1722
+ nowMs(),
1723
+ );
1724
+ sqlite
1725
+ .query(
1726
+ `
1727
+ UPDATE sessions
1728
+ SET
1729
+ status = 'active',
1730
+ message_count = (
1731
+ SELECT COUNT(*)
1732
+ FROM messages
1733
+ WHERE session_id = ?1
1734
+ ),
1735
+ updated_at = ?2,
1736
+ last_active_at = CASE
1737
+ WHEN last_active_at > ?2 THEN last_active_at
1738
+ ELSE ?2
1739
+ END
1740
+ WHERE id = ?1
1741
+ `,
1742
+ )
1743
+ .run(sessionId, touchedAt);
1744
+ }
1745
+
1746
+ const session = getSessionById(sessionId);
1747
+ if (!session) return null;
1748
+ const inserted = insertedInputs.map(message => {
1749
+ const parts = message.parts ? normalizeChatMessageParts(message.parts) : [];
1750
+ return {
1751
+ id: message.id,
1752
+ role: message.role,
1753
+ content: message.content,
1754
+ at: toIso(message.createdAt),
1755
+ parts: parts.length > 0 ? parts : undefined,
1756
+ };
1757
+ });
1758
+ return { session, inserted };
1759
+ });
1760
+
1761
+ return tx();
1762
+ }