Skip to content

Commit eff68f3

Browse files
committed
Add chat history search + vault sync
1 parent 3fbd89a commit eff68f3

9 files changed

Lines changed: 1411 additions & 3 deletions

File tree

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod heartbeat;
77
pub mod llm;
88
pub mod memory;
99
pub mod skills;
10+
pub mod sync;
1011
pub mod telegram;
1112
pub mod tools;
1213
pub mod workspace;

src/main.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use icrab::heartbeat;
1616
use icrab::llm::HttpProvider;
1717
use icrab::memory::db::BrainDb;
1818
use icrab::memory::indexer::VaultIndexer;
19-
use icrab::tools::SearchVaultTool;
19+
use icrab::sync;
20+
use icrab::tools::{GitSyncTool, GrepDirTool, SearchChatTool, SearchVaultTool};
2021
use icrab::telegram::{self, OutboundMsg};
2122
use icrab::tools;
2223
use icrab::tools::cron::{CronStore, CronTool};
@@ -84,10 +85,23 @@ async fn main() {
8485
});
8586
}
8687

87-
// Build subagent registry (core + search — no spawn, no cron).
88+
// Background git pull + re-index loop (every 15 min).
89+
sync::spawn_git_pull_loop(
90+
workspace.clone(),
91+
Arc::clone(&db),
92+
sync::DEFAULT_PULL_INTERVAL_SECS,
93+
);
94+
eprintln!(
95+
"background git pull loop started (interval: {}h)",
96+
sync::DEFAULT_PULL_INTERVAL_SECS / 3600
97+
);
98+
99+
// Build subagent registry (core + search tools — no spawn, no cron).
88100
let subagent_registry = Arc::new({
89101
let reg = tools::build_core_registry(&cfg);
90102
reg.register(SearchVaultTool::new(Arc::clone(&db)));
103+
reg.register(SearchChatTool::new(Arc::clone(&db)));
104+
reg.register(GrepDirTool);
91105
reg
92106
});
93107

@@ -101,9 +115,12 @@ async fn main() {
101115
SUBAGENT_MAX_ITERATIONS,
102116
));
103117

104-
// Main registry: core + search + spawn + cron (cron is main-agent-only).
118+
// Main registry: core + search + git + grep + spawn + cron.
105119
let registry = tools::build_core_registry(&cfg);
106120
registry.register(SearchVaultTool::new(Arc::clone(&db)));
121+
registry.register(SearchChatTool::new(Arc::clone(&db)));
122+
registry.register(GrepDirTool);
123+
registry.register(GitSyncTool);
107124
registry.register(SpawnTool::new(Arc::clone(&manager)));
108125
registry.register(SubagentTool::new(Arc::clone(&manager)));
109126

src/memory/db.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,32 @@ impl BrainDb {
111111
summary TEXT NOT NULL DEFAULT ''
112112
);
113113
114+
-- ── Chat FTS5 ──────────────────────────────────────────────────────────
115+
CREATE VIRTUAL TABLE IF NOT EXISTS chat_fts USING fts5(
116+
content,
117+
content=chat_history,
118+
content_rowid=id
119+
);
120+
121+
-- Triggers: keep chat_fts in sync with chat_history
122+
CREATE TRIGGER IF NOT EXISTS chat_history_ai
123+
AFTER INSERT ON chat_history BEGIN
124+
INSERT INTO chat_fts(rowid, content)
125+
VALUES (new.id, new.content);
126+
END;
127+
CREATE TRIGGER IF NOT EXISTS chat_history_ad
128+
AFTER DELETE ON chat_history BEGIN
129+
INSERT INTO chat_fts(chat_fts, rowid, content)
130+
VALUES ('delete', old.id, old.content);
131+
END;
132+
CREATE TRIGGER IF NOT EXISTS chat_history_au
133+
AFTER UPDATE ON chat_history BEGIN
134+
INSERT INTO chat_fts(chat_fts, rowid, content)
135+
VALUES ('delete', old.id, old.content);
136+
INSERT INTO chat_fts(rowid, content)
137+
VALUES (new.id, new.content);
138+
END;
139+
114140
-- ── Vault index ──────────────────────────────────────────────────────
115141
CREATE TABLE IF NOT EXISTS vault_index (
116142
filepath TEXT PRIMARY KEY,
@@ -410,6 +436,47 @@ impl BrainDb {
410436
let results: Vec<(String, String)> = rows.collect::<Result<_, _>>()?;
411437
Ok(results)
412438
}
439+
440+
/// BM25-ranked keyword search over `chat_fts`.
441+
///
442+
/// Returns at most `limit` triples of `(chat_id, role, snippet)`.
443+
pub fn chat_fts_search(
444+
&self,
445+
fts_query: &str,
446+
limit: usize,
447+
) -> Result<Vec<(String, String, String)>, DbError> {
448+
if fts_query.trim().is_empty() {
449+
return Ok(Vec::new());
450+
}
451+
452+
let conn = self
453+
.conn
454+
.lock()
455+
.map_err(|e| DbError(format!("lock: {e}")))?;
456+
457+
#[allow(clippy::cast_possible_wrap)]
458+
let limit_i64 = limit as i64;
459+
460+
let mut stmt = conn.prepare(
461+
"SELECT h.chat_id, h.role,
462+
snippet(chat_fts, 0, '**', '**', '...', 10) AS snip
463+
FROM chat_fts
464+
JOIN chat_history h ON h.id = chat_fts.rowid
465+
WHERE chat_fts MATCH ?1
466+
ORDER BY bm25(chat_fts)
467+
LIMIT ?2",
468+
)?;
469+
470+
let rows = stmt.query_map(params![fts_query, limit_i64], |row| {
471+
Ok((
472+
row.get::<_, String>(0)?,
473+
row.get::<_, String>(1)?,
474+
row.get::<_, String>(2)?,
475+
))
476+
})?;
477+
478+
rows.collect::<Result<_, _>>().map_err(DbError::from)
479+
}
413480
}
414481

415482
// ---------------------------------------------------------------------------
@@ -854,6 +921,71 @@ mod tests {
854921
assert_eq!(summary, "日本語サマリー");
855922
}
856923

924+
// ── chat_fts: search ─────────────────────────────────────────────────────
925+
926+
#[test]
927+
fn chat_fts_search_finds_saved_message() {
928+
let (_tmp, db) = temp_db();
929+
db.save_session(
930+
"chat1",
931+
&[StoredMessage {
932+
role: "user".into(),
933+
content: "I want to do squats tomorrow".into(),
934+
tool_call_id: None,
935+
tool_calls: None,
936+
}],
937+
"",
938+
)
939+
.unwrap();
940+
941+
let rows = db.chat_fts_search("squats", 5).unwrap();
942+
assert_eq!(rows.len(), 1);
943+
assert_eq!(rows[0].0, "chat1");
944+
assert_eq!(rows[0].1, "user");
945+
assert!(rows[0].2.contains("squats") || rows[0].2.contains("**"));
946+
}
947+
948+
#[test]
949+
fn chat_fts_search_empty_query_returns_empty() {
950+
let (_tmp, db) = temp_db();
951+
let rows = db.chat_fts_search(" ", 5).unwrap();
952+
assert!(rows.is_empty());
953+
}
954+
955+
#[test]
956+
fn chat_fts_search_no_match_returns_empty() {
957+
let (_tmp, db) = temp_db();
958+
db.save_session(
959+
"c",
960+
&[StoredMessage {
961+
role: "user".into(),
962+
content: "hello world".into(),
963+
tool_call_id: None,
964+
tool_calls: None,
965+
}],
966+
"",
967+
)
968+
.unwrap();
969+
let rows = db.chat_fts_search("squats", 5).unwrap();
970+
assert!(rows.is_empty());
971+
}
972+
973+
#[test]
974+
fn chat_fts_search_respects_limit() {
975+
let (_tmp, db) = temp_db();
976+
let messages: Vec<StoredMessage> = (0..10)
977+
.map(|i| StoredMessage {
978+
role: "user".into(),
979+
content: format!("workout session {i} squats reps"),
980+
tool_call_id: None,
981+
tool_calls: None,
982+
})
983+
.collect();
984+
db.save_session("bulk", &messages, "").unwrap();
985+
let rows = db.chat_fts_search("squats", 3).unwrap();
986+
assert!(rows.len() <= 3);
987+
}
988+
857989
#[test]
858990
fn message_ordering_preserved() {
859991
let (_tmp, db) = temp_db();

src/sync.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! Background git pull loop: keeps the local Obsidian vault clone in sync
2+
//! with GitHub and triggers vault re-indexing after each successful pull.
3+
//!
4+
//! Chat history (`brain.db`) is strictly local and is never pushed to Git.
5+
6+
use std::path::PathBuf;
7+
use std::sync::Arc;
8+
use std::time::Duration;
9+
10+
use crate::memory::db::BrainDb;
11+
use crate::memory::indexer::VaultIndexer;
12+
13+
/// Default interval between background pulls (3 hours).
14+
pub const DEFAULT_PULL_INTERVAL_SECS: u64 = 3 * 60 * 60;
15+
16+
/// Spawn a background task that periodically runs `git pull --rebase origin
17+
/// main` in `workspace`, then re-scans the vault FTS5 index.
18+
///
19+
/// Errors are logged but never fatal — the app keeps running regardless.
20+
pub fn spawn_git_pull_loop(workspace: PathBuf, db: Arc<BrainDb>, interval_secs: u64) {
21+
tokio::spawn(pull_loop(workspace, db, interval_secs));
22+
}
23+
24+
async fn pull_loop(workspace: PathBuf, db: Arc<BrainDb>, interval_secs: u64) {
25+
let indexer = VaultIndexer::new(db);
26+
let interval = Duration::from_secs(interval_secs);
27+
28+
loop {
29+
tokio::time::sleep(interval).await;
30+
31+
let ws = workspace.clone();
32+
match tokio::process::Command::new("git")
33+
.args(["pull", "--rebase", "origin", "main"])
34+
.current_dir(&ws)
35+
.output()
36+
.await
37+
{
38+
Ok(out) if out.status.success() => {
39+
let stdout = String::from_utf8_lossy(&out.stdout);
40+
eprintln!("git pull: ok — {}", stdout.trim());
41+
42+
// Re-index vault so FTS5 reflects any new notes from PC.
43+
let idx = indexer.clone();
44+
match tokio::task::spawn_blocking(move || idx.scan(&ws)).await {
45+
Ok(Ok(stats)) => eprintln!("vault re-index: {stats}"),
46+
Ok(Err(e)) => eprintln!("vault re-index warning: {e}"),
47+
Err(e) => eprintln!("vault re-index task error: {e}"),
48+
}
49+
}
50+
Ok(out) => {
51+
let stderr = String::from_utf8_lossy(&out.stderr);
52+
eprintln!(
53+
"git pull: non-zero exit ({}): {}",
54+
out.status,
55+
stderr.trim()
56+
);
57+
}
58+
Err(e) => eprintln!("git pull: failed to spawn: {e}"),
59+
}
60+
}
61+
}

src/tools.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
pub mod context;
44
pub mod cron;
55
pub mod file;
6+
pub mod git;
7+
pub mod grep_dir;
68
pub mod message;
79
pub mod registry;
810
pub mod result;
911
pub mod search;
12+
pub mod search_chat;
1013
pub mod spawn;
1114
pub mod subagent;
1215
pub mod web;
1316

1417
pub use context::ToolCtx;
18+
pub use git::GitSyncTool;
19+
pub use grep_dir::GrepDirTool;
1520
pub use registry::{Tool, ToolRegistry, build_core_registry, build_default_registry, tool_to_def};
1621
pub use result::ToolResult;
1722
pub use search::SearchVaultTool;
23+
pub use search_chat::SearchChatTool;

0 commit comments

Comments
 (0)