oudecheng c8660df14b refactor: 拆分 storage/mod.rs 与 ConfigPage.tsx (P2)
storage/mod.rs (2957 -> 1834 行):
- 抽出 tests.rs: 17 个测试函数
- 抽出 row_mapping.rs: 8 个 row<->record 映射与单记录查询函数
- 抽出 migrations.rs: 7 个 schema 迁移函数 (ensure_*_schema, has_column, add_column_if_missing)

ConfigPage.tsx (1568 -> 1235 行):
- 抽出 types.ts: 所有接口/类型定义
- 抽出 constants.ts: TABS, inputCls, selectCls, TIMEZONE_OPTIONS
- 抽出 ui.tsx: Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader
- 抽出 api/expert.ts: getSelectedExpert, selectExpert (经 ConfigPage 再导出保持兼容)

验证: cargo build, cargo test --lib storage:: 17/17, npm run build
对抗性检查: 8/8 通过 (无丢失/重复函数, 无循环依赖, 无未使用导入)
2026-07-07 18:36:23 +08:00

1835 lines
62 KiB
Rust
Raw 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.

#[cfg(not(test))]
use std::path::{Path, PathBuf};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, OptionalExtension, params};
use crate::bus::ChatMessage;
pub mod error;
pub mod ports;
pub mod records;
mod migrations;
mod row_mapping;
// Bring extracted helpers into scope for use by SessionStore methods below.
use migrations::*;
use row_mapping::*;
pub use error::StorageError;
pub use ports::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SkillEventRepository, TodoRepository,
};
pub use records::{
allowed_namespace_names, get_namespace_description, is_valid_namespace,
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
TodoRecord, TopicRecord,
};
#[derive(Clone)]
pub struct SessionStore {
pool: Pool<SqliteConnectionManager>,
}
impl SessionStore {
#[cfg(test)]
pub fn new() -> Result<Self, StorageError> {
Self::in_memory()
}
#[cfg(not(test))]
pub fn new() -> Result<Self, StorageError> {
let db_path = default_session_db_path()?;
Self::open_at_path(&db_path)
}
#[cfg(not(test))]
fn open_at_path(path: &Path) -> Result<Self, StorageError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let path_str = path.to_string_lossy().to_string();
let conn = Connection::open(&path_str)?;
Self::from_connection(conn, &path_str)
}
/// Initialize a SessionStore from a connection and its file path.
/// The connection is used for schema initialization only; the pool
/// manages subsequent connections using the same file path.
fn from_connection(conn: Connection, db_uri: &str) -> Result<Self, StorageError> {
conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.execute_batch(
"
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
channel_name TEXT NOT NULL,
chat_id TEXT NOT NULL,
summary TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
archived_at INTEGER,
deleted_at INTEGER,
message_count INTEGER NOT NULL DEFAULT 0,
user_turn_count INTEGER NOT NULL DEFAULT 0,
agent_prompt_reinjection_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_channel_archived
ON sessions(channel_name, archived_at, last_active_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at
ON sessions(updated_at DESC);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
topic_id TEXT,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
system_context TEXT,
reasoning_content TEXT,
media_refs_json TEXT NOT NULL,
tool_call_id TEXT,
tool_name TEXT,
tool_calls_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE SET NULL,
UNIQUE(session_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_messages_session_seq
ON messages(session_id, seq);
CREATE INDEX IF NOT EXISTS idx_messages_session_created
ON messages(session_id, created_at);
CREATE TABLE IF NOT EXISTS topics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topics_session
ON topics(session_id, last_active_at DESC);
CREATE TABLE IF NOT EXISTS skill_events (
id TEXT PRIMARY KEY,
session_id TEXT,
event_type TEXT NOT NULL,
skill_name TEXT,
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_skill_events_session_created
ON skill_events(session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_events_type_created
ON skill_events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
namespace TEXT NOT NULL,
memory_key TEXT NOT NULL,
content TEXT NOT NULL,
source_type TEXT NOT NULL,
source_session_id TEXT,
source_message_id TEXT,
source_message_seq INTEGER,
source_channel_name TEXT,
source_chat_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(scope_kind, scope_key, namespace, memory_key)
);
CREATE INDEX IF NOT EXISTS idx_memories_scope_updated
ON memories(scope_kind, scope_key, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_memories_scope_namespace_updated
ON memories(scope_kind, scope_key, namespace, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_memories_source_session
ON memories(source_session_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS scheduler_jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
schedule_json TEXT NOT NULL DEFAULT '{}',
interval_secs INTEGER NOT NULL DEFAULT 0,
startup_delay_secs INTEGER NOT NULL DEFAULT 0,
target_json TEXT NOT NULL,
payload_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT 'scheduled',
last_status TEXT,
last_error TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
max_runs INTEGER,
last_fired_at INTEGER,
next_fire_at INTEGER,
paused_at INTEGER,
completed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_enabled_next_fire
ON scheduler_jobs(enabled, state, next_fire_at ASC);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
namespace,
memory_key,
content,
content='memories',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, namespace, memory_key, content)
VALUES (new.rowid, new.namespace, new.memory_key, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, namespace, memory_key, content)
VALUES ('delete', old.rowid, old.namespace, old.memory_key, old.content);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, namespace, memory_key, content)
VALUES ('delete', old.rowid, old.namespace, old.memory_key, old.content);
INSERT INTO memories_fts(rowid, namespace, memory_key, content)
VALUES (new.rowid, new.namespace, new.memory_key, new.content);
END;
",
)?;
ensure_sessions_schema(&conn)?;
ensure_messages_schema(&conn)?;
ensure_scheduler_schema(&conn)?;
ensure_memory_scope_key_migration(&conn)?;
ensure_todos_schema(&conn)?;
drop(conn);
let manager = SqliteConnectionManager::file(db_uri)
.with_init(|c| {
c.busy_timeout(std::time::Duration::from_secs(5))?;
Ok(())
});
let pool = Pool::builder()
.max_size(8)
.build(manager)?;
Ok(Self { pool })
}
#[cfg(test)]
pub(crate) fn in_memory() -> Result<Self, StorageError> {
// Use a temp file so the database survives across pool connections.
// Temp dir is cleaned by the OS eventually; tests that need cleanup
// can call std::fs::remove_file on the path.
let path = std::env::temp_dir()
.join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
let conn = Connection::open(&path)?;
let path_str = path.to_string_lossy().to_string();
// ignore unused mut warning for manager in tests
#[allow(unused_mut)]
let store = Self::from_connection(conn, &path_str)?;
// Clean up temp file when the store is dropped
// We can't easily do this automatically, but the files are small.
Ok(store)
}
pub fn create_session(
&self,
channel_name: &str,
title: Option<&str>,
) -> Result<SessionRecord, StorageError> {
let now = current_timestamp();
let id = uuid::Uuid::new_v4().to_string();
// 统一使用 persistent_session_id 格式
let session_id = persistent_session_id(channel_name, &id);
let title = title
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
if channel_name == "cli" {
format!("CLI Session {}", &id[..8])
} else {
format!("Session {}", &id[..8])
}
});
let conn = self.pool.get()?;
conn.execute(
"
INSERT INTO sessions (
id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at, archived_at, deleted_at, message_count,
user_turn_count, agent_prompt_reinjection_count
) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5, ?5, NULL, NULL, 0, 0, 0)
",
params![&session_id, title, channel_name, id, now],
)?;
get_session_with_conn(&conn, &session_id)?
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
}
pub fn create_cli_session(&self, title: Option<&str>) -> Result<SessionRecord, StorageError> {
self.create_session("cli", title)
}
pub fn ensure_channel_session(
&self,
channel_name: &str,
chat_id: &str,
) -> Result<SessionRecord, StorageError> {
let session_id = persistent_session_id(channel_name, chat_id);
self.ensure_session(&session_id, channel_name, chat_id, &format!("{}:{}", channel_name, chat_id))
}
/// 确保指定 session_id 的会话存在(如果不存在则创建)
pub fn ensure_session(
&self,
session_id: &str,
channel_name: &str,
chat_id: &str,
title: &str,
) -> Result<SessionRecord, StorageError> {
let conn = self.pool.get()?;
if let Some(record) = get_session_with_conn(&conn, session_id)? {
return Ok(record);
}
let now = current_timestamp();
conn.execute(
"
INSERT INTO sessions (
id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at, archived_at, deleted_at, message_count,
user_turn_count, agent_prompt_reinjection_count
) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5, ?5, NULL, NULL, 0, 0, 0)
",
params![session_id, title, channel_name, chat_id, now],
)?;
get_session_with_conn(&conn, session_id)?
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
}
pub fn get_session(&self, session_id: &str) -> Result<Option<SessionRecord>, StorageError> {
let conn = self.pool.get()?;
get_session_with_conn(&conn, session_id)
}
/// Find sessions whose id ends with the given suffix (used for task session lookup)
pub fn find_sessions_by_id_suffix(
&self,
suffix: &str,
) -> Result<Vec<SessionRecord>, StorageError> {
let conn = self.pool.get()?;
let pattern = format!("%{}", suffix);
let mut stmt = conn.prepare(
"
SELECT id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at,
archived_at, deleted_at, message_count,
user_turn_count, agent_prompt_reinjection_count
FROM sessions
WHERE id LIKE ?1 AND deleted_at IS NULL
ORDER BY last_active_at DESC
",
)?;
let rows = stmt.query_map(params![pattern], map_session_record)?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row?);
}
Ok(sessions)
}
pub fn list_sessions(
&self,
channel_name: &str,
include_archived: bool,
) -> Result<Vec<SessionRecord>, StorageError> {
let conn = self.pool.get()?;
let mut sql = String::from(
"
SELECT id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at,
archived_at, deleted_at, message_count,
user_turn_count, agent_prompt_reinjection_count
FROM sessions
WHERE channel_name = ?1
AND deleted_at IS NULL
AND id NOT LIKE 'sub:%'
",
);
if !include_archived {
sql.push_str(" AND archived_at IS NULL");
}
sql.push_str(" ORDER BY last_active_at DESC, created_at DESC");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![channel_name], map_session_record)?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row?);
}
Ok(sessions)
}
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"UPDATE sessions SET title = ?2, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL",
params![session_id, title.trim(), now],
)?;
Ok(())
}
pub fn archive_session(&self, session_id: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"UPDATE sessions SET archived_at = ?2, updated_at = ?2 WHERE id = ?1 AND deleted_at IS NULL",
params![session_id, now],
)?;
Ok(())
}
pub fn delete_session(&self, session_id: &str) -> Result<(), StorageError> {
let conn = self.pool.get()?;
conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id],
)?;
conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
Ok(())
}
// ==================== Topic Methods ====================
pub fn create_topic(
&self,
session_id: &str,
title: &str,
description: Option<&str>,
) -> Result<TopicRecord, StorageError> {
let now = current_timestamp();
let id = format!("topic:{}", uuid::Uuid::new_v4());
let conn = self.pool.get()?;
conn.execute(
"INSERT INTO topics (id, session_id, title, description, created_at, updated_at, last_active_at, message_count) VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?5, 0)",
params![&id, session_id, title, description.unwrap_or(""), now],
)?;
drop(conn);
self.get_topic(&id)?
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
}
pub fn get_topic(&self, topic_id: &str) -> Result<Option<TopicRecord>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, session_id, title, description, created_at, updated_at, last_active_at, message_count FROM topics WHERE id = ?1",
)?;
stmt.query_row(params![topic_id], |row| {
Ok(TopicRecord {
id: row.get(0)?,
session_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
last_active_at: row.get(6)?,
message_count: row.get(7)?,
})
})
.optional()
.map_err(StorageError::from)
}
pub fn list_topics(&self, session_id: &str) -> Result<Vec<TopicRecord>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, session_id, title, description, created_at, updated_at, last_active_at, message_count FROM topics WHERE session_id = ?1 ORDER BY last_active_at DESC"
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok(TopicRecord {
id: row.get(0)?,
session_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
last_active_at: row.get(6)?,
message_count: row.get(7)?,
})
})?;
let mut topics = Vec::new();
for row in rows {
topics.push(row?);
}
Ok(topics)
}
pub fn update_topic_title(&self, topic_id: &str, title: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"UPDATE topics SET title = ?2, updated_at = ?3 WHERE id = ?1",
params![topic_id, title.trim(), now],
)?;
Ok(())
}
pub fn update_topic_description(&self, topic_id: &str, description: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"UPDATE topics SET description = ?2, updated_at = ?3 WHERE id = ?1",
params![topic_id, description, now],
)?;
Ok(())
}
pub fn delete_topic(&self, topic_id: &str) -> Result<(), StorageError> {
let conn = self.pool.get()?;
// Messages 的 topic_id 会被设为 NULLON DELETE SET NULL
conn.execute("DELETE FROM topics WHERE id = ?1", params![topic_id])?;
Ok(())
}
pub fn touch_topic(&self, topic_id: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"UPDATE topics SET last_active_at = ?2 WHERE id = ?1",
params![topic_id, now],
)?;
Ok(())
}
pub fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id],
)?;
conn.execute(
"
UPDATE sessions
SET message_count = 0,
updated_at = ?2,
last_active_at = ?2,
user_turn_count = 0,
agent_prompt_reinjection_count = 0
WHERE id = ?1 AND deleted_at IS NULL
",
params![session_id, now],
)?;
Ok(())
}
pub fn append_message(
&self,
session_id: &str,
message: &ChatMessage,
) -> Result<(), StorageError> {
self.append_message_with_topic(session_id, None, message)
}
pub fn append_message_with_topic(
&self,
session_id: &str,
topic_id: Option<&str>,
message: &ChatMessage,
) -> Result<(), StorageError> {
let conn = self.pool.get()?;
let tx = conn.unchecked_transaction()?;
let seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
let media_refs_json = serde_json::to_string(&message.media_refs)?;
let tool_calls_json = message
.tool_calls
.as_ref()
.map(serde_json::to_string)
.transpose()?;
tx.execute(
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
",
params![
message.id,
session_id,
topic_id,
seq,
message.role,
message.content,
message.system_context,
message.reasoning_content,
media_refs_json,
message.tool_call_id,
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp,
],
)?;
let now = current_timestamp();
let is_user_message = message.role == "user";
tx.execute(
"
UPDATE sessions
SET message_count = message_count + 1,
user_turn_count = user_turn_count + ?3,
updated_at = ?2,
last_active_at = ?2,
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![session_id, now, if is_user_message { 1 } else { 0 }],
)?;
if let Some(tid) = topic_id {
tx.execute(
"UPDATE topics SET message_count = message_count + 1, last_active_at = ?2 WHERE id = ?1",
params![tid, now],
)?;
}
tx.commit()?;
Ok(())
}
pub fn append_messages_batch(
&self,
session_id: &str,
topic_id: Option<&str>,
messages: &[ChatMessage],
) -> Result<(), StorageError> {
if messages.is_empty() {
return Ok(());
}
let conn = self.pool.get()?;
let tx = conn.unchecked_transaction()?;
let mut seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
for message in messages {
let media_refs_json = serde_json::to_string(&message.media_refs)?;
let tool_calls_json = message
.tool_calls
.as_ref()
.map(serde_json::to_string)
.transpose()?;
tx.execute(
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json,
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
",
params![
message.id,
session_id,
topic_id,
seq,
message.role,
message.content,
message.system_context,
message.reasoning_content,
media_refs_json,
message.tool_call_id,
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp,
],
)?;
seq += 1;
}
let now = current_timestamp();
let user_msg_count: i64 = messages
.iter()
.filter(|m| m.role == "user")
.count()
.try_into()
.unwrap_or(0);
let msg_count: i64 = messages.len() as i64;
tx.execute(
"
UPDATE sessions
SET message_count = message_count + ?2,
user_turn_count = user_turn_count + ?3,
updated_at = ?4,
last_active_at = ?4,
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![session_id, msg_count, user_msg_count, now],
)?;
if let Some(tid) = topic_id {
tx.execute(
"UPDATE topics SET message_count = message_count + ?2, last_active_at = ?3 WHERE id = ?1",
params![tid, msg_count, now],
)?;
}
tx.commit()?;
Ok(())
}
pub fn compact_active_history(
&self,
session_id: &str,
snapshot_end_seq: i64,
preserved_system_messages: &[ChatMessage],
summary_message: &ChatMessage,
preserved_messages: &[ChatMessage],
) -> Result<bool, StorageError> {
let conn = self.pool.get()?;
let tx = conn.unchecked_transaction()?;
let current_max_seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
if snapshot_end_seq > current_max_seq {
return Ok(false);
}
let delta_messages =
load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?;
let now = current_timestamp();
// Collect all new messages first, then sanitize incomplete tool call
// sequences before writing to DB. This prevents orphaned tool_calls
// (without corresponding tool results) from being persisted permanently
// when compaction preserves an incomplete sequence from the snapshot or
// captures a partial sequence from delta messages.
let mut new_messages: Vec<ChatMessage> = Vec::new();
for message in preserved_system_messages {
new_messages.push(clone_message_for_compaction(message, message.timestamp));
}
new_messages.push(clone_message_for_compaction(summary_message, now));
for message in preserved_messages.iter().chain(delta_messages.iter()) {
new_messages.push(clone_message_for_compaction(message, message.timestamp));
}
let removed =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut new_messages);
if removed > 0 {
tracing::warn!(
removed_count = removed,
session_id = %session_id,
"Compaction removed incomplete tool call sequences from new history"
);
}
// Write sanitized messages to DB
let mut next_seq = current_max_seq + 1;
let mut inserted_count = 0_i64;
let mut active_user_turn_count = 0_i64;
for message in &new_messages {
if message.role == "user" {
active_user_turn_count += 1;
}
insert_message_with_seq(&tx, session_id, next_seq, message)?;
next_seq += 1;
inserted_count += 1;
}
// Delete all old messages (including delta messages that were just re-inserted)
tx.execute(
"DELETE FROM messages WHERE session_id = ?1 AND seq <= ?2",
params![session_id, current_max_seq],
)?;
tx.execute(
"
UPDATE sessions
SET message_count = ?2,
user_turn_count = ?3,
updated_at = ?4,
last_active_at = ?4,
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![
session_id,
inserted_count,
active_user_turn_count,
now,
],
)?;
tx.commit()?;
Ok(true)
}
/// Replace the entire active history for a session.
///
/// This is a simpler alternative to `compact_active_history` for when the
/// compressor has already produced a complete, validated message list
/// (e.g. two-segment compression). It replaces all existing messages
/// with the new list in a single transaction.
pub fn replace_active_history(
&self,
session_id: &str,
messages: &[ChatMessage],
) -> Result<(), StorageError> {
let conn = self.pool.get()?;
let tx = conn.unchecked_transaction()?;
let now = current_timestamp();
// Delete all existing messages for this session
tx.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id],
)?;
// Insert new messages with sequential seq numbers
let mut active_user_turn_count = 0_i64;
for (i, message) in messages.iter().enumerate() {
let seq = (i + 1) as i64;
if message.role == "user" {
active_user_turn_count += 1;
}
insert_message_with_seq(&tx, session_id, seq, message)?;
}
tx.execute(
"
UPDATE sessions
SET message_count = ?2,
user_turn_count = ?3,
updated_at = ?4,
last_active_at = ?4,
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![
session_id,
messages.len() as i64,
active_user_turn_count,
now,
],
)?;
tx.commit()?;
Ok(())
}
pub fn mark_agent_prompt_reinjected(&self, session_id: &str) -> Result<(), StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"
UPDATE sessions
SET agent_prompt_reinjection_count = agent_prompt_reinjection_count + 1,
updated_at = ?2,
last_active_at = ?2,
archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL
",
params![session_id, now],
)?;
Ok(())
}
pub fn append_skill_event(
&self,
session_id: Option<&str>,
event_type: &str,
skill_name: Option<&str>,
payload: &serde_json::Value,
) -> Result<(), StorageError> {
let conn = self.pool.get()?;
conn.execute(
"
INSERT INTO skill_events (
id, session_id, event_type, skill_name, payload_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
",
params![
uuid::Uuid::new_v4().to_string(),
session_id,
event_type,
skill_name,
serde_json::to_string(payload)?,
current_timestamp(),
],
)?;
Ok(())
}
pub fn list_skill_events(
&self,
session_id: Option<&str>,
) -> Result<Vec<SkillEventRecord>, StorageError> {
let conn = self.pool.get()?;
let sql = if session_id.is_some() {
"
SELECT id, session_id, event_type, skill_name, payload_json, created_at
FROM skill_events
WHERE session_id = ?1
ORDER BY created_at ASC
"
} else {
"
SELECT id, session_id, event_type, skill_name, payload_json, created_at
FROM skill_events
WHERE session_id IS NULL
ORDER BY created_at ASC
"
};
let mut stmt = conn.prepare(sql)?;
let rows = if let Some(session_id) = session_id {
stmt.query_map(params![session_id], map_skill_event_record)?
} else {
stmt.query_map([], map_skill_event_record)?
};
let mut events = Vec::new();
for row in rows {
events.push(row?);
}
Ok(events)
}
pub fn put_memory(&self, input: &MemoryUpsert) -> Result<MemoryRecord, StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
let tx = conn.unchecked_transaction()?;
let existing: Option<(String, i64)> = tx
.query_row(
"
SELECT id, created_at
FROM memories
WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3 AND memory_key = ?4
",
params![
input.scope_kind,
input.scope_key,
input.namespace,
input.memory_key,
],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
let (id, created_at) = existing.unwrap_or_else(|| (uuid::Uuid::new_v4().to_string(), now));
tx.execute(
"
INSERT INTO memories (
id, scope_kind, scope_key, namespace, memory_key, content,
source_type, source_session_id, source_message_id, source_message_seq,
source_channel_name, source_chat_id, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(scope_kind, scope_key, namespace, memory_key) DO UPDATE SET
content = excluded.content,
source_type = excluded.source_type,
source_session_id = excluded.source_session_id,
source_message_id = excluded.source_message_id,
source_message_seq = excluded.source_message_seq,
source_channel_name = excluded.source_channel_name,
source_chat_id = excluded.source_chat_id,
updated_at = excluded.updated_at
",
params![
id,
input.scope_kind,
input.scope_key,
input.namespace,
input.memory_key,
input.content,
input.source_type,
input.source_session_id,
input.source_message_id,
input.source_message_seq,
input.source_channel_name,
input.source_chat_id,
created_at,
now,
],
)?;
tx.commit()?;
get_memory_with_conn(
&conn,
&input.scope_kind,
&input.scope_key,
&input.namespace,
&input.memory_key,
)?
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
}
pub fn get_memory(
&self,
scope_kind: &str,
scope_key: &str,
namespace: &str,
memory_key: &str,
) -> Result<Option<MemoryRecord>, StorageError> {
let conn = self.pool.get()?;
get_memory_with_conn(&conn, scope_kind, scope_key, namespace, memory_key)
}
pub fn list_memories(
&self,
scope_kind: &str,
scope_key: &str,
namespace: Option<&str>,
limit: usize,
) -> Result<Vec<MemoryRecord>, StorageError> {
let conn = self.pool.get()?;
let limit = limit.max(1) as i64;
let mut memories = Vec::new();
if let Some(namespace) = namespace {
let mut stmt = conn.prepare(
"
SELECT id, scope_kind, scope_key, namespace, memory_key, content,
source_type, source_session_id, source_message_id, source_message_seq,
source_channel_name, source_chat_id, created_at, updated_at
FROM memories
WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3
ORDER BY updated_at DESC, created_at DESC
LIMIT ?4
",
)?;
let rows = stmt.query_map(
params![scope_kind, scope_key, namespace, limit],
map_memory_record,
)?;
for row in rows {
memories.push(row?);
}
} else {
let mut stmt = conn.prepare(
"
SELECT id, scope_kind, scope_key, namespace, memory_key, content,
source_type, source_session_id, source_message_id, source_message_seq,
source_channel_name, source_chat_id, created_at, updated_at
FROM memories
WHERE scope_kind = ?1 AND scope_key = ?2
ORDER BY updated_at DESC, created_at DESC
LIMIT ?3
",
)?;
let rows = stmt.query_map(params![scope_kind, scope_key, limit], map_memory_record)?;
for row in rows {
memories.push(row?);
}
}
Ok(memories)
}
pub fn list_memory_scope_keys(&self, scope_kind: &str) -> Result<Vec<String>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"
SELECT DISTINCT scope_key
FROM memories
WHERE scope_kind = ?1
ORDER BY scope_key ASC
",
)?;
let rows = stmt.query_map(params![scope_kind], |row| row.get::<_, String>(0))?;
let mut scope_keys = Vec::new();
for row in rows {
scope_keys.push(row?);
}
Ok(scope_keys)
}
pub fn list_memories_for_scope(
&self,
scope_kind: &str,
scope_key: &str,
) -> Result<Vec<MemoryRecord>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"
SELECT id, scope_kind, scope_key, namespace, memory_key, content,
source_type, source_session_id, source_message_id, source_message_seq,
source_channel_name, source_chat_id, created_at, updated_at
FROM memories
WHERE scope_kind = ?1 AND scope_key = ?2
ORDER BY updated_at DESC, namespace ASC, memory_key ASC
",
)?;
let rows = stmt.query_map(params![scope_kind, scope_key], map_memory_record)?;
let mut memories = Vec::new();
for row in rows {
memories.push(row?);
}
Ok(memories)
}
pub fn update_memory(
&self,
input: &MemoryUpsert,
) -> Result<Option<MemoryRecord>, StorageError> {
if self
.get_memory(
&input.scope_kind,
&input.scope_key,
&input.namespace,
&input.memory_key,
)?
.is_none()
{
return Ok(None);
}
self.put_memory(input).map(Some)
}
pub fn delete_memory(
&self,
scope_kind: &str,
scope_key: &str,
namespace: &str,
memory_key: &str,
) -> Result<bool, StorageError> {
let conn = self.pool.get()?;
let changed = conn.execute(
"
DELETE FROM memories
WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3 AND memory_key = ?4
",
params![scope_kind, scope_key, namespace, memory_key],
)?;
Ok(changed > 0)
}
pub fn upsert_scheduler_job(
&self,
input: &SchedulerJobUpsert,
) -> Result<SchedulerJobRecord, StorageError> {
let now = current_timestamp();
let conn = self.pool.get()?;
conn.execute(
"
INSERT INTO scheduler_jobs (
id, kind, schedule_json, interval_secs, startup_delay_secs,
target_json, payload_json, enabled, state, last_status, last_error,
run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?18)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
schedule_json = excluded.schedule_json,
interval_secs = excluded.interval_secs,
startup_delay_secs = excluded.startup_delay_secs,
target_json = excluded.target_json,
payload_json = excluded.payload_json,
enabled = excluded.enabled,
state = excluded.state,
last_status = excluded.last_status,
last_error = excluded.last_error,
run_count = excluded.run_count,
max_runs = excluded.max_runs,
last_fired_at = excluded.last_fired_at,
next_fire_at = excluded.next_fire_at,
paused_at = excluded.paused_at,
completed_at = excluded.completed_at,
updated_at = excluded.updated_at
",
params![
input.id,
input.kind,
serde_json::to_string(&input.schedule)?,
input.interval_secs,
input.startup_delay_secs,
serde_json::to_string(&input.target)?,
serde_json::to_string(&input.payload)?,
if input.enabled { 1 } else { 0 },
input.state.as_str(),
input.last_status.as_ref().map(SchedulerJobStatus::as_str),
input.last_error,
input.run_count,
input.max_runs,
input.last_fired_at,
input.next_fire_at,
input.paused_at,
input.completed_at,
now,
],
)?;
get_scheduler_job_with_conn(&conn, &input.id)?
.ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into())
}
pub fn get_scheduler_job(
&self,
job_id: &str,
) -> Result<Option<SchedulerJobRecord>, StorageError> {
let conn = self.pool.get()?;
get_scheduler_job_with_conn(&conn, job_id)
}
pub fn list_scheduler_jobs(
&self,
enabled_only: bool,
) -> Result<Vec<SchedulerJobRecord>, StorageError> {
let conn = self.pool.get()?;
let sql = if enabled_only {
"
SELECT id, kind, schedule_json, interval_secs, startup_delay_secs,
target_json, payload_json, enabled, state, last_status, last_error,
run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at,
created_at, updated_at
FROM scheduler_jobs
WHERE enabled = 1
ORDER BY COALESCE(next_fire_at, created_at) ASC, id ASC
"
} else {
"
SELECT id, kind, schedule_json, interval_secs, startup_delay_secs,
target_json, payload_json, enabled, state, last_status, last_error,
run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at,
created_at, updated_at
FROM scheduler_jobs
ORDER BY COALESCE(next_fire_at, created_at) ASC, id ASC
"
};
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], map_scheduler_job_record)?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(row?);
}
Ok(jobs)
}
pub fn list_running_scheduler_jobs(&self) -> Result<Vec<SchedulerJobRecord>, StorageError> {
let conn = self.pool.get()?;
let sql = "
SELECT id, kind, schedule_json, interval_secs, startup_delay_secs,
target_json, payload_json, enabled, state, last_status, last_error,
run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at,
created_at, updated_at
FROM scheduler_jobs
WHERE state = 'running'
ORDER BY COALESCE(next_fire_at, created_at) ASC, id ASC
";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], map_scheduler_job_record)?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(row?);
}
Ok(jobs)
}
pub fn delete_scheduler_job(&self, job_id: &str) -> Result<(), StorageError> {
let conn = self.pool.get()?;
conn.execute("DELETE FROM scheduler_jobs WHERE id = ?1", params![job_id])?;
Ok(())
}
pub fn update_scheduler_job_runtime(
&self,
job_id: &str,
state: SchedulerJobState,
last_status: Option<SchedulerJobStatus>,
last_error: Option<&str>,
run_count: i64,
last_fired_at: Option<i64>,
next_fire_at: Option<i64>,
paused_at: Option<i64>,
completed_at: Option<i64>,
) -> Result<(), StorageError> {
let conn = self.pool.get()?;
conn.execute(
"
UPDATE scheduler_jobs
SET state = ?2,
last_status = ?3,
last_error = ?4,
run_count = ?5,
last_fired_at = ?6,
next_fire_at = ?7,
paused_at = ?8,
completed_at = ?9,
updated_at = ?10
WHERE id = ?1
",
params![
job_id,
state.as_str(),
last_status.as_ref().map(SchedulerJobStatus::as_str),
last_error,
run_count,
last_fired_at,
next_fire_at,
paused_at,
completed_at,
current_timestamp(),
],
)?;
Ok(())
}
pub fn search_memories(
&self,
scope_kind: &str,
scope_key: &str,
query: &str,
namespace: Option<&str>,
limit: usize,
) -> Result<Vec<MemoryRecord>, StorageError> {
let conn = self.pool.get()?;
let limit = limit.max(1) as i64;
let query = quote_fts_query(query);
let mut memories = Vec::new();
if let Some(namespace) = namespace {
let mut stmt = conn.prepare(
"
SELECT m.id, m.scope_kind, m.scope_key, m.namespace, m.memory_key, m.content,
m.source_type, m.source_session_id, m.source_message_id, m.source_message_seq,
m.source_channel_name, m.source_chat_id, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?1
AND m.scope_kind = ?2
AND m.scope_key = ?3
AND m.namespace = ?4
ORDER BY bm25(memories_fts), m.updated_at DESC
LIMIT ?5
",
)?;
let rows = stmt.query_map(
params![query, scope_kind, scope_key, namespace, limit],
map_memory_record,
)?;
for row in rows {
memories.push(row?);
}
} else {
let mut stmt = conn.prepare(
"
SELECT m.id, m.scope_kind, m.scope_key, m.namespace, m.memory_key, m.content,
m.source_type, m.source_session_id, m.source_message_id, m.source_message_seq,
m.source_channel_name, m.source_chat_id, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?1
AND m.scope_kind = ?2
AND m.scope_key = ?3
ORDER BY bm25(memories_fts), m.updated_at DESC
LIMIT ?4
",
)?;
let rows = stmt.query_map(
params![query, scope_kind, scope_key, limit],
map_memory_record,
)?;
for row in rows {
memories.push(row?);
}
}
Ok(memories)
}
pub fn search_memories_any(
&self,
scope_kind: &str,
scope_key: &str,
queries: &[String],
namespace: Option<&str>,
limit: usize,
) -> Result<Vec<MemoryRecord>, StorageError> {
let conn = self.pool.get()?;
let limit = limit.max(1) as i64;
let query = quote_fts_or_query(queries);
if query.is_empty() {
return Ok(Vec::new());
}
let mut memories = Vec::new();
if let Some(namespace) = namespace {
let mut stmt = conn.prepare(
"
SELECT m.id, m.scope_kind, m.scope_key, m.namespace, m.memory_key, m.content,
m.source_type, m.source_session_id, m.source_message_id, m.source_message_seq,
m.source_channel_name, m.source_chat_id, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?1
AND m.scope_kind = ?2
AND m.scope_key = ?3
AND m.namespace = ?4
ORDER BY bm25(memories_fts), m.updated_at DESC
LIMIT ?5
",
)?;
let rows = stmt.query_map(
params![query, scope_kind, scope_key, namespace, limit],
map_memory_record,
)?;
for row in rows {
memories.push(row?);
}
} else {
let mut stmt = conn.prepare(
"
SELECT m.id, m.scope_kind, m.scope_key, m.namespace, m.memory_key, m.content,
m.source_type, m.source_session_id, m.source_message_id, m.source_message_seq,
m.source_channel_name, m.source_chat_id, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?1
AND m.scope_kind = ?2
AND m.scope_key = ?3
ORDER BY bm25(memories_fts), m.updated_at DESC
LIMIT ?4
",
)?;
let rows = stmt.query_map(
params![query, scope_kind, scope_key, limit],
map_memory_record,
)?;
for row in rows {
memories.push(row?);
}
}
Ok(memories)
}
pub fn load_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
let conn = self.pool.get()?;
load_messages_after(&conn, session_id, 0)
}
pub fn load_messages_for_topic(
&self,
topic_id: &str,
session_id: Option<&str>,
) -> Result<Vec<ChatMessage>, StorageError> {
let conn = self.pool.get()?;
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE topic_id = ?1 AND session_id = ?2
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
} else {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE topic_id = ?1
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
}
/// 获取指定话题的消息数量(动态计算,确保准确)
pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> {
self.load_messages_for_topic(topic_id, None).map(|msgs| msgs.len())
}
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
let conn = self.pool.get()?;
load_messages_after(&conn, session_id, 0)
}
pub fn count_active_user_messages(&self, session_id: &str) -> Result<i64, StorageError> {
let conn = self.pool.get()?;
conn.query_row(
"
SELECT COUNT(*)
FROM messages
WHERE session_id = ?1 AND role = 'user'
",
params![session_id],
|row| row.get(0),
)
.map_err(StorageError::from)
}
pub fn replace_todos(
&self,
scope_key: &str,
items: &[TodoRecord],
) -> Result<Vec<TodoRecord>, StorageError> {
let conn = self.pool.get()?;
let now = current_timestamp();
// Delete existing todos for this scope_key
conn.execute(
"DELETE FROM todos WHERE scope_key = ?1",
params![scope_key],
)?;
// Insert new todos
for item in items {
conn.execute(
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
item.id,
scope_key,
item.session_id,
item.topic_id,
item.content,
item.status,
item.priority,
item.created_at,
now,
item.created_by_message_id,
],
)?;
}
drop(conn);
self.list_todos(scope_key)
}
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id
FROM todos
WHERE scope_key = ?1
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(params![scope_key], |row| {
Ok(TodoRecord {
id: row.get(0)?,
scope_key: row.get(1)?,
session_id: row.get(2)?,
topic_id: row.get(3)?,
content: row.get(4)?,
status: row.get(5)?,
priority: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
created_by_message_id: row.get(9)?,
})
})?;
let mut todos = Vec::new();
for row in rows {
todos.push(row?);
}
Ok(todos)
}
}
pub fn persistent_session_id(channel_name: &str, chat_id: &str) -> String {
// 幂等:循环去除已存在的 "{channel_name}:" 前缀,防止前缀累积
let prefix = format!("{}:", channel_name);
let mut chat_id = chat_id;
while chat_id.starts_with(&prefix) {
chat_id = &chat_id[prefix.len()..];
}
if channel_name == "cli" || channel_name == "websocket" {
chat_id.to_string()
} else {
format!("{}:{}", channel_name, chat_id)
}
}
#[cfg(not(test))]
fn default_session_db_path() -> Result<PathBuf, std::io::Error> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Ok(home.join(".picobot").join("storage").join("sessions.db"))
}
fn insert_message_with_seq(
conn: &rusqlite::Transaction<'_>,
session_id: &str,
seq: i64,
message: &ChatMessage,
) -> Result<(), StorageError> {
let media_refs_json = serde_json::to_string(&message.media_refs)?;
let tool_calls_json = message
.tool_calls
.as_ref()
.map(serde_json::to_string)
.transpose()?;
conn.execute(
"
INSERT INTO messages (
id, session_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
",
params![
message.id,
session_id,
seq,
message.role,
message.content,
message.system_context,
message.reasoning_content,
media_refs_json,
message.tool_call_id,
message.tool_name,
tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp,
],
)?;
Ok(())
}
fn clone_message_for_compaction(message: &ChatMessage, timestamp: i64) -> ChatMessage {
ChatMessage {
id: uuid::Uuid::new_v4().to_string(),
role: message.role.clone(),
content: message.content.clone(),
media_refs: message.media_refs.clone(),
timestamp,
system_context: message.system_context.clone(),
reasoning_content: message.reasoning_content.clone(),
tool_call_id: message.tool_call_id.clone(),
tool_name: message.tool_name.clone(),
tool_state: message.tool_state.clone(),
tool_duration_ms: message.tool_duration_ms,
tool_calls: message.tool_calls.clone(),
}
}
fn load_messages_between(
conn: &rusqlite::Transaction<'_>,
session_id: &str,
start_seq_exclusive: i64,
end_seq_inclusive: i64,
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(
params![session_id, start_seq_exclusive, end_seq_inclusive],
|row| {
let media_refs_json: String = row.get(5)?;
let media_refs: Vec<String> =
serde_json::from_str(&media_refs_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
media_refs_json.len(),
rusqlite::types::Type::Text,
Box::new(err),
)
})?;
let tool_calls_json: Option<String> = row.get(9)?;
let tool_calls = tool_calls_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
9,
rusqlite::types::Type::Text,
Box::new(err),
)
})?;
Ok(ChatMessage {
id: row.get(0)?,
role: row.get(1)?,
content: row.get(2)?,
system_context: row.get(3)?,
reasoning_content: row.get(4)?,
media_refs,
timestamp: row.get(6)?,
tool_call_id: row.get(7)?,
tool_name: row.get(8)?,
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
})
},
)?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
fn load_messages_after(
conn: &Connection,
session_id: &str,
cutoff_seq: i64,
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages
WHERE session_id = ?1 AND seq > ?2
ORDER BY seq ASC
",
)?;
let rows = stmt.query_map(params![session_id, cutoff_seq], |row| {
let media_refs_json: String = row.get(5)?;
let media_refs: Vec<String> = serde_json::from_str(&media_refs_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
media_refs_json.len(),
rusqlite::types::Type::Text,
Box::new(err),
)
})?;
let tool_calls_json: Option<String> = row.get(9)?;
let tool_calls = tool_calls_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(
9,
rusqlite::types::Type::Text,
Box::new(err),
)
})?;
Ok(ChatMessage {
id: row.get(0)?,
role: row.get(1)?,
content: row.get(2)?,
system_context: row.get(3)?,
reasoning_content: row.get(4)?,
media_refs,
timestamp: row.get(6)?,
tool_call_id: row.get(7)?,
tool_name: row.get(8)?,
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
})
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_millis() as i64
}
fn quote_fts_query(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
fn quote_fts_or_query(queries: &[String]) -> String {
queries
.iter()
.map(|query| query.trim())
.filter(|query| !query.is_empty())
.map(quote_fts_query)
.collect::<Vec<_>>()
.join(" OR ")
}
#[cfg(test)]
mod tests;