Skip to content

Commit a987d5e

Browse files
committed
Implement /clear + tests + update Readme
1 parent d291611 commit a987d5e

4 files changed

Lines changed: 169 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ I tried a few options of OpenClaw alternatives. They were bloated with complex d
2626

2727
- **Telegram Interface:** Chat natively. It reads, thinks, uses tools, and replies.
2828
- **Workspace Integration:** Reads and writes files directly in your Obsidian vault. It knows what you wrote yesterday because it can read your daily notes.
29-
- **Local Memory & Search:** SQLite-backed persistence with FTS5. The agent can search your entire vault or your chat history blazingly fast.
29+
- **Local Memory & Search:** SQLite-backed persistence with FTS5. The agent can search your entire vault or your chat history blazingly fast. Send `/clear` to archive the current conversation and start fresh — old messages stay in the database and remain searchable.
3030
- **Background Subagents:** Tell it to "Search the web for X and summarize it." It spawns a background agent, freeing up the main chat, and messages you when it's done.
3131
- **Cron & Heartbeat:** Schedule recurring tasks or reminders (e.g., "Summarize unread messages every hour").
3232
- **Basic Tools:**

src/agent/session.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ impl Session {
119119
Ok(())
120120
}
121121

122+
/// Rotate to a fresh session for `chat_id`, archiving the old one in place.
123+
///
124+
/// Old messages remain in `chat_history` under their previous `session_id`
125+
/// and are still searchable via FTS. The next `Session::load` for this
126+
/// `chat_id` will start with an empty history and a new `session_id`.
127+
pub async fn reset(db: Arc<BrainDb>, chat_id: &str) -> Result<(), SessionError> {
128+
let chat_id = chat_id.to_string();
129+
tokio::task::spawn_blocking(move || db.reset_session_id(&chat_id))
130+
.await
131+
.map_err(|e| SessionError::Db(format!("spawn_blocking: {e}")))?
132+
.map_err(SessionError::from)?;
133+
Ok(())
134+
}
135+
122136
// -----------------------------------------------------------------------
123137
// Mutation helpers
124138
// -----------------------------------------------------------------------
@@ -333,6 +347,55 @@ mod tests {
333347
assert_eq!(session.history().first().unwrap().content, "msg 5");
334348
}
335349

350+
// ── Session::reset archives old session and starts fresh ──────────────────
351+
352+
#[tokio::test]
353+
async fn session_reset_starts_fresh_history() {
354+
let (_tmp, db) = temp_db();
355+
356+
// Build up some history and save it
357+
let mut s = Session::load(Arc::clone(&db), "chat").await.unwrap();
358+
s.add_user_message("before clear");
359+
s.save().await.unwrap();
360+
let old_sid = s.session_id().to_string();
361+
362+
// Reset — rotates to a new session_id
363+
Session::reset(Arc::clone(&db), "chat").await.unwrap();
364+
365+
// Loading now gives an empty history with a different session_id
366+
let fresh = Session::load(Arc::clone(&db), "chat").await.unwrap();
367+
assert!(fresh.history().is_empty(), "history must be empty after reset");
368+
assert!(fresh.summary().is_empty(), "summary must be cleared after reset");
369+
assert_ne!(
370+
fresh.session_id(),
371+
old_sid,
372+
"session_id must rotate after reset"
373+
);
374+
}
375+
376+
#[tokio::test]
377+
async fn session_reset_preserves_old_messages_in_db() {
378+
let (_tmp, db) = temp_db();
379+
380+
let mut s = Session::load(Arc::clone(&db), "chat").await.unwrap();
381+
let old_sid = s.session_id().to_string();
382+
s.add_user_message("archived message");
383+
s.save().await.unwrap();
384+
385+
Session::reset(Arc::clone(&db), "chat").await.unwrap();
386+
387+
// Old messages are still in chat_history under the previous session_id
388+
let inner_db = Arc::clone(&db);
389+
let (old_msgs, _) = tokio::task::spawn_blocking(move || {
390+
inner_db.load_session("chat", &old_sid)
391+
})
392+
.await
393+
.unwrap()
394+
.unwrap();
395+
assert_eq!(old_msgs.len(), 1);
396+
assert_eq!(old_msgs[0].content, "archived message");
397+
}
398+
336399
// ── All pending inserts reach the DB even when history is capped ──────────
337400

338401
#[tokio::test]

src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicI64, Ordering};
99
use tokio::sync::mpsc;
1010

1111
use icrab::agent;
12+
use icrab::agent::session::Session;
1213
use icrab::agent::subagent_manager::SubagentManager;
1314
use icrab::config;
1415
use icrab::cron_runner;
@@ -182,7 +183,15 @@ async fn main() {
182183
};
183184
let chat_id_str = msg.chat_id.to_string();
184185

185-
let reply = if msg.channel == "heartbeat" {
186+
let reply = if msg.text.trim() == "/clear" {
187+
match Session::reset(Arc::clone(&db), &chat_id_str).await {
188+
Ok(()) => "Session cleared. Starting fresh! 🦀".to_string(),
189+
Err(e) => {
190+
eprintln!("clear session error: {}", e);
191+
format!("Error clearing session: {}.", e)
192+
}
193+
}
194+
} else if msg.channel == "heartbeat" {
186195
match agent::process_heartbeat_message(
187196
&llm,
188197
&registry,

src/memory/db.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,29 @@ impl BrainDb {
211211
// Chat history operations
212212
// -----------------------------------------------------------------------
213213

214+
/// Force-rotate to a brand-new session for `chat_id`.
215+
///
216+
/// Generates a fresh UUID, stores it as `current_session_id` in
217+
/// `chat_summary`, and resets `summary` to `""`. Old messages remain
218+
/// intact in `chat_history` under their previous `session_id`.
219+
pub fn reset_session_id(&self, chat_id: &str) -> Result<String, DbError> {
220+
let conn = self
221+
.conn
222+
.lock()
223+
.map_err(|e| DbError(format!("lock: {e}")))?;
224+
225+
let new_id = uuid::Uuid::new_v4().to_string();
226+
conn.execute(
227+
"INSERT INTO chat_summary (chat_id, current_session_id, summary)
228+
VALUES (?1, ?2, '')
229+
ON CONFLICT(chat_id) DO UPDATE
230+
SET current_session_id = excluded.current_session_id,
231+
summary = ''",
232+
params![chat_id, &new_id],
233+
)?;
234+
Ok(new_id)
235+
}
236+
214237
/// Return the active `session_id` UUID for `chat_id`, creating and
215238
/// persisting a new one if none exists yet.
216239
pub fn get_or_create_session_id(&self, chat_id: &str) -> Result<String, DbError> {
@@ -608,6 +631,78 @@ mod tests {
608631
assert!(db2.health_check());
609632
}
610633

634+
// ── reset_session_id ─────────────────────────────────────────────────────
635+
636+
#[test]
637+
fn reset_session_id_returns_new_uuid() {
638+
let (_tmp, db) = temp_db();
639+
let sid1 = db.get_or_create_session_id("chat").unwrap();
640+
let sid2 = db.reset_session_id("chat").unwrap();
641+
assert_ne!(sid1, sid2, "reset must produce a different session_id");
642+
assert_eq!(sid2.len(), 36);
643+
}
644+
645+
#[test]
646+
fn reset_session_id_clears_summary() {
647+
let (_tmp, db) = temp_db();
648+
let sid = db.get_or_create_session_id("chat").unwrap();
649+
db.append_session("chat", &sid, &[], "old summary").unwrap();
650+
651+
db.reset_session_id("chat").unwrap();
652+
let new_sid = db.get_or_create_session_id("chat").unwrap();
653+
// The session_id must have changed and the summary must be empty
654+
assert_ne!(sid, new_sid);
655+
let (_, summary) = db.load_session("chat", &new_sid).unwrap();
656+
assert_eq!(summary, "");
657+
}
658+
659+
#[test]
660+
fn reset_session_id_keeps_old_messages() {
661+
let (_tmp, db) = temp_db();
662+
let old_sid = db.get_or_create_session_id("chat").unwrap();
663+
db.append_session(
664+
"chat",
665+
&old_sid,
666+
&[StoredMessage {
667+
role: "user".into(),
668+
content: "preserved".into(),
669+
tool_call_id: None,
670+
tool_calls: None,
671+
}],
672+
"",
673+
)
674+
.unwrap();
675+
676+
db.reset_session_id("chat").unwrap();
677+
678+
// Old messages still retrievable by their original session_id
679+
let (old_msgs, _) = db.load_session("chat", &old_sid).unwrap();
680+
assert_eq!(old_msgs.len(), 1);
681+
assert_eq!(old_msgs[0].content, "preserved");
682+
}
683+
684+
#[test]
685+
fn reset_session_id_new_session_starts_empty() {
686+
let (_tmp, db) = temp_db();
687+
let old_sid = db.get_or_create_session_id("chat").unwrap();
688+
db.append_session(
689+
"chat",
690+
&old_sid,
691+
&[StoredMessage {
692+
role: "user".into(),
693+
content: "old".into(),
694+
tool_call_id: None,
695+
tool_calls: None,
696+
}],
697+
"",
698+
)
699+
.unwrap();
700+
701+
let new_sid = db.reset_session_id("chat").unwrap();
702+
let (new_msgs, _) = db.load_session("chat", &new_sid).unwrap();
703+
assert!(new_msgs.is_empty(), "new session must start with no messages");
704+
}
705+
611706
// ── get_or_create_session_id ─────────────────────────────────────────────
612707

613708
#[test]

0 commit comments

Comments
 (0)