P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
@@ -1,37 +1,180 @@
|
||||
//! Identity store — manages agent identity and preferences.
|
||||
//!
|
||||
//! Provides a two-layer identity system:
|
||||
//! - **L0**: Compact identity (name, role, user_name, user_role) that must
|
||||
//! fit within ~157 tokens for injection into every system prompt.
|
||||
//! - **L1**: Full narrative / story with traits, preferences, and learned
|
||||
//! behaviours.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
/// Agent identity information stored in memory.
|
||||
// ─── Data types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Two-layer agent identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
/// Unique agent identifier.
|
||||
pub agent_id: String,
|
||||
/// Short display name (L0).
|
||||
pub name: String,
|
||||
pub bio: String,
|
||||
/// One-line role description (L0).
|
||||
pub role: String,
|
||||
/// Name of the human user this agent serves (L0).
|
||||
pub user_name: String,
|
||||
/// Role / title of the human user (L0).
|
||||
pub user_role: String,
|
||||
/// Full narrative / story (L1).
|
||||
pub story: String,
|
||||
/// Key-value preferences.
|
||||
pub preferences: HashMap<String, String>,
|
||||
/// Traits learned or assigned over time.
|
||||
pub learned_traits: Vec<String>,
|
||||
/// When this identity was created.
|
||||
pub created_at: String,
|
||||
/// When this identity was last updated.
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl AgentIdentity {
|
||||
/// Check whether the L0 identity fits within the token budget.
|
||||
///
|
||||
/// LLMs typically consume ~4 characters per token. The recommended
|
||||
/// budget is 157 tokens (~628 characters) for fast system-prompt
|
||||
/// injection.
|
||||
pub fn l0_token_estimate(&self) -> usize {
|
||||
let l0 = format!(
|
||||
"name: {}\nrole: {}\nuser: {} ({})\n",
|
||||
self.name, self.role, self.user_name, self.user_role
|
||||
);
|
||||
// Rough estimate: ~4 chars per token
|
||||
(l0.len() / 4).max(1)
|
||||
}
|
||||
|
||||
/// Returns true if the L0 identity fits within the recommended budget.
|
||||
pub fn l0_fits_budget(&self) -> bool {
|
||||
self.l0_token_estimate() <= 157
|
||||
}
|
||||
|
||||
/// Render the L0 identity as a compact string.
|
||||
pub fn l0_string(&self) -> String {
|
||||
format!(
|
||||
"name: {}\nrole: {}\nuser: {} ({})",
|
||||
self.name, self.role, self.user_name, self.user_role
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the L1 story.
|
||||
pub fn l1_string(&self) -> String {
|
||||
let mut out = format!(
|
||||
"# {}\n\n**Role:** {}\n**User:** {} ({})\n\n{}",
|
||||
self.name, self.role, self.user_name, self.user_role, self.story
|
||||
);
|
||||
|
||||
if !self.preferences.is_empty() {
|
||||
out.push_str("\n\n## Preferences\n");
|
||||
for (k, v) in &self.preferences {
|
||||
out.push_str(&format!("- {k}: {v}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.learned_traits.is_empty() {
|
||||
out.push_str("\n## Learned Traits\n");
|
||||
for t in &self.learned_traits {
|
||||
out.push_str(&format!("- {t}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IdentityStore ──────────────────────────────────────────────────────
|
||||
|
||||
/// Manages persistent agent identity data.
|
||||
pub struct IdentityStore {
|
||||
/// In-memory identity map (agent_id → identity).
|
||||
identities: HashMap<String, AgentIdentity>,
|
||||
/// Optional directory for JSON file persistence.
|
||||
storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl IdentityStore {
|
||||
/// Create a new identity store.
|
||||
/// Create a new in-memory identity store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
identities: HashMap::new(),
|
||||
storage_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register or update an agent's identity.
|
||||
/// Create an identity store with file-backed persistence.
|
||||
///
|
||||
/// Identities are saved as `{agent_id}.json` in the given directory.
|
||||
pub fn with_persistence(storage_dir: PathBuf) -> Self {
|
||||
fs::create_dir_all(&storage_dir).ok();
|
||||
// Load existing identities from disk
|
||||
let mut identities = HashMap::new();
|
||||
if let Ok(entries) = fs::read_dir(&storage_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(identity) = serde_json::from_str::<AgentIdentity>(&content) {
|
||||
identities.insert(identity.agent_id.clone(), identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
"Loaded {} identities from {}",
|
||||
identities.len(),
|
||||
storage_dir.display()
|
||||
);
|
||||
Self {
|
||||
identities,
|
||||
storage_dir: Some(storage_dir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new agent identity or update an existing one.
|
||||
///
|
||||
/// If the identity already exists, the `updated_at` timestamp is
|
||||
/// refreshed. Existing preferences and traits are merged (new values
|
||||
/// take precedence, new traits are appended).
|
||||
pub fn register(&mut self, identity: AgentIdentity) {
|
||||
self.identities.insert(identity.agent_id.clone(), identity);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mut identity = identity;
|
||||
identity.updated_at = now.clone();
|
||||
|
||||
if let Some(existing) = self.identities.get(&identity.agent_id) {
|
||||
// Preserve creation timestamp
|
||||
identity.created_at = existing.created_at.clone();
|
||||
// Merge preferences (existing keep their values unless overridden)
|
||||
for (k, v) in &existing.preferences {
|
||||
identity.preferences.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
// Merge traits (existing traits preserved, new ones appended)
|
||||
for t in &existing.learned_traits {
|
||||
if !identity.learned_traits.contains(t) {
|
||||
identity.learned_traits.push(t.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
identity.created_at = now;
|
||||
}
|
||||
|
||||
let agent_id = identity.agent_id.clone();
|
||||
self.identities.insert(agent_id.clone(), identity);
|
||||
self.persist(&agent_id);
|
||||
debug!("Registered/updated identity for agent '{agent_id}'");
|
||||
}
|
||||
|
||||
/// Get an agent's identity by ID.
|
||||
@@ -39,27 +182,88 @@ impl IdentityStore {
|
||||
self.identities.get(agent_id)
|
||||
}
|
||||
|
||||
/// Update a preference for an agent.
|
||||
pub fn set_preference(&mut self, agent_id: &str, key: String, value: String) -> Option<()> {
|
||||
self.identities
|
||||
.get_mut(agent_id)?
|
||||
.preferences
|
||||
.insert(key, value);
|
||||
/// Get a mutable reference to an agent's identity.
|
||||
pub fn get_mut(&mut self, agent_id: &str) -> Option<&mut AgentIdentity> {
|
||||
self.identities.get_mut(agent_id)
|
||||
}
|
||||
|
||||
/// Update an agent's identity using a closure.
|
||||
///
|
||||
/// Returns `None` if the agent ID was not found.
|
||||
pub fn update<F>(&mut self, agent_id: &str, f: F) -> Option<()>
|
||||
where
|
||||
F: FnOnce(&mut AgentIdentity),
|
||||
{
|
||||
let identity = self.identities.get_mut(agent_id)?;
|
||||
f(identity);
|
||||
identity.updated_at = Utc::now().to_rfc3339();
|
||||
self.persist(agent_id);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Set a preference for an agent.
|
||||
///
|
||||
/// Returns `None` if the agent ID was not found.
|
||||
pub fn set_preference(&mut self, agent_id: &str, key: &str, value: &str) -> Option<()> {
|
||||
self.update(agent_id, |id| {
|
||||
id.preferences
|
||||
.insert(key.to_string(), value.to_string());
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a learned trait to an agent's identity.
|
||||
pub fn add_trait(&mut self, agent_id: &str, trait_name: String) -> Option<()> {
|
||||
self.identities
|
||||
.get_mut(agent_id)?
|
||||
.learned_traits
|
||||
.push(trait_name);
|
||||
Some(())
|
||||
///
|
||||
/// Skips duplicates. Returns `None` if the agent ID was not found.
|
||||
pub fn add_trait(&mut self, agent_id: &str, trait_name: &str) -> Option<()> {
|
||||
self.update(agent_id, |id| {
|
||||
if !id.learned_traits.contains(&trait_name.to_string()) {
|
||||
id.learned_traits.push(trait_name.to_string());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove an agent identity.
|
||||
///
|
||||
/// Returns the removed identity, or `None` if not found.
|
||||
pub fn remove(&mut self, agent_id: &str) -> Option<AgentIdentity> {
|
||||
let removed = self.identities.remove(agent_id);
|
||||
if removed.is_some() {
|
||||
if let Some(ref storage_dir) = self.storage_dir {
|
||||
let path = storage_dir.join(format!("{agent_id}.json"));
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// List all registered agent IDs.
|
||||
pub fn list_agents(&self) -> Vec<String> {
|
||||
self.identities.keys().cloned().collect()
|
||||
let mut ids: Vec<String> = self.identities.keys().cloned().collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Number of registered identities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.identities.len()
|
||||
}
|
||||
|
||||
/// True if no identities are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.identities.is_empty()
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────────────────────────────
|
||||
|
||||
fn persist(&self, agent_id: &str) {
|
||||
if let Some(ref storage_dir) = self.storage_dir {
|
||||
if let Some(identity) = self.identities.get(agent_id) {
|
||||
let path = storage_dir.join(format!("{agent_id}.json"));
|
||||
if let Ok(json) = serde_json::to_string_pretty(identity) {
|
||||
let _ = fs::write(&path, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,44 +273,176 @@ impl Default for IdentityStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_identity_register_and_retrieve() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(AgentIdentity {
|
||||
agent_id: "agent-1".to_string(),
|
||||
name: "GyanDoot".to_string(),
|
||||
bio: "An AI Agent".to_string(),
|
||||
fn make_identity(name: &str, role: &str) -> AgentIdentity {
|
||||
AgentIdentity {
|
||||
agent_id: format!("agent-{name}"),
|
||||
name: name.to_string(),
|
||||
role: role.to_string(),
|
||||
user_name: "TestUser".to_string(),
|
||||
user_role: "Developer".to_string(),
|
||||
story: format!("{name} is a test agent serving as {role}."),
|
||||
preferences: HashMap::new(),
|
||||
learned_traits: vec![],
|
||||
created_at: "now".to_string(),
|
||||
updated_at: "now".to_string(),
|
||||
});
|
||||
let identity = store.get("agent-1").unwrap();
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
updated_at: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_retrieve() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(make_identity("GyanDoot", "AI Assistant"));
|
||||
let identity = store.get("agent-GyanDoot").unwrap();
|
||||
assert_eq!(identity.name, "GyanDoot");
|
||||
assert_eq!(identity.role, "AI Assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferences() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(AgentIdentity {
|
||||
agent_id: "a1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
bio: String::new(),
|
||||
preferences: HashMap::new(),
|
||||
learned_traits: vec![],
|
||||
created_at: "now".to_string(),
|
||||
updated_at: "now".to_string(),
|
||||
});
|
||||
store.register(make_identity("Test", "Bot"));
|
||||
|
||||
store
|
||||
.set_preference("a1", "language".to_string(), "rust".to_string())
|
||||
.set_preference("agent-Test", "language", "rust")
|
||||
.unwrap();
|
||||
|
||||
let identity = store.get("agent-Test").unwrap();
|
||||
assert_eq!(
|
||||
store.get("a1").unwrap().preferences.get("language").unwrap(),
|
||||
identity.preferences.get("language").unwrap(),
|
||||
"rust"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_trait() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(make_identity("Test", "Bot"));
|
||||
|
||||
store.add_trait("agent-Test", "helpful").unwrap();
|
||||
store.add_trait("agent-Test", "helpful").unwrap(); // duplicate, should be ignored
|
||||
|
||||
let identity = store.get("agent-Test").unwrap();
|
||||
assert_eq!(identity.learned_traits.len(), 1);
|
||||
assert_eq!(identity.learned_traits[0], "helpful");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_agents() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(make_identity("Alpha", "A"));
|
||||
store.register(make_identity("Beta", "B"));
|
||||
let agents = store.list_agents();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert!(agents.contains(&"agent-Alpha".to_string()));
|
||||
assert!(agents.contains(&"agent-Beta".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(make_identity("Temp", "T"));
|
||||
assert_eq!(store.len(), 1);
|
||||
let removed = store.remove("agent-Temp");
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(make_identity("Updatable", "Assistant"));
|
||||
|
||||
store
|
||||
.update("agent-Updatable", |id| {
|
||||
id.role = "Senior Assistant".to_string()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let identity = store.get("agent-Updatable").unwrap();
|
||||
assert_eq!(identity.role, "Senior Assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l0_token_budget() {
|
||||
let identity = make_identity("GyanDoot", "AI Assistant");
|
||||
let tokens = identity.l0_token_estimate();
|
||||
assert!(tokens < 157, "L0 identity should fit in 157 tokens (got {tokens})");
|
||||
assert!(identity.l0_fits_budget());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l0_string() {
|
||||
let identity = make_identity("TestBot", "Helper");
|
||||
let l0 = identity.l0_string();
|
||||
assert!(l0.contains("TestBot"));
|
||||
assert!(l0.contains("Helper"));
|
||||
assert!(l0.contains("TestUser"));
|
||||
assert!(l0.contains("Developer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l1_string() {
|
||||
let identity = make_identity("StoryBot", "Storyteller");
|
||||
let l1 = identity.l1_string();
|
||||
assert!(l1.contains("StoryBot"));
|
||||
assert!(l1.contains("Storyteller"));
|
||||
assert!(l1.contains("test agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_persistence() {
|
||||
let tmp = std::env::temp_dir().join("gyandoot_identity_test");
|
||||
|
||||
// Create store and register
|
||||
{
|
||||
let mut store = IdentityStore::with_persistence(tmp.clone());
|
||||
store.register(make_identity("PersistBot", "Test"));
|
||||
store
|
||||
.set_preference("agent-PersistBot", "mode", "test")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create a new store from same directory and verify
|
||||
{
|
||||
let store = IdentityStore::with_persistence(tmp.clone());
|
||||
assert_eq!(store.len(), 1);
|
||||
let identity = store.get("agent-PersistBot").unwrap();
|
||||
assert_eq!(identity.name, "PersistBot");
|
||||
assert_eq!(
|
||||
identity.preferences.get("mode").unwrap(),
|
||||
"test"
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_updates_existing() {
|
||||
let mut store = IdentityStore::new();
|
||||
let mut id = make_identity("Merge", "Bot");
|
||||
id.learned_traits.push("helpful".to_string());
|
||||
store.register(id);
|
||||
|
||||
// Register again with a new preference
|
||||
let mut id2 = make_identity("Merge", "Bot");
|
||||
id2.preferences.insert("style".to_string(), "concise".to_string());
|
||||
store.register(id2);
|
||||
|
||||
let identity = store.get("agent-Merge").unwrap();
|
||||
// Should have merged traits
|
||||
assert_eq!(identity.learned_traits.len(), 1);
|
||||
assert!(identity.learned_traits.contains(&"helpful".to_string()));
|
||||
// Should have the preference
|
||||
assert_eq!(
|
||||
identity.preferences.get("style").unwrap(),
|
||||
"concise"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! GyanDoot Memory — MemPalace persistent memory system.
|
||||
//!
|
||||
//! The MemPalace stores conversation transcripts, agent identity data,
|
||||
//! and provides semantic query over past interactions using embeddings
|
||||
//! stored alongside text.
|
||||
//! and provides query over past interactions. Data is organized into
|
||||
//! "wings" under `~/.gyandoot/palace/`.
|
||||
|
||||
pub mod palace;
|
||||
pub mod transcript;
|
||||
|
||||
@@ -1,227 +1,556 @@
|
||||
//! MemPalace — The core persistent memory store.
|
||||
//! MemPalace — The core persistent memory store for GyanDoot agents.
|
||||
//!
|
||||
//! Stores conversation entries with embeddings for semantic retrieval,
|
||||
//! handles memory compaction, and provides query capabilities.
|
||||
//! Directory structure:
|
||||
//! ```text
|
||||
//! ~/.gyandoot/palace/wings/<wing>/
|
||||
//! sessions/YYYY-MM-DD_transcript.md (append-only daily logs)
|
||||
//! identity/
|
||||
//! L0_identity.md (short, < 157 tokens)
|
||||
//! L1_story.md (full narrative)
|
||||
//! drawer.md (append-only summary archive)
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gyandoot_core::MemoryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::identity::IdentityStore;
|
||||
use crate::transcript::TranscriptStore;
|
||||
|
||||
/// A single memory entry stored in the palace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryEntry {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub agent_id: String,
|
||||
pub session_id: String,
|
||||
pub entry_type: EntryType,
|
||||
pub embedding: Option<Vec<f64>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
// ─── Error type ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Errors that may occur during MemPalace operations.
|
||||
#[derive(Debug)]
|
||||
pub enum PalaceError {
|
||||
Io(std::io::Error),
|
||||
NotFound(String),
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// The type of a memory entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EntryType {
|
||||
Conversation,
|
||||
Fact,
|
||||
Identity,
|
||||
Task,
|
||||
Skill,
|
||||
impl std::fmt::Display for PalaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PalaceError::Io(e) => write!(f, "IO error: {e}"),
|
||||
PalaceError::NotFound(s) => write!(f, "Not found: {s}"),
|
||||
PalaceError::Serialization(s) => write!(f, "Serialization error: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory query result with relevance score.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryQueryResult {
|
||||
pub content: String,
|
||||
pub relevance: f64,
|
||||
pub entry_type: EntryType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
impl std::error::Error for PalaceError {}
|
||||
|
||||
impl From<std::io::Error> for PalaceError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
PalaceError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status information for the memory palace.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryStatus {
|
||||
pub entry_count: usize,
|
||||
pub backend: String,
|
||||
pub storage_size: u64,
|
||||
/// Convenience alias.
|
||||
pub type PalaceResult<T> = Result<T, PalaceError>;
|
||||
|
||||
// ─── Palace paths ───────────────────────────────────────────────────────
|
||||
|
||||
/// Return the base palace root directory.
|
||||
fn palace_root() -> PathBuf {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".gyandoot").join("palace")
|
||||
}
|
||||
|
||||
/// The MemPalace — persistent, searchable agent memory.
|
||||
/// Return the wing directory for a given wing name.
|
||||
fn wing_dir(wing: &str) -> PathBuf {
|
||||
palace_root().join("wings").join(wing)
|
||||
}
|
||||
|
||||
// ─── MemPalace ──────────────────────────────────────────────────────────
|
||||
|
||||
/// The MemPalace — persistent, file-backed agent memory organized by wing.
|
||||
///
|
||||
/// Entries are stored as JSON files in the data directory, organized by
|
||||
/// agent and session. Future versions will include vector embeddings
|
||||
/// for semantic search.
|
||||
/// Each wing corresponds to an agent profile or deployment context.
|
||||
/// Within a wing, conversations are logged as daily Markdown transcripts,
|
||||
/// identity is stored as L0 (short, compact) and L1 (full story) files,
|
||||
/// and summaries are archived in an append-only `drawer.md`.
|
||||
pub struct MemPalace {
|
||||
entries: Vec<MemoryEntry>,
|
||||
storage_path: PathBuf,
|
||||
wing: String,
|
||||
base_dir: PathBuf,
|
||||
sessions_dir: PathBuf,
|
||||
identity_dir: PathBuf,
|
||||
transcript_store: TranscriptStore,
|
||||
identity_store: IdentityStore,
|
||||
}
|
||||
|
||||
impl MemPalace {
|
||||
/// Create a new MemPalace with memory directory in the user's data dir.
|
||||
pub fn new() -> Self {
|
||||
let data_dir = gyandoot_core::data_dir().join("memory");
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
storage_path: data_dir,
|
||||
transcript_store: TranscriptStore::new(),
|
||||
identity_store: IdentityStore::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a MemPalace with a custom storage path.
|
||||
pub fn with_path(path: PathBuf) -> Self {
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
storage_path: path,
|
||||
transcript_store: TranscriptStore::new(),
|
||||
identity_store: IdentityStore::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a memory entry in the palace.
|
||||
pub fn store(&mut self, entry: MemoryEntry) -> MemoryResult<()> {
|
||||
debug!("Storing memory entry: {}", entry.id);
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store a conversation turn (the last user + assistant pair) as memory.
|
||||
pub async fn store_conversation_turn(
|
||||
&mut self,
|
||||
messages: &[gyandoot_core::Message],
|
||||
) -> MemoryResult<()> {
|
||||
let recent: Vec<&gyandoot_core::Message> = messages.iter().rev().take(4).collect();
|
||||
for msg in recent.iter().rev() {
|
||||
let entry = MemoryEntry {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
content: msg.content.clone(),
|
||||
agent_id: "default".to_string(),
|
||||
session_id: String::new(),
|
||||
entry_type: EntryType::Conversation,
|
||||
embedding: None,
|
||||
created_at: Utc::now(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
};
|
||||
self.store(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query memory by simple text matching.
|
||||
/// Open (or create) the palace for a given wing.
|
||||
///
|
||||
/// Future versions will use embedding-based semantic search.
|
||||
pub fn query(&self, query: &str, limit: usize) -> MemoryResult<Vec<MemoryQueryResult>> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<MemoryQueryResult> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
||||
.map(|e| MemoryQueryResult {
|
||||
content: e.content.clone(),
|
||||
relevance: 1.0, // placeholder; real relevance from embedding cosine sim
|
||||
entry_type: e.entry_type.clone(),
|
||||
created_at: e.created_at,
|
||||
})
|
||||
.collect();
|
||||
/// Creates directory structure if it does not exist.
|
||||
pub fn new(wing: &str) -> Self {
|
||||
let base = wing_dir(wing);
|
||||
let sessions = base.join("sessions");
|
||||
let identity = base.join("identity");
|
||||
|
||||
results.sort_by(|a, b| b.relevance.partial_cmp(&a.relevance).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(limit);
|
||||
Ok(results)
|
||||
// Ensure directory structure exists
|
||||
std::fs::create_dir_all(&sessions).unwrap_or_else(|e| {
|
||||
warn!("Failed to create sessions dir: {e}");
|
||||
});
|
||||
std::fs::create_dir_all(&identity).unwrap_or_else(|e| {
|
||||
warn!("Failed to create identity dir: {e}");
|
||||
});
|
||||
|
||||
info!("MemPalace initialized for wing '{wing}' at {}", base.display());
|
||||
|
||||
Self {
|
||||
wing: wing.to_string(),
|
||||
base_dir: base,
|
||||
sessions_dir: sessions,
|
||||
identity_dir: identity,
|
||||
transcript_store: TranscriptStore::new(),
|
||||
identity_store: IdentityStore::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all memory to a JSON file at the given path.
|
||||
pub fn export_to(&self, path: &PathBuf) -> MemoryResult<()> {
|
||||
let json = serde_json::to_string_pretty(&self.entries)
|
||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
||||
std::fs::write(path, &json)
|
||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
||||
/// Open the palace for a wing at a custom base path.
|
||||
pub fn with_path(wing: &str, base: PathBuf) -> Self {
|
||||
let sessions = base.join("sessions");
|
||||
let identity = base.join("identity");
|
||||
|
||||
std::fs::create_dir_all(&sessions).unwrap_or_else(|e| {
|
||||
warn!("Failed to create sessions dir: {e}");
|
||||
});
|
||||
std::fs::create_dir_all(&identity).unwrap_or_else(|e| {
|
||||
warn!("Failed to create identity dir: {e}");
|
||||
});
|
||||
|
||||
Self {
|
||||
wing: wing.to_string(),
|
||||
base_dir: base,
|
||||
sessions_dir: sessions,
|
||||
identity_dir: identity,
|
||||
transcript_store: TranscriptStore::new(),
|
||||
identity_store: IdentityStore::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generic storage ─────────────────────────────────────────────────
|
||||
|
||||
/// Store a value as a JSON file under `sessions/`.
|
||||
///
|
||||
/// The file is named `{key}.json` in the sessions directory.
|
||||
pub fn store(&self, key: &str, value: &serde_json::Value) -> PalaceResult<()> {
|
||||
let path = self.sessions_dir.join(format!("{key}.json"));
|
||||
let json = serde_json::to_string_pretty(value)
|
||||
.map_err(|e| PalaceError::Serialization(e.to_string()))?;
|
||||
std::fs::write(&path, &json)?;
|
||||
debug!("Stored memory key '{key}' to {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get memory status information.
|
||||
pub fn status() -> MemoryResult<MemoryStatus> {
|
||||
let data_dir = gyandoot_core::data_dir().join("memory");
|
||||
let size = std::fs::read_dir(&data_dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
/// Query memory by simple string matching against stored JSON files.
|
||||
///
|
||||
/// Searches the `sessions/` directory for files whose content contains
|
||||
/// the query string. Returns up to `limit` matches.
|
||||
pub fn query(&self, query: &str, limit: usize) -> PalaceResult<Vec<String>> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<(String, String)> = Vec::new(); // (filename, content snippet)
|
||||
|
||||
let entry_count = std::fs::read_dir(&data_dir)
|
||||
.map(|entries| entries.filter_map(|e| e.ok()).count())
|
||||
.unwrap_or(0);
|
||||
let entries = std::fs::read_dir(&self.sessions_dir)?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
if content.to_lowercase().contains(&query_lower) {
|
||||
let snippet = if content.len() > 200 {
|
||||
format!("{}…", &content[..200])
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
results.push((
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
snippet,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MemoryStatus {
|
||||
entry_count,
|
||||
backend: "local-json".to_string(),
|
||||
storage_size: size,
|
||||
})
|
||||
results.truncate(limit);
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|(name, snippet)| format!("[{name}]\n{snippet}"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get a reference to the transcript store.
|
||||
/// Export all palace data to a single file.
|
||||
///
|
||||
/// The export bundles daily transcripts, identity files, and the drawer
|
||||
/// summary into one file at `path`.
|
||||
pub fn export_to(&self, path: &Path) -> PalaceResult<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str(&format!("# MemPalace Export — Wing: {}\n\n", self.wing));
|
||||
|
||||
// Identity
|
||||
output.push_str("## Identity\n\n");
|
||||
if let Ok(id) = std::fs::read_to_string(self.identity_dir.join("L0_identity.md")) {
|
||||
output.push_str(&id);
|
||||
output.push('\n');
|
||||
}
|
||||
if let Ok(story) = std::fs::read_to_string(self.identity_dir.join("L1_story.md")) {
|
||||
output.push_str(&story);
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
// Drawer
|
||||
output.push_str("## Drawer (Summary Archive)\n\n");
|
||||
if let Ok(drawer) = std::fs::read_to_string(self.base_dir.join("drawer.md")) {
|
||||
output.push_str(&drawer);
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
// Recent sessions
|
||||
output.push_str("## Recent Sessions\n\n");
|
||||
let mut session_files: Vec<_> = std::fs::read_dir(&self.sessions_dir)
|
||||
.into_iter()
|
||||
.flat_map(|d| d.flatten())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md" || ext == "json"))
|
||||
.collect();
|
||||
session_files.sort_by_key(|e| e.path().metadata().map(|m| m.modified().ok()).ok().flatten());
|
||||
for f in session_files.iter().rev().take(10) {
|
||||
if let Ok(content) = std::fs::read_to_string(f.path()) {
|
||||
output.push_str(&format!("--- {} ---\n", f.file_name().to_string_lossy()));
|
||||
let truncated = if content.len() > 2000 {
|
||||
format!("{}…\n[truncated {} chars]\n", &content[..2000], content.len())
|
||||
} else {
|
||||
content
|
||||
};
|
||||
output.push_str(&truncated);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::write(path, &output)?;
|
||||
info!(
|
||||
"Exported palace wing '{}' to {} ({} bytes)",
|
||||
self.wing,
|
||||
path.display(),
|
||||
output.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Transcript operations ───────────────────────────────────────────
|
||||
|
||||
/// Append a message to the daily transcript file.
|
||||
///
|
||||
/// Format: `YYYY-MM-DD_transcript.md` in the sessions directory.
|
||||
/// Each entry is timestamped and tagged with the role.
|
||||
pub async fn transcript_append(&self, role: &str, content: &str) -> PalaceResult<()> {
|
||||
let date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let path = self.sessions_dir.join(format!("{date_str}_transcript.md"));
|
||||
|
||||
let timestamp = Utc::now().format("%H:%M:%S%.3f").to_string();
|
||||
let line = format!("\n## [{timestamp}] **{role}**\n{content}\n");
|
||||
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?
|
||||
.sync_all()?;
|
||||
|
||||
// We write via a separate open to use std::io::Write
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.flush()?;
|
||||
|
||||
debug!(
|
||||
"Appended {role} message ({len}B) to {}",
|
||||
path.display(),
|
||||
len = content.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read recent transcript content.
|
||||
///
|
||||
/// `max_chars` caps the total returned content. `tail_lines` limits to
|
||||
/// the last N lines of the most recent transcript file.
|
||||
pub async fn get_transcript(&self, max_chars: usize, tail_lines: usize) -> PalaceResult<String> {
|
||||
let date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let path = self.sessions_dir.join(format!("{date_str}_transcript.md"));
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
// Take the last `tail_lines` lines
|
||||
let start = lines.len().saturating_sub(tail_lines);
|
||||
let tail: String = lines[start..].join("\n");
|
||||
|
||||
if tail.len() <= max_chars {
|
||||
Ok(tail)
|
||||
} else {
|
||||
let truncated: String = tail.chars().take(max_chars).collect();
|
||||
Ok(format!("{truncated}\n…[truncated]"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity operations ─────────────────────────────────────────────
|
||||
|
||||
/// Set the agent identity, writing both L0 (compact) and L1 (full story)
|
||||
/// files.
|
||||
///
|
||||
/// **L0** must fit in roughly 157 tokens (~628 chars) for fast
|
||||
/// injection into every system prompt.
|
||||
pub fn identity_set(
|
||||
&self,
|
||||
name: &str,
|
||||
role: &str,
|
||||
user_name: &str,
|
||||
user_role: &str,
|
||||
) -> PalaceResult<()> {
|
||||
// L0 identity: compact, < 157 tokens (~628 chars)
|
||||
let l0 = format!(
|
||||
"name: {name}\nrole: {role}\nuser: {user_name} ({user_role})\n"
|
||||
);
|
||||
|
||||
if estimate_tokens(&l0) > 157 {
|
||||
warn!(
|
||||
"L0 identity may exceed 157 tokens (estimated {})",
|
||||
estimate_tokens(&l0)
|
||||
);
|
||||
}
|
||||
|
||||
let l0_path = self.identity_dir.join("L0_identity.md");
|
||||
std::fs::write(&l0_path, &l0)?;
|
||||
|
||||
// L1 story: full narrative
|
||||
let l1 = format!(
|
||||
"# {name}\n\n**Role:** {role}\n\n**User:** {user_name}\n\n**User Role:** {user_role}\n\n\
|
||||
This agent assists {user_name} ({user_role}) as {name}, playing the role of {role}.\n\n\
|
||||
## Traits\n- Helpful\n- Knowledgeable\n- Direct\n\n## Preferences\n- Concise responses\n\
|
||||
- Action-oriented\n- Evidence-based\n"
|
||||
);
|
||||
let l1_path = self.identity_dir.join("L1_story.md");
|
||||
std::fs::write(&l1_path, &l1)?;
|
||||
|
||||
info!("Identity set: name={name}, role={role}, user={user_name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read current identity (L0 + L1) as a tuple.
|
||||
pub fn identity_get(&self) -> PalaceResult<(String, String)> {
|
||||
let l0_path = self.identity_dir.join("L0_identity.md");
|
||||
let l1_path = self.identity_dir.join("L1_story.md");
|
||||
|
||||
let l0 = if l0_path.exists() {
|
||||
std::fs::read_to_string(&l0_path)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let l1 = if l1_path.exists() {
|
||||
std::fs::read_to_string(&l1_path)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok((l0, l1))
|
||||
}
|
||||
|
||||
// ── Drawer / session digest ─────────────────────────────────────────
|
||||
|
||||
/// Append a summary/digest of the current session to `drawer.md`.
|
||||
///
|
||||
/// The drawer is an append-only file that accumulates session-level
|
||||
/// summaries for long-term recall.
|
||||
pub async fn save_session_digest(&self, summary: &str) -> PalaceResult<()> {
|
||||
let path = self.base_dir.join("drawer.md");
|
||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
let entry = format!("\n---\n**Session Digest — {timestamp}**\n\n{summary}\n");
|
||||
file.write_all(entry.as_bytes())?;
|
||||
file.flush()?;
|
||||
|
||||
info!("Session digest saved to drawer.md");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Getters ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Get a reference to the in-memory transcript store.
|
||||
pub fn transcript_store(&self) -> &TranscriptStore {
|
||||
&self.transcript_store
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the in-memory transcript store.
|
||||
pub fn transcript_store_mut(&mut self) -> &mut TranscriptStore {
|
||||
&mut self.transcript_store
|
||||
}
|
||||
|
||||
/// Get a reference to the identity store.
|
||||
pub fn identity_store(&self) -> &IdentityStore {
|
||||
&self.identity_store
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemPalace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
/// Get a mutable reference to the identity store.
|
||||
pub fn identity_store_mut(&mut self) -> &mut IdentityStore {
|
||||
&mut self.identity_store
|
||||
}
|
||||
|
||||
/// The wing name for this palace.
|
||||
pub fn wing(&self) -> &str {
|
||||
&self.wing
|
||||
}
|
||||
|
||||
/// The base directory of this palace.
|
||||
pub fn base_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rough token estimation (~4 chars per token).
|
||||
fn estimate_tokens(text: &str) -> usize {
|
||||
(text.len() / 4).max(1)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn test_wing() -> String {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
format!("test_wing_{}", COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_creates_directories() {
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
assert!(palace.sessions_dir.exists());
|
||||
assert!(palace.identity_dir.exists());
|
||||
assert_eq!(palace.wing(), &wing);
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_query() {
|
||||
let mut palace = MemPalace::new();
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
palace
|
||||
.store(MemoryEntry {
|
||||
id: "1".to_string(),
|
||||
content: "The user likes Python".to_string(),
|
||||
agent_id: "default".to_string(),
|
||||
session_id: "s1".to_string(),
|
||||
entry_type: EntryType::Fact,
|
||||
embedding: None,
|
||||
created_at: Utc::now(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
})
|
||||
.store("test_key", &serde_json::json!({"content": "Python is great"}))
|
||||
.unwrap();
|
||||
|
||||
let results = palace.query("python", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].content.contains("Python"));
|
||||
let results = palace.query("Python", 10).unwrap();
|
||||
assert!(!results.is_empty());
|
||||
assert!(results[0].contains("Python"));
|
||||
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transcript_append_and_read() {
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
palace
|
||||
.transcript_append("user", "Hello there!")
|
||||
.await
|
||||
.unwrap();
|
||||
palace
|
||||
.transcript_append("assistant", "Hi! How can I help?")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let transcript = palace.get_transcript(4096, 50).await.unwrap();
|
||||
assert!(transcript.contains("Hello there!"));
|
||||
assert!(transcript.contains("Hi! How can I help?"));
|
||||
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export() {
|
||||
let palace = MemPalace::new();
|
||||
let tmp = std::env::temp_dir().join("gyandoot_test_export.json");
|
||||
fn test_identity_set_and_get() {
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
palace
|
||||
.identity_set("GyanDoot", "AI Assistant", "Alice", "Developer")
|
||||
.unwrap();
|
||||
|
||||
let (l0, l1) = palace.identity_get().unwrap();
|
||||
assert!(l0.contains("GyanDoot"));
|
||||
assert!(l0.contains("Alice"));
|
||||
assert!(l0.contains("Developer"));
|
||||
assert!(l0.contains("AI Assistant"));
|
||||
assert!(l1.contains("GyanDoot"));
|
||||
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_digest() {
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
palace
|
||||
.save_session_digest("Discussed project architecture and planned refactoring.")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let drawer_path = palace.base_dir().join("drawer.md");
|
||||
assert!(drawer_path.exists());
|
||||
let content = std::fs::read_to_string(drawer_path).unwrap();
|
||||
assert!(content.contains("Session Digest"));
|
||||
assert!(content.contains("refactoring"));
|
||||
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_to() {
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!("gyandoot_export_{wing}.md"));
|
||||
palace.export_to(&tmp).unwrap();
|
||||
assert!(tmp.exists());
|
||||
std::fs::remove_file(tmp).ok();
|
||||
let content = std::fs::read_to_string(&tmp).unwrap();
|
||||
assert!(content.contains(&wing));
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_path() {
|
||||
let wing = test_wing();
|
||||
let tmp = std::env::temp_dir().join(format!("gyandoot_palace_{wing}"));
|
||||
let palace = MemPalace::with_path(&wing, tmp.clone());
|
||||
|
||||
assert!(palace.sessions_dir.exists());
|
||||
assert!(palace.identity_dir.exists());
|
||||
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,174 @@
|
||||
//! Transcript store — structured conversation log management.
|
||||
//!
|
||||
//! Provides a file-backed store for indexed, timestamped conversation
|
||||
//! transcripts. Supports creating, appending turns to, finalizing, and
|
||||
//! listing transcripts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
/// A single turn in a conversation transcript.
|
||||
// ─── Data types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A single turn (exchange) in a transcript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscriptTurn {
|
||||
/// Sequential turn number within the transcript (1-indexed).
|
||||
pub turn_number: u64,
|
||||
/// The user's message content.
|
||||
pub user_message: String,
|
||||
/// The assistant's response content.
|
||||
pub assistant_message: String,
|
||||
pub tool_calls: Vec<String>,
|
||||
/// Tool call identifiers made during this turn.
|
||||
pub tool_call_ids: Vec<String>,
|
||||
/// Round-trip latency in milliseconds.
|
||||
pub latency_ms: u64,
|
||||
/// When this turn occurred.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A complete conversation transcript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transcript {
|
||||
/// Unique transcript identifier (UUID).
|
||||
pub id: String,
|
||||
/// The agent that participated in this conversation.
|
||||
pub agent_id: String,
|
||||
/// The session this transcript belongs to.
|
||||
pub session_id: String,
|
||||
/// When the transcript was started.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// When the transcript was ended (None if still active).
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
/// Ordered list of conversation turns.
|
||||
pub turns: Vec<TranscriptTurn>,
|
||||
/// Optional summary / digest of the conversation.
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Manages conversation transcripts.
|
||||
impl Transcript {
|
||||
/// Render the transcript to a human-readable string.
|
||||
pub fn to_display(&self) -> String {
|
||||
let mut output = format!(
|
||||
"Transcript {}\nAgent: {}\nSession: {}\nStarted: {}\n",
|
||||
self.id,
|
||||
self.agent_id,
|
||||
self.session_id,
|
||||
self.started_at.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
);
|
||||
|
||||
if let Some(ref ended) = self.ended_at {
|
||||
output.push_str(&format!("Ended: {}\n", ended.format("%Y-%m-%d %H:%M:%S UTC")));
|
||||
}
|
||||
if let Some(ref summary) = self.summary {
|
||||
output.push_str(&format!("Summary: {summary}\n"));
|
||||
}
|
||||
|
||||
output.push_str(&format!("\nTurns: {}\n", self.turns.len()));
|
||||
for turn in &self.turns {
|
||||
output.push_str(&format!(
|
||||
"\n[{turn_number}] {timestamp} — User:\n{user}\n— Assistant ({latency_ms}ms):\n{assistant}\n",
|
||||
turn_number = turn.turn_number,
|
||||
timestamp = turn.timestamp.format("%H:%M:%S"),
|
||||
user = turn.user_message,
|
||||
assistant = turn.assistant_message,
|
||||
latency_ms = turn.latency_ms,
|
||||
));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TranscriptStore ────────────────────────────────────────────────────
|
||||
|
||||
/// Manages conversation transcripts with optional file persistence.
|
||||
pub struct TranscriptStore {
|
||||
/// In-memory transcript map.
|
||||
transcripts: HashMap<String, Transcript>,
|
||||
/// Optional directory for JSON file persistence.
|
||||
storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TranscriptStore {
|
||||
/// Create a new empty transcript store.
|
||||
/// Create a new empty transcript store (in-memory only).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
transcripts: HashMap::new(),
|
||||
storage_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new transcript.
|
||||
pub fn start_transcript(&mut self, id: String, agent_id: String, session_id: String) {
|
||||
self.transcripts.insert(
|
||||
id.clone(),
|
||||
Transcript {
|
||||
id,
|
||||
agent_id,
|
||||
session_id,
|
||||
started_at: Utc::now(),
|
||||
ended_at: None,
|
||||
turns: Vec::new(),
|
||||
summary: None,
|
||||
},
|
||||
);
|
||||
/// Create a new transcript store with file-backed persistence.
|
||||
///
|
||||
/// Transcripts are saved as `{transcript_id}.json` in the given
|
||||
/// directory.
|
||||
pub fn with_persistence(storage_dir: PathBuf) -> Self {
|
||||
fs::create_dir_all(&storage_dir).ok();
|
||||
Self {
|
||||
transcripts: HashMap::new(),
|
||||
storage_dir: Some(storage_dir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a turn to an existing transcript.
|
||||
pub fn append_turn(&mut self, transcript_id: &str, turn: TranscriptTurn) -> Option<()> {
|
||||
self.transcripts.get_mut(transcript_id)?.turns.push(turn);
|
||||
/// Start a new transcript with the given metadata.
|
||||
///
|
||||
/// Returns the generated transcript ID (UUID).
|
||||
pub fn start_transcript(&mut self, agent_id: &str, session_id: &str) -> String {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let transcript = Transcript {
|
||||
id: id.clone(),
|
||||
agent_id: agent_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
started_at: Utc::now(),
|
||||
ended_at: None,
|
||||
turns: Vec::new(),
|
||||
summary: None,
|
||||
};
|
||||
self.transcripts.insert(id.clone(), transcript);
|
||||
debug!("Started transcript {id} (agent={agent_id}, session={session_id})");
|
||||
id
|
||||
}
|
||||
|
||||
/// Append a turn to a transcript.
|
||||
///
|
||||
/// Returns `None` if the transcript ID was not found.
|
||||
pub fn append_turn(
|
||||
&mut self,
|
||||
transcript_id: &str,
|
||||
turn: TranscriptTurn,
|
||||
) -> Option<()> {
|
||||
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||
transcript.turns.push(turn);
|
||||
self.persist(transcript_id);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// End a transcript by setting the end timestamp.
|
||||
///
|
||||
/// Returns `None` if the transcript ID was not found.
|
||||
pub fn end_transcript(&mut self, transcript_id: &str) -> Option<()> {
|
||||
self.transcripts
|
||||
.get_mut(transcript_id)?
|
||||
.ended_at = Some(Utc::now());
|
||||
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||
transcript.ended_at = Some(Utc::now());
|
||||
self.persist(transcript_id);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Set a summary for the transcript.
|
||||
pub fn set_summary(&mut self, transcript_id: &str, summary: String) -> Option<()> {
|
||||
self.transcripts.get_mut(transcript_id)?.summary = Some(summary);
|
||||
/// Finalize a transcript with a summary.
|
||||
///
|
||||
/// This both sets the summary and ends the transcript.
|
||||
pub fn finalize_transcript(
|
||||
&mut self,
|
||||
transcript_id: &str,
|
||||
summary: String,
|
||||
) -> Option<()> {
|
||||
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||
transcript.summary = Some(summary);
|
||||
transcript.ended_at = Some(Utc::now());
|
||||
self.persist(transcript_id);
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -82,9 +177,49 @@ impl TranscriptStore {
|
||||
self.transcripts.get(transcript_id)
|
||||
}
|
||||
|
||||
/// List all transcript IDs.
|
||||
/// Get a mutable reference to a transcript by ID.
|
||||
pub fn get_mut(&mut self, transcript_id: &str) -> Option<&mut Transcript> {
|
||||
self.transcripts.get_mut(transcript_id)
|
||||
}
|
||||
|
||||
/// List all available transcript IDs.
|
||||
pub fn list_ids(&self) -> Vec<String> {
|
||||
self.transcripts.keys().cloned().collect()
|
||||
let mut ids: Vec<String> = self.transcripts.keys().cloned().collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Delete a transcript by ID.
|
||||
pub fn delete(&mut self, transcript_id: &str) -> Option<Transcript> {
|
||||
let removed = self.transcripts.remove(transcript_id);
|
||||
if let Some(ref storage_dir) = self.storage_dir {
|
||||
let path = storage_dir.join(format!("{transcript_id}.json"));
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Number of active transcripts.
|
||||
pub fn len(&self) -> usize {
|
||||
self.transcripts.len()
|
||||
}
|
||||
|
||||
/// True if no transcripts are stored.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.transcripts.is_empty()
|
||||
}
|
||||
|
||||
// ── Persistence helpers ─────────────────────────────────────────────
|
||||
|
||||
fn persist(&self, transcript_id: &str) {
|
||||
if let Some(ref storage_dir) = self.storage_dir {
|
||||
if let Some(transcript) = self.transcripts.get(transcript_id) {
|
||||
let path = storage_dir.join(format!("{transcript_id}.json"));
|
||||
if let Ok(json) = serde_json::to_string_pretty(transcript) {
|
||||
let _ = fs::write(&path, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,32 +229,121 @@ impl Default for TranscriptStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_turn(num: u64, user: &str, assistant: &str) -> TranscriptTurn {
|
||||
TranscriptTurn {
|
||||
turn_number: num,
|
||||
user_message: user.to_string(),
|
||||
assistant_message: assistant.to_string(),
|
||||
tool_call_ids: vec![],
|
||||
latency_ms: 100,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_lifecycle() {
|
||||
let mut store = TranscriptStore::new();
|
||||
store.start_transcript("t1".to_string(), "agent1".to_string(), "session1".to_string());
|
||||
|
||||
let id = store.start_transcript("agent-1", "session-1");
|
||||
assert!(!id.is_empty());
|
||||
|
||||
store
|
||||
.append_turn(
|
||||
"t1",
|
||||
TranscriptTurn {
|
||||
turn_number: 1,
|
||||
user_message: "Hello".to_string(),
|
||||
assistant_message: "Hi there!".to_string(),
|
||||
tool_calls: vec![],
|
||||
latency_ms: 500,
|
||||
timestamp: Utc::now(),
|
||||
},
|
||||
)
|
||||
.append_turn(&id, make_turn(1, "Hello", "Hi there!"))
|
||||
.unwrap();
|
||||
|
||||
store.end_transcript("t1").unwrap();
|
||||
let t = store.get("t1").unwrap();
|
||||
store.end_transcript(&id).unwrap();
|
||||
|
||||
let t = store.get(&id).unwrap();
|
||||
assert_eq!(t.turns.len(), 1);
|
||||
assert!(t.ended_at.is_some());
|
||||
assert_eq!(t.agent_id, "agent-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finalize_transcript() {
|
||||
let mut store = TranscriptStore::new();
|
||||
let id = store.start_transcript("agent-2", "session-2");
|
||||
store
|
||||
.append_turn(&id, make_turn(1, "Hello", "Hi"))
|
||||
.unwrap();
|
||||
store
|
||||
.finalize_transcript(&id, "Brief greeting exchange.".to_string())
|
||||
.unwrap();
|
||||
|
||||
let t = store.get(&id).unwrap();
|
||||
assert_eq!(t.summary.as_deref(), Some("Brief greeting exchange."));
|
||||
assert!(t.ended_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_ids() {
|
||||
let mut store = TranscriptStore::new();
|
||||
let id1 = store.start_transcript("a1", "s1");
|
||||
let id2 = store.start_transcript("a2", "s2");
|
||||
let ids = store.list_ids();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&id1));
|
||||
assert!(ids.contains(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete() {
|
||||
let mut store = TranscriptStore::new();
|
||||
let id = store.start_transcript("a1", "s1");
|
||||
assert_eq!(store.len(), 1);
|
||||
let removed = store.delete(&id);
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_to_nonexistent_returns_none() {
|
||||
let mut store = TranscriptStore::new();
|
||||
let result = store.append_turn("nonexistent", make_turn(1, "hi", "bye"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_format() {
|
||||
let mut store = TranscriptStore::new();
|
||||
let id = store.start_transcript("agent-1", "session-1");
|
||||
store
|
||||
.append_turn(&id, make_turn(1, "Hello", "Hi!"))
|
||||
.unwrap();
|
||||
|
||||
let t = store.get(&id).unwrap();
|
||||
let display = t.to_display();
|
||||
assert!(display.contains("agent-1"));
|
||||
assert!(display.contains("Hello"));
|
||||
assert!(display.contains("Hi!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_persistence() {
|
||||
let tmp = std::env::temp_dir().join("gyandoot_transcript_test");
|
||||
let mut store = TranscriptStore::with_persistence(tmp.clone());
|
||||
let id = store.start_transcript("agent-1", "session-1");
|
||||
store
|
||||
.append_turn(&id, make_turn(1, "Hello", "Hi"))
|
||||
.unwrap();
|
||||
store.end_transcript(&id).unwrap();
|
||||
|
||||
let file_path = tmp.join(format!("{id}.json"));
|
||||
assert!(file_path.exists());
|
||||
|
||||
// Read back
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
let restored: Transcript = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(restored.agent_id, "agent-1");
|
||||
assert_eq!(restored.turns.len(), 1);
|
||||
assert!(restored.ended_at.is_some());
|
||||
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user