核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
1920 lines
75 KiB
JavaScript
1920 lines
75 KiB
JavaScript
import Database from 'better-sqlite3'
|
||
import { paths } from './paths.js'
|
||
|
||
const DB_PATH = paths.dbFile
|
||
|
||
const CANONICAL_USER_ID = 'ID:000001'
|
||
const CANONICAL_AGENT_ENTITY = 'agent:jarvis'
|
||
const CANONICAL_USER_ROOT_MEM_ID = 'person_000001'
|
||
const CANONICAL_AGENT_ROOT_MEM_ID = 'agent_jarvis_identity'
|
||
|
||
const USER_ID_ALIASES = new Set(['000001', 'id:000001', 'yuanda', '1187048501994078249'])
|
||
const AGENT_ENTITY_ALIASES = new Set(['jarvis', 'agent_jarvis', 'agent:jarvis'])
|
||
const USER_ROOT_ALIASES = new Set([
|
||
'contact_000001',
|
||
'person_000001',
|
||
'person_id000001_interaction',
|
||
'person_yuanda_identity',
|
||
'user_000001',
|
||
'user_000001_identity',
|
||
'user_000001_profile',
|
||
])
|
||
const AUTO_CANONICAL_IDENTITY_ROOTS = false
|
||
|
||
let db
|
||
|
||
export function getDB() {
|
||
if (!db) {
|
||
db = new Database(DB_PATH)
|
||
db.pragma('journal_mode = WAL')
|
||
initSchema()
|
||
}
|
||
return db
|
||
}
|
||
|
||
function initSchema() {
|
||
// 迁移:添加 parent_id 字段(已存在时跳过)
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN parent_id INTEGER REFERENCES memories(id)`) } catch {}
|
||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_parent_id ON memories(parent_id)`) } catch {}
|
||
// 迁移:新增 title / mem_id / links 字段
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN title TEXT DEFAULT ''`) } catch {}
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN mem_id TEXT`) } catch {}
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN links TEXT DEFAULT '[]'`) } catch {}
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN salience INTEGER DEFAULT 3`) } catch {}
|
||
try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_mem_id ON memories(mem_id) WHERE mem_id IS NOT NULL`) } catch {}
|
||
// 迁移:visibility 软隐藏三件套(动态上下文记忆池:剔除=软隐藏,不硬删除)
|
||
// visibility : 1=可见、0=软隐藏。所有读路径默认 WHERE visibility = 1。
|
||
// hidden_at : 软隐藏时间戳(ISO 8601),便于回溯与第3步专注帧恢复路径。
|
||
// merged_into : 因 merge_memories 被隐藏时,记录 keep 的 mem_id,形成可追踪链路。
|
||
// FTS5 索引不动:所有 SELECT 已 JOIN memories 过滤 visibility=1,无需 trigger 改动。
|
||
// 已存在行 visibility 默认取 1(向后兼容,无需 backfill)。
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN visibility INTEGER NOT NULL DEFAULT 1`) } catch {}
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN hidden_at TEXT`) } catch {}
|
||
try { db.exec(`ALTER TABLE memories ADD COLUMN merged_into TEXT`) } catch {}
|
||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_visibility ON memories(visibility)`) } catch {}
|
||
// 迁移:conversations 加 channel 列
|
||
try { db.exec(`ALTER TABLE conversations ADD COLUMN channel TEXT DEFAULT ''`) } catch {}
|
||
// 迁移:conversations 加 external_party_id 列(保留外部渠道原始 ID,供回送投递)
|
||
try { db.exec(`ALTER TABLE conversations ADD COLUMN external_party_id TEXT DEFAULT ''`) } catch {}
|
||
|
||
// 迁移:FTS5 tokenizer 从默认 unicode61 升级到 trigram。
|
||
// 默认 tokenizer 把中文整段当成一个 token("咖啡偏好"被存为一个整体),
|
||
// 搜 "咖啡" 完全不命中。trigram 把字符串切成 3 字符滑动窗口,对中文子串可搜。
|
||
// 注意:trigram 要求查询至少 3 字符;2 字符查询走 LIKE fallback(见 searchMemories)。
|
||
//
|
||
// 数据安全性:只 DROP virtual 索引表 memories_fts 和 3 个 trigger;
|
||
// memories 真数据表完全不动。下文 schema 重建 memories_fts + trigger,
|
||
// 末尾 line ~280 的 rebuild 命令把 memories 全表重新索引化。
|
||
// 整段 try-catch;失败时回到老行为(FTS5 中文召回不工作但程序不崩)。
|
||
try {
|
||
const ftsRow = db.prepare(`SELECT sql FROM sqlite_master WHERE name='memories_fts'`).get()
|
||
if (ftsRow && !/trigram/i.test(String(ftsRow.sql || ''))) {
|
||
const memCountBefore = (() => { try { return db.prepare('SELECT COUNT(*) AS c FROM memories').get().c } catch { return -1 } })()
|
||
console.log(`[DB migration] Upgrading memories_fts: unicode61 → trigram. memories rows=${memCountBefore}. memories table itself is NOT touched.`)
|
||
db.exec(`
|
||
DROP TRIGGER IF EXISTS memories_ai;
|
||
DROP TRIGGER IF EXISTS memories_au;
|
||
DROP TRIGGER IF EXISTS memories_ad;
|
||
DROP TABLE IF EXISTS memories_fts;
|
||
`)
|
||
// memories 行数应该保持不变(DROP 只动 fts 虚拟表)
|
||
const memCountAfter = (() => { try { return db.prepare('SELECT COUNT(*) AS c FROM memories').get().c } catch { return -1 } })()
|
||
if (memCountBefore !== memCountAfter) {
|
||
console.error(`[DB migration] WARN memories row count changed during drop: ${memCountBefore} → ${memCountAfter} (this should never happen, please report)`)
|
||
} else {
|
||
console.log(`[DB migration] DROP complete, memories rows preserved (${memCountAfter}). Schema will recreate memories_fts with trigram + rebuild index below.`)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('[DB migration] FTS5 tokenizer migration check failed:', err.message, '— program continues, FTS5 remains in previous state')
|
||
}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS conversations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
role TEXT NOT NULL, -- 'user' | 'jarvis'
|
||
from_id TEXT NOT NULL, -- 发送者 ID
|
||
to_id TEXT, -- 接收者 ID(jarvis 发出时有值)
|
||
content TEXT NOT NULL,
|
||
channel TEXT NOT NULL DEFAULT '',
|
||
timestamp TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_conv_timestamp ON conversations(timestamp);
|
||
CREATE INDEX IF NOT EXISTS idx_conv_from_id ON conversations(from_id);
|
||
`)
|
||
try { db.exec(`ALTER TABLE conversations ADD COLUMN channel TEXT DEFAULT ''`) } catch {}
|
||
try { db.exec(`ALTER TABLE conversations ADD COLUMN external_party_id TEXT DEFAULT ''`) } catch {}
|
||
// 迁移:focus_absorbed 标记(动态上下文记忆池 3.5 「主线深化时剔除残留噪声」)。
|
||
// focus_absorbed=1 表示这条对话所属的专注帧已被压缩回填吸收(focus_conclusion 已写入仓库),
|
||
// 下一轮主线注入对话窗口时默认 WHERE focus_absorbed=0 把它隐去。
|
||
// 关键:absorbed != deleted。对话物理仍在 conversations 表,admin 端点 / 显式 includeAbsorbed=true
|
||
// 仍可拿到;这跟 memories.visibility 是平行的「软隐藏」概念。
|
||
// 已存在行默认 0(向后兼容,无需 backfill)。
|
||
try { db.exec(`ALTER TABLE conversations ADD COLUMN focus_absorbed INTEGER NOT NULL DEFAULT 0`) } catch {}
|
||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_conv_focus_absorbed ON conversations(focus_absorbed)`) } catch {}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS memories (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
event_type TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
detail TEXT NOT NULL,
|
||
title TEXT DEFAULT '',
|
||
mem_id TEXT,
|
||
entities TEXT DEFAULT '[]',
|
||
concepts TEXT DEFAULT '[]',
|
||
tags TEXT DEFAULT '[]',
|
||
links TEXT DEFAULT '[]',
|
||
salience INTEGER DEFAULT 3,
|
||
source_ref TEXT,
|
||
timestamp TEXT NOT NULL,
|
||
parent_id INTEGER REFERENCES memories(id),
|
||
embedding BLOB,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp);
|
||
CREATE INDEX IF NOT EXISTS idx_memories_event_type ON memories(event_type);
|
||
CREATE INDEX IF NOT EXISTS idx_memories_parent_id ON memories(parent_id);
|
||
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||
content, detail, entities, concepts, tags,
|
||
content='memories', content_rowid='id',
|
||
tokenize='trigram'
|
||
);
|
||
|
||
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||
INSERT INTO memories_fts(rowid, content, detail, entities, concepts, tags)
|
||
VALUES (new.id, new.content, new.detail, new.entities, new.concepts, new.tags);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||
INSERT INTO memories_fts(memories_fts, rowid, content, detail, entities, concepts, tags)
|
||
VALUES ('delete', old.id, old.content, old.detail, old.entities, old.concepts, old.tags);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||
INSERT INTO memories_fts(memories_fts, rowid, content, detail, entities, concepts, tags)
|
||
VALUES ('delete', old.id, old.content, old.detail, old.entities, old.concepts, old.tags);
|
||
INSERT INTO memories_fts(rowid, content, detail, entities, concepts, tags)
|
||
VALUES (new.id, new.content, new.detail, new.entities, new.concepts, new.tags);
|
||
END;
|
||
|
||
CREATE TABLE IF NOT EXISTS config (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS entities (
|
||
id TEXT PRIMARY KEY,
|
||
label TEXT,
|
||
last_seen TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
`)
|
||
|
||
// 迁移:memories 表添加 embedding BLOB 列(向量语义召回用,与 FTS5 双路融合)。
|
||
// 用 PRAGMA table_info 检查,保证幂等:已有 embedding 列时彻底 no-op。
|
||
try {
|
||
const cols = db.prepare(`PRAGMA table_info(memories)`).all()
|
||
const hasEmbedding = cols.some(c => c.name === 'embedding')
|
||
if (!hasEmbedding) {
|
||
db.exec(`ALTER TABLE memories ADD COLUMN embedding BLOB`)
|
||
}
|
||
} catch {}
|
||
|
||
// 迁移(兜底):visibility / hidden_at / merged_into 三件套。
|
||
// 上文 line ~51 已经尝试过这三个 ALTER,但顺序在 CREATE TABLE memories 之前——
|
||
// 全新安装时 memories 表不存在,ALTER 会失败被吞掉,导致新建的 memories 表缺这三列,
|
||
// 后续 insertMemory 里 `WHERE visibility = 1` 立刻崩。这里在 CREATE TABLE 之后再补一次,
|
||
// 用 PRAGMA table_info 做幂等检查(与上面 embedding 同模式),不会重复加列。
|
||
try {
|
||
const cols = db.prepare(`PRAGMA table_info(memories)`).all()
|
||
const have = new Set(cols.map(c => c.name))
|
||
if (!have.has('visibility')) db.exec(`ALTER TABLE memories ADD COLUMN visibility INTEGER NOT NULL DEFAULT 1`)
|
||
if (!have.has('hidden_at')) db.exec(`ALTER TABLE memories ADD COLUMN hidden_at TEXT`)
|
||
if (!have.has('merged_into')) db.exec(`ALTER TABLE memories ADD COLUMN merged_into TEXT`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_visibility ON memories(visibility)`)
|
||
} catch (err) {
|
||
// 这一步真的失败的话后续 SELECT visibility 会全崩——日志告警让用户知道
|
||
console.error('[DB migration] critical: visibility column migration failed:', err.message)
|
||
}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS action_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
timestamp TEXT NOT NULL,
|
||
tool TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
detail TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_action_logs_timestamp ON action_logs(timestamp);
|
||
`)
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN status TEXT NOT NULL DEFAULT 'ok'`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN risk TEXT NOT NULL DEFAULT 'medium'`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN args_json TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN result_preview TEXT NOT NULL DEFAULT ''`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN error TEXT NOT NULL DEFAULT ''`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0`) } catch {}
|
||
try { db.exec(`ALTER TABLE action_logs ADD COLUMN source TEXT NOT NULL DEFAULT ''`) } catch {}
|
||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_action_logs_status ON action_logs(status)`) } catch {}
|
||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_action_logs_risk ON action_logs(risk)`) } catch {}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS reminders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id TEXT NOT NULL,
|
||
due_at TEXT NOT NULL,
|
||
task TEXT NOT NULL,
|
||
system_message TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
fired_at TEXT,
|
||
cancelled_at TEXT,
|
||
source TEXT DEFAULT '',
|
||
recurrence_type TEXT,
|
||
recurrence_config TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_reminders_due_at ON reminders(status, due_at);
|
||
`)
|
||
// 迁移:老库补上周期提醒字段
|
||
try { db.exec(`ALTER TABLE reminders ADD COLUMN recurrence_type TEXT`) } catch {}
|
||
try { db.exec(`ALTER TABLE reminders ADD COLUMN recurrence_config TEXT`) } catch {}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS prefetch_tasks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
source TEXT NOT NULL UNIQUE,
|
||
label TEXT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
ttl_minutes INTEGER NOT NULL DEFAULT 60,
|
||
tags TEXT DEFAULT '[]',
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_prefetch_tasks_enabled ON prefetch_tasks(enabled);
|
||
`)
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS prefetch_cache (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
source TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
fetched_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
tags TEXT DEFAULT '[]',
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_prefetch_expires ON prefetch_cache(expires_at);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_prefetch_source ON prefetch_cache(source);
|
||
`)
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS ui_signals (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
type TEXT NOT NULL,
|
||
target TEXT,
|
||
payload TEXT NOT NULL DEFAULT '{}',
|
||
ts INTEGER NOT NULL,
|
||
consumed INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_ui_signals_unconsumed ON ui_signals(consumed, ts);
|
||
`)
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS media_history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
kind TEXT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
video_id TEXT,
|
||
platform TEXT,
|
||
played_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_media_history_played_at ON media_history(played_at);
|
||
`)
|
||
try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_history_url ON media_history(url)`) } catch {}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS music_library (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
artist TEXT NOT NULL DEFAULT '',
|
||
album TEXT NOT NULL DEFAULT '',
|
||
file_path TEXT NOT NULL UNIQUE,
|
||
duration INTEGER NOT NULL DEFAULT 0,
|
||
lrc TEXT NOT NULL DEFAULT '',
|
||
cover TEXT NOT NULL DEFAULT '',
|
||
source_url TEXT NOT NULL DEFAULT '',
|
||
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_music_title ON music_library(title);
|
||
CREATE INDEX IF NOT EXISTS idx_music_artist ON music_library(artist);
|
||
CREATE INDEX IF NOT EXISTS idx_music_added ON music_library(added_at);
|
||
`)
|
||
|
||
// known_agents 表:记录启动时发现的本地 AI Agent
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS known_agents (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
available INTEGER NOT NULL DEFAULT 0,
|
||
version TEXT,
|
||
invoke_type TEXT,
|
||
invoke_cmd TEXT,
|
||
invoke_args TEXT NOT NULL DEFAULT '[]',
|
||
notes TEXT NOT NULL DEFAULT '',
|
||
docs_url TEXT,
|
||
docs_search_query TEXT,
|
||
detected_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
`)
|
||
// 老库迁移:补上文档字段
|
||
try { db.exec(`ALTER TABLE known_agents ADD COLUMN docs_url TEXT`) } catch {}
|
||
try { db.exec(`ALTER TABLE known_agents ADD COLUMN docs_search_query TEXT`) } catch {}
|
||
|
||
// user_identities 表:渠道外部 ID → canonical 用户 ID 的绑定(多用户阶段使用,单用户阶段保留为空)
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS user_identities (
|
||
canonical_id TEXT NOT NULL,
|
||
channel TEXT NOT NULL,
|
||
external_id TEXT NOT NULL,
|
||
alias TEXT DEFAULT '',
|
||
bound_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
PRIMARY KEY (channel, external_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_identity_canonical ON user_identities(canonical_id);
|
||
`)
|
||
|
||
// 一次性历史数据迁移:把外部前缀 ID 统一为 PRIMARY_USER_ID,原值搬到 external_party_id
|
||
try {
|
||
const flag = db.prepare(`SELECT value FROM config WHERE key = ?`).get('migration_canonical_user_v1')
|
||
if (!flag) {
|
||
const externalRows = db.prepare(`
|
||
SELECT COUNT(*) AS c FROM conversations
|
||
WHERE from_id LIKE 'wechat:%' OR from_id LIKE 'discord:%'
|
||
OR from_id LIKE 'feishu:%' OR from_id LIKE 'wecom:%'
|
||
OR to_id LIKE 'wechat:%' OR to_id LIKE 'discord:%'
|
||
OR to_id LIKE 'feishu:%' OR to_id LIKE 'wecom:%'
|
||
`).get()
|
||
if (externalRows.c > 0) {
|
||
console.log(`[DB migration] Canonicalizing ${externalRows.c} conversation row(s) with external-channel IDs → ID:000001`)
|
||
db.exec(`
|
||
UPDATE conversations
|
||
SET external_party_id = CASE WHEN external_party_id = '' OR external_party_id IS NULL THEN from_id ELSE external_party_id END,
|
||
from_id = 'ID:000001'
|
||
WHERE from_id LIKE 'wechat:%' OR from_id LIKE 'discord:%'
|
||
OR from_id LIKE 'feishu:%' OR from_id LIKE 'wecom:%';
|
||
UPDATE conversations
|
||
SET external_party_id = CASE WHEN external_party_id = '' OR external_party_id IS NULL THEN to_id ELSE external_party_id END,
|
||
to_id = 'ID:000001'
|
||
WHERE to_id LIKE 'wechat:%' OR to_id LIKE 'discord:%'
|
||
OR to_id LIKE 'feishu:%' OR to_id LIKE 'wecom:%';
|
||
`)
|
||
}
|
||
db.prepare(`INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))`)
|
||
.run('migration_canonical_user_v1', new Date().toISOString())
|
||
}
|
||
} catch (err) {
|
||
console.warn('[DB migration] canonical user migration failed:', err.message)
|
||
}
|
||
|
||
// focus_stack 表:动态上下文记忆池第 5c 步——持久化注意力焦点栈,让重启不丢栈。
|
||
// depth : 栈深,主键。0=栈底,length-1=栈顶。
|
||
// topic : JSON array of strings(主题关键词)。
|
||
// started_at : 帧创建时间(ISO timestamp)。
|
||
// started_at_tick / last_seen_tick : 创建/最后命中的 tickCounter。
|
||
// hit_count : 累计命中次数。
|
||
// conclusions : JSON array,存放从被 pop 子帧回填的结论字符串。
|
||
// updated_at : 行写入时间。
|
||
// 写入策略:每次 saveFocusStack 都先 DELETE 全表再批量 INSERT,整栈原子替换。
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS focus_stack (
|
||
depth INTEGER PRIMARY KEY,
|
||
topic TEXT NOT NULL,
|
||
started_at TEXT NOT NULL,
|
||
started_at_tick INTEGER NOT NULL,
|
||
last_seen_tick INTEGER NOT NULL,
|
||
hit_count INTEGER NOT NULL DEFAULT 1,
|
||
conclusions TEXT NOT NULL DEFAULT '[]',
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
`)
|
||
|
||
// wechat-clawbot 上下文令牌持久化:
|
||
// wechat-ilink-client 库内部用一个内存 Map<from_user_id, context_token> 缓存每个用户的会话令牌,
|
||
// 每次入站消息刷新一次,重启即丢——重启后想"主动"给该用户发消息会抛 No context_token。
|
||
// 把这层映射持久化下来,启动时回填到 client.contextTokens,能让"老朋友"在重启后立即可达。
|
||
// 服务端令牌仍可能过期,这只是个尽力而为的缓存,所以 executor 兜底文案保留。
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS wechat_clawbot_tokens (
|
||
from_user_id TEXT PRIMARY KEY,
|
||
context_token TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
`)
|
||
|
||
// 重建 FTS 索引(覆盖已有数据,确保历史记忆也被索引)
|
||
db.exec(`INSERT INTO memories_fts(memories_fts) VALUES('rebuild')`)
|
||
}
|
||
|
||
export function upsertClawbotToken(fromUserId, contextToken) {
|
||
if (!fromUserId || !contextToken) return
|
||
getDB().prepare(
|
||
`INSERT INTO wechat_clawbot_tokens (from_user_id, context_token, updated_at)
|
||
VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(from_user_id) DO UPDATE SET
|
||
context_token = excluded.context_token,
|
||
updated_at = excluded.updated_at`
|
||
).run(String(fromUserId), String(contextToken))
|
||
}
|
||
|
||
export function getAllClawbotTokens() {
|
||
return getDB().prepare(
|
||
`SELECT from_user_id, context_token FROM wechat_clawbot_tokens`
|
||
).all()
|
||
}
|
||
|
||
export function insertUISignal({ type, target = null, payload = {}, ts = Date.now() }) {
|
||
return getDB().prepare(
|
||
`INSERT INTO ui_signals (type, target, payload, ts) VALUES (?, ?, ?, ?)`
|
||
).run(type, target, JSON.stringify(payload || {}), ts).lastInsertRowid
|
||
}
|
||
|
||
export function getUnconsumedUISignals(windowMs = 60_000) {
|
||
const since = Date.now() - windowMs
|
||
return getDB().prepare(
|
||
`SELECT id, type, target, payload, ts FROM ui_signals
|
||
WHERE consumed = 0 AND ts >= ?
|
||
ORDER BY ts ASC`
|
||
).all(since)
|
||
}
|
||
|
||
export function markUISignalsConsumed(ids = []) {
|
||
if (!ids.length) return
|
||
const placeholders = ids.map(() => '?').join(',')
|
||
getDB().prepare(`UPDATE ui_signals SET consumed = 1 WHERE id IN (${placeholders})`).run(...ids)
|
||
}
|
||
|
||
export function normalizeConversationPartyId(id) {
|
||
if (!id) return id
|
||
const text = String(id).trim()
|
||
if (!text) return text
|
||
if (/^ID:\d+$/i.test(text)) return `ID:${text.replace(/^ID:/i, '')}`
|
||
if (/^\d+$/.test(text)) return `ID:${text}`
|
||
return text
|
||
}
|
||
|
||
function normalizeMemoryEntity(entity) {
|
||
if (!entity) return null
|
||
const normalizedParty = normalizeConversationPartyId(entity)
|
||
if (normalizedParty !== entity) return normalizedParty
|
||
|
||
const lower = String(entity).trim().toLowerCase()
|
||
if (USER_ID_ALIASES.has(lower)) return CANONICAL_USER_ID
|
||
if (AGENT_ENTITY_ALIASES.has(lower)) return CANONICAL_AGENT_ENTITY
|
||
|
||
// 处理平台复合 ID(如 discord:channelId:userId):提取最后一段检查别名
|
||
const lastColon = lower.lastIndexOf(':')
|
||
if (lastColon !== -1 && lower.indexOf(':') !== lastColon) {
|
||
const lastSegment = lower.slice(lastColon + 1)
|
||
if (lastSegment && USER_ID_ALIASES.has(lastSegment)) return CANONICAL_USER_ID
|
||
if (lastSegment && AGENT_ENTITY_ALIASES.has(lastSegment)) return CANONICAL_AGENT_ENTITY
|
||
}
|
||
|
||
return String(entity).trim()
|
||
}
|
||
|
||
function canonicalRootMemIdForEntity(entityId) {
|
||
if (entityId === CANONICAL_USER_ID) return CANONICAL_USER_ROOT_MEM_ID
|
||
if (entityId === CANONICAL_AGENT_ENTITY) return CANONICAL_AGENT_ROOT_MEM_ID
|
||
return null
|
||
}
|
||
|
||
function canonicalRootMetaForEntity(entityId) {
|
||
if (entityId === CANONICAL_USER_ID) {
|
||
return {
|
||
memId: CANONICAL_USER_ROOT_MEM_ID,
|
||
eventType: 'person',
|
||
title: '用户 ID:000001 身份标识',
|
||
content: '用户唯一身份为 ID:000001,别名 Yuanda。',
|
||
tags: ['identity', 'user', 'alias:Yuanda'],
|
||
}
|
||
}
|
||
if (entityId === CANONICAL_AGENT_ENTITY) {
|
||
return {
|
||
memId: CANONICAL_AGENT_ROOT_MEM_ID,
|
||
eventType: 'object',
|
||
title: 'Agent Jarvis 身份标识',
|
||
content: 'Agent Jarvis 是当前运行中的本地 AI 助手实例。',
|
||
tags: ['identity', 'agent', 'jarvis'],
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function safeJsonArray(value) {
|
||
if (Array.isArray(value)) return value
|
||
if (!value) return []
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return Array.isArray(parsed) ? parsed : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function safeStringify(value) {
|
||
try {
|
||
return JSON.stringify(value ?? {})
|
||
} catch {
|
||
return '{}'
|
||
}
|
||
}
|
||
|
||
function uniqueStrings(values) {
|
||
return [...new Set((values || []).filter(Boolean).map(v => String(v).trim()).filter(Boolean))]
|
||
}
|
||
|
||
// LLM 可能传字符串/越界值,强制归一到 1-5
|
||
function clampSalience(value) {
|
||
const n = Math.round(Number(value))
|
||
if (!Number.isFinite(n)) return 3
|
||
return Math.max(1, Math.min(5, n))
|
||
}
|
||
|
||
function inferIdentityEntities(memory) {
|
||
const text = [
|
||
memory.mem_id,
|
||
memory.title,
|
||
memory.content,
|
||
memory.detail,
|
||
...(memory.tags || []),
|
||
...(memory.entities || []),
|
||
].filter(Boolean).join(' ')
|
||
|
||
const entities = []
|
||
const memId = String(memory.mem_id || '').toLowerCase()
|
||
const title = String(memory.title || '')
|
||
|
||
if (
|
||
/(?:^|[^a-z0-9])(000001|yuanda)(?:[^a-z0-9]|$)|ID:\s*000001/i.test(text) ||
|
||
/^user_|^person_/.test(memId) ||
|
||
/用户/.test(title)
|
||
) {
|
||
entities.push(CANONICAL_USER_ID)
|
||
}
|
||
if (
|
||
/Jarvis|Agent_Jarvis|JARVIS/i.test(text) ||
|
||
/jarvis|^agent_/.test(memId)
|
||
) {
|
||
entities.push(CANONICAL_AGENT_ENTITY)
|
||
}
|
||
|
||
return uniqueStrings(entities)
|
||
}
|
||
|
||
function canonicalizeLinkedTarget(targetId) {
|
||
if (!targetId) return targetId
|
||
if (USER_ROOT_ALIASES.has(targetId)) return CANONICAL_USER_ROOT_MEM_ID
|
||
return targetId
|
||
}
|
||
|
||
function normalizeMemoryLinks(links) {
|
||
return safeJsonArray(links).map(link => ({
|
||
...link,
|
||
target_id: canonicalizeLinkedTarget(link.target_id),
|
||
}))
|
||
}
|
||
|
||
function choosePrimaryIdentityEntity(memory) {
|
||
const entities = memory.entities || []
|
||
if (!entities.length) return null
|
||
|
||
const text = [memory.mem_id, memory.title, memory.content].filter(Boolean).join(' ')
|
||
const hasUser = entities.includes(CANONICAL_USER_ID)
|
||
const hasAgent = entities.includes(CANONICAL_AGENT_ENTITY)
|
||
|
||
if (hasUser && !hasAgent) return CANONICAL_USER_ID
|
||
if (hasAgent && !hasUser) return CANONICAL_AGENT_ENTITY
|
||
if (hasUser && hasAgent) {
|
||
if (/用户|ID:\s*000001|\b000001\b|\bYuanda\b/i.test(text)) return CANONICAL_USER_ID
|
||
return CANONICAL_AGENT_ENTITY
|
||
}
|
||
return null
|
||
}
|
||
|
||
function isCanonicalRootMemory(memory) {
|
||
return [CANONICAL_USER_ROOT_MEM_ID, CANONICAL_AGENT_ROOT_MEM_ID].includes(memory.mem_id)
|
||
}
|
||
|
||
function ensureCanonicalIdentityRoot(entityId) {
|
||
if (!AUTO_CANONICAL_IDENTITY_ROOTS) return null
|
||
|
||
const meta = canonicalRootMetaForEntity(entityId)
|
||
if (!meta) return null
|
||
|
||
const db = getDB()
|
||
const existing = db.prepare(`
|
||
SELECT id, entities, tags, links, title, content
|
||
FROM memories
|
||
WHERE mem_id = ?
|
||
LIMIT 1
|
||
`).get(meta.memId)
|
||
|
||
if (existing) {
|
||
const entities = uniqueStrings([...safeJsonArray(existing.entities), entityId])
|
||
const tags = uniqueStrings([...safeJsonArray(existing.tags), ...meta.tags])
|
||
const links = normalizeMemoryLinks(existing.links)
|
||
db.prepare(`
|
||
UPDATE memories
|
||
SET event_type = ?, title = ?, content = ?, entities = ?, tags = ?, links = ?, timestamp = ?
|
||
WHERE id = ?
|
||
`).run(
|
||
meta.eventType,
|
||
existing.title || meta.title,
|
||
existing.content || meta.content,
|
||
JSON.stringify(entities),
|
||
JSON.stringify(tags),
|
||
JSON.stringify(links),
|
||
new Date().toISOString(),
|
||
existing.id
|
||
)
|
||
return existing.id
|
||
}
|
||
|
||
const result = db.prepare(`
|
||
INSERT INTO memories (event_type, content, detail, title, mem_id, entities, concepts, tags, links, source_ref, timestamp, parent_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||
`).run(
|
||
meta.eventType,
|
||
meta.content,
|
||
meta.content,
|
||
meta.title,
|
||
meta.memId,
|
||
JSON.stringify([entityId]),
|
||
JSON.stringify([]),
|
||
JSON.stringify(meta.tags),
|
||
JSON.stringify([]),
|
||
'identity_normalizer',
|
||
new Date().toISOString()
|
||
)
|
||
|
||
return result.lastInsertRowid
|
||
}
|
||
|
||
// 按语义 mem_id 读取单条记忆(用于 Agent 可自改的身份/人格类根记忆 + 整合器)
|
||
// 返回完整 row(含 salience/entities/timestamp),整合器需要这些字段
|
||
export function getMemoryByMemId(memId) {
|
||
const db = getDB()
|
||
return db.prepare('SELECT * FROM memories WHERE mem_id = ? LIMIT 1').get(memId) || null
|
||
}
|
||
|
||
export function deleteMemoryByMemId(mem_id) {
|
||
const db = getDB()
|
||
if (!mem_id) throw new Error('deleteMemoryByMemId 需要 mem_id')
|
||
const result = db.prepare(`DELETE FROM memories WHERE mem_id = ?`).run(mem_id)
|
||
return result.changes > 0
|
||
}
|
||
|
||
// 软隐藏记忆(动态记忆池:剔除 = 看不见,不是删除)。
|
||
// 把行的 visibility 设为 0,hidden_at 落时间戳,mergedInto 可选记录合并去向。
|
||
// 读路径默认 WHERE visibility = 1,所以隐藏后 search / get* 等都自动过滤。
|
||
// 数据仍完整保留:FTS5 索引、embedding、links、parent 链全部不动,
|
||
// 第 3 步专注帧恢复机制可以靠 mem_id 反向 UPDATE visibility=1 复活。
|
||
export function hideMemoryByMemId(memId, { mergedInto = null, hiddenAt = null } = {}) {
|
||
const db = getDB()
|
||
if (!memId) throw new Error('hideMemoryByMemId 需要 mem_id')
|
||
const ts = hiddenAt || new Date().toISOString()
|
||
const result = db.prepare(`
|
||
UPDATE memories
|
||
SET visibility = 0, hidden_at = ?, merged_into = ?
|
||
WHERE mem_id = ?
|
||
`).run(ts, mergedInto || null, memId)
|
||
return result.changes > 0
|
||
}
|
||
|
||
// 集中点:所有读路径共用的可见性谓词。
|
||
// 写成常量 + 拼接片段,确保改一处所有路径同步变。
|
||
// 注意:memoryExistsByMemId / getMemoryByMemId / mem_id 主键去重 SELECT 故意不用这个常量,
|
||
// 因为它们要看到隐藏行(避免 UNIQUE 冲突,且 merge 工具自己要能取 drops 的当前状态)。
|
||
const VISIBLE_CLAUSE = 'visibility = 1'
|
||
|
||
// 候选实体:fact/person 记忆数 ≥3 的 entity ID,按出现次数倒序
|
||
// 只统计 visible 行(否则已经被合并隐藏的记忆还会反复让同一 entity 被挑出来)
|
||
export function getCandidateEntitiesForConsolidation(limit = 10) {
|
||
const db = getDB()
|
||
const rows = db.prepare(`SELECT entities FROM memories WHERE event_type IN ('fact','person') AND ${VISIBLE_CLAUSE}`).all()
|
||
const counts = new Map()
|
||
for (const r of rows) {
|
||
try {
|
||
const arr = JSON.parse(r.entities || '[]')
|
||
for (const e of arr) counts.set(e, (counts.get(e) || 0) + 1)
|
||
} catch {}
|
||
}
|
||
return [...counts.entries()]
|
||
.filter(([e, c]) => e && c >= 3)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, limit)
|
||
.map(([entity, count]) => ({ entity, count }))
|
||
}
|
||
|
||
// 读取配置
|
||
export function getConfig(key) {
|
||
const db = getDB()
|
||
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key)
|
||
return row ? row.value : null
|
||
}
|
||
|
||
// 写入配置
|
||
export function setConfig(key, value) {
|
||
const db = getDB()
|
||
db.prepare(`
|
||
INSERT INTO config (key, value, updated_at)
|
||
VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||
`).run(key, value)
|
||
}
|
||
|
||
// 解析语义 mem_id 字符串 → 真实整数 id
|
||
function resolveMemId(memId) {
|
||
if (!memId) return null
|
||
const db = getDB()
|
||
const row = db.prepare(`SELECT id FROM memories WHERE mem_id = ? LIMIT 1`).get(memId)
|
||
return row ? row.id : null
|
||
}
|
||
|
||
// 解析 parent_ref 语义字符串 → 真实 memory id(兼容旧格式 "type:identifier")
|
||
// 格式:"person:ID:000001" → 找该 entity 最新的 person 根节点
|
||
// "knowledge:X框架" → FTS 搜索最近匹配的 knowledge 记录
|
||
function resolveParentRef(parentRef) {
|
||
if (!parentRef) return null
|
||
const db = getDB()
|
||
const normalizedParentRef = canonicalizeLinkedTarget(parentRef)
|
||
|
||
// 优先尝试按 mem_id 查找(新格式)
|
||
const byMemId = db.prepare(`SELECT id FROM memories WHERE mem_id = ? LIMIT 1`).get(normalizedParentRef)
|
||
if (byMemId) return byMemId.id
|
||
|
||
// 旧格式:type:identifier
|
||
const colonIdx = normalizedParentRef.indexOf(':')
|
||
if (colonIdx === -1) return null
|
||
|
||
const type = normalizedParentRef.slice(0, colonIdx).trim()
|
||
const identifier = normalizedParentRef.slice(colonIdx + 1).trim()
|
||
if (!type || !identifier) return null
|
||
|
||
// person / object:identifier 是 entity ID,精确匹配根节点
|
||
if (['person', 'object'].includes(type)) {
|
||
const row = db.prepare(`
|
||
SELECT id FROM memories
|
||
WHERE event_type = ? AND entities LIKE ? AND parent_id IS NULL
|
||
ORDER BY timestamp DESC LIMIT 1
|
||
`).get(type, `%${identifier}%`)
|
||
return row ? row.id : null
|
||
}
|
||
|
||
// 其他类型:identifier 是关键词,FTS 搜索最近匹配记录
|
||
try {
|
||
const row = db.prepare(`
|
||
SELECT m.id FROM memories m
|
||
JOIN memories_fts ON memories_fts.rowid = m.id
|
||
WHERE m.event_type = ? AND memories_fts MATCH ?
|
||
ORDER BY m.timestamp DESC LIMIT 1
|
||
`).get(type, identifier)
|
||
return row ? row.id : null
|
||
} catch {
|
||
const row = db.prepare(`
|
||
SELECT id FROM memories
|
||
WHERE event_type = ? AND content LIKE ?
|
||
ORDER BY timestamp DESC LIMIT 1
|
||
`).get(type, `%${identifier}%`)
|
||
return row ? row.id : null
|
||
}
|
||
}
|
||
|
||
// 写入一条记忆(写入前检查去重)
|
||
// 支持旧格式(event_type/entities/detail 等)和新格式(type/id/title/links/parent_id 语义字符串)
|
||
export function insertMemory(memory) {
|
||
const db = getDB()
|
||
|
||
// 新格式适配:将 type → event_type,id → mem_id,parent_id(语义)→ parent_ref
|
||
const normalizedMemory = { ...memory }
|
||
if (memory.type && !memory.event_type) {
|
||
normalizedMemory.event_type = memory.type
|
||
}
|
||
if (memory.id && !memory.mem_id) {
|
||
normalizedMemory.mem_id = memory.id
|
||
}
|
||
// 新格式的 parent_id 是语义字符串,映射到 parent_ref 走旧解析流程
|
||
if (memory.parent_id && typeof memory.parent_id === 'string' && !memory.parent_ref) {
|
||
normalizedMemory.parent_ref = memory.parent_id
|
||
}
|
||
// 新格式无 detail 字段时,用 content 填充保持 NOT NULL 约束
|
||
if (!normalizedMemory.detail) {
|
||
normalizedMemory.detail = normalizedMemory.content || ''
|
||
}
|
||
|
||
normalizedMemory.entities = uniqueStrings([
|
||
...safeJsonArray(normalizedMemory.entities),
|
||
...inferIdentityEntities(normalizedMemory),
|
||
]).map(normalizeMemoryEntity)
|
||
|
||
normalizedMemory.tags = uniqueStrings(safeJsonArray(normalizedMemory.tags))
|
||
normalizedMemory.links = normalizeMemoryLinks(normalizedMemory.links)
|
||
|
||
const m = normalizedMemory
|
||
|
||
if (!m.parent_ref && !isCanonicalRootMemory(m)) {
|
||
const primaryEntity = choosePrimaryIdentityEntity(m)
|
||
const rootMemId = canonicalRootMemIdForEntity(primaryEntity)
|
||
if (rootMemId) {
|
||
ensureCanonicalIdentityRoot(primaryEntity)
|
||
m.parent_ref = rootMemId
|
||
|
||
const existingTargets = new Set(m.links.map(link => link.target_id))
|
||
if (!existingTargets.has(rootMemId)) {
|
||
m.links.push({ target_id: rootMemId, relation: 'child_of' })
|
||
}
|
||
}
|
||
}
|
||
|
||
// mem_id 去重:同 mem_id 已存在时直接更新
|
||
if (m.mem_id) {
|
||
const existing = db.prepare(`SELECT id FROM memories WHERE mem_id = ? LIMIT 1`).get(m.mem_id)
|
||
if (existing) {
|
||
db.prepare(`
|
||
UPDATE memories SET content = ?, detail = ?, title = ?, entities = ?, tags = ?, links = ?, timestamp = ?
|
||
WHERE id = ?
|
||
`).run(
|
||
m.content,
|
||
m.detail,
|
||
m.title || '',
|
||
JSON.stringify(m.entities || []),
|
||
JSON.stringify(m.tags || []),
|
||
JSON.stringify(m.links || []),
|
||
m.timestamp || new Date().toISOString(),
|
||
existing.id
|
||
)
|
||
console.log(`[DB] 更新记忆节点:${m.mem_id}`)
|
||
return { id: existing.id, updated: true }
|
||
}
|
||
}
|
||
|
||
// person / object 根节点:按 entity ID upsert,避免重复根节点(旧格式兼容)
|
||
// 只看 visible 行:被隐藏的根概念上"暂时不在",允许新写入复活该实体
|
||
if (['person', 'object'].includes(m.event_type) && !m.parent_ref) {
|
||
const firstEntity = (m.entities || [])[0]
|
||
if (firstEntity) {
|
||
const existing = db.prepare(`
|
||
SELECT id FROM memories
|
||
WHERE event_type = ? AND entities LIKE ? AND parent_id IS NULL AND ${VISIBLE_CLAUSE}
|
||
LIMIT 1
|
||
`).get(m.event_type, `%${firstEntity}%`)
|
||
if (existing) {
|
||
db.prepare(`
|
||
UPDATE memories SET content = ?, detail = ?, title = ?, entities = ?, concepts = ?, tags = ?, links = ?, timestamp = ?
|
||
WHERE id = ?
|
||
`).run(
|
||
m.content,
|
||
m.detail,
|
||
m.title || '',
|
||
JSON.stringify(m.entities || []),
|
||
JSON.stringify(m.concepts || []),
|
||
JSON.stringify(m.tags || []),
|
||
JSON.stringify(m.links || []),
|
||
m.timestamp || new Date().toISOString(),
|
||
existing.id
|
||
)
|
||
console.log(`[DB] 更新根节点:${m.event_type} ${firstEntity}`)
|
||
return { id: existing.id, updated: true }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 解析 parent_ref → parent_id(整数)
|
||
const parentId = m.parent_ref ? resolveParentRef(m.parent_ref) : null
|
||
|
||
// 工具知识记忆去重:按 tool:标签匹配,同工具只保留最新(旧格式兼容)
|
||
// 只看 visible:被隐藏的工具知识让位给新记忆
|
||
const memoryTags = m.tags || []
|
||
const toolTag = Array.isArray(memoryTags) ? memoryTags.find(t => t.startsWith('tool:')) : null
|
||
if (toolTag && m.event_type === 'knowledge') {
|
||
const toolName = toolTag.replace('tool:', '')
|
||
const existing = db.prepare(`
|
||
SELECT id FROM memories
|
||
WHERE event_type = 'knowledge'
|
||
AND tags LIKE ?
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC LIMIT 1
|
||
`).get(`%tool:${toolName}%`)
|
||
if (existing) {
|
||
db.prepare(`
|
||
UPDATE memories SET content = ?, detail = ?, title = ?, concepts = ?, tags = ?, links = ?, timestamp = ?
|
||
WHERE id = ?
|
||
`).run(
|
||
m.content, m.detail, m.title || '',
|
||
JSON.stringify(m.concepts || []),
|
||
JSON.stringify(m.tags || []),
|
||
JSON.stringify(m.links || []),
|
||
m.timestamp || new Date().toISOString(),
|
||
existing.id
|
||
)
|
||
console.log(`[DB] 更新工具记忆:${toolName}`)
|
||
return { id: existing.id, updated: true }
|
||
}
|
||
}
|
||
|
||
// 普通记忆去重:同类型且 content 前40字相同则跳过
|
||
// 只看 visible:之前被合并隐藏的同义内容,让 LLM 重新插入为新记忆——
|
||
// 隐藏 ≈ "概念上不再 load-bearing",如果用户重新提起就该出现,下一轮 consolidator 自然合并
|
||
const contentPrefix = (m.content || '').slice(0, 40)
|
||
const dup = db.prepare(`
|
||
SELECT id FROM memories WHERE event_type = ? AND content LIKE ? AND ${VISIBLE_CLAUSE} LIMIT 1
|
||
`).get(m.event_type, `${contentPrefix}%`)
|
||
if (dup) {
|
||
console.log(`[DB] 跳过重复记忆:${contentPrefix}…`)
|
||
return null
|
||
}
|
||
|
||
// URL 去重:同 URL 当天已有记录则跳过(同样只看 visible)
|
||
const urlTag = Array.isArray(memoryTags) ? memoryTags.find(t => t.startsWith('url:')) : null
|
||
if (urlTag) {
|
||
const today = new Date().toISOString().slice(0, 10)
|
||
const urlDup = db.prepare(`
|
||
SELECT id FROM memories WHERE tags LIKE ? AND timestamp LIKE ? AND ${VISIBLE_CLAUSE} LIMIT 1
|
||
`).get(`%${urlTag}%`, `${today}%`)
|
||
if (urlDup) {
|
||
console.log(`[DB] 跳过当日重复 URL 记忆:${urlTag}`)
|
||
return null
|
||
}
|
||
}
|
||
|
||
return db.prepare(`
|
||
INSERT INTO memories (event_type, content, detail, title, mem_id, entities, concepts, tags, links, source_ref, timestamp, parent_id)
|
||
VALUES (@event_type, @content, @detail, @title, @mem_id, @entities, @concepts, @tags, @links, @source_ref, @timestamp, @parent_id)
|
||
`).run({
|
||
event_type: m.event_type,
|
||
content: m.content,
|
||
detail: m.detail,
|
||
title: m.title || '',
|
||
mem_id: m.mem_id || null,
|
||
entities: JSON.stringify(m.entities || []),
|
||
concepts: JSON.stringify(m.concepts || []),
|
||
tags: JSON.stringify(m.tags || []),
|
||
links: JSON.stringify(m.links || []),
|
||
source_ref: m.source_ref || null,
|
||
timestamp: m.timestamp || new Date().toISOString(),
|
||
parent_id: parentId,
|
||
})
|
||
}
|
||
|
||
export function memoryExistsByMemId(mem_id) {
|
||
const db = getDB()
|
||
return !!db.prepare(`SELECT id FROM memories WHERE mem_id = ? LIMIT 1`).get(mem_id)
|
||
}
|
||
|
||
// 按 mem_id 做 PATCH 式 upsert:识别器走工具调用主动判重时使用。
|
||
// 与 insertMemory 区别:
|
||
// - 必须有 mem_id
|
||
// - 已存在 mem_id:只更新传入字段(PATCH 语义),未传字段保留
|
||
// - 不存在:直接 INSERT,绕开 content 前 40 字 / URL 当日去重
|
||
// - body_path 自动写入 tags 作为 body_path:xxx 标签
|
||
export function upsertMemoryByMemId(memory) {
|
||
const db = getDB()
|
||
if (!memory?.mem_id) throw new Error('upsertMemoryByMemId 需要 mem_id')
|
||
|
||
const m = { ...memory }
|
||
if (m.type && !m.event_type) m.event_type = m.type
|
||
if (m.parent_mem_id && !m.parent_ref) m.parent_ref = m.parent_mem_id
|
||
|
||
// body_path 写入 tags(避免新增列;formatMemoriesForPrompt 解析此 tag 显示)
|
||
if (m.body_path) {
|
||
const baseTags = safeJsonArray(m.tags)
|
||
const filtered = baseTags.filter(t => !String(t).startsWith('body_path:'))
|
||
m.tags = [...filtered, `body_path:${m.body_path}`]
|
||
}
|
||
|
||
if (m.entities !== undefined) {
|
||
m.entities = uniqueStrings(safeJsonArray(m.entities)).map(normalizeMemoryEntity)
|
||
}
|
||
if (m.tags !== undefined) {
|
||
m.tags = uniqueStrings(safeJsonArray(m.tags))
|
||
}
|
||
if (m.links !== undefined) {
|
||
m.links = normalizeMemoryLinks(m.links)
|
||
}
|
||
|
||
const existing = db.prepare(`SELECT id FROM memories WHERE mem_id = ? LIMIT 1`).get(m.mem_id)
|
||
|
||
if (existing) {
|
||
const sets = []
|
||
const params = { id: existing.id }
|
||
|
||
if (m.event_type !== undefined) { sets.push('event_type = @event_type'); params.event_type = m.event_type }
|
||
if (m.content !== undefined) { sets.push('content = @content'); params.content = m.content }
|
||
if (m.detail !== undefined) { sets.push('detail = @detail'); params.detail = m.detail }
|
||
if (m.title !== undefined) { sets.push('title = @title'); params.title = m.title }
|
||
if (m.entities !== undefined) { sets.push('entities = @entities'); params.entities = JSON.stringify(m.entities) }
|
||
if (m.concepts !== undefined) { sets.push('concepts = @concepts'); params.concepts = JSON.stringify(m.concepts) }
|
||
if (m.tags !== undefined) { sets.push('tags = @tags'); params.tags = JSON.stringify(m.tags) }
|
||
if (m.links !== undefined) { sets.push('links = @links'); params.links = JSON.stringify(m.links) }
|
||
if (m.source_ref !== undefined) { sets.push('source_ref = @source_ref'); params.source_ref = m.source_ref }
|
||
if (m.salience !== undefined) { sets.push('salience = @salience'); params.salience = clampSalience(m.salience) }
|
||
if (m.parent_ref !== undefined) {
|
||
sets.push('parent_id = @parent_id')
|
||
params.parent_id = m.parent_ref ? resolveParentRef(m.parent_ref) : null
|
||
}
|
||
|
||
sets.push('timestamp = @timestamp')
|
||
params.timestamp = m.timestamp || new Date().toISOString()
|
||
|
||
db.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE id = @id`).run(params)
|
||
console.log(`[DB] PATCH 记忆:${m.mem_id}`)
|
||
return { id: existing.id, mem_id: m.mem_id, updated: true }
|
||
}
|
||
|
||
if (!m.event_type) throw new Error('新建记忆需要 type')
|
||
if (!m.title) throw new Error('新建记忆需要 title')
|
||
if (!m.content) throw new Error('新建记忆需要 content')
|
||
|
||
const parentId = m.parent_ref ? resolveParentRef(m.parent_ref) : null
|
||
const result = db.prepare(`
|
||
INSERT INTO memories (event_type, content, detail, title, mem_id, entities, concepts, tags, links, source_ref, timestamp, salience, parent_id)
|
||
VALUES (@event_type, @content, @detail, @title, @mem_id, @entities, @concepts, @tags, @links, @source_ref, @timestamp, @salience, @parent_id)
|
||
`).run({
|
||
event_type: m.event_type,
|
||
content: m.content,
|
||
detail: m.detail !== undefined ? m.detail : m.content,
|
||
title: m.title,
|
||
mem_id: m.mem_id,
|
||
entities: JSON.stringify(m.entities || []),
|
||
concepts: JSON.stringify(m.concepts || []),
|
||
tags: JSON.stringify(m.tags || []),
|
||
links: JSON.stringify(m.links || []),
|
||
source_ref: m.source_ref || null,
|
||
timestamp: m.timestamp || new Date().toISOString(),
|
||
salience: clampSalience(m.salience),
|
||
parent_id: parentId,
|
||
})
|
||
|
||
console.log(`[DB] INSERT 新记忆:${m.mem_id}`)
|
||
return { id: result.lastInsertRowid, mem_id: m.mem_id, updated: false }
|
||
}
|
||
|
||
// 批量按关键词搜索:每个关键词独立 FTS5 检索,返回 { mem_id, type, title, content_excerpt, matched_by[] }
|
||
// 同一 mem_id 在多个关键词命中时合并,matched_by 列出所有命中关键词
|
||
export function searchMemoriesByKeywords(keywords, { limitPerKeyword = 5, typeFilter = null } = {}) {
|
||
if (!Array.isArray(keywords) || keywords.length === 0) return []
|
||
const merged = new Map() // mem_id (or 'row:'+id) → { row, matched_by:Set }
|
||
|
||
for (const keyword of keywords) {
|
||
if (!keyword) continue
|
||
const hits = searchMemories(keyword, limitPerKeyword)
|
||
for (const row of hits) {
|
||
if (typeFilter && row.event_type !== typeFilter) continue
|
||
const key = row.mem_id || `row:${row.id}`
|
||
if (!merged.has(key)) merged.set(key, { row, matched_by: new Set() })
|
||
merged.get(key).matched_by.add(keyword)
|
||
}
|
||
}
|
||
|
||
return [...merged.values()].map(({ row, matched_by }) => {
|
||
const tags = safeJsonArray(row.tags)
|
||
const bodyPathTag = tags.find(t => String(t).startsWith('body_path:'))
|
||
return {
|
||
mem_id: row.mem_id || null,
|
||
id: row.id,
|
||
type: row.event_type,
|
||
title: row.title || '',
|
||
content_excerpt: (row.content || '').slice(0, 80),
|
||
timestamp: row.timestamp,
|
||
body_path: bodyPathTag ? String(bodyPathTag).replace('body_path:', '') : null,
|
||
matched_by: [...matched_by],
|
||
}
|
||
})
|
||
}
|
||
|
||
// 查询最近 N 条记忆
|
||
export function getRecentMemories(limit = 10) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories ORDER BY timestamp DESC LIMIT ?
|
||
`).all(limit)
|
||
}
|
||
|
||
export function getMemoryCount() {
|
||
const db = getDB()
|
||
return db.prepare('SELECT COUNT(*) AS c FROM memories').get().c
|
||
}
|
||
|
||
// 查询某时间段内的记忆
|
||
export function getMemoriesByTimeRange(from, to, limit = 20) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE timestamp >= ? AND timestamp <= ?
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(from, to, limit)
|
||
}
|
||
|
||
// 按日期窗口拉记忆,给"听见昨天/前天"类的时间词触发的自动注入用。
|
||
// 跟 getMemoriesByTimeRange 的区别:
|
||
// - 半开区间 [from, to),避免跨日边界双重计入
|
||
// - 支持 types / minSalience 过滤
|
||
// - 默认按 salience desc, timestamp asc:先重要、再时间早晚
|
||
// 时区注意:from/to 用本地带偏移 ISO(同 nowTimestamp),memories.timestamp 也是。
|
||
// 用 strftime('%s', ...) 转 unixepoch 比较,避开字符串字典序在 '+08:00' / 'Z' 上的踩坑。
|
||
export function getMemoriesByDateRange(from, to, {
|
||
types = null,
|
||
minSalience = null,
|
||
limit = 8,
|
||
orderBy = 'COALESCE(salience, 3) DESC, timestamp ASC',
|
||
} = {}) {
|
||
const db = getDB()
|
||
const conditions = [
|
||
`strftime('%s', timestamp) >= strftime('%s', ?)`,
|
||
`strftime('%s', timestamp) < strftime('%s', ?)`,
|
||
VISIBLE_CLAUSE,
|
||
]
|
||
const params = [from, to]
|
||
if (Array.isArray(types) && types.length > 0) {
|
||
conditions.push(`event_type IN (${types.map(() => '?').join(',')})`)
|
||
params.push(...types)
|
||
}
|
||
if (minSalience != null) {
|
||
conditions.push(`COALESCE(salience, 3) >= ?`)
|
||
params.push(minSalience)
|
||
}
|
||
params.push(limit)
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE ${conditions.join(' AND ')}
|
||
ORDER BY ${orderBy}
|
||
LIMIT ?
|
||
`).all(...params)
|
||
}
|
||
|
||
// 清除所有记忆和配置(测试用,谨慎使用)
|
||
export function resetAll() {
|
||
const db = getDB()
|
||
db.prepare('DELETE FROM memories').run()
|
||
db.prepare('DELETE FROM config').run()
|
||
db.prepare('DELETE FROM entities').run()
|
||
}
|
||
|
||
// 注册/更新一个已知实体
|
||
export function upsertEntity(id, label = null) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeConversationPartyId(id)
|
||
db.prepare(`
|
||
INSERT INTO entities (id, label, last_seen)
|
||
VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(id) DO UPDATE SET last_seen = datetime('now'), label = COALESCE(excluded.label, label)
|
||
`).run(normalizedId, label)
|
||
}
|
||
|
||
// 获取所有已知实体
|
||
export function getKnownEntities() {
|
||
const db = getDB()
|
||
return db.prepare('SELECT * FROM entities ORDER BY last_seen DESC').all()
|
||
}
|
||
|
||
// 查询意识体对某 ID 表达过的观点(opinion_expressed)
|
||
export function getOpinionsByTarget(entityId, limit = 5) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type = 'opinion_expressed'
|
||
AND tags LIKE ?
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(`%target:${entityId}%`, limit)
|
||
}
|
||
|
||
// 查询某 ID 说过的印象深刻的话(impressive_statement,score >= 3 已在写入时过滤)
|
||
export function getImpressiveBySource(entityId, limit = 5) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type = 'impressive_statement'
|
||
AND tags LIKE ?
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(`%from:${entityId}%`, limit)
|
||
}
|
||
|
||
// ── 对话记录 ──
|
||
|
||
// 写入一条对话记录
|
||
export function insertConversation({ role, from_id, to_id = null, content, timestamp, channel = '', external_party_id = '' }) {
|
||
const db = getDB()
|
||
const fromId = normalizeConversationPartyId(from_id)
|
||
const toId = normalizeConversationPartyId(to_id)
|
||
db.prepare(`
|
||
INSERT INTO conversations (role, from_id, to_id, content, timestamp, channel, external_party_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`).run(role, fromId, toId, content, timestamp, channel || '', external_party_id || '')
|
||
}
|
||
|
||
// 将最近一条 jarvis 消息内容裁剪为已说出的部分(TTS 被打断时调用)
|
||
export function updateLastJarvisConversationContent(spokenContent) {
|
||
const db = getDB()
|
||
const row = db.prepare(`SELECT id FROM conversations WHERE role = 'jarvis' ORDER BY id DESC LIMIT 1`).get()
|
||
if (!row) return false
|
||
db.prepare(`UPDATE conversations SET content = ? WHERE id = ?`).run(spokenContent, row.id)
|
||
return true
|
||
}
|
||
|
||
// 获取某个对话对象的最近 N 条消息(用户消息 + Jarvis 回复,按时序)
|
||
// anchor: 锚点消息 id,null 表示最新;offset: 向上偏移(用于窗口上移)
|
||
export function getConversationWindow(entityId, userCount = 5, anchorId = null, offsetUp = 0) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeConversationPartyId(entityId)
|
||
|
||
// 找到最近 userCount 条用户消息的时间范围
|
||
let userRows
|
||
if (anchorId) {
|
||
const anchor = db.prepare('SELECT timestamp FROM conversations WHERE id = ?').get(anchorId)
|
||
userRows = db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND role = 'user'
|
||
AND timestamp <= ?
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(normalizedId, normalizedId, anchor.timestamp, userCount + offsetUp)
|
||
} else {
|
||
userRows = db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND role = 'user'
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(normalizedId, normalizedId, userCount + offsetUp)
|
||
}
|
||
|
||
if (!userRows.length) return []
|
||
|
||
// 取这些用户消息的时间范围
|
||
const timestamps = userRows.map(r => r.timestamp)
|
||
const minTs = timestamps[timestamps.length - 1]
|
||
const maxTs = timestamps[0]
|
||
|
||
// 取该时间范围内所有消息(包含 Jarvis 回复),按时序排列
|
||
return db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND timestamp >= ? AND timestamp <= ?
|
||
ORDER BY timestamp ASC
|
||
`).all(normalizedId, normalizedId, minTs, maxTs)
|
||
}
|
||
|
||
// 搜索对话记录(关键词),返回匹配行及其上下文(前后各 N 条)
|
||
export function searchConversations(entityId, keyword, context = 5) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeConversationPartyId(entityId)
|
||
const matches = db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND content LIKE ?
|
||
ORDER BY timestamp DESC
|
||
LIMIT 10
|
||
`).all(normalizedId, normalizedId, `%${keyword}%`)
|
||
|
||
if (!matches.length) return []
|
||
|
||
// 取第一个匹配的上下文窗口
|
||
const anchor = matches[0]
|
||
return db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND ABS(CAST((julianday(timestamp) - julianday(?)) * 86400 AS INTEGER)) < ${context * 30}
|
||
ORDER BY timestamp ASC
|
||
LIMIT ?
|
||
`).all(normalizedId, normalizedId, anchor.timestamp, context * 2 + 1)
|
||
}
|
||
|
||
// 获取或初始化首次启动时间(持久化,重启不丢失)
|
||
export function getOrInitBirthTime() {
|
||
const db = getDB()
|
||
const row = db.prepare('SELECT value FROM config WHERE key = ?').get('birth_time')
|
||
if (row) return row.value
|
||
const now = new Date().toISOString()
|
||
db.prepare(`INSERT INTO config (key, value, updated_at) VALUES ('birth_time', ?, datetime('now'))`).run(now)
|
||
return now
|
||
}
|
||
|
||
// 获取所有激活的行为约束(同维度只保留最新一条)
|
||
export function getActiveConstraints() {
|
||
const db = getDB()
|
||
const rows = db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type = 'behavioral_constraint'
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC
|
||
`).all()
|
||
|
||
// 同维度去重,保留最新(rows 已按 timestamp DESC 排序)
|
||
const seen = new Set()
|
||
return rows.filter(row => {
|
||
const tags = JSON.parse(row.tags || '[]')
|
||
const dimTag = tags.find(t => t.startsWith('dimension:'))
|
||
const dim = dimTag ? dimTag : `_id_${row.id}` // 无维度标签则每条独立
|
||
if (seen.has(dim)) return false
|
||
seen.add(dim)
|
||
return true
|
||
})
|
||
}
|
||
|
||
// 获取任务知识条目(task_knowledge 类型,带完整 detail)
|
||
export function getTaskKnowledge(limit = 30) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type = 'task_knowledge'
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(limit)
|
||
}
|
||
|
||
// 获取工具使用记忆(kind:tool_usage 标签)
|
||
export function getToolMemories(limit = 20) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type = 'knowledge'
|
||
AND tags LIKE '%kind:tool_usage%'
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(limit)
|
||
}
|
||
|
||
// 获取某实体的 person/object 根节点记忆
|
||
export function getPersonMemory(entityId) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeMemoryEntity(entityId)
|
||
const rootMemId = canonicalRootMemIdForEntity(normalizedId)
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE event_type IN ('person', 'object')
|
||
AND entities LIKE ?
|
||
AND parent_id IS NULL
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY CASE WHEN mem_id = ? THEN 0 ELSE 1 END, timestamp DESC
|
||
LIMIT 1
|
||
`).get(`%${normalizedId}%`, rootMemId || '')
|
||
}
|
||
|
||
// 获取某实体相关的所有记忆(非根节点本身,按时间倒序)
|
||
export function getMemoriesByEntity(entityId, limit = 10) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeMemoryEntity(entityId)
|
||
const root = getPersonMemory(normalizedId)
|
||
return db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE (
|
||
entities LIKE ?
|
||
OR parent_id = ?
|
||
OR links LIKE ?
|
||
)
|
||
AND id != ?
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY COALESCE(salience, 3) DESC, timestamp DESC
|
||
LIMIT ?
|
||
`).all(`%${normalizedId}%`, root?.id || -1, `%${root?.mem_id || ''}%`, root?.id || -1, limit)
|
||
}
|
||
|
||
// 获取与某实体的近期对话记录(最近 limit 条,不超过 maxHours 小时)
|
||
// 动态上下文记忆池 3.5:默认 WHERE focus_absorbed=0,把已被压缩回填吸收的子帧对话隐去
|
||
// (主线深化时的「剔除残留噪声」)。absorbed != deleted——对话物理仍在表里,
|
||
// 传 includeAbsorbed=true 即可拿全量(admin / 调试 / focus-compress 自身的回看)。
|
||
export function getRecentConversation(entityId, limit = 20, maxHours = 24, { includeAbsorbed = false } = {}) {
|
||
const db = getDB()
|
||
const normalizedId = normalizeConversationPartyId(entityId)
|
||
const cutoff = new Date(Date.now() - maxHours * 3600 * 1000).toISOString()
|
||
const absorbedClause = includeAbsorbed ? '' : 'AND focus_absorbed = 0'
|
||
const rows = db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE (from_id = ? OR to_id = ?)
|
||
AND timestamp >= ?
|
||
${absorbedClause}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(normalizedId, normalizedId, cutoff, limit)
|
||
return rows.reverse() // 按时间正序返回
|
||
}
|
||
|
||
// 获取全局近期对话时间线(用于 TICK/heartbeat 场景,无明确发送者时仍可注入最近聊天上下文)
|
||
// includeAbsorbed 语义同 getRecentConversation。
|
||
export function getRecentConversationTimeline(limit = 20, maxHours = 24, { includeAbsorbed = false } = {}) {
|
||
const db = getDB()
|
||
const cutoff = new Date(Date.now() - maxHours * 3600 * 1000).toISOString()
|
||
const absorbedClause = includeAbsorbed ? '' : 'AND focus_absorbed = 0'
|
||
const rows = db.prepare(`
|
||
SELECT * FROM conversations
|
||
WHERE timestamp >= ?
|
||
${absorbedClause}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`).all(cutoff, limit)
|
||
return rows.reverse()
|
||
}
|
||
|
||
// 把 [startedAt, endedAt) 区间内未被吸收的对话标记为 focus_absorbed=1。
|
||
// 动态上下文记忆池 3.5:仅在 focus-compress.js 真正成功写出 conclusion 后才调用——
|
||
// 如果 LLM 调用失败、conclusion 为空就不标记,否则对话被错误地永久隐藏。
|
||
// 返回受影响行数;任何错误一律吞掉返回 0(fire-and-forget 路径不能因为标记失败崩到主对话)。
|
||
export function markConversationsAbsorbed(startedAt, endedAt = null) {
|
||
if (!startedAt) return 0
|
||
const db = getDB()
|
||
const end = endedAt || new Date().toISOString()
|
||
// 时区注意:frame.startedAt 来自 new Date().toISOString() 是 UTC("...Z"),
|
||
// 而 conversations.timestamp 来自 nowTimestamp() 是本地带偏移("...+08:00")。
|
||
// 直接字符串字典序比较会失败("T17" vs "T09"),所以用 strftime('%s', ...) 转
|
||
// unixepoch 比较——SQLite 能识别 'Z' 和 '+HH:MM' 两种时区格式。
|
||
try {
|
||
const result = db.prepare(`
|
||
UPDATE conversations
|
||
SET focus_absorbed = 1
|
||
WHERE strftime('%s', timestamp) >= strftime('%s', ?)
|
||
AND strftime('%s', timestamp) < strftime('%s', ?)
|
||
AND focus_absorbed = 0
|
||
`).run(startedAt, end)
|
||
return result.changes
|
||
} catch {
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 获取最近 N 小时内有过双向对话的所有他者 ID(按最近对话时间倒序)
|
||
// 用于 TICK 场景给 send_message 提供"熟人"白名单,让意识体可主动联系已建立过连接的对象
|
||
export function getRecentConversationPartners(maxHours = 24, limit = 20) {
|
||
const db = getDB()
|
||
const cutoff = new Date(Date.now() - maxHours * 3600 * 1000).toISOString()
|
||
const rows = db.prepare(`
|
||
SELECT party, MAX(timestamp) AS last_ts FROM (
|
||
SELECT from_id AS party, timestamp FROM conversations
|
||
WHERE timestamp >= ? AND from_id IS NOT NULL AND from_id <> 'jarvis'
|
||
UNION ALL
|
||
SELECT to_id AS party, timestamp FROM conversations
|
||
WHERE timestamp >= ? AND to_id IS NOT NULL AND to_id <> 'jarvis'
|
||
)
|
||
WHERE party IS NOT NULL AND party <> ''
|
||
GROUP BY party
|
||
ORDER BY last_ts DESC
|
||
LIMIT ?
|
||
`).all(cutoff, cutoff, limit)
|
||
return rows.map(r => normalizeConversationPartyId(r.party)).filter(Boolean)
|
||
}
|
||
|
||
// 写入一条行动日志
|
||
export function insertActionLog({
|
||
timestamp,
|
||
tool,
|
||
summary,
|
||
detail = '',
|
||
status = 'ok',
|
||
risk = 'medium',
|
||
args = null,
|
||
argsJson = null,
|
||
resultPreview = '',
|
||
error = '',
|
||
durationMs = 0,
|
||
source = '',
|
||
}) {
|
||
const db = getDB()
|
||
const serializedArgs = argsJson ?? safeStringify(args ?? {})
|
||
db.prepare(`
|
||
INSERT INTO action_logs (
|
||
timestamp, tool, summary, detail,
|
||
status, risk, args_json, result_preview, error, duration_ms, source
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
timestamp,
|
||
tool,
|
||
summary,
|
||
String(detail).slice(0, 300),
|
||
status,
|
||
risk,
|
||
String(serializedArgs || '{}').slice(0, 2000),
|
||
String(resultPreview || '').slice(0, 500),
|
||
String(error || '').slice(0, 500),
|
||
Number(durationMs) || 0,
|
||
String(source || '').slice(0, 120)
|
||
)
|
||
}
|
||
|
||
// 获取最近 N 条行动日志(时间正序)
|
||
export function getRecentActionLogs(limit = 50) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM action_logs ORDER BY id DESC LIMIT ?
|
||
`).all(limit).reverse()
|
||
}
|
||
|
||
export function createReminder({ userId, dueAt, task, systemMessage, source = '', recurrenceType = null, recurrenceConfig = null }) {
|
||
const db = getDB()
|
||
const normalizedUserId = normalizeConversationPartyId(userId || CANONICAL_USER_ID)
|
||
const configStr = recurrenceConfig ? JSON.stringify(recurrenceConfig) : null
|
||
return db.prepare(`
|
||
INSERT INTO reminders (user_id, due_at, task, system_message, status, source, recurrence_type, recurrence_config)
|
||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
|
||
`).run(normalizedUserId, dueAt, task, systemMessage, source, recurrenceType, configStr)
|
||
}
|
||
|
||
// 找到同 user + 同 due_at(精确到分钟)且非周期的待触发提醒,用于合并
|
||
export function findMergeableOneOffReminder(userId, dueAtIsoMinute) {
|
||
const db = getDB()
|
||
const normalizedUserId = normalizeConversationPartyId(userId || CANONICAL_USER_ID)
|
||
return db.prepare(`
|
||
SELECT * FROM reminders
|
||
WHERE status = 'pending'
|
||
AND recurrence_type IS NULL
|
||
AND user_id = ?
|
||
AND substr(due_at, 1, 16) = ?
|
||
ORDER BY id ASC
|
||
LIMIT 1
|
||
`).get(normalizedUserId, dueAtIsoMinute) || null
|
||
}
|
||
|
||
export function appendReminderTask(id, additionalTask, newSystemMessage) {
|
||
const db = getDB()
|
||
const row = db.prepare(`SELECT task FROM reminders WHERE id = ?`).get(id)
|
||
if (!row) return { changes: 0 }
|
||
const mergedTask = `${row.task}; ${additionalTask}`
|
||
return db.prepare(`
|
||
UPDATE reminders
|
||
SET task = ?, system_message = ?
|
||
WHERE id = ? AND status = 'pending'
|
||
`).run(mergedTask, newSystemMessage, id)
|
||
}
|
||
|
||
export function getDueReminders(now = new Date().toISOString(), limit = 20) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM reminders
|
||
WHERE status = 'pending' AND due_at <= ?
|
||
ORDER BY due_at ASC, id ASC
|
||
LIMIT ?
|
||
`).all(now, limit)
|
||
}
|
||
|
||
export function markReminderFired(id, firedAt = new Date().toISOString()) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
UPDATE reminders
|
||
SET status = 'fired', fired_at = ?
|
||
WHERE id = ? AND status = 'pending'
|
||
`).run(firedAt, id)
|
||
}
|
||
|
||
// 周期提醒触发后:保持 pending,推进 due_at 到下次发生时间
|
||
export function advanceReminderDueAt(id, nextDueAtIso) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
UPDATE reminders
|
||
SET due_at = ?
|
||
WHERE id = ? AND status = 'pending'
|
||
`).run(nextDueAtIso, id)
|
||
}
|
||
|
||
export function cancelReminder(id, cancelledAt = new Date().toISOString()) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
UPDATE reminders
|
||
SET status = 'cancelled', cancelled_at = ?
|
||
WHERE id = ? AND status = 'pending'
|
||
`).run(cancelledAt, id)
|
||
}
|
||
|
||
export function listPendingReminders(limit = 50) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM reminders
|
||
WHERE status = 'pending'
|
||
ORDER BY due_at ASC, id ASC
|
||
LIMIT ?
|
||
`).all(limit)
|
||
}
|
||
|
||
export function getReminderById(id) {
|
||
const db = getDB()
|
||
return db.prepare(`SELECT * FROM reminders WHERE id = ?`).get(id) || null
|
||
}
|
||
|
||
export function getNextPendingReminder() {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM reminders
|
||
WHERE status = 'pending'
|
||
ORDER BY due_at ASC, id ASC
|
||
LIMIT 1
|
||
`).get() || null
|
||
}
|
||
|
||
// 按关键词搜索记忆(FTS5 全文搜索,优先相关度排序)
|
||
// 注意:trigram tokenizer 需要查询至少 3 字符;< 3 字符(典型如 2 字中文 ngram)走 LIKE fallback。
|
||
// 软隐藏过滤:FTS5 索引保留全量内容,但 JOIN memories 后用 m.visibility=1 过滤;
|
||
// LIKE fallback 直接 WHERE 加 visibility=1。两条路径都不会返回隐藏行。
|
||
export function searchMemories(keyword, limit = 10) {
|
||
const db = getDB()
|
||
const kw = String(keyword || '')
|
||
const likeFallback = () => db.prepare(`
|
||
SELECT * FROM memories
|
||
WHERE (content LIKE ? OR detail LIKE ? OR concepts LIKE ?)
|
||
AND ${VISIBLE_CLAUSE}
|
||
ORDER BY COALESCE(salience, 3) DESC, timestamp DESC
|
||
LIMIT ?
|
||
`).all(`%${kw}%`, `%${kw}%`, `%${kw}%`, limit)
|
||
|
||
// trigram tokenizer 对 < 3 字符的查询无法匹配,直接走 LIKE
|
||
if (kw.length < 3) return likeFallback()
|
||
|
||
try {
|
||
const hits = db.prepare(`
|
||
SELECT m.* FROM memories m
|
||
JOIN memories_fts ON memories_fts.rowid = m.id
|
||
WHERE memories_fts MATCH ? AND m.${VISIBLE_CLAUSE}
|
||
ORDER BY bm25(memories_fts), m.timestamp DESC
|
||
LIMIT ?
|
||
`).all(kw, limit)
|
||
if (hits.length > 0) return hits
|
||
// FTS5 命中 0 时再 LIKE 兜底(数据未索引、特殊字符、tokenizer 边界等)
|
||
return likeFallback()
|
||
} catch {
|
||
// FTS 语法错误时降级为 LIKE
|
||
return likeFallback()
|
||
}
|
||
}
|
||
|
||
// ── 向量语义召回(与 FTS5 字面召回并行的兜底路径)─────────────────────────
|
||
//
|
||
// 写入:识别器把命中的记忆通过 updateMemoryEmbedding 落 BLOB。
|
||
// 召回:注入器把 focusText 算 embedding,调 searchByEmbedding 拿 top-N。
|
||
//
|
||
// 数量级 < 50k 之前先用 JS 内存全表扫描,避免引入 sqlite-vec 扩展。
|
||
|
||
export function updateMemoryEmbedding(memId, embeddingBuffer) {
|
||
if (!memId) return
|
||
const db = getDB()
|
||
// null 也允许写入(清除某条的 embedding)
|
||
const value = embeddingBuffer == null ? null : embeddingBuffer
|
||
try {
|
||
db.prepare(`UPDATE memories SET embedding = ? WHERE mem_id = ?`).run(value, memId)
|
||
} catch {
|
||
// 静默忽略(schema 未迁移、磁盘只读、并发冲突等)— 不让 embedding 写入影响主流程
|
||
}
|
||
}
|
||
|
||
// cosine 相似度:两个 Buffer(都是 Float32Array 序列化字节)。
|
||
// 长度不一致或为空时返回 -1,让排序自然把它沉底。
|
||
function cosineSimilarity(aBuf, bBuf) {
|
||
if (!aBuf || !bBuf) return -1
|
||
if (aBuf.byteLength !== bBuf.byteLength) return -1
|
||
if (aBuf.byteLength === 0 || aBuf.byteLength % 4 !== 0) return -1
|
||
const a = new Float32Array(aBuf.buffer, aBuf.byteOffset, aBuf.byteLength / 4)
|
||
const b = new Float32Array(bBuf.buffer, bBuf.byteOffset, bBuf.byteLength / 4)
|
||
let dot = 0, na = 0, nb = 0
|
||
for (let i = 0; i < a.length; i++) {
|
||
const x = a[i], y = b[i]
|
||
dot += x * y
|
||
na += x * x
|
||
nb += y * y
|
||
}
|
||
const denom = Math.sqrt(na) * Math.sqrt(nb)
|
||
return denom > 0 ? dot / denom : -1
|
||
}
|
||
|
||
// 全表扫描所有有 embedding 的 memories,返回 cosine 相似度 top-N。
|
||
// 输入 queryBuffer:Buffer,包裹 Float32Array。
|
||
// 返回:每条形如 {...memoryRow, _vecScore: number}。
|
||
export function searchByEmbedding(queryBuffer, limit = 20) {
|
||
if (!queryBuffer || !(queryBuffer instanceof Buffer) || queryBuffer.byteLength === 0) return []
|
||
const db = getDB()
|
||
let rows
|
||
try {
|
||
// 软隐藏过滤:被隐藏的记忆即使有 embedding 也不参与召回
|
||
rows = db.prepare(`SELECT * FROM memories WHERE embedding IS NOT NULL AND ${VISIBLE_CLAUSE}`).all()
|
||
} catch {
|
||
// 老库 schema 未迁移 / embedding 列不存在
|
||
return []
|
||
}
|
||
if (!rows.length) return []
|
||
|
||
const scored = []
|
||
for (const row of rows) {
|
||
const score = cosineSimilarity(queryBuffer, row.embedding)
|
||
if (score <= -1) continue
|
||
// 别把 BLOB 一路传到调用方(大、没用、JSON 序列化会出乱码)
|
||
const { embedding: _drop, ...rest } = row
|
||
scored.push({ ...rest, _vecScore: score })
|
||
}
|
||
scored.sort((a, b) => b._vecScore - a._vecScore)
|
||
return scored.slice(0, Math.max(0, limit))
|
||
}
|
||
|
||
// ── 预热缓存 ──────────────────────────────────────────────────────────────
|
||
|
||
export function savePrefetchCache(source, content, ttlMinutes, tags = []) {
|
||
const db = getDB()
|
||
const now = new Date()
|
||
const fetched_at = now.toISOString()
|
||
const expires_at = new Date(now.getTime() + ttlMinutes * 60 * 1000).toISOString()
|
||
db.prepare(`
|
||
INSERT INTO prefetch_cache (source, content, fetched_at, expires_at, tags)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(source) DO UPDATE SET
|
||
content = excluded.content,
|
||
fetched_at = excluded.fetched_at,
|
||
expires_at = excluded.expires_at,
|
||
tags = excluded.tags
|
||
`).run(source, content, fetched_at, expires_at, JSON.stringify(tags))
|
||
}
|
||
|
||
export function getValidPrefetchCache() {
|
||
const db = getDB()
|
||
const now = new Date().toISOString()
|
||
return db.prepare(`
|
||
SELECT * FROM prefetch_cache
|
||
WHERE expires_at > ?
|
||
ORDER BY fetched_at DESC
|
||
`).all(now)
|
||
}
|
||
|
||
export function clearExpiredPrefetchCache() {
|
||
const db = getDB()
|
||
const now = new Date().toISOString()
|
||
db.prepare(`DELETE FROM prefetch_cache WHERE expires_at <= ?`).run(now)
|
||
}
|
||
|
||
// ── 预热任务管理 ──────────────────────────────────────────────────────────
|
||
|
||
export function upsertPrefetchTask({ source, label, url, ttlMinutes = 60, tags = [] }) {
|
||
const db = getDB()
|
||
const now = new Date().toISOString()
|
||
db.prepare(`
|
||
INSERT INTO prefetch_tasks (source, label, url, ttl_minutes, tags, enabled, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||
ON CONFLICT(source) DO UPDATE SET
|
||
label = excluded.label,
|
||
url = excluded.url,
|
||
ttl_minutes = excluded.ttl_minutes,
|
||
tags = excluded.tags,
|
||
enabled = 1,
|
||
updated_at = excluded.updated_at
|
||
`).run(source, label, url, ttlMinutes, JSON.stringify(tags), now)
|
||
}
|
||
|
||
export function removePrefetchTask(source) {
|
||
const db = getDB()
|
||
const result = db.prepare(`DELETE FROM prefetch_tasks WHERE source = ?`).run(source)
|
||
return result.changes > 0
|
||
}
|
||
|
||
export function listPrefetchTasks() {
|
||
const db = getDB()
|
||
return db.prepare(`SELECT * FROM prefetch_tasks ORDER BY created_at ASC`).all()
|
||
}
|
||
|
||
export function getEnabledPrefetchTasks() {
|
||
const db = getDB()
|
||
return db.prepare(`SELECT * FROM prefetch_tasks WHERE enabled = 1 ORDER BY created_at ASC`).all()
|
||
}
|
||
|
||
// ── 媒体播放历史 ──────────────────────────────────────────────────────────────
|
||
|
||
export function upsertMediaHistory({ kind, url, title = '', videoId = null, platform = null }) {
|
||
const db = getDB()
|
||
const now = new Date().toISOString()
|
||
db.prepare(`
|
||
INSERT INTO media_history (kind, url, title, video_id, platform, played_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(url) DO UPDATE SET
|
||
title = excluded.title,
|
||
played_at = excluded.played_at
|
||
`).run(kind, url, title, videoId || null, platform || null, now)
|
||
}
|
||
|
||
export function getMediaHistory(limit = 30) {
|
||
const db = getDB()
|
||
return db.prepare(`
|
||
SELECT * FROM media_history ORDER BY played_at DESC LIMIT ?
|
||
`).all(limit)
|
||
}
|
||
|
||
// ── Music Library ────────────────────────────────────────────────────────────
|
||
|
||
export function upsertMusicTrack({ title = '', artist = '', album = '', filePath, duration = 0, lrc = '', cover = '', sourceUrl = '' }) {
|
||
const db = getDB()
|
||
db.prepare(`
|
||
INSERT INTO music_library (title, artist, album, file_path, duration, lrc, cover, source_url)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(file_path) DO UPDATE SET
|
||
title = excluded.title,
|
||
artist = excluded.artist,
|
||
album = excluded.album,
|
||
duration = excluded.duration,
|
||
lrc = CASE WHEN excluded.lrc != '' THEN excluded.lrc ELSE lrc END,
|
||
cover = CASE WHEN excluded.cover != '' THEN excluded.cover ELSE cover END,
|
||
source_url = CASE WHEN excluded.source_url != '' THEN excluded.source_url ELSE source_url END
|
||
`).run(title, artist, album, filePath, duration, lrc, cover, sourceUrl)
|
||
return db.prepare(`SELECT * FROM music_library WHERE file_path = ?`).get(filePath)
|
||
}
|
||
|
||
export function getMusicTrack(id) {
|
||
return getDB().prepare(`SELECT * FROM music_library WHERE id = ?`).get(id)
|
||
}
|
||
|
||
export function searchMusicLibrary(query, limit = 20) {
|
||
const db = getDB()
|
||
const q = `%${query}%`
|
||
return db.prepare(`
|
||
SELECT * FROM music_library
|
||
WHERE title LIKE ? OR artist LIKE ? OR album LIKE ?
|
||
ORDER BY added_at DESC LIMIT ?
|
||
`).all(q, q, q, limit)
|
||
}
|
||
|
||
export function listMusicLibrary(limit = 50) {
|
||
return getDB().prepare(`SELECT * FROM music_library ORDER BY added_at DESC LIMIT ?`).all(limit)
|
||
}
|
||
|
||
export function updateMusicLrc(id, lrc) {
|
||
getDB().prepare(`UPDATE music_library SET lrc = ? WHERE id = ?`).run(lrc, id)
|
||
}
|
||
|
||
export function deleteMusicTrack(id) {
|
||
getDB().prepare(`DELETE FROM music_library WHERE id = ?`).run(id)
|
||
}
|
||
|
||
// ============================================================
|
||
// focus_stack —— 动态上下文记忆池 5c 步:注意力焦点栈持久化
|
||
// ============================================================
|
||
//
|
||
// loadFocusStack: 启动时一次性读出整栈(按 depth ASC);任何异常都返回 [],
|
||
// 不阻塞主流程。frame 形状与内存中的 state.focusStack[i] 完全一致:
|
||
// { topic, startedAt, startedAtTick, lastSeenTick, hitCount, conclusions }
|
||
//
|
||
// saveFocusStack: 整栈原子替换。先 DELETE 再 INSERT,全部包在 transaction 里。
|
||
// focus.js 只在内存里改 state.focusStack,所以 index.js 在每次 updateFocusFrame
|
||
// 返回非 noop 时主动调;focus-compress.js 也通过 onConclusionAttached 回调触发。
|
||
// 写库失败 console.warn 后吞掉——专注栈丢一次远比阻塞主对话轻。
|
||
export function loadFocusStack() {
|
||
const db = getDB()
|
||
try {
|
||
const rows = db.prepare(`SELECT * FROM focus_stack ORDER BY depth ASC`).all()
|
||
return rows.map(r => ({
|
||
topic: JSON.parse(r.topic || '[]'),
|
||
startedAt: r.started_at,
|
||
startedAtTick: r.started_at_tick,
|
||
lastSeenTick: r.last_seen_tick,
|
||
hitCount: r.hit_count,
|
||
conclusions: JSON.parse(r.conclusions || '[]'),
|
||
}))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
export function saveFocusStack(stack) {
|
||
const db = getDB()
|
||
try {
|
||
const tx = db.transaction((frames) => {
|
||
db.prepare(`DELETE FROM focus_stack`).run()
|
||
const insert = db.prepare(`
|
||
INSERT INTO focus_stack (depth, topic, started_at, started_at_tick, last_seen_tick, hit_count, conclusions)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`)
|
||
for (let i = 0; i < frames.length; i++) {
|
||
const f = frames[i]
|
||
insert.run(
|
||
i,
|
||
JSON.stringify(f.topic || []),
|
||
f.startedAt || new Date().toISOString(),
|
||
f.startedAtTick || 0,
|
||
f.lastSeenTick || 0,
|
||
f.hitCount || 1,
|
||
JSON.stringify(f.conclusions || [])
|
||
)
|
||
}
|
||
})
|
||
tx(stack || [])
|
||
} catch (err) {
|
||
console.warn('[focus-persist] saveFocusStack failed:', err.message)
|
||
}
|
||
}
|