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, ProjectChannel, ProjectCreateInput, ScheduleCreateInput, TokenUsageRecord, TokenUsageSummary } from '../../shared/assistant-contracts' import { computerControlErrorCodeSchema, computerControlRiskSchema, type ComputerControlErrorCode, type ComputerControlRisk } from '../../shared/computer-control-contracts' import { channelResultMessageSchema, type ChannelResultMessage } from '../../shared/channel-contracts' import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, repairAgentRuntimeSelection, repairChannelRuntimeSelection, type AgentRuntimeSelection, type RuntimeSelectionRepairSettings } from '../../shared/runtime-selection-contracts' import { MAGIC_NOTE_MAX_NOTE_EMBED_BYTES, type MagicNoteComment, type MagicNoteDetail, type MagicNoteEntry, type MagicNoteRichContent, type MagicNoteSearchResult, type MagicNoteSummary, type MagicTodoItem, type MagicTodoUpdateInput } from '../../shared/magic-notes-contracts' import type { ComputerControlAuditEvent } from '../computer-control/audit' import { magicNoteChecklistItems, magicNoteEmbeddedBytes, magicNoteImageBytes, magicNotePlainText, magicNotePreview, setMagicNoteChecklistCompletion } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' type ProjectRow = { id: string name: string description: string root_path: string default_work_mode: ProjectCreateInput['defaultWorkMode'] runtime_selection_json: string | null kind: AssistantProject['kind'] channel: ProjectChannel | null 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 channel: ProjectChannel | null external_account_id: string | null external_conversation_id: string | null conversation_type: 'direct' | 'group' | null account_display: string | null 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 MagicNoteRow = { id: string project_id: string | null title: string pinned: number revision: number created_at: string updated_at: string entry_count: number latest_plain_text: string | null } type MagicNoteEntryRow = { id: string note_id: string content_json: string plain_text: string comments_json: string analyzed_at: string | null revision: number created_at: string updated_at: string } type MagicTodoRow = { id: string project_id: string | null note_id: string | null entry_id: string | null note_title: string | null source_index: number | null source: MagicTodoItem['source'] title: string instructions: string completed: number comments_json: string analyzed_at: string | null revision: number created_at: string updated_at: string } type MessageMetadata = { createdAt?: number status?: string reasoning?: ConversationSnapshot['messages'][number]['reasoning'] blocks?: ConversationSnapshot['messages'][number]['blocks'] tools?: ConversationSnapshot['messages'][number]['tools'] sources?: string[] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] artifactIds?: string[] attachments?: ConversationSnapshot['messages'][number]['attachments'] } const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024 const MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES = 8 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, runtimeSelection: row.kind === 'channel' ? parseRuntimeSelection(row.runtime_selection_json) ?? { provider: 'auto' } : parseRuntimeSelection(row.runtime_selection_json), kind: row.kind, channel: row.channel ?? undefined, 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 toMagicNoteEntry(row: MagicNoteEntryRow): MagicNoteEntry { return { id: row.id, noteId: row.note_id, content: JSON.parse(row.content_json) as MagicNoteRichContent, plainText: row.plain_text, comments: JSON.parse(row.comments_json) as MagicNoteComment[], analyzedAt: row.analyzed_at ?? undefined, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at } } function toMagicTodo(row: MagicTodoRow): MagicTodoItem { if ( row.source !== 'note' || !row.note_id || !row.entry_id || row.source_index === null || !row.note_title ) { throw new Error('待办来源数据无效') } return { id: row.id, noteId: row.note_id, entryId: row.entry_id, noteTitle: row.note_title, sourceIndex: row.source_index, source: 'note', title: row.title, instructions: row.instructions, completed: row.completed === 1, comments: JSON.parse(row.comments_json) as MagicNoteComment[], analyzedAt: row.analyzed_at ?? undefined, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at } } function toMagicNoteSummary(row: MagicNoteRow): MagicNoteSummary { return { id: row.id, title: row.title, preview: magicNotePreview(row.latest_plain_text ?? ''), entryCount: row.entry_count, pinned: row.pinned === 1, revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at } } 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( 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 ) } function interruptActiveToolBlocks( blocks: MessageMetadata['blocks'] ): MessageMetadata['blocks'] { return blocks?.map((block) => block.type === 'tool' && (block.tool.state === 'pending' || block.tool.state === 'running') ? { ...block, tool: { ...block.tool, state: 'interrupted' as const } } : block ) } export class AssistantDatabase { private database?: DatabaseSync private channelEventWrites = 0 private channelOutboxWrites = 0 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 this.channelEventWrites = ( database .prepare('SELECT COUNT(*) AS count FROM channel_events') .get() as { count: number } ).count % 128 this.channelOutboxWrites = ( database .prepare('SELECT COUNT(*) AS count FROM channel_outbox') .get() as { count: number } ).count % 128 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' ) || metadata.blocks?.some( (block) => block.type === 'tool' && (block.tool.state === 'pending' || block.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), blocks: interruptActiveToolBlocks(metadata.blocks) }), 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 [ 'magic_todos', 'magic_note_entries', 'magic_notes', 'heartbeat_configs', 'delegation_outbox', 'delegations', 'notifications', 'schedule_runs', 'schedules', 'memory_items', 'artifacts', 'computer_control_actions', 'task_events', 'runs', 'model_usage_calls', 'channel_outbox', 'channel_events', '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 created_at ASC, rowid ASC` : `SELECT * FROM projects WHERE status = 'active' ORDER BY created_at ASC, rowid ASC` ) .all() as ProjectRow[] return rows.map(toProject) } ensureChannelProjects( defaultRootPath: string, defaultModelProfileId: string ): AssistantProject[] { const database = this.requireDatabase() const definitions: ReadonlyArray<{ channel: ProjectChannel name: string description: string }> = [ { channel: 'weixin', name: '微信 ClawBot', description: '个人微信远程消息与受控任务' }, { channel: 'wecom', name: '企业微信', description: '企业微信远程消息与受控任务' }, { channel: 'dingtalk', name: '钉钉', description: '钉钉远程消息与受控任务' } ] const find = database.prepare( 'SELECT * FROM projects WHERE channel = ?' ) const insert = database.prepare( `INSERT INTO projects (id, name, description, root_path, default_work_mode, runtime_selection_json, kind, channel, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'ask', ?, 'channel', ?, 'active', ?, ?)` ) database.exec('BEGIN IMMEDIATE') try { for (const definition of definitions) { if (find.get(definition.channel)) { continue } const now = new Date().toISOString() insert.run( randomUUID(), definition.name, definition.description, defaultRootPath, JSON.stringify({ provider: 'model', profileId: defaultModelProfileId }), definition.channel, now, now ) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return definitions.map((definition) => { const row = find.get(definition.channel) as ProjectRow | undefined if (!row) { throw new Error(`未能创建${definition.name}通道项目`) } return toProject(row) }) } 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, runtime_selection_json, kind, channel, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)` ) .run( id, input.name, input.description, input.rootPath, input.defaultWorkMode, input.runtimeSelection ? JSON.stringify(input.runtimeSelection) : null, now, now ) return this.getProject(id) } updateProject( projectId: string, input: ProjectCreateInput ): AssistantProject { const database = this.requireDatabase() const current = this.getProject(projectId) if ( current.kind === 'channel' && input.rootPath.trim().length === 0 ) { throw new Error('通道项目必须设置默认工作目录') } const result = database .prepare( `UPDATE projects SET name = ?, description = ?, root_path = ?, default_work_mode = ?, runtime_selection_json = ?, updated_at = ? WHERE id = ?` ) .run( current.kind === 'channel' ? current.name : input.name, input.description, input.rootPath, input.defaultWorkMode, input.runtimeSelection || current.runtimeSelection ? JSON.stringify( input.runtimeSelection ?? current.runtimeSelection ) : null, 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() if (this.getProject(projectId).kind === 'channel') { throw new Error('系统通道项目不能归档') } 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('项目不存在') } } deleteProject(projectId: string, confirmation: string): void { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') try { const project = database .prepare('SELECT name, kind, status FROM projects WHERE id = ?') .get(projectId) as | { name: string kind: AssistantProject['kind'] status: AssistantProject['status'] } | undefined if (!project) { throw new Error('项目不存在') } if (project.kind === 'channel') { throw new Error('系统通道项目不能删除') } if (confirmation !== project.name) { throw new Error('项目名称确认不匹配') } const activeProjectCount = database .prepare( `SELECT COUNT(*) AS count FROM projects WHERE kind = 'user' AND status = 'active'` ) .get() as { count: number } if (project.status === 'active' && activeProjectCount.count <= 1) { throw new Error('至少需要保留一个可用项目') } const activeTaskCount = database .prepare( `SELECT COUNT(*) AS count FROM tasks WHERE project_id = ? AND visible = 1 AND status IN ('queued', 'running', 'waiting_approval', 'paused')` ) .get(projectId) as { count: number } if (activeTaskCount.count > 0) { throw new Error('项目仍有进行中的任务,请先停止任务') } database .prepare( `DELETE FROM notifications WHERE task_id IN ( SELECT id FROM tasks WHERE project_id = ? ) OR schedule_id IN ( SELECT id FROM schedules WHERE project_id = ? )` ) .run(projectId, projectId) database .prepare( `DELETE FROM delegation_outbox WHERE task_id IN ( SELECT id FROM tasks WHERE project_id = ? )` ) .run(projectId) database .prepare( `DELETE FROM memory_items WHERE (scope = 'project' AND scope_id = ?) OR (scope = 'conversation' AND scope_id IN ( SELECT id FROM conversations WHERE project_id = ? ))` ) .run(projectId, projectId) database .prepare('DELETE FROM heartbeat_configs WHERE project_id = ?') .run(projectId) database .prepare('DELETE FROM artifacts WHERE project_id = ?') .run(projectId) database .prepare('DELETE FROM tasks WHERE project_id = ?') .run(projectId) database .prepare('DELETE FROM conversations WHERE project_id = ?') .run(projectId) database .prepare('DELETE FROM schedules WHERE project_id = ?') .run(projectId) const result = database .prepare('DELETE FROM projects WHERE id = ?') .run(projectId) if (result.changes !== 1) { throw new Error('项目不存在') } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } listConversations(): ConversationSnapshot[] { const database = this.requireDatabase() const conversations = database .prepare( `SELECT id, project_id, runtime_selection_json, title, channel, external_account_id, external_conversation_id, conversation_type, account_display, 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 ), ...(conversation.channel && conversation.conversation_type && conversation.account_display ? { remote: { channel: conversation.channel, accountDisplay: conversation.account_display, conversationType: conversation.conversation_type } } : {}), 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, reasoning: metadata.reasoning, blocks: interrupted ? interruptActiveToolBlocks(metadata.blocks) : metadata.blocks, 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 } }) })) } getConversation(conversationId: string): ConversationSnapshot { const conversation = this.listConversations().find( (candidate) => candidate.id === conversationId ) if (!conversation) { throw new Error('对话不存在') } return conversation } repairConversationRuntimeSelections( settings: RuntimeSelectionRepairSettings ): number { const database = this.requireDatabase() const projects = database .prepare( `SELECT id, runtime_selection_json FROM projects WHERE kind = 'channel'` ) .all() as Array<{ id: string runtime_selection_json: string | null }> const conversations = database .prepare( `SELECT id, runtime_selection_json, channel FROM conversations WHERE runtime_selection_json IS NOT NULL` ) .all() as Array<{ id: string runtime_selection_json: string channel: ProjectChannel | null }> const update = database.prepare( `UPDATE conversations SET runtime_selection_json = ? WHERE id = ?` ) const updateProject = database.prepare( `UPDATE projects SET runtime_selection_json = ?, updated_at = ? WHERE id = ?` ) let repaired = 0 database.exec('BEGIN IMMEDIATE') try { for (const project of projects) { const stored = parseRuntimeSelection( project.runtime_selection_json ) const current = stored ?? { provider: 'auto' as const } const next = repairChannelRuntimeSelection(current, settings) if ( stored && agentRuntimeSelectionKey(next) === agentRuntimeSelectionKey(current) ) { continue } updateProject.run( JSON.stringify(next), new Date().toISOString(), project.id ) repaired += 1 } for (const conversation of conversations) { const current = parseRuntimeSelection( conversation.runtime_selection_json ) if (!current) { continue } const next = conversation.channel ? repairChannelRuntimeSelection(current, settings) : 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 WHERE conversation_id IN ( SELECT id FROM conversations WHERE channel IS NULL ); DELETE FROM conversations WHERE channel IS NULL; `) 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)) { if (conversation.remote) { continue } 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, reasoning: message.reasoning, blocks: message.blocks, 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 } } getOrCreateRemoteConversation(input: { projectId: string channel: ProjectChannel accountId: string externalConversationId: string conversationType: 'direct' | 'group' title: string accountDisplay: string runtimeSelection?: AgentRuntimeSelection }): ConversationSnapshot { const database = this.requireDatabase() const existing = database .prepare( `SELECT id FROM conversations WHERE channel = ? AND external_account_id = ? AND external_conversation_id = ?` ) .get( input.channel, input.accountId, input.externalConversationId ) as { id: string } | undefined if (existing) { database .prepare( `UPDATE conversations SET project_id = ?, title = ?, conversation_type = ?, account_display = ?, runtime_selection_json = COALESCE(?, runtime_selection_json), status = 'active', updated_at = ? WHERE id = ?` ) .run( input.projectId, input.title, input.conversationType, input.accountDisplay, input.runtimeSelection ? JSON.stringify(input.runtimeSelection) : null, new Date().toISOString(), existing.id ) return this.getConversation(existing.id) } const id = randomUUID() const now = new Date().toISOString() database .prepare( `INSERT INTO conversations (id, project_id, runtime_selection_json, work_mode, title, status, channel, external_account_id, external_conversation_id, conversation_type, account_display, created_at, updated_at) VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?, ?, ?, ?, ?, ?)` ) .run( id, input.projectId, input.runtimeSelection ? JSON.stringify(input.runtimeSelection) : null, input.title, input.channel, input.accountId, input.externalConversationId, input.conversationType, input.accountDisplay, now, now ) return this.getConversation(id) } appendRemoteConversationMessage(input: { conversationId: string role: 'user' | 'assistant' content: string status?: string attachments?: ConversationSnapshot['messages'][number]['attachments'] artifactIds?: string[] }): void { const database = this.requireDatabase() const now = Date.now() const sequence = database .prepare( `SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence FROM messages WHERE conversation_id = ?` ) .get(input.conversationId) as { sequence: number } database.exec('BEGIN IMMEDIATE') try { database .prepare( `INSERT INTO messages (id, conversation_id, request_id, role, content, state, sequence, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, 'complete', ?, ?, ?)` ) .run( randomUUID(), input.conversationId, input.role, input.content, sequence.sequence, JSON.stringify({ createdAt: now, ...(input.status ? { status: input.status } : {}), ...(input.attachments?.length ? { attachments: input.attachments } : {}), ...(input.artifactIds?.length ? { artifactIds: input.artifactIds } : {}) }), new Date(now).toISOString() ) database .prepare( 'UPDATE conversations SET updated_at = ? WHERE id = ?' ) .run(new Date(now).toISOString(), input.conversationId) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } claimChannelEvent(channel: string, eventId: string): boolean { const database = this.requireDatabase() const result = database .prepare( `INSERT OR IGNORE INTO channel_events (channel, event_id, claimed_at) VALUES (?, ?, ?)` ) .run(channel, eventId, Date.now()) if (result.changes === 1) { this.channelEventWrites += 1 if (this.channelEventWrites % 128 === 0) { database.exec(` DELETE FROM channel_events WHERE rowid IN ( SELECT rowid FROM channel_events ORDER BY claimed_at ASC, rowid ASC LIMIT MAX( (SELECT COUNT(*) FROM channel_events) - 10000, 0 ) ); `) } } return result.changes === 1 } releaseChannelEvent(channel: string, eventId: string): void { this.requireDatabase() .prepare( 'DELETE FROM channel_events WHERE channel = ? AND event_id = ?' ) .run(channel, eventId) } enqueueChannelResult(message: ChannelResultMessage): { id: string message: ChannelResultMessage state: 'pending' attempts: number createdAt: number } { const parsed = channelResultMessageSchema.parse(message) if (parsed.attachments?.length) { const pendingMedia = ( this.requireDatabase() .prepare( `SELECT COUNT(*) AS count FROM channel_outbox WHERE state != 'delivered' AND attempts < 5 AND json_type(message_json, '$.attachments') = 'array'` ) .get() as { count: number } ).count if (pendingMedia >= MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES) { throw new Error('媒体结果等待发送过多,请恢复通道连接后重试') } } const entry = { id: randomUUID(), message: parsed, state: 'pending' as const, attempts: 0, createdAt: Date.now() } this.requireDatabase() .prepare( `INSERT INTO channel_outbox (id, channel, event_id, message_json, state, attempts, created_at) VALUES (?, ?, ?, ?, 'pending', 0, ?)` ) .run( entry.id, parsed.channel, parsed.eventId, JSON.stringify(parsed), entry.createdAt ) this.channelOutboxWrites += 1 if (this.channelOutboxWrites % 128 === 0) { this.requireDatabase().exec(` DELETE FROM channel_outbox WHERE rowid IN ( SELECT rowid FROM channel_outbox ORDER BY CASE state WHEN 'delivered' THEN 0 ELSE 1 END, created_at ASC, rowid ASC LIMIT MAX( (SELECT COUNT(*) FROM channel_outbox) - 10000, 0 ) ); `) } return structuredClone(entry) } markChannelResult( id: string, state: 'delivered' | 'failed' ): void { this.requireDatabase() .prepare( `UPDATE channel_outbox SET state = ?, attempts = attempts + 1, message_json = CASE WHEN ? = 'delivered' OR attempts + 1 >= 5 THEN json_remove(message_json, '$.attachments') ELSE message_json END WHERE id = ?` ) .run(state, state, id) } listUndeliveredChannelResults( channel?: string, limit = 100 ): Array<{ id: string message: ChannelResultMessage state: 'pending' | 'failed' attempts: number createdAt: number }> { const safeLimit = Math.min( Math.max(Number.isSafeInteger(limit) ? limit : 100, 1), 1_000 ) const rows = this.requireDatabase() .prepare( `WITH pending AS ( SELECT id, message_json, state, attempts, created_at, ROW_NUMBER() OVER ( ORDER BY attempts ASC, created_at ASC ) AS position, SUM(LENGTH(CAST(message_json AS BLOB))) OVER ( ORDER BY attempts ASC, created_at ASC ) AS cumulative_bytes FROM channel_outbox WHERE state != 'delivered' AND attempts < 5 ${channel === undefined ? '' : 'AND channel = ?'} ) SELECT id, message_json, state, attempts, created_at FROM pending WHERE position = 1 OR cumulative_bytes <= ? ORDER BY attempts ASC, created_at ASC LIMIT ?` ) .all( ...(channel === undefined ? [MAX_CHANNEL_OUTBOX_RETRY_BYTES, safeLimit] : [ channel, MAX_CHANNEL_OUTBOX_RETRY_BYTES, safeLimit ]) ) as Array<{ id: string message_json: string state: 'pending' | 'failed' attempts: number created_at: number }> return rows.map((row) => ({ id: row.id, message: channelResultMessageSchema.parse( JSON.parse(row.message_json) ), state: row.state, attempts: row.attempts, createdAt: row.created_at })) } listMagicNotes(): MagicNoteSummary[] { const database = this.requireDatabase() const rows = database .prepare( `SELECT n.*, (SELECT COUNT(*) FROM magic_note_entries e WHERE e.note_id = n.id) AS entry_count, (SELECT plain_text FROM magic_note_entries e WHERE e.note_id = n.id ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1) AS latest_plain_text FROM magic_notes n ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC LIMIT 200` ) .all() as MagicNoteRow[] return rows.map(toMagicNoteSummary) } getMagicNote(noteId: string): MagicNoteDetail { const database = this.requireDatabase() const row = database .prepare( `SELECT n.*, (SELECT COUNT(*) FROM magic_note_entries e WHERE e.note_id = n.id) AS entry_count, (SELECT plain_text FROM magic_note_entries e WHERE e.note_id = n.id ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1) AS latest_plain_text FROM magic_notes n WHERE n.id = ?` ) .get(noteId) as MagicNoteRow | undefined if (!row) { throw new Error('笔记不存在') } const entryRows = database .prepare( `SELECT * FROM magic_note_entries WHERE note_id = ? ORDER BY created_at ASC, rowid ASC` ) .all(noteId) as MagicNoteEntryRow[] return { ...toMagicNoteSummary(row), entries: entryRows.map(toMagicNoteEntry) } } getMagicNoteContext(noteId: string): { id: string title: string } { const row = this.requireDatabase() .prepare( `SELECT id, project_id, title FROM magic_notes WHERE id = ?` ) .get(noteId) as | { id: string; project_id: string | null; title: string } | undefined if (!row) { throw new Error('笔记不存在') } return { id: row.id, title: row.title } } createMagicNote(input: { title: string }): MagicNoteDetail { const id = randomUUID() const now = new Date().toISOString() this.requireDatabase() .prepare( `INSERT INTO magic_notes (id, project_id, title, pinned, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 0, ?, ?)` ) .run(id, null, input.title, now, now) return this.getMagicNote(id) } updateMagicNote(input: { noteId: string title?: string pinned?: boolean expectedRevision: number }): MagicNoteDetail { const now = new Date().toISOString() const result = this.requireDatabase() .prepare( `UPDATE magic_notes SET title = COALESCE(?, title), pinned = COALESCE(?, pinned), revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?` ) .run( input.title ?? null, input.pinned === undefined ? null : input.pinned ? 1 : 0, now, input.noteId, input.expectedRevision ) if (result.changes !== 1) { throw new Error('笔记已被更新,请刷新后重试') } return this.getMagicNote(input.noteId) } deleteMagicNote(noteId: string): void { const result = this.requireDatabase() .prepare('DELETE FROM magic_notes WHERE id = ?') .run(noteId) if (result.changes !== 1) { throw new Error('笔记不存在') } } createMagicNoteEntry(input: { noteId: string content: MagicNoteRichContent plainText: string }): MagicNoteDetail { const database = this.requireDatabase() const entryId = randomUUID() const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { this.assertMagicNoteEmbedBudget(input.noteId, input.content) const noteResult = database .prepare( `UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?` ) .run(now, input.noteId) if (noteResult.changes !== 1) { throw new Error('笔记不存在') } database .prepare( `INSERT INTO magic_note_entries (id, note_id, content_json, plain_text, comments_json, actions_json, analyzed_at, revision, created_at, updated_at, image_bytes) VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)` ) .run( entryId, input.noteId, JSON.stringify(input.content), input.plainText, now, now, magicNoteEmbeddedBytes(input.content) ) this.syncMagicNoteTodos( database, input.noteId, entryId, input.content, now ) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return this.getMagicNote(input.noteId) } updateMagicNoteEntry(input: { entryId: string content: MagicNoteRichContent plainText: string expectedRevision: number }): MagicNoteDetail { const database = this.requireDatabase() const existing = database .prepare('SELECT note_id FROM magic_note_entries WHERE id = ?') .get(input.entryId) as { note_id: string } | undefined if (!existing) { throw new Error('记录不存在') } const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { this.assertMagicNoteEmbedBudget( existing.note_id, input.content, input.entryId ) const result = database .prepare( `UPDATE magic_note_entries SET content_json = ?, plain_text = ?, comments_json = '[]', analyzed_at = NULL, revision = revision + 1, updated_at = ?, image_bytes = ? WHERE id = ? AND revision = ?` ) .run( JSON.stringify(input.content), input.plainText, now, magicNoteEmbeddedBytes(input.content), input.entryId, input.expectedRevision ) if (result.changes !== 1) { throw new Error('记录已被更新,请刷新后重试') } database .prepare( `UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?` ) .run(now, existing.note_id) this.syncMagicNoteTodos( database, existing.note_id, input.entryId, input.content, now ) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return this.getMagicNote(existing.note_id) } deleteMagicNoteEntry(entryId: string): MagicNoteDetail { const database = this.requireDatabase() const existing = database .prepare('SELECT note_id FROM magic_note_entries WHERE id = ?') .get(entryId) as { note_id: string } | undefined if (!existing) { throw new Error('记录不存在') } const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { database .prepare('DELETE FROM magic_note_entries WHERE id = ?') .run(entryId) database .prepare( `UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?` ) .run(now, existing.note_id) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return this.getMagicNote(existing.note_id) } getMagicNoteEntry(entryId: string): MagicNoteEntry { const row = this.requireDatabase() .prepare('SELECT * FROM magic_note_entries WHERE id = ?') .get(entryId) as MagicNoteEntryRow | undefined if (!row) { throw new Error('记录不存在') } return toMagicNoteEntry(row) } saveMagicNoteAnalysis(input: { entryId: string expectedRevision: number comments: MagicNoteComment[] }): MagicNoteDetail { const database = this.requireDatabase() const existing = database .prepare( `SELECT note_id, comments_json FROM magic_note_entries WHERE id = ?` ) .get(input.entryId) as | { note_id: string; comments_json: string } | undefined if (!existing) { throw new Error('记录不存在') } const now = new Date().toISOString() const comments = [ ...(JSON.parse(existing.comments_json) as MagicNoteComment[]), ...input.comments.map((comment) => ({ ...comment, analyzedAt: now })) ] const result = database .prepare( `UPDATE magic_note_entries SET comments_json = ?, analyzed_at = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?` ) .run( JSON.stringify(comments), now, now, input.entryId, input.expectedRevision ) if (result.changes !== 1) { throw new Error('记录已被更新,请重新分析') } return this.getMagicNote(existing.note_id) } listMagicTodos(): MagicTodoItem[] { return ( this.requireDatabase() .prepare( `SELECT t.*, n.title AS note_title FROM magic_todos t LEFT JOIN magic_notes n ON n.id = t.note_id WHERE t.source = 'note' ORDER BY t.completed ASC, t.updated_at DESC, t.rowid DESC LIMIT 500` ) .all() as MagicTodoRow[] ).map(toMagicTodo) } searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] { const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%` return ( this.requireDatabase() .prepare( `SELECT n.id AS note_id, n.title AS note_title, e.id AS entry_id, e.plain_text, e.updated_at FROM magic_note_entries e INNER JOIN magic_notes n ON n.id = e.note_id WHERE n.title LIKE ? ESCAPE '\\' OR e.plain_text LIKE ? ESCAPE '\\' ORDER BY e.updated_at DESC, e.rowid DESC LIMIT ?` ) .all(pattern, pattern, limit) as Array<{ note_id: string note_title: string entry_id: string plain_text: string updated_at: string }> ).map((row) => ({ noteId: row.note_id, noteTitle: row.note_title.slice(0, 100), entryId: row.entry_id, content: row.plain_text.slice(0, 12_000), updatedAt: row.updated_at })) } getMagicTodo(todoId: string): MagicTodoItem { const row = this.requireDatabase() .prepare( `SELECT t.*, n.title AS note_title FROM magic_todos t LEFT JOIN magic_notes n ON n.id = t.note_id WHERE t.id = ?` ) .get(todoId) as MagicTodoRow | undefined if (!row) { throw new Error('待办不存在') } return toMagicTodo(row) } updateMagicTodo(input: MagicTodoUpdateInput): MagicTodoItem { const database = this.requireDatabase() const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') try { const existing = database .prepare( `SELECT t.note_id, t.entry_id, t.source_index, t.completed, t.revision AS todo_revision, e.content_json, e.revision AS entry_revision FROM magic_todos t INNER JOIN magic_note_entries e ON e.id = t.entry_id WHERE t.id = ? AND t.source = 'note'` ) .get(input.todoId) as | { note_id: string entry_id: string source_index: number completed: number todo_revision: number content_json: string entry_revision: number } | undefined if (!existing) { throw new Error('待办不存在') } if (existing.todo_revision !== input.expectedRevision) { throw new Error('待办已被更新,请刷新后重试') } if (Boolean(existing.completed) === input.completed) { database.exec('COMMIT') return this.getMagicTodo(input.todoId) } const content = setMagicNoteChecklistCompletion( JSON.parse(existing.content_json) as MagicNoteRichContent, existing.source_index, input.completed ) const result = database .prepare( `UPDATE magic_note_entries SET content_json = ?, plain_text = ?, comments_json = '[]', analyzed_at = NULL, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?` ) .run( JSON.stringify(content), magicNotePlainText(content), now, existing.entry_id, existing.entry_revision ) if (result.changes !== 1) { throw new Error('记录已被更新,请刷新后重试') } database .prepare( `UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?` ) .run(now, existing.note_id) this.syncMagicNoteTodos( database, existing.note_id, existing.entry_id, content, now ) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return this.getMagicTodo(input.todoId) } saveMagicTodoAnalysis(input: { todoId: string expectedRevision: number comments: MagicNoteComment[] }): MagicTodoItem { const database = this.requireDatabase() const existing = database .prepare('SELECT comments_json FROM magic_todos WHERE id = ?') .get(input.todoId) as { comments_json: string } | undefined if (!existing) { throw new Error('待办不存在') } const now = new Date().toISOString() const comments = [ ...(JSON.parse(existing.comments_json) as MagicNoteComment[]), ...input.comments.map((comment) => ({ ...comment, analyzedAt: now })) ] const result = database .prepare( `UPDATE magic_todos SET comments_json = ?, analyzed_at = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?` ) .run( JSON.stringify(comments), now, now, input.todoId, input.expectedRevision ) if (result.changes !== 1) { throw new Error('待办已被更新,请刷新后重试') } return this.getMagicTodo(input.todoId) } 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 WHERE visible = 1 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' visible?: boolean }): 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, visible) 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, Number(input.visible ?? true) ) 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 visible = 1 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 visible = 1 AND 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 syncMagicNoteTodos( database: DatabaseSync, noteId: string, entryId: string, content: MagicNoteRichContent, now: string ): void { const note = database .prepare('SELECT id FROM magic_notes WHERE id = ?') .get(noteId) as { id: string } | undefined if (!note) { throw new Error('笔记不存在') } const items = magicNoteChecklistItems(content) const existing = database .prepare( `SELECT id, project_id, note_id, source_index, title, completed FROM magic_todos WHERE entry_id = ? AND source = 'note'` ) .all(entryId) as Array<{ id: string project_id: string | null note_id: string | null source_index: number title: string completed: number }> const unmatched = new Set(existing) const byExactPosition = new Map( existing.map((todo) => [ `${todo.source_index}\u0000${todo.title}`, todo ]) ) const byPosition = new Map( existing.map((todo) => [todo.source_index, todo]) ) const byTitle = new Map() const unmatchedTitleCounts = new Map() for (const todo of existing) { const titleMatches = byTitle.get(todo.title) ?? [] titleMatches.push(todo) byTitle.set(todo.title, titleMatches) unmatchedTitleCounts.set( todo.title, (unmatchedTitleCounts.get(todo.title) ?? 0) + 1 ) } const matches: Array<{ item: (typeof items)[number] matched?: (typeof existing)[number] }> = items.map((item) => ({ item })) const assign = ( match: (typeof matches)[number], todo: (typeof existing)[number] | undefined ): void => { if (!todo || !unmatched.delete(todo)) { return } match.matched = todo unmatchedTitleCounts.set( todo.title, (unmatchedTitleCounts.get(todo.title) ?? 1) - 1 ) } for (const match of matches) { assign( match, byExactPosition.get( `${match.item.sourceIndex}\u0000${match.item.title}` ) ) } for (const match of matches.filter((candidate) => !candidate.matched)) { if (unmatchedTitleCounts.get(match.item.title) === 1) { assign( match, byTitle .get(match.item.title) ?.find((todo) => unmatched.has(todo)) ) } } for (const match of matches.filter((candidate) => !candidate.matched)) { assign(match, byPosition.get(match.item.sourceIndex)) } const deleteTodo = database.prepare( 'DELETE FROM magic_todos WHERE id = ?' ) for (const todo of unmatched) { deleteTodo.run(todo.id) } const parkSourceIndex = database.prepare( `UPDATE magic_todos SET source_index = -source_index - 1 WHERE id = ?` ) for (const { item, matched } of matches) { if (matched && matched.source_index !== item.sourceIndex) { parkSourceIndex.run(matched.id) } } const insertTodo = database.prepare( `INSERT INTO magic_todos (id, project_id, note_id, entry_id, source_index, source, title, instructions, completed, comments_json, analyzed_at, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'note', ?, '', ?, '[]', NULL, 0, ?, ?)` ) const updateTodo = database.prepare( `UPDATE magic_todos SET project_id = ?, note_id = ?, source_index = ?, title = ?, completed = ?, comments_json = CASE WHEN ? THEN '[]' ELSE comments_json END, analyzed_at = CASE WHEN ? THEN NULL ELSE analyzed_at END, revision = revision + 1, updated_at = ? WHERE id = ?` ) for (const { item, matched } of matches) { if (!matched) { insertTodo.run( randomUUID(), null, noteId, entryId, item.sourceIndex, item.title, Number(item.completed), now, now ) continue } const titleChanged = matched.title !== item.title const completionChanged = Boolean(matched.completed) !== item.completed const positionChanged = matched.source_index !== item.sourceIndex const scopeChanged = matched.project_id !== null || matched.note_id !== noteId if ( !titleChanged && !completionChanged && !positionChanged && !scopeChanged ) { continue } updateTodo.run( null, noteId, item.sourceIndex, item.title, Number(item.completed), Number(titleChanged), Number(titleChanged), now, matched.id ) } } private assertMagicNoteEmbedBudget( noteId: string, content: MagicNoteRichContent, excludedEntryId?: string ): void { const existing = this.requireDatabase() .prepare( `SELECT COALESCE(SUM(image_bytes), 0) AS image_bytes FROM magic_note_entries WHERE note_id = ? AND id <> ?` ) .get(noteId, excludedEntryId ?? '') as { image_bytes: number } if ( existing.image_bytes + magicNoteEmbeddedBytes(content) > MAGIC_NOTE_MAX_NOTE_EMBED_BYTES ) { throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB') } } private migrate(database: DatabaseSync): void { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } if (version.user_version > 17) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } if (version.user_version === 17) { 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')), runtime_selection_json TEXT, 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 } } if (version.user_version < 9) { database.exec(` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS magic_notes ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, title TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1)), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS magic_note_entries ( id TEXT PRIMARY KEY, note_id TEXT NOT NULL REFERENCES magic_notes(id) ON DELETE CASCADE, content_json TEXT NOT NULL, plain_text TEXT NOT NULL, comments_json TEXT NOT NULL DEFAULT '[]', actions_json TEXT NOT NULL DEFAULT '[]', analyzed_at TEXT, revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS magic_notes_scope_updated_idx ON magic_notes(project_id, pinned DESC, updated_at DESC); CREATE INDEX IF NOT EXISTS magic_note_entries_note_created_idx ON magic_note_entries(note_id, created_at ASC); PRAGMA user_version = 9; COMMIT; `) } if (version.user_version < 10) { database.exec('BEGIN IMMEDIATE') try { database.exec(` CREATE TABLE IF NOT EXISTS magic_todos ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, note_id TEXT REFERENCES magic_notes(id) ON DELETE CASCADE, entry_id TEXT REFERENCES magic_note_entries(id) ON DELETE CASCADE, source_index INTEGER, source TEXT NOT NULL CHECK(source IN ('note', 'manual')), title TEXT NOT NULL, instructions TEXT NOT NULL DEFAULT '', completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)), comments_json TEXT NOT NULL DEFAULT '[]', analyzed_at TEXT, revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK( (source = 'note' AND note_id IS NOT NULL AND entry_id IS NOT NULL AND source_index IS NOT NULL) OR (source = 'manual' AND note_id IS NULL AND entry_id IS NULL AND source_index IS NULL) ), UNIQUE(entry_id, source_index) ); CREATE INDEX IF NOT EXISTS magic_todos_scope_status_idx ON magic_todos(project_id, completed, updated_at DESC); CREATE INDEX IF NOT EXISTS magic_todos_note_idx ON magic_todos(note_id, entry_id, source_index); `) const entries = database .prepare( `SELECT id, note_id, content_json, updated_at FROM magic_note_entries` ) .iterate() as Iterable<{ id: string note_id: string content_json: string updated_at: string }> for (const entry of entries) { this.syncMagicNoteTodos( database, entry.note_id, entry.id, JSON.parse(entry.content_json) as MagicNoteRichContent, entry.updated_at ) } database.exec('PRAGMA user_version = 10') database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 11) { const hasVisibilityColumn = ( database.prepare('PRAGMA table_info(tasks)').all() as Array<{ name: string }> ).some((column) => column.name === 'visible') database.exec('BEGIN IMMEDIATE') try { if (!hasVisibilityColumn) { database.exec(` ALTER TABLE tasks ADD COLUMN visible INTEGER NOT NULL DEFAULT 1 CHECK(visible IN (0, 1)); `) } database.exec('PRAGMA user_version = 11') database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 12) { const hasImageBytesColumn = ( database .prepare('PRAGMA table_info(magic_note_entries)') .all() as Array<{ name: string }> ).some((column) => column.name === 'image_bytes') database.exec('BEGIN IMMEDIATE') try { if (!hasImageBytesColumn) { database.exec(` ALTER TABLE magic_note_entries ADD COLUMN image_bytes INTEGER NOT NULL DEFAULT 0 CHECK(image_bytes >= 0); `) } const entries = database .prepare('SELECT id, content_json FROM magic_note_entries') .iterate() as Iterable<{ id: string content_json: string }> const updateImageBytes = database.prepare( `UPDATE magic_note_entries SET image_bytes = ? WHERE id = ?` ) for (const entry of entries) { updateImageBytes.run( magicNoteImageBytes( JSON.parse(entry.content_json) as MagicNoteRichContent ), entry.id ) } database.exec('PRAGMA user_version = 12') database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 13) { const projectColumns = new Set( ( database.prepare('PRAGMA table_info(projects)').all() as Array<{ name: string }> ).map((column) => column.name) ) database.exec('BEGIN IMMEDIATE') try { if (!projectColumns.has('kind')) { database.exec(` ALTER TABLE projects ADD COLUMN kind TEXT NOT NULL DEFAULT 'user' CHECK(kind IN ('user', 'channel')); `) } if (!projectColumns.has('channel')) { database.exec(` ALTER TABLE projects ADD COLUMN channel TEXT CHECK( channel IS NULL OR channel IN ('weixin', 'wecom', 'dingtalk') ); `) } database.exec(` CREATE UNIQUE INDEX IF NOT EXISTS projects_channel_unique ON projects(channel) WHERE channel IS NOT NULL; PRAGMA user_version = 13; COMMIT; `) } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 14) { 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('channel')) { database.exec(` ALTER TABLE conversations ADD COLUMN channel TEXT CHECK( channel IS NULL OR channel IN ('weixin', 'wecom', 'dingtalk') ); `) } if (!conversationColumns.has('external_account_id')) { database.exec(` ALTER TABLE conversations ADD COLUMN external_account_id TEXT; `) } if (!conversationColumns.has('external_conversation_id')) { database.exec(` ALTER TABLE conversations ADD COLUMN external_conversation_id TEXT; `) } if (!conversationColumns.has('conversation_type')) { database.exec(` ALTER TABLE conversations ADD COLUMN conversation_type TEXT CHECK( conversation_type IS NULL OR conversation_type IN ('direct', 'group') ); `) } if (!conversationColumns.has('account_display')) { database.exec(` ALTER TABLE conversations ADD COLUMN account_display TEXT; `) } database.exec(` CREATE UNIQUE INDEX IF NOT EXISTS conversations_remote_identity_unique ON conversations( channel, external_account_id, external_conversation_id ) WHERE channel IS NOT NULL; PRAGMA user_version = 14; COMMIT; `) } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 15) { database.exec('BEGIN IMMEDIATE') try { database.exec(` CREATE TABLE IF NOT EXISTS channel_events ( channel TEXT NOT NULL, event_id TEXT NOT NULL, claimed_at INTEGER NOT NULL, PRIMARY KEY(channel, event_id) ); CREATE INDEX IF NOT EXISTS channel_events_claimed_at ON channel_events(claimed_at); CREATE TABLE IF NOT EXISTS channel_outbox ( id TEXT PRIMARY KEY, channel TEXT NOT NULL, event_id TEXT NOT NULL, message_json TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending', 'delivered', 'failed')), attempts INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS channel_outbox_state_created ON channel_outbox(state, created_at); PRAGMA user_version = 15; COMMIT; `) } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 16) { database.exec('BEGIN IMMEDIATE') try { const projectColumns = new Set( ( database.prepare('PRAGMA table_info(projects)').all() as Array<{ name: string }> ).map((column) => column.name) ) if (!projectColumns.has('runtime_selection_json')) { database.exec(` ALTER TABLE projects ADD COLUMN runtime_selection_json TEXT; `) } database.exec(` UPDATE projects SET runtime_selection_json = '{"provider":"auto"}' WHERE kind = 'channel' AND runtime_selection_json IS NULL; PRAGMA user_version = 16; COMMIT; `) } catch (error) { database.exec('ROLLBACK') throw error } } if (version.user_version < 17) { database.exec('BEGIN IMMEDIATE') try { database.exec(` UPDATE magic_notes SET project_id = NULL WHERE project_id IS NOT NULL; UPDATE magic_todos SET project_id = NULL WHERE source = 'note' AND project_id IS NOT NULL; `) const manualTodos = database .prepare( `SELECT id, title, instructions, completed, comments_json, analyzed_at, revision, created_at, updated_at FROM magic_todos WHERE source = 'manual' ORDER BY created_at ASC, rowid ASC` ) .all() as Array<{ id: string title: string instructions: string completed: number comments_json: string analyzed_at: string | null revision: number created_at: string updated_at: string }> if (manualTodos.length > 0) { const noteId = randomUUID() const createdAt = manualTodos[0]!.created_at const updatedAt = manualTodos.at(-1)!.updated_at database .prepare( `INSERT INTO magic_notes (id, project_id, title, pinned, revision, created_at, updated_at) VALUES (?, NULL, '迁入的待办', 0, 0, ?, ?)` ) .run(noteId, createdAt, updatedAt) const insertEntry = database.prepare( `INSERT INTO magic_note_entries (id, note_id, content_json, plain_text, comments_json, actions_json, analyzed_at, revision, created_at, updated_at, image_bytes) VALUES (?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, 0)` ) const updateMigratedTodo = database.prepare( `UPDATE magic_todos SET instructions = ?, comments_json = ?, analyzed_at = ?, revision = ? WHERE entry_id = ? AND source = 'note'` ) const deleteManualTodo = database.prepare( `DELETE FROM magic_todos WHERE id = ? AND source = 'manual'` ) for (const todo of manualTodos) { const entryId = randomUUID() const content: MagicNoteRichContent = { version: 1, ops: [ { insert: todo.title }, { insert: '\n', attributes: { list: todo.completed ? 'checked' : 'unchecked' } }, ...(todo.instructions ? [ { insert: todo.instructions }, { insert: '\n' } ] : []) ] } insertEntry.run( entryId, noteId, JSON.stringify(content), [todo.title, todo.instructions].filter(Boolean).join('\n'), todo.comments_json, todo.analyzed_at, todo.revision, todo.created_at, todo.updated_at ) this.syncMagicNoteTodos( database, noteId, entryId, content, todo.updated_at ) updateMigratedTodo.run( todo.instructions, todo.comments_json, todo.analyzed_at, todo.revision, entryId ) deleteManualTodo.run(todo.id) } } database.exec('PRAGMA user_version = 17') database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } } private requireDatabase(): DatabaseSync { if (!this.database) { throw new Error('助手数据库尚未初始化') } return this.database } }