use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::store::StoreError;
pub const MEMORY_SCHEMA: &str = "roteiro.memory/v1";
pub const DEFAULT_MEMORY_SCOPE: &str = "repo";
pub const MAX_MEMORY_BODY: usize = 64 * 1024;
pub const MAX_MEMORY_SCOPE: usize = 128;
#[derive(Debug, thiserror::Error)]
pub enum MemoryError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(
"invalid scope {0:?} (expected 1 to {MAX_MEMORY_SCOPE} bytes, no control characters, no surrounding whitespace)"
)]
InvalidScope(String),
#[error("invalid body: {0}")]
InvalidBody(String),
#[error("invalid confidence {0}: expected a finite number in [0.0, 1.0]")]
InvalidConfidence(f64),
#[error("no memory record with id {0}")]
NotFound(i64),
#[error("memory record {id} is already superseded by {by}")]
AlreadySuperseded {
id: i64,
by: i64,
},
#[error("corrupt memory record: {0}")]
Corrupt(String),
}
impl From<rusqlite::Error> for MemoryError {
fn from(err: rusqlite::Error) -> Self {
Self::Store(StoreError::Sqlite(err))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryKind {
Lesson,
Attempt,
Decision,
Pattern,
Outcome,
}
impl MemoryKind {
pub const ALL: [Self; 5] = [
Self::Lesson,
Self::Attempt,
Self::Decision,
Self::Pattern,
Self::Outcome,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Lesson => "lesson",
Self::Attempt => "attempt",
Self::Decision => "decision",
Self::Pattern => "pattern",
Self::Outcome => "outcome",
}
}
#[must_use]
pub fn from_token(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == s)
}
}
impl std::fmt::Display for MemoryKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for MemoryKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_token(s).ok_or_else(|| {
let known = Self::ALL.map(Self::as_str).join(", ");
format!("unknown memory kind {s:?} (expected one of: {known})")
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnchorState {
Unanchored,
Valid,
Drifted,
Vanished,
Unverifiable,
}
impl AnchorState {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Unanchored => "unanchored",
Self::Valid => "valid",
Self::Drifted => "drifted",
Self::Vanished => "vanished",
Self::Unverifiable => "unverifiable",
}
}
#[must_use]
pub fn applies(self) -> bool {
matches!(self, Self::Unanchored | Self::Valid)
}
#[must_use]
pub fn is_stale(self) -> bool {
matches!(self, Self::Drifted | Self::Vanished)
}
}
impl std::fmt::Display for AnchorState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryAnchor {
pub key: String,
pub blob: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemoryRecord {
pub id: i64,
pub scope: String,
pub kind: MemoryKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor: Option<MemoryAnchor>,
pub anchor_state: AnchorState,
pub applies: bool,
pub body: String,
pub confidence: Option<f64>,
pub tree: Option<String>,
pub created_at: String,
pub superseded_by: Option<i64>,
pub superseded_at: Option<String>,
}
impl MemoryRecord {
#[must_use]
pub fn is_live(&self) -> bool {
self.superseded_by.is_none()
}
}
#[derive(Debug, Clone, Copy)]
pub struct MemoryWrite<'a> {
pub scope: &'a str,
pub kind: MemoryKind,
pub anchor: Option<&'a str>,
pub body: &'a str,
pub confidence: Option<f64>,
pub supersedes: Option<i64>,
}
impl MemoryWrite<'_> {
pub fn validate(&self) -> Result<(), MemoryError> {
if self.scope.is_empty()
|| self.scope.len() > MAX_MEMORY_SCOPE
|| self.scope.trim() != self.scope
|| self.scope.chars().any(char::is_control)
{
return Err(MemoryError::InvalidScope(self.scope.to_owned()));
}
if self.body.trim().is_empty() {
return Err(MemoryError::InvalidBody(
"it is empty or only whitespace".to_owned(),
));
}
if self.body.len() > MAX_MEMORY_BODY {
return Err(MemoryError::InvalidBody(format!(
"it is {} bytes, over the {MAX_MEMORY_BODY}-byte limit",
self.body.len()
)));
}
if let Some(confidence) = self.confidence
&& !(confidence.is_finite() && (0.0..=1.0).contains(&confidence))
{
return Err(MemoryError::InvalidConfidence(confidence));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MemoryFilter<'a> {
pub scope: Option<&'a str>,
pub kind: Option<MemoryKind>,
pub anchor_key: Option<&'a str>,
pub include_superseded: bool,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemoryListing {
pub schema: &'static str,
pub records: Vec<MemoryRecord>,
pub live: u64,
pub superseded: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryForgotten {
pub id: i64,
pub restored: Vec<i64>,
}
const RECORD_COLS: &str = "m.id, m.scope, m.kind, m.anchor_key, m.anchor_blob, m.anchor_path, \
m.body, m.confidence, m.tree, m.created_at, m.superseded_by, m.superseded_at, \
n.key, n.blob_hash";
const RECORD_FROM: &str = " FROM agent_memory m LEFT JOIN nodes n ON n.key = m.anchor_key";
pub(crate) fn record(conn: &Connection, write: &MemoryWrite<'_>) -> Result<i64, MemoryError> {
write.validate()?;
if let Some(target) = write.supersedes {
let existing: Option<Option<i64>> = conn
.query_row(
"SELECT superseded_by FROM agent_memory WHERE id = ?1",
[target],
|r| r.get(0),
)
.optional()?;
match existing {
None => return Err(MemoryError::NotFound(target)),
Some(Some(by)) => return Err(MemoryError::AlreadySuperseded { id: target, by }),
Some(None) => {}
}
}
let anchor: Option<(Option<String>, Option<String>)> = match write.anchor {
Some(key) => Some(
conn.query_row(
"SELECT blob_hash, path FROM nodes WHERE key = ?1",
[key],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?
.unwrap_or((None, None)),
),
None => None,
};
let tree: Option<String> = conn
.query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
.optional()?;
conn.execute(
"INSERT INTO agent_memory (
scope, kind, anchor_key, anchor_blob, anchor_path, body, confidence, tree
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
write.scope,
write.kind.as_str(),
write.anchor,
anchor.as_ref().and_then(|(blob, _)| blob.as_deref()),
anchor.as_ref().and_then(|(_, path)| path.as_deref()),
write.body,
write.confidence,
tree,
],
)?;
let id = conn.last_insert_rowid();
if let Some(target) = write.supersedes {
conn.execute(
"UPDATE agent_memory
SET superseded_by = ?1, superseded_at = datetime('now')
WHERE id = ?2",
params![id, target],
)?;
}
Ok(id)
}
pub(crate) fn records(
conn: &Connection,
filter: &MemoryFilter<'_>,
) -> Result<Vec<MemoryRecord>, StoreError> {
let mut where_parts: Vec<&str> = Vec::new();
let mut bound: Vec<String> = Vec::new();
if let Some(scope) = filter.scope {
where_parts.push("m.scope = ?");
bound.push(scope.to_owned());
}
if let Some(kind) = filter.kind {
where_parts.push("m.kind = ?");
bound.push(kind.as_str().to_owned());
}
if let Some(key) = filter.anchor_key {
where_parts.push("m.anchor_key = ?");
bound.push(key.to_owned());
}
if !filter.include_superseded {
where_parts.push("m.superseded_by IS NULL");
}
let clause = if where_parts.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_parts.join(" AND "))
};
let limit = match filter.limit {
Some(n) => format!(" LIMIT {n}"),
None => String::new(),
};
let sql = format!("SELECT {RECORD_COLS}{RECORD_FROM}{clause} ORDER BY m.id DESC{limit}");
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bound))?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
out.push(record_from_row(row)?);
}
Ok(out)
}
pub(crate) fn get(conn: &Connection, id: i64) -> Result<Option<MemoryRecord>, StoreError> {
let sql = format!("SELECT {RECORD_COLS}{RECORD_FROM} WHERE m.id = ?1");
conn.query_row(&sql, [id], |row| Ok(record_from_row(row)))
.optional()?
.transpose()
}
pub(crate) fn forget(conn: &Connection, id: i64) -> Result<Option<MemoryForgotten>, StoreError> {
let present: Option<i64> = conn
.query_row("SELECT id FROM agent_memory WHERE id = ?1", [id], |r| {
r.get(0)
})
.optional()?;
if present.is_none() {
return Ok(None);
}
let restored: Vec<i64> = {
let mut stmt =
conn.prepare("SELECT id FROM agent_memory WHERE superseded_by = ?1 ORDER BY id")?;
let mut rows = stmt.query([id])?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
out.push(row.get::<_, i64>(0)?);
}
out
};
conn.execute(
"UPDATE agent_memory SET superseded_by = NULL, superseded_at = NULL
WHERE superseded_by = ?1",
[id],
)?;
conn.execute("DELETE FROM agent_memory WHERE id = ?1", [id])?;
Ok(Some(MemoryForgotten { id, restored }))
}
pub(crate) fn counts(conn: &Connection) -> Result<(u64, u64), StoreError> {
let (live, superseded): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(superseded_by IS NULL), 0), COALESCE(SUM(superseded_by IS NOT NULL), 0)
FROM agent_memory",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((
u64::try_from(live).unwrap_or(0),
u64::try_from(superseded).unwrap_or(0),
))
}
fn record_from_row(row: &rusqlite::Row<'_>) -> Result<MemoryRecord, StoreError> {
let kind_token: String = row.get(2)?;
let kind = MemoryKind::from_token(&kind_token)
.ok_or_else(|| StoreError::Corrupt(format!("unknown memory kind: {kind_token}")))?;
let anchor_key: Option<String> = row.get(3)?;
let anchor_blob: Option<String> = row.get(4)?;
let node_key: Option<String> = row.get(12)?;
let node_blob: Option<String> = row.get(13)?;
let anchor_state = match (&anchor_key, &node_key) {
(None, _) => AnchorState::Unanchored,
(Some(_), None) => AnchorState::Vanished,
(Some(_), Some(_)) => match (&anchor_blob, &node_blob) {
(Some(captured), Some(current)) if captured == current => AnchorState::Valid,
(Some(_), Some(_)) => AnchorState::Drifted,
_ => AnchorState::Unverifiable,
},
};
let anchor = anchor_key.map(|key| MemoryAnchor {
key,
blob: anchor_blob,
path: row.get(5).unwrap_or(None),
});
Ok(MemoryRecord {
id: row.get(0)?,
scope: row.get(1)?,
kind,
anchor,
anchor_state,
applies: anchor_state.applies(),
body: row.get(6)?,
confidence: row.get(7)?,
tree: row.get(8)?,
created_at: row.get(9)?,
superseded_by: row.get(10)?,
superseded_at: row.get(11)?,
})
}
#[cfg(test)]
mod tests {
use super::{
AnchorState, DEFAULT_MEMORY_SCOPE, MAX_MEMORY_BODY, MAX_MEMORY_SCOPE, MemoryKind,
MemoryWrite,
};
fn write(body: &str) -> MemoryWrite<'_> {
MemoryWrite {
scope: DEFAULT_MEMORY_SCOPE,
kind: MemoryKind::Lesson,
anchor: None,
body,
confidence: None,
supersedes: None,
}
}
#[test]
fn kind_tokens_round_trip_and_reject_the_unknown() {
for kind in MemoryKind::ALL {
assert_eq!(MemoryKind::from_token(kind.as_str()), Some(kind));
assert_eq!(kind.to_string(), kind.as_str());
}
assert_eq!(MemoryKind::from_token("note"), None);
assert_eq!(MemoryKind::from_token("Lesson"), None);
let err = "note".parse::<MemoryKind>().expect_err("unknown kind");
assert!(
err.contains("lesson"),
"the error lists the vocabulary: {err}"
);
}
#[test]
fn anchor_state_tokens_and_staleness() {
assert!(AnchorState::Drifted.is_stale());
assert!(AnchorState::Vanished.is_stale());
for state in [
AnchorState::Unanchored,
AnchorState::Valid,
AnchorState::Unverifiable,
] {
assert!(!state.is_stale(), "{state} must not read as stale");
}
assert_eq!(AnchorState::Vanished.to_string(), "vanished");
}
#[test]
fn validation_names_what_was_actually_wrong() {
write("a real lesson").validate().expect("the good case");
let over_long = "x".repeat(MAX_MEMORY_BODY + 1);
for (case, w) in [
("empty body", write("")),
("whitespace body", write(" \n\t ")),
("over-long body", write(&over_long)),
] {
assert!(w.validate().is_err(), "{case} must be refused");
}
let long_scope = "s".repeat(MAX_MEMORY_SCOPE + 1);
for scope in ["", " repo", "repo ", "re\npo", &long_scope] {
let w = MemoryWrite {
scope,
..write("body")
};
assert!(w.validate().is_err(), "scope {scope:?} must be refused");
}
for confidence in [Some(0.0), Some(1.0), Some(0.5), None] {
let w = MemoryWrite {
confidence,
..write("body")
};
w.validate().expect("a probability is fine");
}
for confidence in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
let w = MemoryWrite {
confidence: Some(confidence),
..write("body")
};
assert!(
w.validate().is_err(),
"{confidence} is not a probability and must be refused"
);
}
}
}