1use std::path::PathBuf;
7use std::sync::Arc;
8
9use crate::error::RuntimeError;
10use crate::event::EventEnvelope;
11use crate::index::AnchorIndex;
12use crate::message::Message;
13use crate::projection::message_window::replay_all_messages_with_seq;
14use crate::session::SessionOpenError;
15
16pub enum SearchScope {
17 Session,
18 Project,
19}
20
21pub struct HistoryQuery {
22 pub session_id: String,
23 pub offset: usize,
24 pub limit: usize,
25 pub role_filter: Option<Vec<String>>,
26}
27
28#[derive(Debug)]
29pub struct HistoryPage {
30 pub total: u64,
31 pub offset: usize,
32 pub limit: usize,
33 pub items: Vec<Message>,
34}
35
36#[derive(Debug)]
37pub struct SearchHit {
38 pub session_id: String,
39 pub seq: u64,
40 pub ts: String,
41 pub kind: String,
42 pub snippet: String,
43}
44
45#[derive(Debug)]
46pub struct SearchResult {
47 pub total: u64,
48 pub hits: Vec<SearchHit>,
49}
50
51pub trait HistoryStore: Send + Sync {
52 fn count(&self, session_id: &str, role_filter: Option<&[&str]>) -> Result<u64, RuntimeError>;
53 fn read(&self, query: HistoryQuery) -> Result<HistoryPage, RuntimeError>;
54 fn search(
55 &self,
56 query: &str,
57 scope: SearchScope,
58 limit: usize,
59 ) -> Result<SearchResult, RuntimeError>;
60 fn recent(&self, n: usize) -> Result<(u64, Vec<Message>), RuntimeError>;
61}
62
63pub struct HistoryStoreImpl {
64 project_index: Option<Arc<AnchorIndex>>,
65 session: Option<Arc<crate::session::Session>>,
66 current_session_id: Option<String>,
67 sessions_root: Option<PathBuf>,
68}
69
70impl HistoryStoreImpl {
71 pub fn new(
72 project_index: Option<Arc<AnchorIndex>>,
73 session: Option<Arc<crate::session::Session>>,
74 current_session_id: Option<String>,
75 sessions_root: Option<PathBuf>,
76 ) -> Self {
77 Self {
78 project_index,
79 session,
80 current_session_id,
81 sessions_root,
82 }
83 }
84
85 fn is_current_session(&self, session_id: &str) -> bool {
86 self.current_session_id
87 .as_deref()
88 .is_some_and(|sid| sid == session_id)
89 }
90
91 fn sqlite_available(&self) -> bool {
92 self.project_index.is_some()
93 }
94
95 fn session_dir(&self, session_id: &str) -> Result<PathBuf, RuntimeError> {
96 self.sessions_root
97 .as_ref()
98 .map(|root| root.join(session_id))
99 .ok_or_else(|| {
100 RuntimeError::ToolFailed(format!(
101 "history: no sessions_root to resolve session `{session_id}`"
102 ))
103 })
104 }
105
106 fn replay_from_jsonl(
107 &self,
108 session_id: &str,
109 role_filter: Option<&[&str]>,
110 ) -> Result<Vec<Message>, RuntimeError> {
111 let dir = self.session_dir(session_id)?;
112 let path = dir.join("events.jsonl");
113 let msgs = replay_all_messages_with_seq(&path).map_err(|e| {
114 RuntimeError::ToolFailed(format!("history: replay {}: {e}", path.display()))
115 })?;
116 let msgs: Vec<Message> = msgs.into_iter().map(|(_, m)| m).collect();
117 Ok(filter_messages_by_role(msgs, role_filter))
118 }
119}
120
121fn role_to_kind(role: &str) -> Option<&'static str> {
122 match role {
123 "user" => Some("user_msg"),
124 "assistant" => Some("assistant_msg"),
125 "tool" => Some("tool_result_msg"),
126 "system" => Some("system_msg"),
127 _ => None,
128 }
129}
130
131const MESSAGE_KINDS: &[&str] = &["user_msg", "assistant_msg", "tool_result_msg", "system_msg"];
132
133fn roles_to_kinds(roles: Option<&[&str]>) -> Vec<&'static str> {
134 match roles {
135 Some(rs) if !rs.is_empty() => rs
136 .iter()
137 .filter_map(|r| role_to_kind(r))
138 .collect::<Vec<_>>(),
139 _ => MESSAGE_KINDS.to_vec(),
140 }
141}
142
143fn filter_messages_by_role(msgs: Vec<Message>, roles: Option<&[&str]>) -> Vec<Message> {
144 match roles {
145 Some(rs) if !rs.is_empty() => msgs
146 .into_iter()
147 .filter(|m| rs.iter().any(|r| *r == m.role.as_str()))
148 .collect(),
149 _ => msgs,
150 }
151}
152
153fn extract_message_from_payload(payload: &str) -> Option<Message> {
154 let env: EventEnvelope = serde_json::from_str(payload).ok()?;
155 match env.event {
156 crate::event::Event::UserMsg { message, .. }
157 | crate::event::Event::AssistantMsg { message, .. }
158 | crate::event::Event::ToolResultMsg { message, .. }
159 | crate::event::Event::SystemMsg { message, .. } => Some(message),
160 _ => None,
161 }
162}
163
164fn rows_to_messages(rows: Vec<crate::index::ProjectEventRow>) -> Vec<Message> {
165 rows.into_iter()
166 .filter_map(|r| extract_message_from_payload(&r.payload))
167 .collect()
168}
169
170impl HistoryStore for HistoryStoreImpl {
171 fn count(&self, session_id: &str, role_filter: Option<&[&str]>) -> Result<u64, RuntimeError> {
172 let kinds = roles_to_kinds(role_filter);
173
174 if let Some(idx) = &self.project_index
175 && self.sqlite_available()
176 {
177 return idx
178 .count_events(session_id, Some(&kinds))
179 .map_err(|e| RuntimeError::ToolFailed(format!("history.count: {e}")));
180 }
181
182 if self.is_current_session(session_id)
183 && let Some(session) = &self.session
184 {
185 let msgs = session.messages_full();
186 let filtered = filter_messages_by_role(msgs.to_vec(), role_filter);
187 return Ok(filtered.len() as u64);
188 }
189
190 let msgs = self.replay_from_jsonl(session_id, role_filter)?;
191 Ok(msgs.len() as u64)
192 }
193
194 fn read(&self, query: HistoryQuery) -> Result<HistoryPage, RuntimeError> {
195 let HistoryQuery {
196 session_id,
197 offset,
198 limit,
199 role_filter,
200 } = query;
201 let role_strs: Option<Vec<&str>> = role_filter
202 .as_ref()
203 .map(|rs| rs.iter().map(|s| s.as_str()).collect());
204 let kinds = roles_to_kinds(role_strs.as_deref());
205 let offset0 = offset.saturating_sub(1);
206
207 if let Some(idx) = &self.project_index
208 && self.sqlite_available()
209 {
210 let total = idx
211 .count_events(&session_id, Some(&kinds))
212 .map_err(|e| RuntimeError::ToolFailed(format!("history.read count: {e}")))?;
213 let rows = idx
214 .read_events_paginated(&session_id, offset0, limit, Some(&kinds))
215 .map_err(|e| RuntimeError::ToolFailed(format!("history.read: {e}")))?;
216 let items = rows_to_messages(rows);
217 return Ok(HistoryPage {
218 total,
219 offset,
220 limit,
221 items,
222 });
223 }
224
225 let msgs = if self.is_current_session(&session_id)
226 && let Some(session) = &self.session
227 {
228 session.messages_full().to_vec()
229 } else {
230 self.replay_from_jsonl(&session_id, None)?
231 };
232 let filtered = filter_messages_by_role(msgs, role_strs.as_deref());
233 let total = filtered.len() as u64;
234 let end = (offset0 + limit).min(filtered.len());
235 let items = if offset0 >= filtered.len() {
236 Vec::new()
237 } else {
238 filtered[offset0..end].to_vec()
239 };
240 Ok(HistoryPage {
241 total,
242 offset,
243 limit,
244 items,
245 })
246 }
247
248 fn search(
249 &self,
250 query: &str,
251 scope: SearchScope,
252 limit: usize,
253 ) -> Result<SearchResult, RuntimeError> {
254 if query.trim().is_empty() {
255 return Err(RuntimeError::ToolFailed(
256 "history.search: empty query".into(),
257 ));
258 }
259
260 let session_filter = match &scope {
261 SearchScope::Project => None,
262 SearchScope::Session => self.current_session_id.clone(),
263 };
264
265 if let Some(idx) = &self.project_index {
266 let total = idx
267 .count_search_hits(query, session_filter.as_deref())
268 .map_err(|e| RuntimeError::ToolFailed(format!("history.search count: {e}")))?;
269 let rows = idx
270 .fts_search_project_events(query, session_filter.as_deref(), limit)
271 .map_err(|e| RuntimeError::ToolFailed(format!("history.search: {e}")))?;
272 let hits = rows
273 .into_iter()
274 .map(|r| {
275 let snippet: String = r
276 .payload
277 .chars()
278 .take(200)
279 .collect::<String>()
280 .replace('\n', " ");
281 SearchHit {
282 session_id: r.session_id,
283 seq: r.seq,
284 ts: r.ts,
285 kind: r.kind,
286 snippet,
287 }
288 })
289 .collect();
290 return Ok(SearchResult { total, hits });
291 }
292
293 if matches!(scope, SearchScope::Project) {
294 return Err(RuntimeError::ToolFailed(
295 "history.search: project scope requires project index".into(),
296 ));
297 }
298
299 if let Some(session) = &self.session {
300 let msgs = session.messages_full();
301 let query_lower = query.to_lowercase();
302 let mut hits = Vec::new();
303 let sid = self.current_session_id.clone().unwrap_or_default();
304 for (i, msg) in msgs.iter().enumerate() {
305 let text = msg.text_concat();
306 if text.to_lowercase().contains(&query_lower) {
307 let snippet: String = text.chars().take(200).collect();
308 hits.push(SearchHit {
309 session_id: sid.clone(),
310 seq: i as u64,
311 ts: String::new(),
312 kind: msg.role.as_str().to_string(),
313 snippet,
314 });
315 if hits.len() >= limit {
316 break;
317 }
318 }
319 }
320 let total = hits.len() as u64;
321 return Ok(SearchResult { total, hits });
322 }
323
324 Err(RuntimeError::ToolFailed(
325 "history.search: no project index or session on context".into(),
326 ))
327 }
328
329 fn recent(&self, n: usize) -> Result<(u64, Vec<Message>), RuntimeError> {
330 if let Some(session) = &self.session {
331 let msgs = session.messages_full();
332 let total = msgs.len() as u64;
333 if n == 0 {
334 return Ok((total, Vec::new()));
335 }
336 let start = msgs.len().saturating_sub(n);
337 let items = msgs[start..].to_vec();
338 return Ok((total, items));
339 }
340
341 if let Some(session_id) = &self.current_session_id {
342 let msgs = self.replay_from_jsonl(session_id, None)?;
343 let total = msgs.len() as u64;
344 if n == 0 {
345 return Ok((total, Vec::new()));
346 }
347 let start = msgs.len().saturating_sub(n);
348 let items = msgs[start..].to_vec();
349 return Ok((total, items));
350 }
351
352 Err(RuntimeError::ToolFailed(
353 "memory.recent_turns: no session available".into(),
354 ))
355 }
356}
357
358impl From<SessionOpenError> for RuntimeError {
359 fn from(e: SessionOpenError) -> Self {
360 RuntimeError::ToolFailed(format!("{e}"))
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::index::{AnchorIndex, ProjectEventInsert};
368 use crate::message::{Message, MessagePart, MessageRole};
369 use tempfile::TempDir;
370
371 fn user_msg(text: &str) -> Message {
372 Message {
373 role: MessageRole::User,
374 parts: vec![MessagePart::Text {
375 text: text.to_string(),
376 }],
377 turn_id: crate::event::TurnId::now(),
378 }
379 }
380
381 fn assistant_msg(text: &str) -> Message {
382 Message {
383 role: MessageRole::Assistant,
384 parts: vec![MessagePart::Text {
385 text: text.to_string(),
386 }],
387 turn_id: crate::event::TurnId::now(),
388 }
389 }
390
391 fn seed(idx: &AnchorIndex, sid: &str, seq: i64, kind: &str, text: &str) {
392 let payload = serde_json::json!({
393 "type": kind,
394 "seq": seq,
395 "turn_id": "019f0000-0000-7000-0000-000000000001",
396 "message": {
397 "role": if kind == "user_msg" { "user" } else { "assistant" },
398 "parts": [{"type": "text", "text": text}],
399 "turn_id": "019f0000-0000-7000-0000-000000000001"
400 },
401 "ts": "2026-07-08T00:00:00Z"
402 });
403 idx.insert_project_event_raw(ProjectEventInsert {
404 session_id: sid,
405 seq,
406 ts: "2026-07-08T00:00:00Z",
407 kind,
408 turn_id: Some("019f0000-0000-7000-0000-000000000001"),
409 flow_run_id: None,
410 text_content: text,
411 payload_json: &payload.to_string(),
412 })
413 .unwrap();
414 }
415
416 #[test]
417 fn count_via_sqlite() {
418 let dir = TempDir::new().unwrap();
419 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
420 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
421 seed(
422 store.project_index.as_ref().unwrap(),
423 "s1",
424 1,
425 "user_msg",
426 "hello",
427 );
428 seed(
429 store.project_index.as_ref().unwrap(),
430 "s1",
431 2,
432 "assistant_msg",
433 "hi",
434 );
435 seed(
436 store.project_index.as_ref().unwrap(),
437 "s1",
438 3,
439 "user_msg",
440 "bye",
441 );
442 assert_eq!(store.count("s1", None).unwrap(), 3);
443 assert_eq!(store.count("s1", Some(&["user"])).unwrap(), 2);
444 assert_eq!(store.count("s1", Some(&["assistant"])).unwrap(), 1);
445 }
446
447 #[test]
448 fn read_via_sqlite_paginated() {
449 let dir = TempDir::new().unwrap();
450 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
451 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
452 for i in 1..=5 {
453 seed(
454 store.project_index.as_ref().unwrap(),
455 "s1",
456 i,
457 "user_msg",
458 &format!("msg {i}"),
459 );
460 }
461 let page = store
462 .read(HistoryQuery {
463 session_id: "s1".into(),
464 offset: 2,
465 limit: 2,
466 role_filter: None,
467 })
468 .unwrap();
469 assert_eq!(page.total, 5);
470 assert_eq!(page.offset, 2);
471 assert_eq!(page.limit, 2);
472 assert_eq!(page.items.len(), 2);
473 assert_eq!(page.items[0].text_concat(), "msg 2");
474 assert_eq!(page.items[1].text_concat(), "msg 3");
475 }
476
477 #[test]
478 fn read_via_sqlite_role_filter() {
479 let dir = TempDir::new().unwrap();
480 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
481 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
482 seed(
483 store.project_index.as_ref().unwrap(),
484 "s1",
485 1,
486 "user_msg",
487 "u1",
488 );
489 seed(
490 store.project_index.as_ref().unwrap(),
491 "s1",
492 2,
493 "assistant_msg",
494 "a1",
495 );
496 seed(
497 store.project_index.as_ref().unwrap(),
498 "s1",
499 3,
500 "user_msg",
501 "u2",
502 );
503 let page = store
504 .read(HistoryQuery {
505 session_id: "s1".into(),
506 offset: 1,
507 limit: 100,
508 role_filter: Some(vec!["user".into()]),
509 })
510 .unwrap();
511 assert_eq!(page.total, 2);
512 assert_eq!(page.items.len(), 2);
513 assert_eq!(page.items[0].text_concat(), "u1");
514 assert_eq!(page.items[1].text_concat(), "u2");
515 }
516
517 #[test]
518 fn search_returns_total_and_hits() {
519 let dir = TempDir::new().unwrap();
520 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
521 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
522 seed(
523 store.project_index.as_ref().unwrap(),
524 "s1",
525 1,
526 "user_msg",
527 "hello world",
528 );
529 seed(
530 store.project_index.as_ref().unwrap(),
531 "s1",
532 2,
533 "user_msg",
534 "hello again",
535 );
536 seed(
537 store.project_index.as_ref().unwrap(),
538 "s1",
539 3,
540 "user_msg",
541 "goodbye",
542 );
543 let result = store.search("hello", SearchScope::Session, 10).unwrap();
544 assert_eq!(result.total, 2);
545 assert_eq!(result.hits.len(), 2);
546 }
547
548 #[test]
549 fn search_cjk() {
550 let dir = TempDir::new().unwrap();
551 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
552 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
553 seed(
554 store.project_index.as_ref().unwrap(),
555 "s1",
556 1,
557 "user_msg",
558 "浮动面板设计",
559 );
560 let result = store.search("浮动", SearchScope::Session, 10).unwrap();
561 assert_eq!(result.total, 1);
562 assert_eq!(result.hits.len(), 1);
563 }
564
565 #[test]
566 fn count_empty_session_via_sqlite() {
567 let dir = TempDir::new().unwrap();
568 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
569 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
570 assert_eq!(store.count("no-such", None).unwrap(), 0);
571 }
572
573 #[test]
574 fn read_empty_session_via_sqlite() {
575 let dir = TempDir::new().unwrap();
576 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
577 let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
578 let page = store
579 .read(HistoryQuery {
580 session_id: "no-such".into(),
581 offset: 1,
582 limit: 10,
583 role_filter: None,
584 })
585 .unwrap();
586 assert_eq!(page.total, 0);
587 assert!(page.items.is_empty());
588 }
589
590 #[test]
596 fn fallback_replay_from_jsonl() {
597 let dir = TempDir::new().unwrap();
598 let sessions_root = dir.path().join("sessions");
599 let sid = "test-sid";
600 let session_dir = sessions_root.join(sid);
601 std::fs::create_dir_all(&session_dir).unwrap();
602 let events_path = session_dir.join("events.jsonl");
603 let u1 = user_msg("first");
604 let a1 = assistant_msg("second");
605 let env1 = crate::event::EventEnvelope::new(
606 1,
607 crate::event::Event::UserMsg {
608 turn_id: u1.turn_id.clone(),
609 message: u1,
610 },
611 );
612 let env2 = crate::event::EventEnvelope::new(
613 2,
614 crate::event::Event::AssistantMsg {
615 turn_id: a1.turn_id.clone(),
616 flow_run_id: None,
617 message: a1,
618 },
619 );
620 let line1 = serde_json::to_string(&env1).unwrap();
621 let line2 = serde_json::to_string(&env2).unwrap();
622 std::fs::write(&events_path, format!("{line1}\n{line2}\n")).unwrap();
623
624 let store = HistoryStoreImpl::new(None, None, Some(sid.into()), Some(sessions_root));
625 assert_eq!(store.count(sid, None).unwrap(), 2);
626 assert_eq!(store.count(sid, Some(&["user"])).unwrap(), 1);
627 assert_eq!(store.count(sid, Some(&["assistant"])).unwrap(), 1);
628
629 let page = store
630 .read(HistoryQuery {
631 session_id: sid.into(),
632 offset: 1,
633 limit: 10,
634 role_filter: None,
635 })
636 .unwrap();
637 assert_eq!(page.total, 2);
638 assert_eq!(page.items.len(), 2);
639 assert_eq!(page.items[0].text_concat(), "first");
640 assert_eq!(page.items[1].text_concat(), "second");
641 }
642
643 #[test]
644 fn fallback_replay_paginated() {
645 let dir = TempDir::new().unwrap();
646 let sessions_root = dir.path().join("sessions");
647 let sid = "test-sid";
648 let session_dir = sessions_root.join(sid);
649 std::fs::create_dir_all(&session_dir).unwrap();
650 let events_path = session_dir.join("events.jsonl");
651 let mut lines = Vec::new();
652 for i in 1..=5 {
653 let m = user_msg(&format!("msg {i}"));
654 let env = crate::event::EventEnvelope::new(
655 i,
656 crate::event::Event::UserMsg {
657 turn_id: m.turn_id.clone(),
658 message: m,
659 },
660 );
661 lines.push(serde_json::to_string(&env).unwrap());
662 }
663 std::fs::write(&events_path, lines.join("\n") + "\n").unwrap();
664
665 let store = HistoryStoreImpl::new(None, None, Some(sid.into()), Some(sessions_root));
666 let page = store
667 .read(HistoryQuery {
668 session_id: sid.into(),
669 offset: 2,
670 limit: 2,
671 role_filter: None,
672 })
673 .unwrap();
674 assert_eq!(page.total, 5);
675 assert_eq!(page.items.len(), 2);
676 assert_eq!(page.items[0].text_concat(), "msg 2");
677 assert_eq!(page.items[1].text_concat(), "msg 3");
678 }
679
680 #[test]
681 fn search_empty_query_errors() {
682 let dir = TempDir::new().unwrap();
683 let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
684 let store = HistoryStoreImpl::new(Some(idx), None, None, None);
685 let err = store.search("", SearchScope::Session, 10).unwrap_err();
686 assert!(matches!(err, RuntimeError::ToolFailed(_)));
687 }
688
689 #[test]
690 fn search_project_scope_without_index_errors() {
691 let store = HistoryStoreImpl::new(None, None, None, None);
692 let err = store.search("hello", SearchScope::Project, 10).unwrap_err();
693 assert!(matches!(err, RuntimeError::ToolFailed(_)));
694 }
695}