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_TOTAL_IMAGE_BYTES,
  type MagicNoteComment,
  type MagicNoteDetail,
  type MagicNoteEntry,
  type MagicNoteRichContent,
  type MagicNoteSearchResult,
  type MagicNoteSummary,
  type MagicTodoItem
} from '../../shared/magic-notes-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import {
  magicNoteChecklistItems,
  magicNoteImageBytes,
  magicNotePreview
} 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<T extends string>(
  value: string,
  label: string,
  allowed: readonly T[]
): T {
  if (!allowed.includes(value as T)) {
    throw new RangeError(`Invalid ${label}`)
  }
  return value as T
}

function toComputerControlAuditEvent(
  row: ComputerControlActionRow
): PersistedComputerControlAuditEvent {
  return {
    timestamp: row.occurred_at,
    taskId: row.task_id,
    conversationId: row.conversation_id,
    leaseId: row.lease_id,
    commandId: row.command_id,
    action: row.action,
    risk: row.risk,
    outcome: row.outcome,
    ...(row.error_code ? { errorCode: row.error_code } : {}),
    ...(row.text_length === null
      ? {}
      : { textLength: row.text_length }),
    ...(row.text_digest === null
      ? {}
      : { textDigest: row.text_digest })
  }
}

const interruptedTaskError = '应用退出时任务仍在运行'
const interruptedMessageStatus = '上次运行意外中断，可以重新发送问题'

function interruptActiveTools(
  tools: MessageMetadata['tools']
): MessageMetadata['tools'] {
  return tools?.map((tool) =>
    tool.state === 'pending' || tool.state === 'running'
      ? { ...tool, state: 'interrupted' as const }
      : tool
  )
}

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.assertMagicNoteImageBudget(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,
          magicNoteImageBytes(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.assertMagicNoteImageBudget(
        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,
          magicNoteImageBytes(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 FROM magic_note_entries WHERE id = ?')
      .get(input.entryId) as { note_id: string } | undefined
    if (!existing) {
      throw new Error('记录不存在')
    }
    const now = new Date().toISOString()
    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(input.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)
  }

  saveMagicTodoAnalysis(input: {
    todoId: string
    expectedRevision: number
    comments: MagicNoteComment[]
  }): MagicTodoItem {
    const now = new Date().toISOString()
    const result = this.requireDatabase()
      .prepare(
        `UPDATE magic_todos
         SET comments_json = ?, analyzed_at = ?,
             revision = revision + 1, updated_at = ?
         WHERE id = ? AND revision = ?`
      )
      .run(
        JSON.stringify(input.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<string, typeof existing>()
    const unmatchedTitleCounts = new Map<string, number>()
    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 assertMagicNoteImageBudget(
    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 + magicNoteImageBytes(content) >
      MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES
    ) {
      throw new Error('一篇笔记中的图片总大小不能超过 8 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
  }
}
