Skip to main content

harness_core/
recall.rs

1//! Cross-session conversation recall.
2//!
3//! Where [`crate::Memory`] stores curated facts, `RecallStore` stores the raw
4//! transcript of every session so the agent can later search what was actually
5//! said ("what did the user ask three weeks ago"). Same open-harness promise:
6//! operator-owned, transferable, inspectable.
7//!
8//! - Trait + types live here (dependency-light).
9//! - Default file backend: [`harness_context::FileRecall`] (JSONL).
10//! - FTS5 backend: the optional `harness-recall-sqlite` crate.
11//!
12//! ## Wiring
13//! `AgentLoop::with_recall(store)` captures each turn into the store and
14//! registers the `session_search` tool. Owner + session id are read from
15//! `World.profile.extra["recall_owner"|"recall_session"]`.
16
17use serde::{Deserialize, Serialize};
18
19/// One transcript message in a recall session.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct RecallMessage {
23    /// Monotonic id within the session, assigned by the store on append.
24    /// 0 on input.
25    #[serde(default)]
26    pub id: i64,
27    /// "user" | "assistant" | "tool" | "system".
28    pub role: String,
29    /// Message text (assistant text, user prompt, or tool result body).
30    pub content: String,
31    /// For tool messages: the tool name.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub tool_name: Option<String>,
34    /// For assistant messages: JSON-encoded tool-call array, if any.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub tool_calls: Option<String>,
37    /// Milliseconds since unix epoch.
38    pub ts_ms: i64,
39}
40
41impl RecallMessage {
42    pub fn new(role: impl Into<String>, content: impl Into<String>, ts_ms: i64) -> Self {
43        Self {
44            id: 0,
45            role: role.into(),
46            content: content.into(),
47            tool_name: None,
48            tool_calls: None,
49            ts_ms,
50        }
51    }
52    pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
53        self.tool_name = Some(name.into());
54        self
55    }
56    pub fn with_tool_calls(mut self, calls: impl Into<String>) -> Self {
57        self.tool_calls = Some(calls.into());
58        self
59    }
60}
61
62/// Metadata about one session.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct SessionMeta {
66    pub session_id: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub title: Option<String>,
69    /// App-defined origin: "cli" | "web" | …
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub source: Option<String>,
72    pub started_at_ms: i64,
73    #[serde(default)]
74    pub message_count: i64,
75}
76
77impl SessionMeta {
78    pub fn new(session_id: impl Into<String>, started_at_ms: i64) -> Self {
79        Self {
80            session_id: session_id.into(),
81            title: None,
82            source: None,
83            started_at_ms,
84            message_count: 0,
85        }
86    }
87}
88
89/// A search hit: the matched session plus enough surrounding messages for the
90/// agent to orient (Hermes-style bookends + a window around the anchor).
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[non_exhaustive]
93pub struct SessionHit {
94    pub session: SessionMeta,
95    /// Excerpt with match markers (`>>>match<<<`).
96    pub snippet: String,
97    /// Id of the matched message.
98    pub anchor_id: i64,
99    /// First few messages of the session.
100    pub bookend_start: Vec<RecallMessage>,
101    /// ±window messages around the anchor.
102    pub around: Vec<RecallMessage>,
103    /// Last few messages of the session.
104    pub bookend_end: Vec<RecallMessage>,
105}
106
107impl SessionHit {
108    pub fn new(
109        session: SessionMeta,
110        snippet: String,
111        anchor_id: i64,
112        bookend_start: Vec<RecallMessage>,
113        around: Vec<RecallMessage>,
114        bookend_end: Vec<RecallMessage>,
115    ) -> Self {
116        Self {
117            session,
118            snippet,
119            anchor_id,
120            bookend_start,
121            around,
122            bookend_end,
123        }
124    }
125}
126
127#[derive(Debug, thiserror::Error)]
128#[non_exhaustive]
129pub enum RecallError {
130    #[error("recall io: {0}")]
131    Io(String),
132    #[error("recall backend: {0}")]
133    Backend(String),
134    #[error("recall serde: {0}")]
135    Serde(String),
136    #[error("not found: {0}")]
137    NotFound(String),
138}
139
140/// Cross-session transcript store. All methods are owner-scoped: a given
141/// `owner` can never see another owner's sessions.
142#[async_trait::async_trait]
143pub trait RecallStore: Send + Sync + 'static {
144    /// Create/refresh the session row (idempotent).
145    async fn ensure_session(
146        &self,
147        owner: &str,
148        session_id: &str,
149        meta: &SessionMeta,
150    ) -> Result<(), RecallError>;
151
152    /// Append one message; returns the assigned id.
153    async fn append(
154        &self,
155        owner: &str,
156        session_id: &str,
157        msg: &RecallMessage,
158    ) -> Result<i64, RecallError>;
159
160    /// Discovery: top sessions matching `query`, with snippet + bookends.
161    async fn search(
162        &self,
163        owner: &str,
164        query: &str,
165        limit: usize,
166    ) -> Result<Vec<SessionHit>, RecallError>;
167
168    /// Scroll: messages with id in `[around - window, around + window]`.
169    async fn scroll(
170        &self,
171        owner: &str,
172        session_id: &str,
173        around: i64,
174        window: usize,
175    ) -> Result<Vec<RecallMessage>, RecallError>;
176
177    /// Browse: the owner's most recent sessions, newest first.
178    async fn recent(&self, owner: &str, limit: usize) -> Result<Vec<SessionMeta>, RecallError>;
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn types_round_trip_through_serde() {
187        let m = RecallMessage::new("assistant", "hello", 123).with_tool_calls("[]");
188        let j = serde_json::to_string(&m).unwrap();
189        let back: RecallMessage = serde_json::from_str(&j).unwrap();
190        assert_eq!(back.role, "assistant");
191        assert_eq!(back.tool_calls.as_deref(), Some("[]"));
192        assert!(back.tool_name.is_none());
193
194        let hit = SessionHit {
195            session: SessionMeta::new("s1", 1),
196            snippet: ">>>hi<<<".into(),
197            anchor_id: 1,
198            bookend_start: vec![m.clone()],
199            around: vec![m.clone()],
200            bookend_end: vec![m],
201        };
202        let j = serde_json::to_string(&hit).unwrap();
203        assert!(j.contains("\"anchor_id\":1"));
204    }
205}