use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RecordId(pub String);
impl RecordId {
pub fn from_parts(adapter: &str, instance: Option<&str>, native_id: &str) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(adapter.as_bytes());
hasher.update(b":");
hasher.update(instance.unwrap_or("").as_bytes());
hasher.update(b":");
hasher.update(native_id.as_bytes());
Self(hasher.finalize().to_hex().to_string())
}
}
impl std::fmt::Display for RecordId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceDescriptor {
pub adapter: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Embedding {
pub vector: Vec<f32>,
pub model: String,
pub dim: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
Project,
Session,
Ephemeral,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Fact,
Preference,
Feedback,
Reference,
Episode,
Skill,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub native_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_path: Option<String>,
pub captured_at: DateTime<Utc>,
pub raw_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derived_from: Option<RecordId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnamnesisRecord {
pub id: RecordId,
pub source: SourceDescriptor,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<Embedding>,
pub scope: Scope,
pub kind: Kind,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: serde_json::Map<String, serde_json::Value>,
pub provenance: Provenance,
pub schema_version: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_id_is_deterministic() {
let a = RecordId::from_parts("claude-code", Some("default"), "abc123");
let b = RecordId::from_parts("claude-code", Some("default"), "abc123");
let c = RecordId::from_parts("claude-code", Some("other"), "abc123");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn schema_version_is_one() {
assert_eq!(SCHEMA_VERSION, 1);
}
#[test]
fn record_roundtrips_through_json() {
let r = AnamnesisRecord {
id: RecordId::from_parts("claude-code", None, "x"),
source: SourceDescriptor {
adapter: "claude-code".into(),
instance: None,
version: "0.0.1".into(),
},
content: "user prefers vim".into(),
embedding: None,
scope: Scope::User,
kind: Kind::Preference,
created_at: Utc::now(),
updated_at: None,
tags: vec!["editor".into()],
metadata: Default::default(),
provenance: Provenance {
native_id: "x".into(),
native_path: Some("memory/editor.md".into()),
captured_at: Utc::now(),
raw_hash: "deadbeef".into(),
derived_from: None,
},
schema_version: SCHEMA_VERSION,
};
let s = serde_json::to_string(&r).unwrap();
let back: AnamnesisRecord = serde_json::from_str(&s).unwrap();
assert_eq!(r, back);
}
}