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.
- package/.agents/skills/btca-cli/SKILL.md +64 -0
- package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
- package/.env.example +36 -0
- package/.githooks/pre-commit +33 -0
- package/.github/workflows/ci.yml +309 -0
- package/.opencode/bun.lock +18 -0
- package/.opencode/package.json +5 -0
- package/.opencode/tools/agent_type_manager.ts +100 -0
- package/.opencode/tools/config_manager.ts +87 -0
- package/.opencode/tools/cron_manager.ts +145 -0
- package/.opencode/tools/memory_get.ts +43 -0
- package/.opencode/tools/memory_remember.ts +53 -0
- package/.opencode/tools/memory_search.ts +48 -0
- package/AGENTS.md +126 -0
- package/MEMORY.md +2 -0
- package/README.md +451 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/agent-mockingbird.config.example.json +135 -0
- package/apps/server/package.json +32 -0
- package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
- package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
- package/apps/server/src/backend/agents/openclawImport.ts +797 -0
- package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
- package/apps/server/src/backend/agents/service.ts +10 -0
- package/apps/server/src/backend/config/example-config.test.ts +20 -0
- package/apps/server/src/backend/config/orchestration.ts +243 -0
- package/apps/server/src/backend/config/policy.ts +158 -0
- package/apps/server/src/backend/config/schema.test.ts +15 -0
- package/apps/server/src/backend/config/schema.ts +391 -0
- package/apps/server/src/backend/config/semantic.test.ts +34 -0
- package/apps/server/src/backend/config/semantic.ts +149 -0
- package/apps/server/src/backend/config/service.test.ts +75 -0
- package/apps/server/src/backend/config/service.ts +207 -0
- package/apps/server/src/backend/config/smoke.ts +77 -0
- package/apps/server/src/backend/config/store.test.ts +123 -0
- package/apps/server/src/backend/config/store.ts +581 -0
- package/apps/server/src/backend/config/testFixtures.ts +5 -0
- package/apps/server/src/backend/config/types.ts +56 -0
- package/apps/server/src/backend/contracts/events.ts +320 -0
- package/apps/server/src/backend/contracts/runtime.ts +111 -0
- package/apps/server/src/backend/cron/executor.ts +435 -0
- package/apps/server/src/backend/cron/repository.ts +170 -0
- package/apps/server/src/backend/cron/service.ts +660 -0
- package/apps/server/src/backend/cron/storage.ts +92 -0
- package/apps/server/src/backend/cron/types.ts +138 -0
- package/apps/server/src/backend/cron/utils.ts +351 -0
- package/apps/server/src/backend/db/client.ts +20 -0
- package/apps/server/src/backend/db/migrate.ts +40 -0
- package/apps/server/src/backend/db/repository.ts +1762 -0
- package/apps/server/src/backend/db/schema.ts +113 -0
- package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
- package/apps/server/src/backend/db/wipe.ts +13 -0
- package/apps/server/src/backend/defaults.ts +32 -0
- package/apps/server/src/backend/env.ts +48 -0
- package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
- package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
- package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
- package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
- package/apps/server/src/backend/heartbeat/service.ts +176 -0
- package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
- package/apps/server/src/backend/heartbeat/state.ts +167 -0
- package/apps/server/src/backend/heartbeat/types.ts +54 -0
- package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
- package/apps/server/src/backend/http/boundedQueue.ts +92 -0
- package/apps/server/src/backend/http/parsers.ts +40 -0
- package/apps/server/src/backend/http/router.ts +61 -0
- package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
- package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
- package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
- package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
- package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
- package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
- package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
- package/apps/server/src/backend/http/routes/index.ts +101 -0
- package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
- package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
- package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
- package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
- package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
- package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
- package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
- package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
- package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
- package/apps/server/src/backend/http/schemas.ts +64 -0
- package/apps/server/src/backend/http/sse.ts +144 -0
- package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
- package/apps/server/src/backend/logging/logger.ts +64 -0
- package/apps/server/src/backend/mcp/service.ts +326 -0
- package/apps/server/src/backend/memory/cli.ts +170 -0
- package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
- package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
- package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
- package/apps/server/src/backend/memory/qmdPort.ts +61 -0
- package/apps/server/src/backend/memory/records.test.ts +66 -0
- package/apps/server/src/backend/memory/records.ts +229 -0
- package/apps/server/src/backend/memory/service.ts +2012 -0
- package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
- package/apps/server/src/backend/memory/types.ts +104 -0
- package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
- package/apps/server/src/backend/opencode/client.ts +98 -0
- package/apps/server/src/backend/opencode/models.ts +41 -0
- package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
- package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
- package/apps/server/src/backend/paths.ts +57 -0
- package/apps/server/src/backend/prompts/service.ts +100 -0
- package/apps/server/src/backend/queue/queue.test.ts +189 -0
- package/apps/server/src/backend/queue/service.ts +177 -0
- package/apps/server/src/backend/queue/types.ts +39 -0
- package/apps/server/src/backend/run/service.ts +576 -0
- package/apps/server/src/backend/run/storage.ts +47 -0
- package/apps/server/src/backend/run/types.ts +44 -0
- package/apps/server/src/backend/runtime/errors.ts +61 -0
- package/apps/server/src/backend/runtime/index.ts +72 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
- package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
- package/apps/server/src/backend/skills/service.ts +442 -0
- package/apps/server/src/backend/workspace/resolve.ts +27 -0
- package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
- package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
- package/apps/server/src/cli/runtime-assets.mjs +269 -0
- package/apps/server/src/cli/runtime-assets.test.ts +52 -0
- package/apps/server/src/cli/runtime-layout.mjs +75 -0
- package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
- package/apps/server/src/cli/standaloneBuild.ts +19 -0
- package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
- package/apps/server/src/index.ts +178 -0
- package/apps/server/tsconfig.json +12 -0
- package/backlog.md +5 -0
- package/bin/agent-mockingbird +2522 -0
- package/bin/runtime-layout.mjs +75 -0
- package/build-bin.ts +34 -0
- package/build-cli.mjs +37 -0
- package/build.ts +40 -0
- package/bun-env.d.ts +11 -0
- package/bun.lock +888 -0
- package/bunfig.toml +2 -0
- package/components.json +21 -0
- package/config.json +130 -0
- package/deploy/RELEASE_INSTALL.md +112 -0
- package/deploy/docker-compose.yml +42 -0
- package/deploy/systemd/README.md +46 -0
- package/deploy/systemd/agent-mockingbird.service +28 -0
- package/deploy/systemd/opencode.service +25 -0
- package/docs/legacy-config-ui-reference.md +51 -0
- package/docs/memory-e2e-trace-2026-03-04.md +63 -0
- package/docs/memory-ops.md +96 -0
- package/docs/memory-runtime-contract.md +42 -0
- package/docs/memory-tuning-remote-2026-03-04.md +59 -0
- package/docs/opencode-rebase-workflow-plan.md +614 -0
- package/docs/opencode-startup-sync-plan.md +94 -0
- package/docs/vendor-opencode.md +41 -0
- package/drizzle/0000_famous_turbo.sql +49 -0
- package/drizzle/0001_cron_memory_aux.sql +160 -0
- package/drizzle/0002_runtime_session_bindings.sql +28 -0
- package/drizzle/0003_background_runs.sql +27 -0
- package/drizzle/0004_memory_open_write.sql +63 -0
- package/drizzle/0005_signal_channel.sql +47 -0
- package/drizzle/0006_usage_event_dimensions.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +341 -0
- package/drizzle/meta/_journal.json +55 -0
- package/drizzle.config.ts +14 -0
- package/eslint.config.mjs +77 -0
- package/knip.json +18 -0
- package/memory/2026-03-04.md +4 -0
- package/opencode.lock.json +16 -0
- package/package.json +67 -0
- package/packages/agent-mockingbird-installer/README.md +31 -0
- package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
- package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
- package/packages/agent-mockingbird-installer/package.json +23 -0
- package/packages/contracts/package.json +19 -0
- package/packages/contracts/src/agentTypes.ts +122 -0
- package/packages/contracts/src/cron.ts +146 -0
- package/packages/contracts/src/dashboard.ts +378 -0
- package/packages/contracts/src/index.ts +3 -0
- package/packages/contracts/tsconfig.json +4 -0
- package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
- package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
- package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
- package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
- package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
- package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
- package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
- package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
- package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
- package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
- package/runtime-assets/opencode-config/opencode.jsonc +25 -0
- package/runtime-assets/opencode-config/package.json +5 -0
- package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
- package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
- package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
- package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
- package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
- package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
- package/runtime-assets/workspace/AGENTS.md +56 -0
- package/runtime-assets/workspace/MEMORY.md +4 -0
- package/scripts/build-release-bundle.sh +66 -0
- package/scripts/check-ship.ts +383 -0
- package/scripts/dev-opencode.sh +17 -0
- package/scripts/dev-stack-opencode.sh +15 -0
- package/scripts/dev-stack.sh +61 -0
- package/scripts/install-systemd.sh +87 -0
- package/scripts/memory-e2e.sh +76 -0
- package/scripts/memory-trace-e2e.sh +141 -0
- package/scripts/migrate-opencode-env.ts +108 -0
- package/scripts/onboard/bootstrap.sh +32 -0
- package/scripts/opencode-swap.ts +78 -0
- package/scripts/opencode-sync.ts +715 -0
- package/scripts/runtime-assets-sync.mjs +83 -0
- package/scripts/setup-git-hooks.ts +39 -0
- package/tsconfig.json +45 -0
- package/tui.json +98 -0
- package/turbo.json +36 -0
- 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
|
+
}
|