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:
2026-06-11 20:53:38 +05:30
parent 03c103061a
commit 54d8ca3774
29 changed files with 7211 additions and 755 deletions

View File

@@ -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();
}
}