1use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49
50use crate::soch::{SochSchema, SochType, SochValue};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum EpisodeType {
60 Conversation,
62 Task,
64 Workflow,
66 Debug,
68 AgentInteraction,
70 Other,
72}
73
74impl EpisodeType {
75 pub fn as_str(&self) -> &'static str {
76 match self {
77 EpisodeType::Conversation => "conversation",
78 EpisodeType::Task => "task",
79 EpisodeType::Workflow => "workflow",
80 EpisodeType::Debug => "debug",
81 EpisodeType::AgentInteraction => "agent_interaction",
82 EpisodeType::Other => "other",
83 }
84 }
85
86 #[allow(clippy::should_implement_trait)]
87 pub fn from_str(s: &str) -> Self {
88 match s.to_lowercase().as_str() {
89 "conversation" => EpisodeType::Conversation,
90 "task" => EpisodeType::Task,
91 "workflow" => EpisodeType::Workflow,
92 "debug" => EpisodeType::Debug,
93 "agent_interaction" => EpisodeType::AgentInteraction,
94 _ => EpisodeType::Other,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Episode {
102 pub episode_id: String,
104 pub episode_type: EpisodeType,
106 pub entity_ids: Vec<String>,
108 pub ts_start: u64,
110 pub ts_end: u64,
112 pub summary: String,
114 pub tags: Vec<String>,
116 pub embedding: Option<Vec<f32>>,
118 pub metadata: HashMap<String, SochValue>,
120}
121
122impl Episode {
123 pub fn new(episode_id: impl Into<String>, episode_type: EpisodeType) -> Self {
125 Self {
126 episode_id: episode_id.into(),
127 episode_type,
128 entity_ids: Vec::new(),
129 ts_start: Self::now_us(),
130 ts_end: 0,
131 summary: String::new(),
132 tags: Vec::new(),
133 embedding: None,
134 metadata: HashMap::new(),
135 }
136 }
137
138 pub fn schema() -> SochSchema {
140 SochSchema::new("episodes")
141 .field("episode_id", SochType::Text)
142 .field("episode_type", SochType::Text)
143 .field("entity_ids", SochType::Array(Box::new(SochType::Text)))
144 .field("ts_start", SochType::UInt)
145 .field("ts_end", SochType::UInt)
146 .field("summary", SochType::Text)
147 .field("tags", SochType::Array(Box::new(SochType::Text)))
148 .field(
149 "embedding",
150 SochType::Optional(Box::new(SochType::Array(Box::new(SochType::Float)))),
151 )
152 .field("metadata", SochType::Object(vec![]))
153 .primary_key("episode_id")
154 .index("idx_episodes_type", vec!["episode_type".into()], false)
155 .index("idx_episodes_ts", vec!["ts_start".into()], false)
156 }
157
158 fn now_us() -> u64 {
159 std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .unwrap()
162 .as_micros() as u64
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum EventRole {
174 User,
176 Assistant,
178 System,
180 Tool,
182 External,
184}
185
186impl EventRole {
187 pub fn as_str(&self) -> &'static str {
188 match self {
189 EventRole::User => "user",
190 EventRole::Assistant => "assistant",
191 EventRole::System => "system",
192 EventRole::Tool => "tool",
193 EventRole::External => "external",
194 }
195 }
196
197 #[allow(clippy::should_implement_trait)]
198 pub fn from_str(s: &str) -> Self {
199 match s.to_lowercase().as_str() {
200 "user" => EventRole::User,
201 "assistant" => EventRole::Assistant,
202 "system" => EventRole::System,
203 "tool" => EventRole::Tool,
204 "external" => EventRole::External,
205 _ => EventRole::System,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Event {
213 pub episode_id: String,
215 pub seq: u64,
217 pub ts: u64,
219 pub role: EventRole,
221 pub tool_name: Option<String>,
223 pub input_toon: String,
225 pub output_toon: String,
227 pub error: Option<String>,
229 pub metrics: EventMetrics,
231}
232
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct EventMetrics {
236 pub duration_us: u64,
238 pub input_tokens: Option<u32>,
240 pub output_tokens: Option<u32>,
242 pub cost_micros: Option<u64>,
244}
245
246impl Event {
247 pub fn new(episode_id: impl Into<String>, seq: u64, role: EventRole) -> Self {
249 Self {
250 episode_id: episode_id.into(),
251 seq,
252 ts: Self::now_us(),
253 role,
254 tool_name: None,
255 input_toon: String::new(),
256 output_toon: String::new(),
257 error: None,
258 metrics: EventMetrics::default(),
259 }
260 }
261
262 pub fn schema() -> SochSchema {
264 SochSchema::new("events")
265 .field("episode_id", SochType::Text)
266 .field("seq", SochType::UInt)
267 .field("ts", SochType::UInt)
268 .field("role", SochType::Text)
269 .field("tool_name", SochType::Optional(Box::new(SochType::Text)))
270 .field("input_toon", SochType::Text)
271 .field("output_toon", SochType::Text)
272 .field("error", SochType::Optional(Box::new(SochType::Text)))
273 .field("duration_us", SochType::UInt)
274 .field("input_tokens", SochType::Optional(Box::new(SochType::UInt)))
275 .field(
276 "output_tokens",
277 SochType::Optional(Box::new(SochType::UInt)),
278 )
279 .field("cost_micros", SochType::Optional(Box::new(SochType::UInt)))
280 .primary_key("episode_id")
282 .index(
283 "idx_events_episode_seq",
284 vec!["episode_id".into(), "seq".into()],
285 true,
286 )
287 .index("idx_events_ts", vec!["ts".into()], false)
288 .index("idx_events_role", vec!["role".into()], false)
289 }
290
291 fn now_us() -> u64 {
292 std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .unwrap()
295 .as_micros() as u64
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum EntityKind {
307 User,
309 Project,
311 Document,
313 Service,
315 Agent,
317 Organization,
319 Custom,
321}
322
323impl EntityKind {
324 pub fn as_str(&self) -> &'static str {
325 match self {
326 EntityKind::User => "user",
327 EntityKind::Project => "project",
328 EntityKind::Document => "document",
329 EntityKind::Service => "service",
330 EntityKind::Agent => "agent",
331 EntityKind::Organization => "organization",
332 EntityKind::Custom => "custom",
333 }
334 }
335
336 #[allow(clippy::should_implement_trait)]
337 pub fn from_str(s: &str) -> Self {
338 match s.to_lowercase().as_str() {
339 "user" => EntityKind::User,
340 "project" => EntityKind::Project,
341 "document" => EntityKind::Document,
342 "service" => EntityKind::Service,
343 "agent" => EntityKind::Agent,
344 "organization" => EntityKind::Organization,
345 _ => EntityKind::Custom,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct Entity {
353 pub entity_id: String,
355 pub kind: EntityKind,
357 pub name: String,
359 pub attributes: HashMap<String, SochValue>,
361 pub embedding: Option<Vec<f32>>,
363 pub metadata: HashMap<String, SochValue>,
365 pub created_at: u64,
367 pub updated_at: u64,
369}
370
371impl Entity {
372 pub fn new(entity_id: impl Into<String>, kind: EntityKind, name: impl Into<String>) -> Self {
374 let now = Self::now_us();
375 Self {
376 entity_id: entity_id.into(),
377 kind,
378 name: name.into(),
379 attributes: HashMap::new(),
380 embedding: None,
381 metadata: HashMap::new(),
382 created_at: now,
383 updated_at: now,
384 }
385 }
386
387 pub fn schema() -> SochSchema {
389 SochSchema::new("entities")
390 .field("entity_id", SochType::Text)
391 .field("kind", SochType::Text)
392 .field("name", SochType::Text)
393 .field("attributes", SochType::Object(vec![]))
394 .field(
395 "embedding",
396 SochType::Optional(Box::new(SochType::Array(Box::new(SochType::Float)))),
397 )
398 .field("metadata", SochType::Object(vec![]))
399 .field("created_at", SochType::UInt)
400 .field("updated_at", SochType::UInt)
401 .primary_key("entity_id")
402 .index("idx_entities_kind", vec!["kind".into()], false)
403 .index("idx_entities_name", vec!["name".into()], false)
404 }
405
406 fn now_us() -> u64 {
407 std::time::SystemTime::now()
408 .duration_since(std::time::UNIX_EPOCH)
409 .unwrap()
410 .as_micros() as u64
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum TableRole {
422 Log,
424 Dimension,
426 Fact,
428 VectorCollection,
430 Lookup,
432 CoreMemory,
434}
435
436impl TableRole {
437 pub fn as_str(&self) -> &'static str {
438 match self {
439 TableRole::Log => "log",
440 TableRole::Dimension => "dimension",
441 TableRole::Fact => "fact",
442 TableRole::VectorCollection => "vector_collection",
443 TableRole::Lookup => "lookup",
444 TableRole::CoreMemory => "core_memory",
445 }
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct TableSemanticMetadata {
452 pub name: String,
454 pub role: TableRole,
456 pub primary_key: Vec<String>,
458 pub cluster_key: Option<Vec<String>>,
460 pub ts_column: Option<String>,
462 pub backed_by_vector_index: bool,
464 pub embedding_dimension: Option<usize>,
466 pub description: String,
468}
469
470impl TableSemanticMetadata {
471 pub fn episodes() -> Self {
473 Self {
474 name: "episodes".to_string(),
475 role: TableRole::CoreMemory,
476 primary_key: vec!["episode_id".to_string()],
477 cluster_key: Some(vec!["ts_start".to_string()]),
478 ts_column: Some("ts_start".to_string()),
479 backed_by_vector_index: true,
480 embedding_dimension: Some(1536), description: "Task/conversation runs with summaries and embeddings. Search here to find similar past tasks.".to_string(),
482 }
483 }
484
485 pub fn events() -> Self {
487 Self {
488 name: "events".to_string(),
489 role: TableRole::Log,
490 primary_key: vec!["episode_id".to_string(), "seq".to_string()],
491 cluster_key: Some(vec!["episode_id".to_string(), "seq".to_string()]),
492 ts_column: Some("ts".to_string()),
493 backed_by_vector_index: false,
494 embedding_dimension: None,
495 description: "Steps within episodes (tool calls, messages). Use LAST N FROM events WHERE episode_id = $id for timeline.".to_string(),
496 }
497 }
498
499 pub fn entities() -> Self {
501 Self {
502 name: "entities".to_string(),
503 role: TableRole::Dimension,
504 primary_key: vec!["entity_id".to_string()],
505 cluster_key: Some(vec!["kind".to_string()]),
506 ts_column: Some("updated_at".to_string()),
507 backed_by_vector_index: true,
508 embedding_dimension: Some(1536),
509 description: "Users, projects, documents, services. Search by kind and similarity."
510 .to_string(),
511 }
512 }
513
514 pub fn core_tables() -> Vec<Self> {
516 vec![Self::episodes(), Self::events(), Self::entities()]
517 }
518}
519
520#[derive(Debug, Clone)]
526pub struct EpisodeSearchResult {
527 pub episode: Episode,
528 pub score: f32,
529}
530
531#[derive(Debug, Clone)]
533pub struct EntitySearchResult {
534 pub entity: Entity,
535 pub score: f32,
536}
537
538pub trait MemoryStore {
540 fn create_episode(&self, episode: &Episode) -> crate::Result<()>;
542
543 fn get_episode(&self, episode_id: &str) -> crate::Result<Option<Episode>>;
545
546 fn search_episodes(&self, query: &str, k: usize) -> crate::Result<Vec<EpisodeSearchResult>>;
548
549 fn append_event(&self, event: &Event) -> crate::Result<()>;
551
552 fn get_timeline(&self, episode_id: &str, max_events: usize) -> crate::Result<Vec<Event>>;
554
555 fn upsert_entity(&self, entity: &Entity) -> crate::Result<()>;
557
558 fn get_entity(&self, entity_id: &str) -> crate::Result<Option<Entity>>;
560
561 fn search_entities(
563 &self,
564 kind: Option<EntityKind>,
565 query: &str,
566 k: usize,
567 ) -> crate::Result<Vec<EntitySearchResult>>;
568
569 fn get_entity_facts(&self, entity_id: &str) -> crate::Result<EntityFacts>;
571}
572
573#[derive(Debug, Clone)]
575pub struct EntityFacts {
576 pub entity: Entity,
577 pub recent_episodes: Vec<Episode>,
578 pub related_entities: Vec<Entity>,
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_episode_schema() {
587 let schema = Episode::schema();
588 assert_eq!(schema.name, "episodes");
589 assert!(schema.fields.iter().any(|f| f.name == "episode_id"));
590 assert!(schema.fields.iter().any(|f| f.name == "embedding"));
591 }
592
593 #[test]
594 fn test_event_schema() {
595 let schema = Event::schema();
596 assert_eq!(schema.name, "events");
597 assert!(schema.fields.iter().any(|f| f.name == "episode_id"));
598 assert!(schema.fields.iter().any(|f| f.name == "seq"));
599 }
600
601 #[test]
602 fn test_entity_schema() {
603 let schema = Entity::schema();
604 assert_eq!(schema.name, "entities");
605 assert!(schema.fields.iter().any(|f| f.name == "entity_id"));
606 assert!(schema.fields.iter().any(|f| f.name == "kind"));
607 }
608
609 #[test]
610 fn test_table_metadata() {
611 let tables = TableSemanticMetadata::core_tables();
612 assert_eq!(tables.len(), 3);
613 assert!(tables.iter().any(|t| t.name == "episodes"));
614 assert!(tables.iter().any(|t| t.name == "events"));
615 assert!(tables.iter().any(|t| t.name == "entities"));
616 }
617}