Skip to main content

harness_core/
memory.rs

1//! Long-term, cross-session memory.
2//!
3//! Short-term context lives in [`crate::Context`]; the [`crate::Compactor`]
4//! keeps it within budget *within a single run*. Long-term memory is what
5//! survives across runs — the dataset that turns a generic assistant into a
6//! personalised one. Per Harrison Chase / Sarah Wooders: **memory is the
7//! harness**. To keep the framework's "open harness" promise the memory layer
8//! must be:
9//!
10//! - **owned by the operator** (no provider-side stateful APIs),
11//! - **transferable** (a swap to a different model must not lose memory),
12//! - **inspectable** (plain on-disk format, no opaque tokens).
13//!
14//! This module ships the trait + types. Concrete backends live in
15//! [`harness_context::FileMemory`] (JSONL) and downstream crates.
16//!
17//! ## Wiring
18//!
19//! - A `MemoryGuide` from `harness-rs-loop` calls [`Memory::recall`] at the
20//!   start of every session and injects the top-K matches into the system
21//!   prompt.
22//! - A `MemoryWriter` hook captures the final assistant text on
23//!   `Event::TaskCompleted` and calls [`Memory::write`].
24//! - Tools may use the same `Arc<dyn Memory>` to commit explicit facts mid-run.
25
26use serde::{Deserialize, Serialize};
27
28/// One persisted memory record.
29///
30/// Owned (no borrows) so it round-trips through serde and across .await
31/// boundaries cleanly. Fields are intentionally minimal — apps that need
32/// richer schemas can wrap this with their own struct and store JSON in
33/// `content`.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[non_exhaustive]
36pub struct MemoryEntry {
37    /// Stable id assigned by the backend. Empty if the caller has not yet
38    /// committed the entry.
39    #[serde(default)]
40    pub id: String,
41    /// Free-form fact / summary text. This is what recall returns and what
42    /// gets injected into a future system prompt.
43    pub content: String,
44    /// Optional keywords for cheap retrieval. Backends without semantic
45    /// indexing fall back to keyword match across `content` + `tags`.
46    #[serde(default)]
47    pub tags: Vec<String>,
48    /// Where the entry came from (session id, user, app name, …). Useful
49    /// for debugging and for multi-tenant filtering.
50    #[serde(default)]
51    pub source: Option<String>,
52    /// Milliseconds since unix epoch.
53    pub created_ms: i64,
54    /// Optional expiry time as milliseconds since unix epoch. `None` =
55    /// retain indefinitely. Backends MUST filter expired entries out of
56    /// `recall` and MAY drop them on a background compact pass.
57    ///
58    /// Use `with_ttl_days(N)` to set this relative to now. Use `None` for
59    /// stable preferences / identity / long-term project context; use a
60    /// finite TTL for ephemeral state (current task params, session-scoped
61    /// preferences, …).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub expires_ms: Option<i64>,
64}
65
66impl MemoryEntry {
67    /// Convenience constructor. The backend assigns `id` on write.
68    pub fn new(content: impl Into<String>) -> Self {
69        Self {
70            id: String::new(),
71            content: content.into(),
72            tags: Vec::new(),
73            source: None,
74            created_ms: 0,
75            expires_ms: None,
76        }
77    }
78
79    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
80        self.tags = tags.into_iter().map(Into::into).collect();
81        self
82    }
83
84    pub fn with_source(mut self, source: impl Into<String>) -> Self {
85        self.source = Some(source.into());
86        self
87    }
88
89    /// Set `expires_ms` to "now + `days` days" using the system clock. For
90    /// tests that need a fixed clock, set `expires_ms` directly.
91    pub fn with_ttl_days(mut self, days: u32) -> Self {
92        let now_ms = std::time::SystemTime::now()
93            .duration_since(std::time::UNIX_EPOCH)
94            .map(|d| d.as_millis() as i64)
95            .unwrap_or(0);
96        self.expires_ms = Some(now_ms + (days as i64) * 86_400_000);
97        self
98    }
99
100    /// Returns `true` if this entry has an `expires_ms` set and that
101    /// timestamp is now in the past. Backends call this from `recall` and
102    /// `compact` to skip stale entries.
103    pub fn is_expired(&self, now_ms: i64) -> bool {
104        matches!(self.expires_ms, Some(t) if t <= now_ms)
105    }
106}
107
108/// The open-memory primitive.
109///
110/// Implementations:
111/// - **File-backed JSONL** ([`harness_context::FileMemory`]) — append-only,
112///   keyword recall, no extra deps. Default for the bundled examples.
113/// - Future: SQLite, sled, Postgres, vector-DB-backed semantic recall. Plug
114///   in by implementing this trait; nothing else in the framework changes.
115#[async_trait::async_trait]
116pub trait Memory: Send + Sync {
117    /// Return up to `k` entries most relevant to `query`, ordered by
118    /// descending relevance. The query is typically the current task
119    /// description; backends choose how to score (keyword, embedding, BM25…).
120    /// Returning an empty `Vec` is fine and must not be treated as an error.
121    async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError>;
122
123    /// Persist `entry`. The backend assigns the `id` field; callers may
124    /// leave it empty. Implementations must be safe to call concurrently
125    /// from multiple tasks.
126    async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError>;
127}
128
129#[derive(Debug, thiserror::Error)]
130#[non_exhaustive]
131pub enum MemoryError {
132    #[error("memory io: {0}")]
133    Io(String),
134    #[error("memory backend: {0}")]
135    Backend(String),
136    #[error("memory serde: {0}")]
137    Serde(String),
138}