use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::soch::{SochSchema, SochType, SochValue};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EpisodeType {
Conversation,
Task,
Workflow,
Debug,
AgentInteraction,
Other,
}
impl EpisodeType {
pub fn as_str(&self) -> &'static str {
match self {
EpisodeType::Conversation => "conversation",
EpisodeType::Task => "task",
EpisodeType::Workflow => "workflow",
EpisodeType::Debug => "debug",
EpisodeType::AgentInteraction => "agent_interaction",
EpisodeType::Other => "other",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"conversation" => EpisodeType::Conversation,
"task" => EpisodeType::Task,
"workflow" => EpisodeType::Workflow,
"debug" => EpisodeType::Debug,
"agent_interaction" => EpisodeType::AgentInteraction,
_ => EpisodeType::Other,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Episode {
pub episode_id: String,
pub episode_type: EpisodeType,
pub entity_ids: Vec<String>,
pub ts_start: u64,
pub ts_end: u64,
pub summary: String,
pub tags: Vec<String>,
pub embedding: Option<Vec<f32>>,
pub metadata: HashMap<String, SochValue>,
}
impl Episode {
pub fn new(episode_id: impl Into<String>, episode_type: EpisodeType) -> Self {
Self {
episode_id: episode_id.into(),
episode_type,
entity_ids: Vec::new(),
ts_start: Self::now_us(),
ts_end: 0,
summary: String::new(),
tags: Vec::new(),
embedding: None,
metadata: HashMap::new(),
}
}
pub fn schema() -> SochSchema {
SochSchema::new("episodes")
.field("episode_id", SochType::Text)
.field("episode_type", SochType::Text)
.field("entity_ids", SochType::Array(Box::new(SochType::Text)))
.field("ts_start", SochType::UInt)
.field("ts_end", SochType::UInt)
.field("summary", SochType::Text)
.field("tags", SochType::Array(Box::new(SochType::Text)))
.field(
"embedding",
SochType::Optional(Box::new(SochType::Array(Box::new(SochType::Float)))),
)
.field("metadata", SochType::Object(vec![]))
.primary_key("episode_id")
.index("idx_episodes_type", vec!["episode_type".into()], false)
.index("idx_episodes_ts", vec!["ts_start".into()], false)
}
fn now_us() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as u64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventRole {
User,
Assistant,
System,
Tool,
External,
}
impl EventRole {
pub fn as_str(&self) -> &'static str {
match self {
EventRole::User => "user",
EventRole::Assistant => "assistant",
EventRole::System => "system",
EventRole::Tool => "tool",
EventRole::External => "external",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"user" => EventRole::User,
"assistant" => EventRole::Assistant,
"system" => EventRole::System,
"tool" => EventRole::Tool,
"external" => EventRole::External,
_ => EventRole::System,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub episode_id: String,
pub seq: u64,
pub ts: u64,
pub role: EventRole,
pub tool_name: Option<String>,
pub input_toon: String,
pub output_toon: String,
pub error: Option<String>,
pub metrics: EventMetrics,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EventMetrics {
pub duration_us: u64,
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
pub cost_micros: Option<u64>,
}
impl Event {
pub fn new(episode_id: impl Into<String>, seq: u64, role: EventRole) -> Self {
Self {
episode_id: episode_id.into(),
seq,
ts: Self::now_us(),
role,
tool_name: None,
input_toon: String::new(),
output_toon: String::new(),
error: None,
metrics: EventMetrics::default(),
}
}
pub fn schema() -> SochSchema {
SochSchema::new("events")
.field("episode_id", SochType::Text)
.field("seq", SochType::UInt)
.field("ts", SochType::UInt)
.field("role", SochType::Text)
.field("tool_name", SochType::Optional(Box::new(SochType::Text)))
.field("input_toon", SochType::Text)
.field("output_toon", SochType::Text)
.field("error", SochType::Optional(Box::new(SochType::Text)))
.field("duration_us", SochType::UInt)
.field("input_tokens", SochType::Optional(Box::new(SochType::UInt)))
.field(
"output_tokens",
SochType::Optional(Box::new(SochType::UInt)),
)
.field("cost_micros", SochType::Optional(Box::new(SochType::UInt)))
.primary_key("episode_id")
.index(
"idx_events_episode_seq",
vec!["episode_id".into(), "seq".into()],
true,
)
.index("idx_events_ts", vec!["ts".into()], false)
.index("idx_events_role", vec!["role".into()], false)
}
fn now_us() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as u64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityKind {
User,
Project,
Document,
Service,
Agent,
Organization,
Custom,
}
impl EntityKind {
pub fn as_str(&self) -> &'static str {
match self {
EntityKind::User => "user",
EntityKind::Project => "project",
EntityKind::Document => "document",
EntityKind::Service => "service",
EntityKind::Agent => "agent",
EntityKind::Organization => "organization",
EntityKind::Custom => "custom",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"user" => EntityKind::User,
"project" => EntityKind::Project,
"document" => EntityKind::Document,
"service" => EntityKind::Service,
"agent" => EntityKind::Agent,
"organization" => EntityKind::Organization,
_ => EntityKind::Custom,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub entity_id: String,
pub kind: EntityKind,
pub name: String,
pub attributes: HashMap<String, SochValue>,
pub embedding: Option<Vec<f32>>,
pub metadata: HashMap<String, SochValue>,
pub created_at: u64,
pub updated_at: u64,
}
impl Entity {
pub fn new(entity_id: impl Into<String>, kind: EntityKind, name: impl Into<String>) -> Self {
let now = Self::now_us();
Self {
entity_id: entity_id.into(),
kind,
name: name.into(),
attributes: HashMap::new(),
embedding: None,
metadata: HashMap::new(),
created_at: now,
updated_at: now,
}
}
pub fn schema() -> SochSchema {
SochSchema::new("entities")
.field("entity_id", SochType::Text)
.field("kind", SochType::Text)
.field("name", SochType::Text)
.field("attributes", SochType::Object(vec![]))
.field(
"embedding",
SochType::Optional(Box::new(SochType::Array(Box::new(SochType::Float)))),
)
.field("metadata", SochType::Object(vec![]))
.field("created_at", SochType::UInt)
.field("updated_at", SochType::UInt)
.primary_key("entity_id")
.index("idx_entities_kind", vec!["kind".into()], false)
.index("idx_entities_name", vec!["name".into()], false)
}
fn now_us() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as u64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TableRole {
Log,
Dimension,
Fact,
VectorCollection,
Lookup,
CoreMemory,
}
impl TableRole {
pub fn as_str(&self) -> &'static str {
match self {
TableRole::Log => "log",
TableRole::Dimension => "dimension",
TableRole::Fact => "fact",
TableRole::VectorCollection => "vector_collection",
TableRole::Lookup => "lookup",
TableRole::CoreMemory => "core_memory",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableSemanticMetadata {
pub name: String,
pub role: TableRole,
pub primary_key: Vec<String>,
pub cluster_key: Option<Vec<String>>,
pub ts_column: Option<String>,
pub backed_by_vector_index: bool,
pub embedding_dimension: Option<usize>,
pub description: String,
}
impl TableSemanticMetadata {
pub fn episodes() -> Self {
Self {
name: "episodes".to_string(),
role: TableRole::CoreMemory,
primary_key: vec!["episode_id".to_string()],
cluster_key: Some(vec!["ts_start".to_string()]),
ts_column: Some("ts_start".to_string()),
backed_by_vector_index: true,
embedding_dimension: Some(1536), description: "Task/conversation runs with summaries and embeddings. Search here to find similar past tasks.".to_string(),
}
}
pub fn events() -> Self {
Self {
name: "events".to_string(),
role: TableRole::Log,
primary_key: vec!["episode_id".to_string(), "seq".to_string()],
cluster_key: Some(vec!["episode_id".to_string(), "seq".to_string()]),
ts_column: Some("ts".to_string()),
backed_by_vector_index: false,
embedding_dimension: None,
description: "Steps within episodes (tool calls, messages). Use LAST N FROM events WHERE episode_id = $id for timeline.".to_string(),
}
}
pub fn entities() -> Self {
Self {
name: "entities".to_string(),
role: TableRole::Dimension,
primary_key: vec!["entity_id".to_string()],
cluster_key: Some(vec!["kind".to_string()]),
ts_column: Some("updated_at".to_string()),
backed_by_vector_index: true,
embedding_dimension: Some(1536),
description: "Users, projects, documents, services. Search by kind and similarity."
.to_string(),
}
}
pub fn core_tables() -> Vec<Self> {
vec![Self::episodes(), Self::events(), Self::entities()]
}
}
#[derive(Debug, Clone)]
pub struct EpisodeSearchResult {
pub episode: Episode,
pub score: f32,
}
#[derive(Debug, Clone)]
pub struct EntitySearchResult {
pub entity: Entity,
pub score: f32,
}
pub trait MemoryStore {
fn create_episode(&self, episode: &Episode) -> crate::Result<()>;
fn get_episode(&self, episode_id: &str) -> crate::Result<Option<Episode>>;
fn search_episodes(&self, query: &str, k: usize) -> crate::Result<Vec<EpisodeSearchResult>>;
fn append_event(&self, event: &Event) -> crate::Result<()>;
fn get_timeline(&self, episode_id: &str, max_events: usize) -> crate::Result<Vec<Event>>;
fn upsert_entity(&self, entity: &Entity) -> crate::Result<()>;
fn get_entity(&self, entity_id: &str) -> crate::Result<Option<Entity>>;
fn search_entities(
&self,
kind: Option<EntityKind>,
query: &str,
k: usize,
) -> crate::Result<Vec<EntitySearchResult>>;
fn get_entity_facts(&self, entity_id: &str) -> crate::Result<EntityFacts>;
}
#[derive(Debug, Clone)]
pub struct EntityFacts {
pub entity: Entity,
pub recent_episodes: Vec<Episode>,
pub related_entities: Vec<Entity>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_episode_schema() {
let schema = Episode::schema();
assert_eq!(schema.name, "episodes");
assert!(schema.fields.iter().any(|f| f.name == "episode_id"));
assert!(schema.fields.iter().any(|f| f.name == "embedding"));
}
#[test]
fn test_event_schema() {
let schema = Event::schema();
assert_eq!(schema.name, "events");
assert!(schema.fields.iter().any(|f| f.name == "episode_id"));
assert!(schema.fields.iter().any(|f| f.name == "seq"));
}
#[test]
fn test_entity_schema() {
let schema = Entity::schema();
assert_eq!(schema.name, "entities");
assert!(schema.fields.iter().any(|f| f.name == "entity_id"));
assert!(schema.fields.iter().any(|f| f.name == "kind"));
}
#[test]
fn test_table_metadata() {
let tables = TableSemanticMetadata::core_tables();
assert_eq!(tables.len(), 3);
assert!(tables.iter().any(|t| t.name == "episodes"));
assert!(tables.iter().any(|t| t.name == "events"));
assert!(tables.iter().any(|t| t.name == "entities"));
}
}