Files
metona-ai-desktop/electron/services/database.service.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

822 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Database Service — SQLite 数据库管理
*
* 使用 better-sqlite3(同步、高性能、主进程专用)。
* 负责数据库初始化、Schema 迁移、连接管理。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
* @see standard/开发规范.md — 禁止自写数据库层,使用 better-sqlite3
*/
import Database from 'better-sqlite3';
import { join } from 'path';
import { app } from 'electron';
import { existsSync, mkdirSync } from 'fs';
import log from 'electron-log';
/**
* L-8 修复: 提取 toErrorMessage 工具函数,消除 5 处重复的 error instanceof Error 三元表达式
*/
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* v0.8.1: 已废除的配置键(单一来源 —— 迁移 12 与全局配置层清理共用)。
* 分 Provider contextWindow 与 ollama.numCtx 由全局 llm.contextWindow 取代。
*/
export const DEPRECATED_CONFIG_KEYS = [
'ollama.numCtx',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
] as const;
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
export interface ConfigDefaultEntry {
key: string;
value: unknown;
category: string;
}
/**
* 全部配置默认值(唯一维护点)
*
* 注意:新增/修改配置默认值时只需改这里,全局配置层(SEED_DEFAULTS 判定"未配置"的依据)
* 会自动同步,避免双源漂移。
*/
export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
// LLM 配置(无硬编码值,用户必须手动配置)
{ key: 'llm.provider', value: '', category: 'llm' },
{ key: 'llm.model', value: '', category: 'llm' },
{ key: 'llm.apiKey', value: '', category: 'llm' },
{ key: 'llm.baseURL', value: '', category: 'llm' },
// F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎
{ key: 'llm.temperature', value: 0, category: 'llm' },
// v0.8.0 FEAT-1 / v0.8.1: 最大输出上限 —— 唯一合法的输出上限配置(全局,跨 Provider)
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
// v0.8.1: 上下文长度 —— 唯一合法的上下文窗口配置(全局,跨 Provider/模型;
// 驱动引擎压缩预算、前端占用指示,Ollama 场景同时作为 num_ctx 下发)。
// 分 Provider 的 deepseek/agnes/mimo/openai/anthropic.contextWindow 与 ollama.numCtx
// 已废除(迁移 12 清理遗留键)。
{ key: 'llm.contextWindow', value: 131072, category: 'llm' },
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
// 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断)
{ key: 'llm.multimodalEnabled', value: false, category: 'llm' },
// P1: Provider 故障转移配置
{ key: 'llm.fallbackProvider', value: '', category: 'llm' },
{ key: 'llm.fallbackModel', value: '', category: 'llm' },
{ key: 'llm.fallbackApiKey', value: '', category: 'llm' },
{ key: 'llm.fallbackBaseURL', value: '', category: 'llm' },
// Agent 配置
{ key: 'agent.maxIterations', value: 20, category: 'agent' },
{ key: 'agent.totalTimeoutMs', value: 600000, category: 'agent' },
{ key: 'agent.enableThinking', value: true, category: 'agent' },
{ key: 'agent.thinkingEffort', value: 'high', category: 'agent' },
{ key: 'agent.enableReflection', value: false, category: 'agent' },
// C-10 修复: 补充缺失的 agent 配置默认值
// @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s)
{ key: 'agent.confirmationTimeoutMs', value: 120000, category: 'agent' },
{ key: 'agent.toolExecutionTimeoutMs', value: 120000, category: 'agent' },
// 安全配置
// F-8 清理: 移除死配置 security.requireWriteConfirmation / security.maxFileWriteSizeKB
// (无任何消费者 —— 确认策略由工具定义的 requiresPermission/riskLevel 驱动,
// 文件大小上限由 file-guard.ts 的 MAX_FILE_SIZE_BYTES 常量控制)
// F-8 接通: promptInjectionDefense 由 main.tsSecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
// v0.7.3 P1-5: 记忆固化节流(consolidation-policy 消费)
{ key: 'memory.consolidationEnabled', value: true, category: 'memory' },
{ key: 'memory.consolidationMinChars', value: 200, category: 'memory' },
{ key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' },
// v0.8.1 P1-1: 本地向量记忆嵌入模型(Ollama embedding 模型名,空 = 向量检索关闭,
// 仅 TF-IDF)。唯一合法来源是设置面板 —— 代码中不存在默认模型名。
{ key: 'memory.embeddingModel', value: '', category: 'memory' },
// v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试)
{ key: 'mcp.autoReconnect', value: true, category: 'mcp' },
// UI 配置
// F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage
{ key: 'ui.theme', value: 'auto', category: 'ui' },
// 日志配置
// F-8 接通: auditEnabled/traceEnabled 由 main.ts 构建 hooks 与 SessionRecorder 时消费
{ key: 'logging.level', value: 'info', category: 'logging' },
{ key: 'logging.auditEnabled', value: true, category: 'logging' },
{ key: 'logging.traceEnabled', value: true, category: 'logging' },
// v0.8.1: Ollama numCtx 独立配置已废除(与「上下文长度」合并为 llm.contextWindow);
// Provider 上下文窗口分键配置已废除 —— 遗留键由迁移 12 从存量库清理。
// Onboarding
{ key: 'onboarding.completed', value: false, category: 'general' },
];
export class DatabaseService {
private db: Database.Database | null = null;
private dbPath: string;
/**
* v0.6.4 P3-3: 当前 schema 版本号(PRAGMA user_version 目标值)。
* 每次在 runMigrations 中新增一个迁移时 +1。首次升级到版本化机制后,
* 版本号相同的库将跳过整个探测式迁移批次。
* v0.7.4 P4-4: 1 → 2 —— 迁移 9messages_fts trigram)纳入版本化,
* 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。
* v0.8.0 P2-1: 2 → 3 —— 迁移 10sessions.deleted_at 回收站软删除列)。
* v0.8.1: 3 → 4 —— 迁移 11(记忆表 embedding 列,本地向量混合检索)+
* 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。
* v0.8.1 review: 4 → 5 —— 迁移 13working_memories 补 sessions 外键 CASCADE
* 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。
* v0.8.2: 5 → 6 —— 迁移 14(记忆表 embedding_model 列,嵌入模型指纹)。
*/
static readonly SCHEMA_VERSION = 6;
constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
const metonaDir = join(baseDir, '.metona');
// 确保 .metona 目录存在
if (!existsSync(metonaDir)) {
mkdirSync(metonaDir, { recursive: true });
}
this.dbPath = join(metonaDir, 'agent.db');
}
/**
* 初始化数据库(创建表结构)
*/
initialize(): void {
if (this.db) {
log.warn('Database already initialized');
return;
}
log.info(`Initializing database: ${this.dbPath}`);
this.db = new Database(this.dbPath);
// 启用 WAL 模式(更好的并发性能)
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.createTables();
this.runMigrations();
this.seedDefaults();
log.info('Database initialized successfully');
}
/**
* 获取数据库实例
*/
getDB(): Database.Database {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
/**
* 关闭数据库
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
log.info('Database closed');
}
}
/**
* 创建表结构
*/
private createTables(): void {
const db = this.db!;
db.exec(`
-- ===== 会话表 =====
-- v0.8.0 P2-1: deleted_at 回收站软删除(NULL = 正常;时间戳 = 已删除待清理)
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '新会话',
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
message_count INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
-- ===== 消息表 =====
-- C-6 修复: content 允许 NULL — assistant 消息仅有 tool_calls 时 content 必须为 null
-- @see project_memory.md — Assistant messages with tool_calls must set content to null
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== 配置表 =====
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 审计日志表 =====
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
prev_hash TEXT,
current_hash TEXT
);
-- ===== MCP 服务配置表 =====
-- v0.5.0: 建表 CHECK 直接含 streamable-http(与迁移 6 的重建后 schema 对齐,
-- 新库不再依赖迁移 6 立即重建一次表)
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_connected INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
-- ===== 情节记忆表 =====
CREATE TABLE IF NOT EXISTS episodic_memories (
id TEXT PRIMARY KEY,
session_id TEXT,
content TEXT NOT NULL,
summary TEXT,
source TEXT NOT NULL,
importance REAL DEFAULT 0.5,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
expires_at INTEGER
);
-- ===== 语义记忆表 =====
CREATE TABLE IF NOT EXISTS semantic_memories (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT,
confidence REAL DEFAULT 0.8,
source_session TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
access_count INTEGER DEFAULT 0
);
-- ===== 工作记忆表 =====
-- v0.8.1 review: 补 sessions 外键(CASCADE)—— 历史建表缺失级联
CREATE TABLE IF NOT EXISTS working_memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== v0.2.0: 任务表 =====
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')),
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')),
parent_id TEXT,
assigned_to TEXT,
order_idx INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
completed_at INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE
);
-- ===== P2: 会话摘要表(分层上下文——超长会话早期消息压缩为摘要,LLM 只加载摘要 + 近期原文) =====
-- F-3 修复: 补 FOREIGN KEY ON DELETE CASCADE —— 此前无级联,删除会话后摘要残留,
-- 随使用无限累积(messages/tasks 均有级联,唯独此表遗漏)
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== 索引 =====
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC);
-- v0.8.0 P2-1: idx_sessions_deleted 由迁移 10 统一创建(createTables 的静态
-- CREATE INDEX 在"表存在但缺 deleted_at 列"的遗留库上会 no-such-column 失败)
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_config_category ON app_config(category);
CREATE INDEX IF NOT EXISTS idx_semantic_key ON semantic_memories(key);
CREATE INDEX IF NOT EXISTS idx_semantic_category ON semantic_memories(category);
CREATE INDEX IF NOT EXISTS idx_episodic_session ON episodic_memories(session_id);
CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memories(importance DESC);
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
-- ===== v0.5.0: 消息全文搜索(FTS5 外内容表模式,content 列索引) =====
-- 会话内容搜索通过 sessions:searchContent IPC 使用 MATCH 查询;
-- 触发器保持索引与 messages 表实时同步(INSERT/UPDATE/DELETE
-- v0.7.4 P4-4: tokenizer 升级为 trigram —— 支持任意 ≥3 字符子串匹配
-- (中文非连续子串搜索根治;英文整词/短语仍命中)。实测本 SQLite 构建
-- 不支持 'unicode61 trigram' 多 tokenizer 组合(tokenizer constructor 报错),
-- 故用 trigram 单 tokenizer2 字中文由 searchMessages 的 LIKE 回退兜底。
-- 存量库由迁移 9 检测重建。
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid,
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`);
// 审计日志防篡改触发器(INSERT-ONLY
db.exec(`
CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Modification is not allowed.');
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
END;
`);
log.info('Database tables created');
}
/**
* 运行数据库迁移
*/
private runMigrations(): void {
const db = this.db!;
// ===== v0.6.4 P3-3: schema 版本化(PRAGMA user_version=====
//
// 既有模式是"幂等探测式迁移":每次启动都跑全套探测 SQLtable_info、
// foreign_key_list、sqlite_master 匹配等)。在当前体量下可用,但存在两个
// 越来越脆的问题:(1) 启动耗时随迁移数量线性增长;(2) 探测语句之间存在
// 隐式次序耦合(如迁移 5 的无条件 FTS rebuild 依赖虚拟表已建)。
//
// 版本化策略(保留兼容,不破坏任何存量库):
// - SCHEMA_VERSION 每新增一个迁移 +1
// - 存量库首次启动 user_version=0 < SCHEMA_VERSION → 完整跑一遍幂等批次
// (各迁移本身安全),成功后盖章版本号;
// - 已盖章的库 → 直接跳过整个探测批次的执行;
// - 回滚到旧版应用不会降级数据(旧代码不读 user_version,仍走幂等路径)。
const currentVersion =
typeof db.pragma('user_version', { simple: true }) === 'number'
? (db.pragma('user_version', { simple: true }) as number)
: 0;
if (currentVersion >= DatabaseService.SCHEMA_VERSION) {
log.info(
`[DB] Schema up to date (user_version=${currentVersion}, target=${DatabaseService.SCHEMA_VERSION}) — skipping migration probe batch`,
);
return;
}
log.info(
`[DB] Running schema migrations (user_version ${currentVersion}${DatabaseService.SCHEMA_VERSION})`,
);
// L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式
// L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式
const tryAddColumn = (table: string, column: string, type: string) => {
try {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
log.info(`[DB] Migration: added ${column} column to ${table}`);
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = toErrorMessage(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
};
// #33 修复: 整个迁移批次包裹在事务中,保证原子性
// 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败),
// 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。
// better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。
const runAllMigrations = db.transaction(() => {
// 迁移 1: messages 表添加 attachments 列
tryAddColumn('messages', 'attachments', 'TEXT');
// 迁移 2: audit_logs 表添加 iteration 列
tryAddColumn('audit_logs', 'iteration', 'INTEGER');
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
tryAddColumn('audit_logs', 'prev_hash', 'TEXT');
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
tryAddColumn('audit_logs', 'current_hash', 'TEXT');
// P2: 记忆 TF 缓存列(存储 tokenize 结果,避免每次检索重复分词)
tryAddColumn('episodic_memories', 'tf_cache', 'TEXT');
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
// v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http'
// 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入
try {
const schemaRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'")
.get() as { sql: string } | undefined;
// 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建)
if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) {
log.info(
'[DB] Migration: rebuilding mcp_servers table to support streamable-http transport',
);
const rebuildMcpServers = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS mcp_servers_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_connected INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at)
SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at
FROM mcp_servers;
DROP TABLE mcp_servers;
ALTER TABLE mcp_servers_new RENAME TO mcp_servers;
`);
});
rebuildMcpServers();
log.info(
'[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`);
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
}
// v0.5.0 迁移 7: 存量库 FTS 索引回填
// messages_fts 虚表由 createTables 创建(IF NOT EXISTS),但升级到 v0.5.0 的存量库
// 已有消息不会自动进入索引(触发器只覆盖新写入)。行数不一致时执行 rebuild 全量回填。
try {
const ftsCount = db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as {
c: number;
};
const msgCount = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number };
if (ftsCount.c !== msgCount.c) {
log.info(
`[DB] Migration: rebuilding messages_fts index (fts=${ftsCount.c}, messages=${msgCount.c})`,
);
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages_fts index rebuilt successfully');
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 7 (messages_fts rebuild) skipped: ${msg}`);
// 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
// 新写入的消息仍通过触发器正常进入索引
}
// F-3 迁移 8: 重建 session_summaries 表,补 FOREIGN KEY ON DELETE CASCADE
// 此前该表无级联删除 —— 删除会话(sessions:delete / data:clearSessions)后
// 摘要记录永久残留,随使用无限累积。SQLite 不支持 ALTER ADD CONSTRAINT,需重建表。
// 幂等:PRAGMA foreign_key_list 检测已有级联则跳过。
try {
const fkRows = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
table: string;
on_delete: string;
}>;
const hasCascade = fkRows.some(
(fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE',
);
if (!hasCascade) {
log.info('[DB] Migration: rebuilding session_summaries table to add ON DELETE CASCADE');
const rebuildSummaries = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS session_summaries_new (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO session_summaries_new (session_id, summary, summarized_until_rowid, updated_at)
SELECT session_id, summary, summarized_until_rowid, updated_at
FROM session_summaries;
DROP TABLE session_summaries;
ALTER TABLE session_summaries_new RENAME TO session_summaries;
`);
});
rebuildSummaries();
log.info(
'[DB] Migration: session_summaries table rebuilt successfully (ON DELETE CASCADE added)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 8 (session_summaries CASCADE) skipped: ${msg}`);
// 非致命 — 级联缺失仅导致摘要残留(不影响会话读写),但建议用户检查工作空间数据库
}
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表
try {
// 检测 content 列是否有 NOT NULL 约束
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{
name: string;
notnull: number;
}>;
const contentCol = columns.find((c) => c.name === 'content');
if (contentCol && contentCol.notnull === 1) {
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
// #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚
// 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致
const rebuildMessages = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS messages_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at
FROM messages;
DROP TABLE messages;
ALTER TABLE messages_new RENAME TO messages;
`);
// 重建索引
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
// v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`);
});
rebuildMessages();
// v0.5.0: 表重建后 rowid 全部重排,FTS 索引指向的旧 rowid 已失效 — 无条件全量回填
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
}
} catch (error) {
// L-8 修复: 使用 toErrorMessage 替代重复的三元表达式
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`);
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
}
// v0.8.0 P2-1 迁移 10: sessions.deleted_at 回收站软删除列 + 索引
// 存在性守卫:极简遗留库(如 v2 盖章的早期测试库)可能没有 sessions 表,
// 迁移必须可重试且不因缺表中断整个批次
{
const hasSessions = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
if (hasSessions) {
tryAddColumn('sessions', 'deleted_at', 'INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at);');
}
}
// v0.8.1 P1-1 迁移 11: 记忆表 embedding 列(本地向量混合检索)。
// 存量记忆行为 NULL = 未向量化(检索时回退 TF-IDF 路径),新写入由
// MemoryEmbedder 异步回填;向量维度由用户配置的 embedding 模型决定,
// 故用 BLOB 存 Float32Array 而非固定宽度 F32Blob 列。
tryAddColumn('episodic_memories', 'embedding', 'BLOB');
tryAddColumn('semantic_memories', 'embedding', 'BLOB');
// v0.8.1 迁移 12: 清理已废除的上下文窗口配置键(全局 llm.contextWindow 取代)。
// 旧键残留会使设置面板与引擎出现双源语义,启动迁移一次性移除(幂等)。
{
const placeholders = DEPRECATED_CONFIG_KEYS.map(() => '?').join(', ');
const result = db
.prepare(`DELETE FROM app_config WHERE key IN (${placeholders})`)
.run(...DEPRECATED_CONFIG_KEYS);
if (result.changes > 0) {
log.info(
`[DB] Migration: removed ${result.changes} deprecated context-window config key(s) (replaced by llm.contextWindow)`,
);
}
}
// v0.8.1 review 迁移 13: working_memories 补 sessions 外键(ON DELETE CASCADE
// 历史缺陷:该表自 v0.2.0 建表起就没有 sessions 外键 —— 会话删除/清空后
// working 行成为孤儿(行为面已由 0.8.1 终态清理接线覆盖,此处根治 schema)。
// 先清理孤儿行(早于 0.8.1 的历史删除遗留),再以 FK 重建。
{
const hasWorking = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='working_memories'")
.get();
// hasSessions 由迁移 10 块内查询(块作用域),此处独立复查
const hasSessions = !!db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
const fkRows = hasWorking
? (db.prepare('PRAGMA foreign_key_list(working_memories)').all() as Array<{
table: string;
}>)
: [];
const hasSessionsFk = fkRows.some((fk) => fk.table === 'sessions');
if (hasWorking && hasSessions && !hasSessionsFk) {
log.info('[DB] Migration: rebuilding working_memories with sessions FK (CASCADE)');
const rebuildWorking = db.transaction(() => {
db.exec(`
DELETE FROM working_memories
WHERE session_id NOT IN (SELECT id FROM sessions);
CREATE TABLE working_memories_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO working_memories_new (id, session_id, task_id, key, value, updated_at, tf_cache)
SELECT id, session_id, task_id, key, value, updated_at, tf_cache
FROM working_memories;
DROP TABLE working_memories;
ALTER TABLE working_memories_new RENAME TO working_memories;
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
`);
});
rebuildWorking();
log.info('[DB] Migration: working_memories rebuilt with sessions FK');
}
}
// v0.8.2 P3-1 迁移 14: 记忆表 embedding_model 列(嵌入模型指纹)。
// 用户更换 memory.embeddingModel 后,旧 BLOB 以新查询向量算余弦 —— 维度
// 不同静默余弦为 0(降级 TF-IDF),同维不同模型产生噪声分数。现记录每条
// 向量的来源模型,检索时模型不匹配的向量视为缺失(惰性重算自愈)。
tryAddColumn('episodic_memories', 'embedding_model', 'TEXT');
tryAddColumn('semantic_memories', 'embedding_model', 'TEXT');
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
// 建表 SQL,不含 'trigram' 即需重建。幂等:重建后 SQL 含 trigram,下次跳过。
try {
const ftsSqlRow = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
.get() as { sql: string } | undefined;
const ftsSql = ftsSqlRow?.sql ?? '';
if (!ftsSql.includes('trigram')) {
log.info('[DB] Migration: rebuilding messages_fts with trigram tokenizer');
// FTS5 外内容表重建:DROP 虚表(触发器保留),重建后由下方 rebuild 回填
db.exec(`
DROP TABLE IF EXISTS messages_fts;
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid,
tokenize='trigram'
);
INSERT INTO messages_fts(messages_fts) VALUES ('rebuild');
`);
log.info('[DB] Migration: messages_fts rebuilt with trigram tokenizer');
}
} catch (error) {
// v0.7.4 P4-4: 迁移 9 失败必须中断批次(抛错 → 不盖章 user_version=2 →
// 下次启动重试)。旧实现 catch 吞错 + 盖章 1 → 中文子串搜索永久失效且无日志。
const msg = toErrorMessage(error);
log.error(`[DB] Migration 9 (messages_fts trigram) failed: ${msg}`);
throw error;
}
});
runAllMigrations();
// v0.6.4 P3-3: 迁移成功后盖章 user_version —— 后续启动走快速路径,
// 不再每次执行全套 PRAGMA 探测 SQL。
db.pragma(`user_version = ${DatabaseService.SCHEMA_VERSION}`);
}
/**
* 插入默认配置(P1-13: 从 CONFIG_DEFAULTS 单一来源派生)
*/
private seedDefaults(): void {
const db = this.db!;
const insert = db.prepare(`
INSERT OR IGNORE INTO app_config (key, value, category) VALUES (?, ?, ?)
`);
const insertMany = db.transaction((items: ConfigDefaultEntry[]) => {
for (const item of items) {
insert.run(item.key, JSON.stringify(item.value), item.category);
}
});
insertMany(CONFIG_DEFAULTS);
log.info('Default config seeded');
}
}