Files
goodbuddy/src/main/assistant/assistant-database.ts
T
lofyer b8fc7bc86e
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
chore: prepare GoodBuddy 0.8.2
2026-08-06 22:47:13 +08:00

3261 lines
96 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { expertCreateSchema } from '../../shared/assistant-contracts'
import type {
AssistantArtifact,
AssistantExpert,
AssistantHeartbeatConfig,
AssistantHeartbeatEntry,
AssistantHeartbeatRun,
AssistantMemory,
AssistantProject,
AssistantSchedule,
AssistantTask,
ConversationSnapshot,
ExpertCreateInput,
ExpertUpdateInput,
HeartbeatCreateInput,
HeartbeatSummaryOutput,
HeartbeatUpdateInput,
MemoryCreateInput,
ModelUsageCallInput,
ProjectCreateInput,
ScheduleCreateInput,
TokenUsageRecord,
TokenUsageSummary
} from '../../shared/assistant-contracts'
import {
computerControlErrorCodeSchema,
computerControlRiskSchema,
type ComputerControlErrorCode,
type ComputerControlRisk
} from '../../shared/computer-control-contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
type RuntimeSelectionRepairSettings
} from '../../shared/runtime-selection-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
type ProjectRow = {
id: string
name: string
description: string
root_path: string
default_work_mode: ProjectCreateInput['defaultWorkMode']
status: AssistantProject['status']
created_at: string
updated_at: string
}
type TaskRow = {
id: string
project_id: string | null
conversation_id: string | null
parent_task_id: string | null
expert_id: string | null
routing_mode: AssistantTask['routingMode'] | null
title: string
instructions: string
origin: AssistantTask['origin']
status: AssistantTask['status']
progress: number | null
created_at: string
started_at: string | null
completed_at: string | null
error: string | null
}
type ConversationRow = {
id: string
project_id: string | null
runtime_selection_json: string | null
title: string
updated_at: string
}
type MessageRow = {
id: string
conversation_id: string
role: ConversationSnapshot['messages'][number]['role']
content: string
state: ConversationSnapshot['messages'][number]['state']
metadata_json: string
created_at: string
}
type MessageMetadata = {
createdAt?: number
status?: string
tools?: ConversationSnapshot['messages'][number]['tools']
sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
artifactIds?: string[]
attachments?: ConversationSnapshot['messages'][number]['attachments']
}
function parseRuntimeSelection(value: string | null):
| ConversationSnapshot['runtimeSelection']
| undefined {
if (!value) {
return undefined
}
try {
const parsed = agentRuntimeSelectionSchema.safeParse(
JSON.parse(value)
)
return parsed.success ? parsed.data : undefined
} catch {
return undefined
}
}
type ArtifactRow = {
id: string
project_id: string | null
task_id: string | null
kind: AssistantArtifact['kind']
title: string
mime_type: string
inline_content: string | null
byte_size: number
created_at: string
updated_at: string
}
type MemoryRow = {
id: string
scope: AssistantMemory['scope']
scope_id: string | null
type: AssistantMemory['type']
content: string
confidence: number
salience: number
status: AssistantMemory['status']
created_at: string
updated_at: string
}
type ScheduleRow = {
id: string
project_id: string | null
task_template_json: string
recurrence_json: string
next_run_at: string
enabled: number
last_run_at: string | null
created_at: string
updated_at: string
}
type ExpertRow = {
id: string
name: string
description: string
system_instructions: string
capability_policy_json: string
model_policy_json: string
enabled: number
created_at: string
updated_at: string
}
type HeartbeatConfigRow = {
id: string
project_id: string | null
name: string
timezone: string
recurrence_json: string
lookback_hours: number
retention_days: number
enabled: number
next_run_at: string
last_run_at: string | null
last_status: AssistantHeartbeatRun['status'] | null
created_at: string
updated_at: string
}
type HeartbeatRunRow = {
id: string
config_id: string
trigger: AssistantHeartbeatRun['trigger']
scheduled_for: string
idempotency_key: string
status: AssistantHeartbeatRun['status']
attempt_count: number
next_attempt_at: string | null
lease_owner: string | null
lease_expires_at: string | null
started_at: string | null
completed_at: string | null
error: string | null
entry_id: string | null
created_at: string
updated_at: string
}
type HeartbeatEntryRow = {
id: string
config_id: string
run_id: string
scheduled_for: string
summary: string
highlights_json: string
artifact_id: string | null
proposed_memory_ids_json: string
follow_up_task_ids_json: string
created_at: string
}
type TokenUsageRecordRow = {
request_id: string
project_id: string | null
project_name: string | null
conversation_id: string | null
conversation_title: string | null
runtime: string
provider: string
model: string
call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
}
type ComputerControlActionRow = {
command_id: string
task_id: string
conversation_id: string
lease_id: string
action: ComputerControlAuditEvent['action']
risk: ComputerControlRisk
outcome: ComputerControlAuditEvent['outcome']
error_code: ComputerControlErrorCode | null
occurred_at: number
text_length: number | null
text_digest: string | null
}
export type PersistedComputerControlAuditEvent =
ComputerControlAuditEvent
export type ClaimedHeartbeatRun = {
config: AssistantHeartbeatConfig
run: AssistantHeartbeatRun
leaseOwner: string
acquired: boolean
}
export type HeartbeatInputSnapshot = {
conversations: Array<{
id: string
title: string
updatedAt: string
messages: Array<{
role: 'user' | 'assistant'
content: string
createdAt: string
}>
}>
tasks: Array<{
id: string
title: string
status: AssistantTask['status']
createdAt: string
completedAt?: string
}>
confirmedMemories: Array<{
id: string
type: AssistantMemory['type']
content: string
scope: AssistantMemory['scope']
}>
}
function toProject(row: ProjectRow): AssistantProject {
return {
id: row.id,
name: row.name,
description: row.description,
rootPath: row.root_path,
defaultWorkMode: row.default_work_mode,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toTask(row: TaskRow): AssistantTask {
return {
id: row.id,
projectId: row.project_id ?? undefined,
conversationId: row.conversation_id ?? undefined,
parentTaskId: row.parent_task_id ?? undefined,
expertId: row.expert_id ?? undefined,
routingMode: row.routing_mode ?? undefined,
title: row.title,
instructions: row.instructions,
origin: row.origin,
status: row.status,
progress: row.progress ?? undefined,
createdAt: row.created_at,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
error: row.error ?? undefined
}
}
function toArtifact(row: ArtifactRow): AssistantArtifact {
return {
id: row.id,
projectId: row.project_id ?? undefined,
taskId: row.task_id ?? undefined,
kind: row.kind,
title: row.title,
mimeType: row.mime_type,
content: row.inline_content ?? undefined,
byteSize: row.byte_size,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toMemory(row: MemoryRow): AssistantMemory {
return {
id: row.id,
scope: row.scope,
scopeId: row.scope_id ?? undefined,
type: row.type,
content: row.content,
confidence: row.confidence,
salience: row.salience,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toSchedule(row: ScheduleRow): AssistantSchedule {
const template = JSON.parse(row.task_template_json) as {
title: string
prompt: string
workMode: AssistantSchedule['workMode']
}
const recurrence = JSON.parse(row.recurrence_json) as {
type: AssistantSchedule['recurrence']
}
return {
id: row.id,
projectId: row.project_id ?? undefined,
title: template.title,
prompt: template.prompt,
workMode: template.workMode,
recurrence: recurrence.type,
nextRunAt: row.next_run_at,
enabled: row.enabled === 1,
lastRunAt: row.last_run_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toExpert(row: ExpertRow): AssistantExpert {
let routingKeywords: string[]
let modelProfileId: string | undefined
try {
const policy = JSON.parse(row.capability_policy_json) as {
routingKeywords?: unknown
}
routingKeywords = expertCreateSchema.parse({
name: row.name,
description: row.description,
systemInstructions: row.system_instructions,
routingKeywords: Array.isArray(policy.routingKeywords)
? policy.routingKeywords
: []
}).routingKeywords
} catch {
routingKeywords = []
}
try {
const policy = JSON.parse(row.model_policy_json) as {
modelProfileId?: unknown
}
modelProfileId = expertCreateSchema
.pick({ modelProfileId: true })
.parse({
modelProfileId: policy.modelProfileId
}).modelProfileId
} catch {
modelProfileId = undefined
}
return {
id: row.id,
name: row.name,
description: row.description,
systemInstructions: row.system_instructions,
...(modelProfileId ? { modelProfileId } : {}),
routingKeywords,
enabled: row.enabled === 1,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toHeartbeatConfig(
row: HeartbeatConfigRow
): AssistantHeartbeatConfig {
return {
id: row.id,
projectId: row.project_id ?? undefined,
name: row.name,
timezone: row.timezone,
recurrence: JSON.parse(
row.recurrence_json
) as AssistantHeartbeatConfig['recurrence'],
enabled: row.enabled === 1,
lookbackHours: row.lookback_hours,
retentionDays: row.retention_days,
nextRunAt: row.next_run_at,
lastRunAt: row.last_run_at ?? undefined,
lastStatus: row.last_status ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toHeartbeatRun(row: HeartbeatRunRow): AssistantHeartbeatRun {
return {
id: row.id,
configId: row.config_id,
trigger: row.trigger,
scheduledFor: row.scheduled_for,
status: row.status,
attemptCount: row.attempt_count,
nextAttemptAt: row.next_attempt_at ?? undefined,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
error: row.error ?? undefined,
entryId: row.entry_id ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function toHeartbeatEntry(
row: HeartbeatEntryRow
): AssistantHeartbeatEntry {
return {
id: row.id,
configId: row.config_id,
runId: row.run_id,
scheduledFor: row.scheduled_for,
summary: row.summary,
highlights: JSON.parse(row.highlights_json) as string[],
artifactId: row.artifact_id ?? undefined,
proposedMemoryIds: JSON.parse(
row.proposed_memory_ids_json
) as string[],
followUpTaskIds: JSON.parse(
row.follow_up_task_ids_json
) as string[],
createdAt: row.created_at
}
}
function validateUsageText(
value: string,
label: string,
maximumLength: number
): string {
if (typeof value !== 'string') {
throw new TypeError(`${label} must be a string`)
}
const normalized = value.trim()
if (
normalized.length === 0 ||
normalized.length > maximumLength
) {
throw new RangeError(
`${label} must contain between 1 and ${maximumLength} characters`
)
}
return normalized
}
function validateTokenCount(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(
`${label} must be a nonnegative safe integer`
)
}
return value
}
const computerControlActions = [
'observe',
'activate',
'replace_text',
'select_option',
'scroll'
] as const
const computerControlOutcomes = [
'completed',
'denied',
'failed',
'outcome_unknown'
] as const
function validateComputerControlId(
value: string,
label: string,
maximumLength: number,
opaque = false
): string {
if (typeof value !== 'string') {
throw new TypeError(`${label} must be a string`)
}
if (
value.length < (opaque ? 16 : 1) ||
value.length > maximumLength ||
(opaque
? !/^[A-Za-z0-9_-]+$/.test(value)
: [...value].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
}))
) {
throw new RangeError(`Invalid ${label}`)
}
return value
}
function validateComputerControlEnum<T extends string>(
value: string,
label: string,
allowed: readonly T[]
): T {
if (!allowed.includes(value as T)) {
throw new RangeError(`Invalid ${label}`)
}
return value as T
}
function toComputerControlAuditEvent(
row: ComputerControlActionRow
): PersistedComputerControlAuditEvent {
return {
timestamp: row.occurred_at,
taskId: row.task_id,
conversationId: row.conversation_id,
leaseId: row.lease_id,
commandId: row.command_id,
action: row.action,
risk: row.risk,
outcome: row.outcome,
...(row.error_code ? { errorCode: row.error_code } : {}),
...(row.text_length === null
? {}
: { textLength: row.text_length }),
...(row.text_digest === null
? {}
: { textDigest: row.text_digest })
}
}
const interruptedTaskError = '应用退出时任务仍在运行'
const interruptedMessageStatus = '上次运行意外中断,可以重新发送问题'
function interruptActiveTools(
tools: MessageMetadata['tools']
): MessageMetadata['tools'] {
return tools?.map((tool) =>
tool.state === 'pending' || tool.state === 'running'
? { ...tool, state: 'interrupted' as const }
: tool
)
}
export class AssistantDatabase {
private database?: DatabaseSync
constructor(private readonly databasePath: string) {}
initialize(defaultRootPath: string): void {
if (this.database) {
return
}
const database = new DatabaseSync(this.databasePath, {
enableForeignKeyConstraints: true,
timeout: 5_000
})
try {
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
`)
this.migrate(database)
this.database = database
const count = database
.prepare('SELECT COUNT(*) AS count FROM projects')
.get() as { count: number }
if (count.count === 0) {
this.createProject({
name: '默认项目',
description: 'GoodBuddy 默认工作区',
rootPath: defaultRootPath,
defaultWorkMode: 'ask'
})
}
const expertCount = database
.prepare('SELECT COUNT(*) AS count FROM experts')
.get() as { count: number }
if (expertCount.count === 0) {
this.createExpert({
name: '研究分析专家',
description: '负责资料分析、证据整理和结论验证',
systemInstructions:
'Act as a rigorous research analyst. Separate evidence, assumptions, and conclusions. Cite provided sources and identify uncertainty.',
routingKeywords: [
'研究',
'调研',
'分析证据',
'资料分析',
'research',
'evidence',
'investigate'
]
})
this.createExpert({
name: '文档写作专家',
description: '负责结构化写作、编辑和内容润色',
systemInstructions:
'Act as a professional document editor. Produce clear structure, concise language, and actionable content appropriate to the user context.',
routingKeywords: [
'写作',
'撰写',
'润色',
'文档',
'write',
'draft',
'edit'
]
})
this.createExpert({
name: '项目规划专家',
description: '负责目标拆解、风险分析和执行计划',
systemInstructions:
'Act as a project planning specialist. Decompose goals into verifiable steps, dependencies, risks, owners, and acceptance criteria.',
routingKeywords: [
'规划',
'计划',
'拆解',
'里程碑',
'plan',
'roadmap',
'milestone'
]
})
}
const recoveredAt = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const interruptedTasks = database
.prepare(
`SELECT id, error
FROM tasks
WHERE status IN ('running', 'waiting_approval')`
)
.all() as Array<{ id: string; error: string | null }>
const updateTask = database.prepare(
`UPDATE tasks
SET status = 'interrupted', completed_at = ?, error = ?
WHERE id = ?`
)
const insertTaskEvent = database.prepare(
`INSERT INTO task_events
(task_id, run_id, kind, payload_json, created_at)
VALUES (?, NULL, 'status', ?, ?)`
)
for (const task of interruptedTasks) {
const error = task.error ?? interruptedTaskError
updateTask.run(recoveredAt, error, task.id)
insertTaskEvent.run(
task.id,
JSON.stringify({ status: 'interrupted', error }),
recoveredAt
)
}
const recoverableMessages = database
.prepare(
`SELECT id, state, metadata_json
FROM messages`
)
.all() as Array<{
id: string
state: MessageRow['state']
metadata_json: string
}>
const updateMessage = database.prepare(
`UPDATE messages
SET state = ?, metadata_json = ?
WHERE id = ?`
)
for (const message of recoverableMessages) {
const metadata = JSON.parse(
message.metadata_json
) as MessageMetadata
const hasActiveTool = Boolean(
metadata.tools?.some(
(tool) =>
tool.state === 'pending' || tool.state === 'running'
)
)
if (message.state !== 'streaming' && !hasActiveTool) {
continue
}
updateMessage.run(
message.state === 'streaming' ? 'error' : message.state,
JSON.stringify({
...metadata,
status:
message.state === 'streaming'
? interruptedMessageStatus
: metadata.status,
tools: interruptActiveTools(metadata.tools)
}),
message.id
)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
} catch (error) {
database.close()
throw error
}
}
close(): void {
this.database?.close()
this.database = undefined
}
clearAssistantData(): void {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
for (const table of [
'heartbeat_configs',
'delegation_outbox',
'delegations',
'notifications',
'schedule_runs',
'schedules',
'memory_items',
'artifacts',
'computer_control_actions',
'task_events',
'runs',
'model_usage_calls',
'tasks',
'messages',
'conversations'
]) {
database.exec(`DELETE FROM ${table}`)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listProjects(includeArchived = false): AssistantProject[] {
const database = this.requireDatabase()
const rows = database
.prepare(
includeArchived
? 'SELECT * FROM projects ORDER BY updated_at DESC'
: `SELECT * FROM projects
WHERE status = 'active'
ORDER BY updated_at DESC`
)
.all() as ProjectRow[]
return rows.map(toProject)
}
createProject(input: ProjectCreateInput): AssistantProject {
const database = this.requireDatabase()
const id = randomUUID()
const now = new Date().toISOString()
database
.prepare(
`INSERT INTO projects
(id, name, description, root_path, default_work_mode, status,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`
)
.run(
id,
input.name,
input.description,
input.rootPath,
input.defaultWorkMode,
now,
now
)
return this.getProject(id)
}
updateProject(
projectId: string,
input: ProjectCreateInput
): AssistantProject {
const database = this.requireDatabase()
const result = database
.prepare(
`UPDATE projects
SET name = ?, description = ?, root_path = ?,
default_work_mode = ?, updated_at = ?
WHERE id = ?`
)
.run(
input.name,
input.description,
input.rootPath,
input.defaultWorkMode,
new Date().toISOString(),
projectId
)
if (result.changes !== 1) {
throw new Error('项目不存在')
}
return this.getProject(projectId)
}
setProjectArchived(projectId: string, archived: boolean): void {
const database = this.requireDatabase()
const result = database
.prepare(
`UPDATE projects
SET status = ?, updated_at = ?
WHERE id = ?`
)
.run(
archived ? 'archived' : 'active',
new Date().toISOString(),
projectId
)
if (result.changes !== 1) {
throw new Error('项目不存在')
}
}
listConversations(): ConversationSnapshot[] {
const database = this.requireDatabase()
const conversations = database
.prepare(
`SELECT id, project_id, runtime_selection_json, title, updated_at
FROM conversations
WHERE status = 'active'
ORDER BY updated_at DESC
LIMIT 100`
)
.all() as ConversationRow[]
const messageStatement = database.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at
FROM messages
WHERE conversation_id = ?
ORDER BY sequence ASC
LIMIT 500`
)
return conversations.map((conversation) => ({
id: conversation.id,
projectId: conversation.project_id ?? undefined,
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
title: conversation.title,
updatedAt: Date.parse(conversation.updated_at),
messages: (
messageStatement.all(conversation.id) as MessageRow[]
).map((message) => {
const metadata = JSON.parse(
message.metadata_json
) as MessageMetadata
const interrupted = message.state === 'streaming'
return {
id: message.id,
role: message.role,
content: message.content,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
status: interrupted
? interruptedMessageStatus
: metadata.status,
tools: interrupted
? interruptActiveTools(metadata.tools)
: metadata.tools,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
artifactIds: metadata.artifactIds,
attachments: metadata.attachments
}
})
}))
}
repairConversationRuntimeSelections(
settings: RuntimeSelectionRepairSettings
): number {
const database = this.requireDatabase()
const conversations = database
.prepare(
`SELECT id, runtime_selection_json
FROM conversations
WHERE runtime_selection_json IS NOT NULL`
)
.all() as Array<{
id: string
runtime_selection_json: string
}>
const update = database.prepare(
`UPDATE conversations
SET runtime_selection_json = ?
WHERE id = ?`
)
let repaired = 0
database.exec('BEGIN IMMEDIATE')
try {
for (const conversation of conversations) {
const current = parseRuntimeSelection(
conversation.runtime_selection_json
)
if (!current) {
continue
}
const next = repairAgentRuntimeSelection(current, settings)
if (
agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current)
) {
continue
}
update.run(JSON.stringify(next), conversation.id)
repaired += 1
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return repaired
}
replaceConversations(
conversations: ConversationSnapshot[]
): void {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
database.exec('DELETE FROM messages; DELETE FROM conversations;')
const insertConversation = database.prepare(
`INSERT INTO conversations
(id, project_id, runtime_selection_json, work_mode, title, status,
created_at, updated_at)
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)`
)
const insertMessage = database.prepare(
`INSERT INTO messages
(id, conversation_id, request_id, role, content, state, sequence,
metadata_json, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`
)
for (const conversation of conversations.slice(0, 100)) {
const updatedAt = new Date(conversation.updatedAt).toISOString()
insertConversation.run(
conversation.id,
conversation.projectId ?? null,
conversation.runtimeSelection
? JSON.stringify(conversation.runtimeSelection)
: null,
conversation.title,
updatedAt,
updatedAt
)
for (const [sequence, message] of conversation.messages
.slice(-500)
.entries()) {
insertMessage.run(
message.id,
conversation.id,
message.role,
message.content,
message.state,
sequence,
JSON.stringify({
createdAt: message.createdAt,
status: message.status,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
artifactIds: message.artifactIds,
attachments: message.attachments
}),
new Date(message.createdAt).toISOString()
)
}
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listPendingDelegationResults(): Array<{
taskId: string
result: {
status: 'completed' | 'failed'
output?: string
error?: string
}
}> {
const rows = this.requireDatabase()
.prepare(
`SELECT task_id, result_json
FROM delegation_outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100`
)
.all() as Array<{ task_id: string; result_json: string }>
return rows.map((row) => ({
taskId: row.task_id,
result: JSON.parse(row.result_json) as {
status: 'completed' | 'failed'
output?: string
error?: string
}
}))
}
getDelegationDeliveryStatus(
taskId: string
): 'pending' | 'delivered' | undefined {
const row = this.requireDatabase()
.prepare(
`SELECT status FROM delegation_outbox WHERE task_id = ?`
)
.get(taskId) as { status: 'pending' | 'delivered' } | undefined
return row?.status
}
saveDelegationResult(
taskId: string,
result: {
status: 'completed' | 'failed'
output?: string
error?: string
}
): void {
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO delegation_outbox
(task_id, result_json, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
ON CONFLICT(task_id) DO UPDATE SET
result_json = excluded.result_json,
status = 'pending',
updated_at = excluded.updated_at`
)
.run(taskId, JSON.stringify(result), now, now)
}
markDelegationDelivered(taskId: string): void {
const database = this.requireDatabase()
database
.prepare(
`UPDATE delegation_outbox
SET status = 'delivered', result_json = '{}', updated_at = ?
WHERE task_id = ?`
)
.run(new Date().toISOString(), taskId)
database
.prepare(
`DELETE FROM delegation_outbox
WHERE task_id IN (
SELECT task_id FROM delegation_outbox
WHERE status = 'delivered'
ORDER BY updated_at DESC
LIMIT -1 OFFSET 1000
)`
)
.run()
}
listTasks(limit = 100): AssistantTask[] {
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
const rows = this.requireDatabase()
.prepare(
`SELECT * FROM tasks
ORDER BY created_at DESC
LIMIT ?`
)
.all(safeLimit) as TaskRow[]
return rows.map(toTask)
}
createTask(input: {
id: string
projectId?: string
conversationId?: string
parentTaskId?: string
expertId?: string
routingMode?: AssistantTask['routingMode']
title: string
instructions: string
workMode: 'ask' | 'plan' | 'execute'
origin?: AssistantTask['origin']
status?: 'queued' | 'running'
}): AssistantTask {
const now = new Date().toISOString()
const status = input.status ?? 'running'
this.requireDatabase()
.prepare(
`INSERT INTO tasks
(id, project_id, conversation_id, parent_task_id, expert_id,
routing_mode, title, instructions, origin, status, priority,
work_mode, progress, created_at, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)`
)
.run(
input.id,
input.projectId ?? null,
input.conversationId ?? null,
input.parentTaskId ?? null,
input.expertId ?? null,
input.routingMode ?? null,
input.title,
input.instructions,
input.origin ?? 'user',
status,
input.workMode,
now,
status === 'running' ? now : null
)
this.appendTaskEvent(input.id, status, {
workMode: input.workMode
})
return this.getTask(input.id)
}
upsertModelUsageCall(input: ModelUsageCallInput): void {
const requestId = validateUsageText(
input.requestId,
'requestId',
256
)
const callId = validateUsageText(input.callId, 'callId', 256)
const runtime = validateUsageText(input.runtime, 'runtime', 100)
const provider = validateUsageText(
input.provider,
'provider',
100
)
const model = validateUsageText(input.model, 'model', 500)
const inputTokens = validateTokenCount(input.input, 'input')
const outputTokens = validateTokenCount(input.output, 'output')
const cacheReadTokens = validateTokenCount(
input.cacheRead,
'cacheRead'
)
const cacheWriteTokens = validateTokenCount(
input.cacheWrite,
'cacheWrite'
)
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO model_usage_calls
(request_id, call_id, runtime, provider, model, input_tokens,
output_tokens, cache_read_tokens, cache_write_tokens,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id, call_id) DO UPDATE SET
runtime = excluded.runtime,
provider = excluded.provider,
model = excluded.model,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
cache_write_tokens = excluded.cache_write_tokens,
updated_at = excluded.updated_at`
)
.run(
requestId,
callId,
runtime,
provider,
model,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
now,
now
)
}
getTokenUsageSummary(): TokenUsageSummary {
const rows = this.requireDatabase()
.prepare(
`SELECT
usage.request_id,
tasks.project_id,
projects.name AS project_name,
tasks.conversation_id,
conversations.title AS conversation_title,
usage.runtime,
usage.provider,
usage.model,
COUNT(*) AS call_count,
SUM(usage.input_tokens) AS input_tokens,
SUM(usage.output_tokens) AS output_tokens,
SUM(usage.cache_read_tokens) AS cache_read_tokens,
SUM(usage.cache_write_tokens) AS cache_write_tokens
FROM model_usage_calls usage
JOIN tasks ON tasks.id = usage.request_id
LEFT JOIN projects ON projects.id = tasks.project_id
LEFT JOIN conversations
ON conversations.id = tasks.conversation_id
GROUP BY
usage.request_id,
tasks.project_id,
projects.name,
tasks.conversation_id,
conversations.title,
usage.runtime,
usage.provider,
usage.model
ORDER BY MAX(usage.updated_at) DESC
LIMIT 500`
)
.all() as TokenUsageRecordRow[]
const records: TokenUsageRecord[] = rows.map((row) => ({
requestId: row.request_id,
projectId: row.project_id ?? undefined,
projectName: row.project_name ?? undefined,
conversationId: row.conversation_id ?? undefined,
conversationTitle: row.conversation_title ?? undefined,
runtime: row.runtime,
provider: row.provider,
model: row.model,
callCount: row.call_count,
input: row.input_tokens,
output: row.output_tokens,
cacheRead: row.cache_read_tokens,
cacheWrite: row.cache_write_tokens,
totalTokens: row.input_tokens + row.output_tokens
}))
const totalRow = this.requireDatabase()
.prepare(
`SELECT
COUNT(*) AS call_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens
FROM model_usage_calls`
)
.get() as {
call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
}
const totals = {
callCount: totalRow.call_count,
input: totalRow.input_tokens,
output: totalRow.output_tokens,
cacheRead: totalRow.cache_read_tokens,
cacheWrite: totalRow.cache_write_tokens,
totalTokens: totalRow.input_tokens + totalRow.output_tokens
}
return { totals, records }
}
updateTaskStatus(
taskId: string,
status: AssistantTask['status'],
error?: string
): void {
const terminal = [
'completed',
'failed',
'cancelled',
'interrupted'
].includes(status)
const result = this.requireDatabase()
.prepare(
`UPDATE tasks
SET status = ?, error = ?,
started_at = CASE
WHEN ? = 'running' AND started_at IS NULL THEN ?
ELSE started_at
END,
completed_at = CASE WHEN ? THEN ? ELSE completed_at END
WHERE id = ?`
)
.run(
status,
error ?? null,
status,
new Date().toISOString(),
terminal ? 1 : 0,
new Date().toISOString(),
taskId
)
if (result.changes !== 1) {
throw new Error('任务不存在')
}
this.appendTaskEvent(taskId, 'status', { status, error })
}
resolveAssistantSuggestionTask(
taskId: string,
status: 'completed' | 'cancelled'
): void {
const completedAt = new Date().toISOString()
const result = this.requireDatabase()
.prepare(
`UPDATE tasks
SET status = ?, error = NULL, completed_at = ?
WHERE id = ? AND origin = 'assistant' AND status = 'paused'`
)
.run(status, completedAt, taskId)
if (result.changes !== 1) {
throw new Error('待处理的智能心跳建议不存在或状态已变化')
}
this.appendTaskEvent(taskId, 'status', { status })
}
appendTaskEvent(
taskId: string,
kind: string,
payload: unknown
): void {
this.requireDatabase()
.prepare(
`INSERT INTO task_events
(task_id, run_id, kind, payload_json, created_at)
VALUES (?, NULL, ?, ?, ?)`
)
.run(
taskId,
kind.slice(0, 64),
JSON.stringify(payload),
new Date().toISOString()
)
}
persistComputerControlAudit(
event: ComputerControlAuditEvent
): void {
if (!event || typeof event !== 'object') {
throw new TypeError('Computer control audit event is required')
}
const taskId = validateComputerControlId(
event.taskId,
'taskId',
128
)
const conversationId = validateComputerControlId(
event.conversationId,
'conversationId',
128
)
const leaseId = validateComputerControlId(
event.leaseId,
'leaseId',
160,
true
)
const commandId = validateComputerControlId(
event.commandId,
'commandId',
160,
true
)
const action = validateComputerControlEnum(
event.action,
'action',
computerControlActions
)
const risk = computerControlRiskSchema.parse(event.risk)
const outcome = validateComputerControlEnum(
event.outcome,
'outcome',
computerControlOutcomes
)
if (
!Number.isSafeInteger(event.timestamp) ||
event.timestamp < 0
) {
throw new RangeError('Invalid timestamp')
}
const errorCode =
event.errorCode === undefined
? undefined
: computerControlErrorCodeSchema.parse(event.errorCode)
if (
(outcome === 'completed' && errorCode !== undefined) ||
(outcome !== 'completed' && errorCode === undefined)
) {
throw new RangeError('Invalid outcome and errorCode combination')
}
const hasTextMetadata =
event.textLength !== undefined ||
event.textDigest !== undefined
if (
(action === 'replace_text') !== hasTextMetadata ||
(hasTextMetadata &&
(!Number.isInteger(event.textLength) ||
event.textLength! < 0 ||
event.textLength! > 4_096 ||
typeof event.textDigest !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.textDigest)))
) {
throw new RangeError('Invalid redacted text metadata')
}
const database = this.requireDatabase()
const createdAt = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const inserted = database
.prepare(
`INSERT OR IGNORE INTO computer_control_actions
(command_id, task_id, conversation_id, lease_id, action, risk,
outcome, error_code, occurred_at, text_length, text_digest,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
commandId,
taskId,
conversationId,
leaseId,
action,
risk,
outcome,
errorCode ?? null,
event.timestamp,
event.textLength ?? null,
event.textDigest ?? null,
createdAt
)
if (inserted.changes === 1) {
database
.prepare(
`INSERT INTO task_events
(task_id, run_id, kind, payload_json, created_at)
VALUES (?, NULL, 'computer_control', ?, ?)`
)
.run(
taskId,
JSON.stringify({
commandId,
action,
risk,
outcome,
...(errorCode ? { errorCode } : {}),
...(event.textLength === undefined
? {}
: { textLength: event.textLength }),
...(event.textDigest === undefined
? {}
: { textDigest: event.textDigest })
}),
createdAt
)
database
.prepare(
`DELETE FROM computer_control_actions
WHERE command_id IN (
SELECT command_id FROM computer_control_actions
ORDER BY occurred_at DESC, created_at DESC
LIMIT -1 OFFSET 10000
)`
)
.run()
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listRecentComputerControlAudit(
limit = 100
): PersistedComputerControlAuditEvent[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new RangeError(
'Computer control audit limit must be between 1 and 500'
)
}
const rows = this.requireDatabase()
.prepare(
`SELECT command_id, task_id, conversation_id, lease_id, action,
risk, outcome, error_code, occurred_at, text_length,
text_digest
FROM computer_control_actions
ORDER BY occurred_at DESC, created_at DESC
LIMIT ?`
)
.all(limit) as ComputerControlActionRow[]
return rows.map(toComputerControlAuditEvent)
}
listArtifacts(projectId?: string, limit = 100): AssistantArtifact[] {
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
const columns = `id, project_id, task_id, kind, title, mime_type,
CASE WHEN kind = 'image' THEN NULL
ELSE inline_content END AS inline_content,
byte_size, created_at, updated_at`
const rows = projectId
? this.requireDatabase()
.prepare(
`SELECT ${columns} FROM artifacts
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT ?`
)
.all(projectId, safeLimit)
: this.requireDatabase()
.prepare(
`SELECT ${columns} FROM artifacts
ORDER BY created_at DESC
LIMIT ?`
)
.all(safeLimit)
return (rows as ArtifactRow[]).map(toArtifact)
}
getArtifact(artifactId: string): AssistantArtifact {
const row = this.requireDatabase()
.prepare(
`SELECT id, project_id, task_id, kind, title, mime_type,
inline_content, byte_size, created_at, updated_at
FROM artifacts
WHERE id = ?`
)
.get(artifactId) as ArtifactRow | undefined
if (!row) {
throw new Error('成果不存在')
}
return toArtifact(row)
}
createTextArtifact(input: {
projectId?: string
taskId?: string
title: string
content: string
}): AssistantArtifact {
return this.createInlineArtifact({
...input,
kind: 'markdown',
mimeType: 'text/markdown'
})
}
createImageArtifact(input: {
projectId?: string
taskId?: string
title: string
mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
base64: string
}): AssistantArtifact {
return this.createInlineArtifact({
projectId: input.projectId,
taskId: input.taskId,
kind: 'image',
title: input.title,
mimeType: input.mimeType,
content: `data:${input.mimeType};base64,${input.base64}`
})
}
createInlineArtifact(input: {
projectId?: string
taskId?: string
kind: AssistantArtifact['kind']
title: string
mimeType: string
content: string
}): AssistantArtifact {
const id = randomUUID()
const now = new Date().toISOString()
const byteSize = Buffer.byteLength(input.content)
if (byteSize > 5 * 1024 * 1024) {
throw new Error('成果内容超过 5MB 限制')
}
this.requireDatabase()
.prepare(
`INSERT INTO artifacts
(id, project_id, task_id, run_id, kind, title, mime_type,
storage_kind, storage_path, inline_content, checksum, byte_size,
preview_json, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?,
'inline', NULL, ?, NULL, ?, '{}', ?, ?)`
)
.run(
id,
input.projectId ?? null,
input.taskId ?? null,
input.kind,
input.title.slice(0, 240),
input.mimeType.slice(0, 128),
input.content,
byteSize,
now,
now
)
const row = this.requireDatabase()
.prepare('SELECT * FROM artifacts WHERE id = ?')
.get(id) as ArtifactRow
return toArtifact(row)
}
listMemories(scopeId?: string): AssistantMemory[] {
const rows = scopeId
? this.requireDatabase()
.prepare(
`SELECT * FROM memory_items
WHERE scope = 'global' OR scope_id = ?
ORDER BY status = 'confirmed' DESC, updated_at DESC
LIMIT 500`
)
.all(scopeId)
: this.requireDatabase()
.prepare(
`SELECT * FROM memory_items
ORDER BY status = 'confirmed' DESC, updated_at DESC
LIMIT 500`
)
.all()
return (rows as MemoryRow[]).map(toMemory)
}
createMemory(input: MemoryCreateInput): AssistantMemory {
if (input.scope !== 'global' && !input.scopeId) {
throw new Error('项目或会话记忆必须指定作用域')
}
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO memory_items
(id, scope, scope_id, type, content, source_conversation_id,
source_message_id, confidence, salience, status, expires_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL, 1, 1, 'confirmed', NULL, ?, ?)`
)
.run(
id,
input.scope,
input.scopeId ?? null,
input.type,
input.content,
now,
now
)
return this.getMemory(id)
}
setMemoryStatus(
memoryId: string,
status: AssistantMemory['status']
): void {
const result = this.requireDatabase()
.prepare(
`UPDATE memory_items
SET status = ?, updated_at = ?
WHERE id = ?`
)
.run(status, new Date().toISOString(), memoryId)
if (result.changes !== 1) {
throw new Error('记忆不存在')
}
}
removeMemory(memoryId: string): void {
const result = this.requireDatabase()
.prepare('DELETE FROM memory_items WHERE id = ?')
.run(memoryId)
if (result.changes !== 1) {
throw new Error('记忆不存在')
}
}
listSchedules(projectId?: string): AssistantSchedule[] {
const rows = projectId
? this.requireDatabase()
.prepare(
`SELECT * FROM schedules
WHERE project_id = ?
ORDER BY next_run_at`
)
.all(projectId)
: this.requireDatabase()
.prepare('SELECT * FROM schedules ORDER BY next_run_at')
.all()
return (rows as ScheduleRow[]).map(toSchedule)
}
createSchedule(input: ScheduleCreateInput): AssistantSchedule {
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO schedules
(id, project_id, task_template_json, timezone, recurrence_json,
next_run_at, missed_run_policy, enabled, last_run_at,
created_at, updated_at)
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)`
)
.run(
id,
input.projectId ?? null,
JSON.stringify({
title: input.title,
prompt: input.prompt,
workMode: input.workMode
}),
JSON.stringify({ type: input.recurrence }),
input.nextRunAt,
now,
now
)
return this.getSchedule(id)
}
setScheduleEnabled(scheduleId: string, enabled: boolean): void {
const result = this.requireDatabase()
.prepare(
`UPDATE schedules
SET enabled = ?, updated_at = ?
WHERE id = ?`
)
.run(enabled ? 1 : 0, new Date().toISOString(), scheduleId)
if (result.changes !== 1) {
throw new Error('定时任务不存在')
}
}
removeSchedule(scheduleId: string): void {
const result = this.requireDatabase()
.prepare('DELETE FROM schedules WHERE id = ?')
.run(scheduleId)
if (result.changes !== 1) {
throw new Error('定时任务不存在')
}
}
claimDueSchedules(now = new Date()): AssistantSchedule[] {
const database = this.requireDatabase()
const due = (
database
.prepare(
`SELECT * FROM schedules
WHERE enabled = 1 AND next_run_at <= ?
ORDER BY next_run_at
LIMIT 1`
)
.all(now.toISOString()) as ScheduleRow[]
).map(toSchedule)
for (const schedule of due) {
const next = new Date(schedule.nextRunAt)
if (schedule.recurrence === 'daily') {
const intervals =
Math.floor(
(now.getTime() - next.getTime()) / (24 * 60 * 60 * 1_000)
) + 1
next.setUTCDate(next.getUTCDate() + intervals)
} else if (schedule.recurrence === 'weekly') {
const intervals =
Math.floor(
(now.getTime() - next.getTime()) /
(7 * 24 * 60 * 60 * 1_000)
) + 1
next.setUTCDate(next.getUTCDate() + intervals * 7)
}
database
.prepare(
`UPDATE schedules
SET enabled = ?, next_run_at = ?, last_run_at = ?, updated_at = ?
WHERE id = ? AND next_run_at = ?`
)
.run(
schedule.recurrence === 'once' ? 0 : 1,
schedule.recurrence === 'once'
? schedule.nextRunAt
: next.toISOString(),
now.toISOString(),
now.toISOString(),
schedule.id,
schedule.nextRunAt
)
}
return due
}
claimScheduleNow(scheduleId: string): AssistantSchedule {
const schedule = this.getSchedule(scheduleId)
const now = new Date()
this.requireDatabase()
.prepare(
`UPDATE schedules
SET last_run_at = ?, updated_at = ?
WHERE id = ?`
)
.run(now.toISOString(), now.toISOString(), scheduleId)
return schedule
}
listHeartbeatConfigs(projectId?: string): AssistantHeartbeatConfig[] {
const rows = projectId
? this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_configs
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT 100`
)
.all(projectId)
: this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_configs
ORDER BY created_at DESC
LIMIT 100`
)
.all()
return (rows as HeartbeatConfigRow[]).map(toHeartbeatConfig)
}
getHeartbeatConfig(configId: string): AssistantHeartbeatConfig {
const row = this.requireDatabase()
.prepare('SELECT * FROM heartbeat_configs WHERE id = ?')
.get(configId) as HeartbeatConfigRow | undefined
if (!row) {
throw new Error('Heartbeat configuration not found')
}
return toHeartbeatConfig(row)
}
createHeartbeatConfig(
input: HeartbeatCreateInput,
now = new Date()
): AssistantHeartbeatConfig {
const id = randomUUID()
const timestamp = now.toISOString()
const nextRunAt = computeNextHeartbeatRun(
input.recurrence,
input.timezone,
now
).toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO heartbeat_configs
(id, project_id, name, timezone, recurrence_json,
lookback_hours, retention_days, enabled, next_run_at,
last_run_at, last_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`
)
.run(
id,
input.projectId ?? null,
input.name,
input.timezone,
JSON.stringify(input.recurrence),
input.lookbackHours,
input.retentionDays,
input.enabled ? 1 : 0,
nextRunAt,
timestamp,
timestamp
)
return this.getHeartbeatConfig(id)
}
updateHeartbeatConfig(
configId: string,
input: HeartbeatUpdateInput,
now = new Date()
): AssistantHeartbeatConfig {
const timestamp = now.toISOString()
const nextRunAt = computeNextHeartbeatRun(
input.recurrence,
input.timezone,
now
).toISOString()
const result = this.requireDatabase()
.prepare(
`UPDATE heartbeat_configs
SET project_id = ?, name = ?, timezone = ?,
recurrence_json = ?, lookback_hours = ?,
retention_days = ?, enabled = ?, next_run_at = ?,
updated_at = ?
WHERE id = ?`
)
.run(
input.projectId ?? null,
input.name,
input.timezone,
JSON.stringify(input.recurrence),
input.lookbackHours,
input.retentionDays,
input.enabled ? 1 : 0,
nextRunAt,
timestamp,
configId
)
if (result.changes !== 1) {
throw new Error('Heartbeat configuration not found')
}
return this.getHeartbeatConfig(configId)
}
setHeartbeatPaused(configId: string, paused: boolean): void {
const result = this.requireDatabase()
.prepare(
`UPDATE heartbeat_configs
SET enabled = ?, updated_at = ?
WHERE id = ?`
)
.run(paused ? 0 : 1, new Date().toISOString(), configId)
if (result.changes !== 1) {
throw new Error('Heartbeat configuration not found')
}
}
removeHeartbeatConfig(configId: string): void {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const artifactRows = database
.prepare(
`SELECT artifact_id FROM heartbeat_entries
WHERE config_id = ? AND artifact_id IS NOT NULL`
)
.all(configId) as Array<{ artifact_id: string }>
const result = database
.prepare('DELETE FROM heartbeat_configs WHERE id = ?')
.run(configId)
if (result.changes !== 1) {
throw new Error('Heartbeat configuration not found')
}
const deleteArtifact = database.prepare(
'DELETE FROM artifacts WHERE id = ?'
)
for (const row of artifactRows) {
deleteArtifact.run(row.artifact_id)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listHeartbeatRuns(
configId?: string,
limit = 50
): AssistantHeartbeatRun[] {
const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit)))
const rows = configId
? this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_runs
WHERE config_id = ?
ORDER BY created_at DESC
LIMIT ?`
)
.all(configId, safeLimit)
: this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_runs
ORDER BY created_at DESC
LIMIT ?`
)
.all(safeLimit)
return (rows as HeartbeatRunRow[]).map(toHeartbeatRun)
}
getHeartbeatRun(runId: string): AssistantHeartbeatRun {
const row = this.requireDatabase()
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
.get(runId) as HeartbeatRunRow | undefined
if (!row) {
throw new Error('Heartbeat run not found')
}
return toHeartbeatRun(row)
}
listHeartbeatEntries(
configId?: string,
limit = 50
): AssistantHeartbeatEntry[] {
const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit)))
const rows = configId
? this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_entries
WHERE config_id = ?
ORDER BY created_at DESC
LIMIT ?`
)
.all(configId, safeLimit)
: this.requireDatabase()
.prepare(
`SELECT * FROM heartbeat_entries
ORDER BY created_at DESC
LIMIT ?`
)
.all(safeLimit)
return (rows as HeartbeatEntryRow[]).map(toHeartbeatEntry)
}
claimDueHeartbeats(
leaseOwner: string,
now = new Date(),
leaseMilliseconds = 5 * 60_000
): ClaimedHeartbeatRun[] {
const database = this.requireDatabase()
const nowIso = now.toISOString()
const leaseExpiresAt = new Date(
now.getTime() + leaseMilliseconds
).toISOString()
const claimed: ClaimedHeartbeatRun[] = []
database.exec('BEGIN IMMEDIATE')
try {
const retryRows = database
.prepare(
`SELECT r.id AS run_id, r.config_id, r.attempt_count
FROM heartbeat_runs r
JOIN heartbeat_configs c ON c.id = r.config_id
WHERE c.enabled = 1
AND r.attempt_count < 3
AND (
(r.status = 'failed' AND r.next_attempt_at <= ?)
OR
(r.status = 'claimed' AND r.lease_expires_at <= ?)
)
ORDER BY COALESCE(r.next_attempt_at, r.lease_expires_at)
LIMIT 1`
)
.all(nowIso, nowIso) as Array<{
run_id: string
config_id: string
attempt_count: number
}>
for (const joined of retryRows) {
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'claimed', attempt_count = attempt_count + 1,
next_attempt_at = NULL, lease_owner = ?,
lease_expires_at = ?, started_at = ?,
completed_at = NULL, error = NULL, updated_at = ?
WHERE id = ? AND attempt_count = ?`
)
.run(
leaseOwner,
leaseExpiresAt,
nowIso,
nowIso,
joined.run_id,
joined.attempt_count
)
if (result.changes === 1) {
const run = database
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
.get(joined.run_id) as HeartbeatRunRow
const config = database
.prepare('SELECT * FROM heartbeat_configs WHERE id = ?')
.get(joined.config_id) as HeartbeatConfigRow
claimed.push({
run: toHeartbeatRun(run),
config: toHeartbeatConfig(config),
leaseOwner,
acquired: true
})
}
}
const remaining = Math.max(0, 1 - claimed.length)
const configRows = database
.prepare(
`SELECT * FROM heartbeat_configs
WHERE enabled = 1 AND next_run_at <= ?
ORDER BY next_run_at
LIMIT ?`
)
.all(nowIso, remaining) as HeartbeatConfigRow[]
for (const row of configRows) {
const scheduledFor = row.next_run_at
const lag = now.getTime() - Date.parse(scheduledFor)
const nextRunAt = computeNextHeartbeatRun(
JSON.parse(
row.recurrence_json
) as AssistantHeartbeatConfig['recurrence'],
row.timezone,
now
).toISOString()
if (lag > 2 * 60 * 60_000) {
database
.prepare(
`INSERT OR IGNORE INTO heartbeat_runs
(id, config_id, trigger, scheduled_for,
idempotency_key, status, attempt_count,
next_attempt_at, lease_owner, lease_expires_at,
started_at, completed_at, error, entry_id,
created_at, updated_at)
VALUES (?, ?, 'scheduled', ?, ?, 'skipped', 0,
NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?)`
)
.run(
randomUUID(),
row.id,
scheduledFor,
`scheduled:${row.id}:${scheduledFor}`,
nowIso,
'Missed by more than 2 hours',
nowIso,
nowIso
)
database
.prepare(
`UPDATE heartbeat_configs
SET next_run_at = ?, last_run_at = ?,
last_status = 'skipped', updated_at = ?
WHERE id = ? AND next_run_at = ?`
)
.run(nextRunAt, nowIso, nowIso, row.id, scheduledFor)
continue
}
const runId = randomUUID()
const insert = database
.prepare(
`INSERT OR IGNORE INTO heartbeat_runs
(id, config_id, trigger, scheduled_for,
idempotency_key, status, attempt_count,
next_attempt_at, lease_owner, lease_expires_at,
started_at, completed_at, error, entry_id,
created_at, updated_at)
VALUES (?, ?, 'scheduled', ?, ?, 'claimed', 1,
NULL, ?, ?, ?, NULL, NULL, NULL, ?, ?)`
)
.run(
runId,
row.id,
scheduledFor,
`scheduled:${row.id}:${scheduledFor}`,
leaseOwner,
leaseExpiresAt,
nowIso,
nowIso,
nowIso
)
database
.prepare(
`UPDATE heartbeat_configs
SET next_run_at = ?, last_run_at = ?,
last_status = 'claimed', updated_at = ?
WHERE id = ? AND next_run_at = ?`
)
.run(nextRunAt, nowIso, nowIso, row.id, scheduledFor)
if (insert.changes === 1) {
const run = database
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
.get(runId) as HeartbeatRunRow
claimed.push({
run: toHeartbeatRun(run),
config: toHeartbeatConfig({
...row,
next_run_at: nextRunAt,
last_run_at: nowIso,
last_status: 'claimed',
updated_at: nowIso
}),
leaseOwner,
acquired: true
})
}
}
database.exec('COMMIT')
return claimed
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
claimHeartbeatNow(
configId: string,
idempotencyKey: string,
leaseOwner: string,
now = new Date(),
leaseMilliseconds = 5 * 60_000
): ClaimedHeartbeatRun {
const database = this.requireDatabase()
const config = this.getHeartbeatConfig(configId)
const nowIso = now.toISOString()
const runId = randomUUID()
const scopedIdempotencyKey = `manual:${configId}:${idempotencyKey}`
database.exec('BEGIN IMMEDIATE')
try {
const duplicate = database
.prepare(
`SELECT * FROM heartbeat_runs
WHERE config_id = ? AND idempotency_key = ?`
)
.get(configId, scopedIdempotencyKey) as
| HeartbeatRunRow
| undefined
if (duplicate) {
database.exec('COMMIT')
return {
config,
run: toHeartbeatRun(duplicate),
leaseOwner,
acquired: false
}
}
database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = NULL,
lease_owner = NULL, lease_expires_at = NULL,
completed_at = ?, error = ?, updated_at = ?
WHERE config_id = ? AND status = 'claimed'
AND lease_expires_at <= ?`
)
.run(
nowIso,
'Expired run superseded by a new manual heartbeat',
nowIso,
configId,
nowIso
)
const active = database
.prepare(
`SELECT * FROM heartbeat_runs
WHERE config_id = ? AND status = 'claimed'
AND lease_expires_at > ?
ORDER BY created_at DESC LIMIT 1`
)
.get(configId, nowIso) as HeartbeatRunRow | undefined
if (active) {
database.exec('COMMIT')
return {
config,
run: toHeartbeatRun(active),
leaseOwner,
acquired: false
}
}
database
.prepare(
`INSERT INTO heartbeat_runs
(id, config_id, trigger, scheduled_for, idempotency_key,
status, attempt_count, next_attempt_at, lease_owner,
lease_expires_at, started_at, completed_at, error,
entry_id, created_at, updated_at)
VALUES (?, ?, 'manual', ?, ?, 'claimed', 1, NULL, ?, ?,
?, NULL, NULL, NULL, ?, ?)`
)
.run(
runId,
configId,
nowIso,
scopedIdempotencyKey,
leaseOwner,
new Date(
now.getTime() + leaseMilliseconds
).toISOString(),
nowIso,
nowIso,
nowIso
)
database
.prepare(
`UPDATE heartbeat_configs
SET last_run_at = ?, last_status = 'claimed', updated_at = ?
WHERE id = ?`
)
.run(nowIso, nowIso, configId)
const run = database
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
.get(runId) as HeartbeatRunRow
database.exec('COMMIT')
return {
config,
run: toHeartbeatRun(run),
leaseOwner,
acquired: true
}
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
buildHeartbeatInput(
config: AssistantHeartbeatConfig,
now = new Date()
): HeartbeatInputSnapshot {
const database = this.requireDatabase()
const since = new Date(
now.getTime() - config.lookbackHours * 60 * 60_000
).toISOString()
const conversations = (
config.projectId
? database
.prepare(
`SELECT id, title, updated_at FROM conversations
WHERE status = 'active' AND project_id = ?
AND updated_at >= ?
ORDER BY updated_at DESC LIMIT 20`
)
.all(config.projectId, since)
: database
.prepare(
`SELECT id, title, updated_at FROM conversations
WHERE status = 'active' AND updated_at >= ?
ORDER BY updated_at DESC LIMIT 20`
)
.all(since)
) as Array<{ id: string; title: string; updated_at: string }>
const messageStatement = database.prepare(
`SELECT role, content, created_at FROM (
SELECT role, content, created_at, sequence
FROM messages
WHERE conversation_id = ? AND created_at >= ?
AND role IN ('user', 'assistant')
ORDER BY sequence DESC LIMIT 20
) ORDER BY sequence`
)
const tasks = (
config.projectId
? database
.prepare(
`SELECT id, title, status, created_at, completed_at
FROM tasks
WHERE project_id = ? AND created_at >= ?
ORDER BY created_at DESC LIMIT 100`
)
.all(config.projectId, since)
: database
.prepare(
`SELECT id, title, status, created_at, completed_at
FROM tasks
WHERE created_at >= ?
ORDER BY created_at DESC LIMIT 100`
)
.all(since)
) as Array<{
id: string
title: string
status: AssistantTask['status']
created_at: string
completed_at: string | null
}>
const memories = (
config.projectId
? database
.prepare(
`SELECT id, type, content, scope FROM memory_items
WHERE status = 'confirmed'
AND (scope = 'global' OR
(scope = 'project' AND scope_id = ?))
ORDER BY updated_at DESC LIMIT 100`
)
.all(config.projectId)
: database
.prepare(
`SELECT id, type, content, scope FROM memory_items
WHERE status = 'confirmed' AND scope = 'global'
ORDER BY updated_at DESC LIMIT 100`
)
.all()
) as Array<{
id: string
type: AssistantMemory['type']
content: string
scope: AssistantMemory['scope']
}>
return {
conversations: conversations.map((conversation) => ({
id: conversation.id,
title: conversation.title,
updatedAt: conversation.updated_at,
messages: (
messageStatement.all(conversation.id, since) as Array<{
role: 'user' | 'assistant'
content: string
created_at: string
}>
).map((message) => ({
role: message.role,
content: message.content,
createdAt: message.created_at
}))
})),
tasks: tasks.map((task) => ({
id: task.id,
title: task.title,
status: task.status,
createdAt: task.created_at,
completedAt: task.completed_at ?? undefined
})),
confirmedMemories: memories
}
}
completeHeartbeatRun(
claim: ClaimedHeartbeatRun,
output: HeartbeatSummaryOutput,
now = new Date()
): AssistantHeartbeatRun {
const database = this.requireDatabase()
const timestamp = now.toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const active = database
.prepare(
`SELECT id FROM heartbeat_runs
WHERE id = ? AND status = 'claimed' AND lease_owner = ?
AND lease_expires_at > ?`
)
.get(
claim.run.id,
claim.leaseOwner,
timestamp
) as { id: string } | undefined
if (!active) {
throw new Error('Heartbeat lease is no longer active')
}
const artifactId = randomUUID()
const entryId = randomUUID()
const summaryContent = [
`# ${claim.config.name}`,
'',
output.summary,
...(output.highlights.length
? ['', '## Highlights', ...output.highlights.map((item) => `- ${item}`)]
: [])
].join('\n')
database
.prepare(
`INSERT INTO artifacts
(id, project_id, task_id, run_id, kind, title, mime_type,
storage_kind, storage_path, inline_content, checksum,
byte_size, preview_json, created_at, updated_at)
VALUES (?, ?, NULL, NULL, 'markdown', ?, 'text/markdown',
'inline', NULL, ?, NULL, ?, '{}', ?, ?)`
)
.run(
artifactId,
claim.config.projectId ?? null,
`Heartbeat: ${claim.config.name}`.slice(0, 240),
summaryContent,
Buffer.byteLength(summaryContent),
timestamp,
timestamp
)
const proposedMemoryIds: string[] = []
const insertMemory = database.prepare(
`INSERT INTO memory_items
(id, scope, scope_id, type, content, source_conversation_id,
source_message_id, confidence, salience, status, expires_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'proposed',
NULL, ?, ?)`
)
const findExistingMemory = database.prepare(
`SELECT id FROM memory_items
WHERE scope = ?
AND ((? IS NULL AND scope_id IS NULL) OR scope_id = ?)
AND content = ? COLLATE NOCASE
AND status IN ('proposed', 'confirmed')
LIMIT 1`
)
for (const memory of output.proposedMemories) {
const scopeId =
memory.scope === 'project'
? (claim.config.projectId ?? null)
: null
const existing = findExistingMemory.get(
memory.scope,
scopeId,
scopeId,
memory.content
) as { id: string } | undefined
if (existing) {
continue
}
const memoryId = randomUUID()
insertMemory.run(
memoryId,
memory.scope,
scopeId,
memory.type,
memory.content,
memory.confidence,
memory.salience,
timestamp,
timestamp
)
proposedMemoryIds.push(memoryId)
}
const followUpTaskIds: string[] = []
const insertTask = database.prepare(
`INSERT INTO tasks
(id, project_id, conversation_id, schedule_id, title,
instructions, origin, status, priority, work_mode,
progress, created_at, started_at, completed_at, error)
VALUES (?, ?, NULL, NULL, ?, ?, 'assistant', 'paused', 0,
'plan', NULL, ?, NULL, NULL, NULL)`
)
for (const task of output.followUpTasks) {
const taskId = randomUUID()
insertTask.run(
taskId,
claim.config.projectId ?? null,
task.title,
task.instructions,
timestamp
)
followUpTaskIds.push(taskId)
}
database
.prepare(
`INSERT INTO heartbeat_entries
(id, config_id, run_id, scheduled_for, summary,
highlights_json, artifact_id, proposed_memory_ids_json,
follow_up_task_ids_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
entryId,
claim.config.id,
claim.run.id,
claim.run.scheduledFor,
output.summary,
JSON.stringify(output.highlights),
artifactId,
JSON.stringify(proposedMemoryIds),
JSON.stringify(followUpTaskIds),
timestamp
)
database
.prepare(
`UPDATE heartbeat_runs
SET status = 'completed', completed_at = ?, error = NULL,
entry_id = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ?`
)
.run(timestamp, entryId, timestamp, claim.run.id)
database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'completed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
this.pruneHeartbeatHistory(claim.config.id, now)
return this.getHeartbeatRun(claim.run.id)
}
failHeartbeatRun(
claim: ClaimedHeartbeatRun,
error: string,
now = new Date()
): AssistantHeartbeatRun {
const database = this.requireDatabase()
const timestamp = now.toISOString()
const retryDelays = [60_000, 5 * 60_000]
const nextAttemptAt =
claim.run.attemptCount < 3
? new Date(
now.getTime() +
retryDelays[
Math.min(
claim.run.attemptCount - 1,
retryDelays.length - 1
)
]!
).toISOString()
: null
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = ?,
completed_at = ?, error = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND status = 'claimed' AND lease_owner = ?`
)
.run(
nextAttemptAt,
timestamp,
error.slice(0, 2_000),
timestamp,
claim.run.id,
claim.leaseOwner
)
if (result.changes !== 1) {
throw new Error('Heartbeat lease is no longer active')
}
database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'failed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
return this.getHeartbeatRun(claim.run.id)
}
pruneHeartbeatHistory(configId: string, now = new Date()): void {
const database = this.requireDatabase()
const config = this.getHeartbeatConfig(configId)
const cutoff = new Date(
now.getTime() - config.retentionDays * 24 * 60 * 60_000
).toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const artifacts = database
.prepare(
`SELECT artifact_id FROM heartbeat_entries
WHERE config_id = ? AND created_at < ?
AND artifact_id IS NOT NULL`
)
.all(configId, cutoff) as Array<{ artifact_id: string }>
database
.prepare(
`DELETE FROM heartbeat_entries
WHERE config_id = ? AND created_at < ?`
)
.run(configId, cutoff)
database
.prepare(
`DELETE FROM heartbeat_runs
WHERE config_id = ? AND created_at < ?
AND status IN ('completed', 'failed', 'skipped')`
)
.run(configId, cutoff)
const deleteArtifact = database.prepare(
'DELETE FROM artifacts WHERE id = ?'
)
for (const artifact of artifacts) {
deleteArtifact.run(artifact.artifact_id)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
listExperts(): AssistantExpert[] {
return (
this.requireDatabase()
.prepare(
`SELECT * FROM experts
WHERE enabled = 1
ORDER BY name`
)
.all() as ExpertRow[]
).map(toExpert)
}
createExpert(input: ExpertCreateInput): AssistantExpert {
const normalized = expertCreateSchema.parse(input)
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO experts
(id, name, description, system_instructions,
capability_policy_json, model_policy_json, enabled,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`
)
.run(
id,
normalized.name,
normalized.description,
normalized.systemInstructions,
JSON.stringify({
routingKeywords: normalized.routingKeywords
}),
JSON.stringify({
modelProfileId: normalized.modelProfileId
}),
now,
now
)
return this.getExpert(id)
}
updateExpert(
expertId: string,
input: ExpertUpdateInput
): AssistantExpert {
const normalized = expertCreateSchema.parse(input)
const result = this.requireDatabase()
.prepare(
`UPDATE experts
SET name = ?, description = ?, system_instructions = ?,
capability_policy_json = ?,
model_policy_json = ?,
updated_at = ?
WHERE id = ? AND enabled = 1`
)
.run(
normalized.name,
normalized.description,
normalized.systemInstructions,
JSON.stringify({
routingKeywords: normalized.routingKeywords
}),
JSON.stringify({
modelProfileId: normalized.modelProfileId
}),
new Date().toISOString(),
expertId
)
if (result.changes === 0) {
throw new Error('专家不存在或已停用')
}
return this.getExpert(expertId)
}
removeExpert(expertId: string): void {
const result = this.requireDatabase()
.prepare(
`UPDATE experts
SET enabled = 0, updated_at = ?
WHERE id = ? AND enabled = 1`
)
.run(new Date().toISOString(), expertId)
if (result.changes === 0) {
throw new Error('专家不存在或已停用')
}
}
getExpert(expertId: string): AssistantExpert {
const row = this.requireDatabase()
.prepare('SELECT * FROM experts WHERE id = ? AND enabled = 1')
.get(expertId) as ExpertRow | undefined
if (!row) {
throw new Error('专家不存在或已停用')
}
return toExpert(row)
}
getProject(projectId: string): AssistantProject {
const row = this.requireDatabase()
.prepare('SELECT * FROM projects WHERE id = ?')
.get(projectId) as ProjectRow | undefined
if (!row) {
throw new Error('项目不存在')
}
return toProject(row)
}
private getTask(taskId: string): AssistantTask {
const row = this.requireDatabase()
.prepare('SELECT * FROM tasks WHERE id = ?')
.get(taskId) as TaskRow | undefined
if (!row) {
throw new Error('任务不存在')
}
return toTask(row)
}
private getMemory(memoryId: string): AssistantMemory {
const row = this.requireDatabase()
.prepare('SELECT * FROM memory_items WHERE id = ?')
.get(memoryId) as MemoryRow | undefined
if (!row) {
throw new Error('记忆不存在')
}
return toMemory(row)
}
private getSchedule(scheduleId: string): AssistantSchedule {
const row = this.requireDatabase()
.prepare('SELECT * FROM schedules WHERE id = ?')
.get(scheduleId) as ScheduleRow | undefined
if (!row) {
throw new Error('定时任务不存在')
}
return toSchedule(row)
}
private migrate(database: DatabaseSync): void {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version > 8) {
throw new Error(
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
)
}
if (version.user_version === 8) {
return
}
if (version.user_version < 1) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
root_path TEXT NOT NULL DEFAULT '',
default_work_mode TEXT NOT NULL
CHECK(default_work_mode IN ('ask', 'plan', 'execute')),
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
runtime_selection_json TEXT,
work_mode TEXT NOT NULL DEFAULT 'ask'
CHECK(work_mode IN ('ask', 'plan', 'execute')),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL
REFERENCES conversations(id) ON DELETE CASCADE,
request_id TEXT,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('streaming', 'complete', 'error')),
sequence INTEGER NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
UNIQUE(conversation_id, sequence)
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
conversation_id TEXT,
schedule_id TEXT,
title TEXT NOT NULL,
instructions TEXT NOT NULL,
origin TEXT NOT NULL
CHECK(origin IN ('user', 'assistant', 'schedule', 'delegation', 'subagent')),
status TEXT NOT NULL
CHECK(status IN ('queued', 'running', 'waiting_approval', 'paused',
'completed', 'failed', 'cancelled', 'interrupted')),
priority INTEGER NOT NULL DEFAULT 0,
work_mode TEXT NOT NULL DEFAULT 'execute'
CHECK(work_mode IN ('ask', 'plan', 'execute')),
progress REAL,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
error TEXT
);
CREATE TABLE runs (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
parent_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
kind TEXT NOT NULL
CHECK(kind IN ('interactive', 'background', 'scheduled', 'delegated', 'subagent')),
status TEXT NOT NULL,
prompt TEXT NOT NULL,
execution_snapshot_json TEXT NOT NULL,
checkpoint_json TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
error TEXT
);
CREATE TABLE task_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX task_events_task_idx ON task_events(task_id, id);
CREATE TABLE artifacts (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
kind TEXT NOT NULL,
title TEXT NOT NULL,
mime_type TEXT NOT NULL,
storage_kind TEXT NOT NULL
CHECK(storage_kind IN ('inline', 'managed_file', 'reference')),
storage_path TEXT,
inline_content TEXT,
checksum TEXT,
byte_size INTEGER NOT NULL,
preview_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE memory_items (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL CHECK(scope IN ('global', 'project', 'conversation')),
scope_id TEXT,
type TEXT NOT NULL CHECK(type IN ('preference', 'fact', 'summary', 'procedure')),
content TEXT NOT NULL,
source_conversation_id TEXT,
source_message_id TEXT,
confidence REAL NOT NULL,
salience REAL NOT NULL,
status TEXT NOT NULL CHECK(status IN ('proposed', 'confirmed', 'rejected')),
expires_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE schedules (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
task_template_json TEXT NOT NULL,
timezone TEXT NOT NULL,
recurrence_json TEXT NOT NULL,
next_run_at TEXT NOT NULL,
missed_run_policy TEXT NOT NULL
CHECK(missed_run_policy IN ('skip', 'run_once', 'catch_up')),
enabled INTEGER NOT NULL,
last_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE schedule_runs (
id TEXT PRIMARY KEY,
schedule_id TEXT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
scheduled_for TEXT NOT NULL,
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
status TEXT NOT NULL,
UNIQUE(schedule_id, scheduled_for)
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
task_id TEXT,
schedule_id TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'shown', 'dismissed', 'opened')),
created_at TEXT NOT NULL,
shown_at TEXT
);
CREATE TABLE experts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
system_instructions TEXT NOT NULL,
capability_policy_json TEXT NOT NULL,
model_policy_json TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE delegations (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
endpoint_id TEXT NOT NULL,
remote_job_id TEXT,
status TEXT NOT NULL,
request_digest TEXT NOT NULL,
result_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
PRAGMA user_version = 1;
COMMIT;
`)
}
if (version.user_version < 2) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS delegation_outbox (
task_id TEXT PRIMARY KEY,
result_json TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'delivered')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS delegation_outbox_status_idx
ON delegation_outbox(status, updated_at);
PRAGMA user_version = 2;
COMMIT;
`)
}
if (version.user_version < 3) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS heartbeat_configs (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
timezone TEXT NOT NULL,
recurrence_json TEXT NOT NULL,
lookback_hours INTEGER NOT NULL
CHECK(lookback_hours BETWEEN 1 AND 720),
retention_days INTEGER NOT NULL
CHECK(retention_days BETWEEN 1 AND 365),
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
next_run_at TEXT NOT NULL,
last_run_at TEXT,
last_status TEXT
CHECK(last_status IN ('claimed', 'completed', 'failed', 'skipped')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS heartbeat_configs_due_idx
ON heartbeat_configs(enabled, next_run_at);
CREATE TABLE IF NOT EXISTS heartbeat_runs (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL
REFERENCES heartbeat_configs(id) ON DELETE CASCADE,
trigger TEXT NOT NULL CHECK(trigger IN ('scheduled', 'manual')),
scheduled_for TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL
CHECK(status IN ('claimed', 'completed', 'failed', 'skipped')),
attempt_count INTEGER NOT NULL
CHECK(attempt_count BETWEEN 0 AND 3),
next_attempt_at TEXT,
lease_owner TEXT,
lease_expires_at TEXT,
started_at TEXT,
completed_at TEXT,
error TEXT,
entry_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(config_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS heartbeat_runs_claim_idx
ON heartbeat_runs(status, next_attempt_at, lease_expires_at);
CREATE INDEX IF NOT EXISTS heartbeat_runs_history_idx
ON heartbeat_runs(config_id, created_at DESC);
CREATE TABLE IF NOT EXISTS heartbeat_entries (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL
REFERENCES heartbeat_configs(id) ON DELETE CASCADE,
run_id TEXT NOT NULL UNIQUE
REFERENCES heartbeat_runs(id) ON DELETE CASCADE,
scheduled_for TEXT NOT NULL,
summary TEXT NOT NULL,
highlights_json TEXT NOT NULL,
artifact_id TEXT REFERENCES artifacts(id) ON DELETE SET NULL,
proposed_memory_ids_json TEXT NOT NULL,
follow_up_task_ids_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS heartbeat_entries_history_idx
ON heartbeat_entries(config_id, created_at DESC);
PRAGMA user_version = 3;
COMMIT;
`)
}
if (version.user_version < 4) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS model_usage_calls (
request_id TEXT NOT NULL
REFERENCES tasks(id) ON DELETE CASCADE,
call_id TEXT NOT NULL,
runtime TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL CHECK(input_tokens >= 0),
output_tokens INTEGER NOT NULL CHECK(output_tokens >= 0),
cache_read_tokens INTEGER NOT NULL CHECK(cache_read_tokens >= 0),
cache_write_tokens INTEGER NOT NULL CHECK(cache_write_tokens >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(request_id, call_id)
);
CREATE INDEX IF NOT EXISTS model_usage_calls_request_idx
ON model_usage_calls(request_id);
CREATE INDEX IF NOT EXISTS model_usage_calls_dimensions_idx
ON model_usage_calls(runtime, provider, model);
PRAGMA user_version = 4;
COMMIT;
`)
}
if (version.user_version < 5) {
database.exec(`
BEGIN IMMEDIATE;
CREATE INDEX IF NOT EXISTS tasks_status_idx
ON tasks(status);
CREATE INDEX IF NOT EXISTS messages_state_idx
ON messages(state);
PRAGMA user_version = 5;
COMMIT;
`)
}
if (version.user_version < 6) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS computer_control_actions (
command_id TEXT PRIMARY KEY
CHECK(length(command_id) BETWEEN 16 AND 160),
task_id TEXT NOT NULL
REFERENCES tasks(id) ON DELETE CASCADE
CHECK(length(task_id) BETWEEN 1 AND 128),
conversation_id TEXT NOT NULL
CHECK(length(conversation_id) BETWEEN 1 AND 128),
lease_id TEXT NOT NULL
CHECK(length(lease_id) BETWEEN 16 AND 160),
action TEXT NOT NULL
CHECK(action IN ('observe', 'activate', 'replace_text',
'select_option', 'scroll')),
risk TEXT NOT NULL
CHECK(risk IN ('observe', 'navigate', 'input', 'commit',
'forbidden')),
outcome TEXT NOT NULL
CHECK(outcome IN ('completed', 'denied', 'failed',
'outcome_unknown')),
error_code TEXT
CHECK(error_code IS NULL OR error_code IN (
'invalid_request', 'driver_unavailable', 'driver_timeout',
'lease_not_found', 'lease_expired', 'lease_mismatch',
'observation_not_found', 'observation_stale',
'observation_consumed', 'element_not_found',
'window_not_foreground', 'element_identity_changed',
'focus_failed', 'forbidden', 'approval_denied',
'approval_timeout', 'cancelled', 'command_id_conflict',
'outcome_unknown', 'internal_error')),
occurred_at INTEGER NOT NULL
CHECK(occurred_at BETWEEN 0 AND 9007199254740991),
text_length INTEGER
CHECK(text_length IS NULL OR
text_length BETWEEN 0 AND 4096),
text_digest TEXT
CHECK(text_digest IS NULL OR (
length(text_digest) = 64 AND
text_digest NOT GLOB '*[^0-9a-f]*')),
created_at TEXT NOT NULL,
CHECK(
(outcome = 'completed' AND error_code IS NULL) OR
(outcome <> 'completed' AND error_code IS NOT NULL)
),
CHECK(
(action = 'replace_text' AND text_length IS NOT NULL AND
text_digest IS NOT NULL) OR
(action <> 'replace_text' AND text_length IS NULL AND
text_digest IS NULL)
)
);
CREATE INDEX IF NOT EXISTS computer_control_actions_recent_idx
ON computer_control_actions(occurred_at DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS computer_control_actions_task_idx
ON computer_control_actions(task_id, occurred_at DESC);
PRAGMA user_version = 6;
COMMIT;
`)
}
if (version.user_version < 7) {
const taskColumns = new Set(
(database.prepare('PRAGMA table_info(tasks)').all() as Array<{
name: string
}>).map((column) => column.name)
)
database.exec('BEGIN IMMEDIATE')
try {
if (!taskColumns.has('parent_task_id')) {
database.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT
REFERENCES tasks(id) ON DELETE CASCADE`)
}
if (!taskColumns.has('expert_id')) {
database.exec(`ALTER TABLE tasks ADD COLUMN expert_id TEXT
REFERENCES experts(id) ON DELETE SET NULL`)
}
if (!taskColumns.has('routing_mode')) {
database.exec(`ALTER TABLE tasks ADD COLUMN routing_mode TEXT
CHECK(routing_mode IS NULL OR routing_mode IN ('manual', 'smart'))`)
}
database.exec(`
CREATE INDEX IF NOT EXISTS tasks_parent_task_idx
ON tasks(parent_task_id, created_at);
CREATE INDEX IF NOT EXISTS tasks_expert_idx
ON tasks(expert_id, created_at);
PRAGMA user_version = 7;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
if (version.user_version < 8) {
const conversationColumns = new Set(
(
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
name: string
}>
).map((column) => column.name)
)
database.exec('BEGIN IMMEDIATE')
try {
if (!conversationColumns.has('runtime_selection_json')) {
database.exec(
'ALTER TABLE conversations ADD COLUMN runtime_selection_json TEXT'
)
}
database.exec('PRAGMA user_version = 8; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
if (!this.database) {
throw new Error('助手数据库尚未初始化')
}
return this.database
}
}