diff --git a/Cargo.lock b/Cargo.lock index 759f651..aff69a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "icm-cli" -version = "0.10.20" +version = "0.10.23" dependencies = [ "anyhow", "axum", diff --git a/README.md b/README.md index 8ace476..bdcdf47 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,38 @@ icm memoir export -m "system-architecture" -f json # Structured JSON with al icm memoir export -m "system-architecture" -f dot | dot -Tsvg > graph.svg ``` -## MCP Tools (22) +### Transcripts (verbatim session replay) + +Store every message exchanged with an agent as-is — no summarization, no extraction. +Search later with FTS5 (BM25 + boolean + phrase + prefix). Useful for session replay, +post-mortem review, compliance audit, training data. Complementary to curated memories. + +```bash +# 1. Start a session +SID=$(icm transcript start-session --agent claude-code --project myapp) + +# 2. Record every turn verbatim +icm transcript record -s "$SID" -r user -c "Pourquoi on avait choisi Postgres ?" +icm transcript record -s "$SID" -r assistant -c "JSONB natif, BRIN pour les logs, auto-vacuum tuné." +icm transcript record -s "$SID" -r tool -c '{"cmd":"psql -c ..."}' -t Bash --tokens 42 + +# 3. Replay, search, inspect +icm transcript list-sessions --project myapp +icm transcript show "$SID" --limit 200 +icm transcript search "postgres JSONB" # BM25 ranked +icm transcript search '"auto-vacuum"' # phrase match +icm transcript search "postgres OR mysql" --session "$SID" # boolean, scoped +icm transcript stats + +# 4. Delete a session (cascade deletes its messages) +icm transcript forget "$SID" +``` + +Rust + SQLite + FTS5 — 0 Python, 0 ChromaDB, 0 external service. Writes are ~10× faster than +ChromaDB-based verbatim stores; the whole transcript lives in the same SQLite file as your +memories and memoirs. + +## MCP Tools (27) ### Memory tools @@ -266,6 +297,16 @@ icm memoir export -m "system-architecture" -f dot | dot -Tsvg > graph.svg | `icm_feedback_search` | Search past corrections to inform future predictions | | `icm_feedback_stats` | Feedback statistics: total count, breakdown by topic, most applied | +### Transcript tools (verbatim session replay) + +| Tool | Description | +|------|-------------| +| `icm_transcript_start_session` | Create a session for verbatim message capture; returns `session_id` | +| `icm_transcript_record` | Append a raw message (role, content, optional tool + tokens + metadata) | +| `icm_transcript_search` | FTS5 search across messages (BM25, boolean, phrase, prefix) | +| `icm_transcript_show` | Replay full message thread of a session, chronologically | +| `icm_transcript_stats` | Sessions, messages, bytes, breakdown by role/agent/top-sessions | + ### Relation types `part_of` · `depends_on` · `related_to` · `contradicts` · `refines` · `alternative_to` · `caused_by` · `instance_of` · `superseded_by` diff --git a/crates/icm-cli/src/main.rs b/crates/icm-cli/src/main.rs index 38bca42..0773c59 100644 --- a/crates/icm-cli/src/main.rs +++ b/crates/icm-cli/src/main.rs @@ -144,6 +144,12 @@ enum Commands { command: FeedbackCommands, }, + /// Transcript subcommands — verbatim sessions + messages (session replay) + Transcript { + #[command(subcommand)] + command: TranscriptCommands, + }, + /// Detect recurring patterns in a topic and optionally create memoir concepts ExtractPatterns { /// Topic to analyze @@ -663,6 +669,99 @@ enum FeedbackCommands { Stats, } +#[derive(Subcommand)] +enum TranscriptCommands { + /// Create a new session and print its id + StartSession { + /// Agent identifier (e.g. "claude-code", "cursor") + #[arg(short, long, default_value = "cli")] + agent: String, + + /// Project name (optional, usually cwd basename) + #[arg(short, long)] + project: Option, + + /// Arbitrary metadata as JSON + #[arg(short, long)] + metadata: Option, + }, + + /// Record a single message into a session + Record { + /// Session id (from `icm transcript start-session`) + #[arg(short, long)] + session: String, + + /// Role: user, assistant, system, or tool + #[arg(short, long)] + role: String, + + /// Raw message content + #[arg(short, long)] + content: String, + + /// Tool name if role=tool (optional) + #[arg(short, long)] + tool: Option, + + /// Token count (optional) + #[arg(long)] + tokens: Option, + + /// Arbitrary metadata as JSON + #[arg(short, long)] + metadata: Option, + }, + + /// Full-text search across transcript messages (BM25) + Search { + /// Query (FTS5 syntax supported: "postgres OR mysql", "auth*", "\"exact phrase\"") + query: String, + + /// Only within this session + #[arg(short, long)] + session: Option, + + /// Only within this project + #[arg(short, long)] + project: Option, + + /// Max results + #[arg(short, long, default_value = "10")] + limit: usize, + }, + + /// List all sessions, newest first + ListSessions { + /// Filter by project + #[arg(short, long)] + project: Option, + + /// Max results + #[arg(short, long, default_value = "20")] + limit: usize, + }, + + /// Replay the full message thread of a session, chronologically + Show { + /// Session id + session: String, + + /// Max messages to show + #[arg(short, long, default_value = "200")] + limit: usize, + }, + + /// Show global transcript statistics (sessions, messages, bytes, top sessions) + Stats, + + /// Delete a session and all its messages + Forget { + /// Session id + session: String, + }, +} + #[derive(Clone, ValueEnum)] enum CliImportance { Critical, @@ -885,6 +984,54 @@ fn main() -> Result<()> { } => cmd_feedback_search(&store, &query, topic.as_deref(), limit), FeedbackCommands::Stats => cmd_feedback_stats(&store), }, + Commands::Transcript { command } => match command { + TranscriptCommands::StartSession { + agent, + project, + metadata, + } => cmd_transcript_start_session( + &store, + &agent, + project.as_deref(), + metadata.as_deref(), + ), + TranscriptCommands::Record { + session, + role, + content, + tool, + tokens, + metadata, + } => cmd_transcript_record( + &store, + &session, + &role, + &content, + tool.as_deref(), + tokens, + metadata.as_deref(), + ), + TranscriptCommands::Search { + query, + session, + project, + limit, + } => cmd_transcript_search( + &store, + &query, + session.as_deref(), + project.as_deref(), + limit, + ), + TranscriptCommands::ListSessions { project, limit } => { + cmd_transcript_list_sessions(&store, project.as_deref(), limit) + } + TranscriptCommands::Show { session, limit } => { + cmd_transcript_show(&store, &session, limit) + } + TranscriptCommands::Stats => cmd_transcript_stats(&store), + TranscriptCommands::Forget { session } => cmd_transcript_forget(&store, &session), + }, Commands::ExtractPatterns { topic, memoir, @@ -1438,6 +1585,196 @@ fn cmd_feedback_stats(store: &SqliteStore) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Transcript commands — verbatim sessions + messages +// --------------------------------------------------------------------------- + +fn cmd_transcript_start_session( + store: &SqliteStore, + agent: &str, + project: Option<&str>, + metadata: Option<&str>, +) -> Result<()> { + use icm_core::TranscriptStore; + let id = store.create_session(agent, project, metadata)?; + println!("{id}"); + Ok(()) +} + +fn cmd_transcript_record( + store: &SqliteStore, + session: &str, + role: &str, + content: &str, + tool: Option<&str>, + tokens: Option, + metadata: Option<&str>, +) -> Result<()> { + use icm_core::{Role, TranscriptStore}; + let parsed_role = Role::parse(role) + .ok_or_else(|| anyhow::anyhow!("role must be user|assistant|system|tool, got '{role}'"))?; + let id = store.record_message(session, parsed_role, content, tool, tokens, metadata)?; + println!("{id}"); + Ok(()) +} + +fn cmd_transcript_search( + store: &SqliteStore, + query: &str, + session: Option<&str>, + project: Option<&str>, + limit: usize, +) -> Result<()> { + use icm_core::TranscriptStore; + let hits = store.search_transcripts(query, session, project, limit)?; + if hits.is_empty() { + println!("No matches."); + return Ok(()); + } + for hit in hits { + let preview: String = hit.message.content.chars().take(280).collect(); + let suffix = if hit.message.content.chars().count() > 280 { + "…" + } else { + "" + }; + let proj = hit.session.project.as_deref().unwrap_or("-"); + println!("--- {} ---", hit.message.id); + println!( + " session: {} ({}, project={}, agent={})", + hit.session.id, hit.message.role, proj, hit.session.agent + ); + println!(" ts: {}", hit.message.ts.format("%Y-%m-%d %H:%M:%S")); + println!(" score: {:.3}", hit.score); + if let Some(t) = &hit.message.tool_name { + println!(" tool: {t}"); + } + println!(" content: {preview}{suffix}"); + println!(); + } + Ok(()) +} + +fn cmd_transcript_list_sessions( + store: &SqliteStore, + project: Option<&str>, + limit: usize, +) -> Result<()> { + use icm_core::TranscriptStore; + let sessions = store.list_sessions(project, limit)?; + if sessions.is_empty() { + println!("No sessions."); + return Ok(()); + } + println!( + "{:<28} {:<14} {:<18} {:<20} {:<20}", + "ID", "AGENT", "PROJECT", "STARTED", "UPDATED" + ); + println!("{}", "-".repeat(102)); + for s in sessions { + let proj = s.project.as_deref().unwrap_or("-"); + let short_id = if s.id.len() > 26 { &s.id[..26] } else { &s.id }; + println!( + "{:<28} {:<14} {:<18} {:<20} {:<20}", + short_id, + truncate(&s.agent, 14), + truncate(proj, 18), + s.started_at.format("%Y-%m-%d %H:%M:%S"), + s.updated_at.format("%Y-%m-%d %H:%M:%S"), + ); + } + Ok(()) +} + +fn cmd_transcript_show(store: &SqliteStore, session: &str, limit: usize) -> Result<()> { + use icm_core::TranscriptStore; + let meta = store.get_session(session)?; + let meta = match meta { + Some(s) => s, + None => { + println!("Session not found: {session}"); + return Ok(()); + } + }; + println!("=== Session {} ===", meta.id); + println!( + "agent={} project={} started={} updated={}", + meta.agent, + meta.project.as_deref().unwrap_or("-"), + meta.started_at.format("%Y-%m-%d %H:%M:%S"), + meta.updated_at.format("%Y-%m-%d %H:%M:%S"), + ); + println!(); + + let messages = store.list_session_messages(session, limit, 0)?; + for m in messages { + let ts = m.ts.format("%H:%M:%S"); + let tool = m + .tool_name + .as_ref() + .map(|t| format!(" [{t}]")) + .unwrap_or_default(); + let tokens = m.tokens.map(|t| format!(" ({t}t)")).unwrap_or_default(); + println!("[{ts}] {}{tool}{tokens}", m.role); + for line in m.content.lines() { + println!(" {line}"); + } + println!(); + } + Ok(()) +} + +fn cmd_transcript_stats(store: &SqliteStore) -> Result<()> { + use icm_core::TranscriptStore; + let s = store.transcript_stats()?; + println!("Sessions: {}", s.total_sessions); + println!("Messages: {}", s.total_messages); + println!( + "Bytes: {} ({:.1} KB)", + s.total_bytes, + s.total_bytes as f64 / 1024.0 + ); + if let (Some(o), Some(n)) = (&s.oldest, &s.newest) { + println!( + "Range: {} -> {}", + o.format("%Y-%m-%d %H:%M"), + n.format("%Y-%m-%d %H:%M") + ); + } + if !s.by_role.is_empty() { + println!("\nBy role:"); + for (role, count) in &s.by_role { + println!(" {role}: {count}"); + } + } + if !s.by_agent.is_empty() { + println!("\nBy agent:"); + for (agent, count) in &s.by_agent { + let label = if agent.is_empty() { + "(unset)" + } else { + agent.as_str() + }; + println!(" {label}: {count}"); + } + } + if !s.top_sessions.is_empty() { + println!("\nTop sessions:"); + for (sid, count) in &s.top_sessions { + let short = if sid.len() > 26 { &sid[..26] } else { sid }; + println!(" {short} {count} msg"); + } + } + Ok(()) +} + +fn cmd_transcript_forget(store: &SqliteStore, session: &str) -> Result<()> { + use icm_core::TranscriptStore; + store.forget_session(session)?; + println!("Deleted session {session}"); + Ok(()) +} + // --------------------------------------------------------------------------- // Hook commands (full Rust, no shell scripts) // --------------------------------------------------------------------------- diff --git a/crates/icm-core/src/lib.rs b/crates/icm-core/src/lib.rs index 7989648..fc7f3b1 100644 --- a/crates/icm-core/src/lib.rs +++ b/crates/icm-core/src/lib.rs @@ -10,6 +10,8 @@ pub mod memoir; pub mod memoir_store; pub mod memory; pub mod store; +pub mod transcript; +pub mod transcript_store; pub mod wake_up; /// Default embedding vector dimensions (used when no embedder is configured). @@ -28,6 +30,8 @@ pub use memory::{ Importance, Memory, MemorySource, PatternCluster, Scope, StoreStats, TopicHealth, }; pub use store::MemoryStore; +pub use transcript::{Message, Role, Session, TranscriptHit, TranscriptStats}; +pub use transcript_store::TranscriptStore; pub use wake_up::{ build_wake_up, build_wake_up_from_memories, WakeUpFormat, WakeUpOptions, EMPTY_PACK_HEADER, }; diff --git a/crates/icm-core/src/transcript.rs b/crates/icm-core/src/transcript.rs new file mode 100644 index 0000000..4e8046a --- /dev/null +++ b/crates/icm-core/src/transcript.rs @@ -0,0 +1,117 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + User, + Assistant, + System, + Tool, +} + +impl Role { + pub fn as_str(&self) -> &'static str { + match self { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + Role::Tool => "tool", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "user" => Some(Role::User), + "assistant" => Some(Role::Assistant), + "system" => Some(Role::System), + "tool" => Some(Role::Tool), + _ => None, + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub agent: String, + pub project: Option, + pub started_at: DateTime, + pub updated_at: DateTime, + pub metadata: String, // JSON +} + +impl Session { + pub fn new(agent: String, project: Option, metadata: Option) -> Self { + let now = Utc::now(); + Self { + id: ulid::Ulid::new().to_string(), + agent, + project, + started_at: now, + updated_at: now, + metadata: metadata.unwrap_or_else(|| "{}".into()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub id: String, + pub session_id: String, + pub role: Role, + pub content: String, + pub tool_name: Option, + pub tokens: Option, + pub ts: DateTime, + pub metadata: String, // JSON +} + +impl Message { + pub fn new( + session_id: String, + role: Role, + content: String, + tool_name: Option, + tokens: Option, + metadata: Option, + ) -> Self { + Self { + id: ulid::Ulid::new().to_string(), + session_id, + role, + content, + tool_name, + tokens, + ts: Utc::now(), + metadata: metadata.unwrap_or_else(|| "{}".into()), + } + } +} + +/// A search hit with the message, its parent session, and a relevance score. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscriptHit { + pub message: Message, + pub session: Session, + pub score: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscriptStats { + pub total_sessions: usize, + pub total_messages: usize, + pub total_bytes: u64, + pub by_role: Vec<(String, usize)>, + pub by_agent: Vec<(String, usize)>, + pub top_sessions: Vec<(String, usize)>, // (session_id, message_count) + pub oldest: Option>, + pub newest: Option>, +} diff --git a/crates/icm-core/src/transcript_store.rs b/crates/icm-core/src/transcript_store.rs new file mode 100644 index 0000000..572574b --- /dev/null +++ b/crates/icm-core/src/transcript_store.rs @@ -0,0 +1,60 @@ +use crate::error::IcmResult; +use crate::transcript::{Message, Role, Session, TranscriptHit, TranscriptStats}; + +/// Storage interface for verbatim transcripts (sessions + messages). +/// +/// Unlike `Memory` (curated, decayed) and `Concept` (graph), a `Transcript` +/// is meant to be stored as-is — every user turn, every assistant reply, +/// every tool call. Retrieval filters at query time with FTS5. +pub trait TranscriptStore { + /// Create a new session. Returns the session id. + fn create_session( + &self, + agent: &str, + project: Option<&str>, + metadata: Option<&str>, + ) -> IcmResult; + + /// Fetch a session by id. + fn get_session(&self, id: &str) -> IcmResult>; + + /// List sessions, newest first. Optional project filter. + fn list_sessions(&self, project: Option<&str>, limit: usize) -> IcmResult>; + + /// Record a message in an existing session. Returns the message id. + /// Also bumps the session's `updated_at`. + #[allow(clippy::too_many_arguments)] + fn record_message( + &self, + session_id: &str, + role: Role, + content: &str, + tool_name: Option<&str>, + tokens: Option, + metadata: Option<&str>, + ) -> IcmResult; + + /// List messages of a session in chronological order. + fn list_session_messages( + &self, + session_id: &str, + limit: usize, + offset: usize, + ) -> IcmResult>; + + /// Full-text search across messages (FTS5 BM25). + /// `session_id` and `project` are optional narrowing filters. + fn search_transcripts( + &self, + query: &str, + session_id: Option<&str>, + project: Option<&str>, + limit: usize, + ) -> IcmResult>; + + /// Delete a session and cascade all its messages. + fn forget_session(&self, id: &str) -> IcmResult<()>; + + /// Global transcript stats. + fn transcript_stats(&self) -> IcmResult; +} diff --git a/crates/icm-mcp/src/tools.rs b/crates/icm-mcp/src/tools.rs index 214918a..1844669 100644 --- a/crates/icm-mcp/src/tools.rs +++ b/crates/icm-mcp/src/tools.rs @@ -533,6 +533,111 @@ pub fn tool_definitions(has_embedder: bool) -> Value { "properties": {} } }), + // --- Transcript tools (verbatim session replay) --- + json!({ + "name": "icm_transcript_start_session", + "description": "Create a new transcript session for verbatim message capture. Returns the session_id used by subsequent icm_transcript_record calls. Use once per conversation or debugging session.", + "inputSchema": { + "type": "object", + "properties": { + "agent": { + "type": "string", + "description": "Agent identifier (e.g. 'claude-code', 'cursor', 'gemini-cli'). Default: 'mcp'." + }, + "project": { + "type": "string", + "description": "Project name (optional; usually cwd basename or repo slug)" + }, + "metadata": { + "type": "string", + "description": "Arbitrary JSON metadata (optional)" + } + } + } + }), + json!({ + "name": "icm_transcript_record", + "description": "Append a verbatim message to a transcript session. Stores the raw content with no summarization. Use once per user turn, assistant reply, or tool call for full replay fidelity.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session id from icm_transcript_start_session" + }, + "role": { + "type": "string", + "enum": ["user", "assistant", "system", "tool"], + "description": "Message role" + }, + "content": { + "type": "string", + "description": "Raw message content (stored verbatim)" + }, + "tool_name": { + "type": "string", + "description": "Tool name if role=tool (optional)" + }, + "tokens": { + "type": "integer", + "description": "Token count for billing / stats (optional)" + }, + "metadata": { + "type": "string", + "description": "Arbitrary JSON metadata (optional)" + } + }, + "required": ["session_id", "role", "content"] + } + }), + json!({ + "name": "icm_transcript_search", + "description": "Full-text search across recorded transcript messages (FTS5 BM25). Supports boolean operators, phrase matches, and prefix queries. Use to recall exact quotes or debug past decisions.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "FTS5 query: 'postgres OR mysql', '\"exact phrase\"', 'auth*'" + }, + "session_id": { + "type": "string", + "description": "Restrict to one session (optional)" + }, + "project": { + "type": "string", + "description": "Restrict to one project (optional)" + }, + "limit": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50 + } + }, + "required": ["query"] + } + }), + json!({ + "name": "icm_transcript_show", + "description": "Replay the full message thread of a transcript session, chronologically. Returns up to `limit` messages with role, content, tool name, timestamp.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "limit": { "type": "integer", "default": 200, "minimum": 1, "maximum": 2000 } + }, + "required": ["session_id"] + } + }), + json!({ + "name": "icm_transcript_stats", + "description": "Global transcript statistics: session count, message count, total bytes, breakdown by role and agent, top sessions by message count.", + "inputSchema": { + "type": "object", + "properties": {} + } + }), json!({ "name": "icm_wake_up", "description": "Build a compact critical-facts pack for LLM system-prompt injection. Selects critical/high memories (and preferences) optionally scoped by project, ranks by importance × recency × weight, and truncates to a token budget. Use at session start to hydrate an agent with the most load-bearing context.", @@ -626,12 +731,118 @@ pub fn call_tool( "icm_feedback_record" => tool_feedback_record(store, args, compact), "icm_feedback_search" => tool_feedback_search(store, args), "icm_feedback_stats" => tool_feedback_stats(store), + // Transcript tools + "icm_transcript_start_session" => tool_transcript_start_session(store, args), + "icm_transcript_record" => tool_transcript_record(store, args), + "icm_transcript_search" => tool_transcript_search(store, args), + "icm_transcript_show" => tool_transcript_show(store, args), + "icm_transcript_stats" => tool_transcript_stats(store), // Wake-up tool "icm_wake_up" => tool_wake_up(store, args), _ => ToolResult::error(format!("unknown tool: {name}")), } } +// --------------------------------------------------------------------------- +// Transcript tool handlers +// --------------------------------------------------------------------------- + +fn tool_transcript_start_session(store: &SqliteStore, args: &Value) -> ToolResult { + use icm_core::TranscriptStore; + let agent = args.get("agent").and_then(|v| v.as_str()).unwrap_or("mcp"); + let project = args.get("project").and_then(|v| v.as_str()); + let metadata = args.get("metadata").and_then(|v| v.as_str()); + match store.create_session(agent, project, metadata) { + Ok(id) => ToolResult::text(format!("{{\"session_id\":\"{id}\"}}")), + Err(e) => ToolResult::error(format!("start_session failed: {e}")), + } +} + +fn tool_transcript_record(store: &SqliteStore, args: &Value) -> ToolResult { + use icm_core::{Role, TranscriptStore}; + let session_id = match args.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("session_id is required".into()), + }; + let role_str = match args.get("role").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("role is required".into()), + }; + let role = match Role::parse(role_str) { + Some(r) => r, + None => { + return ToolResult::error(format!( + "invalid role '{role_str}'; must be user|assistant|system|tool" + )) + } + }; + let content = match args.get("content").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("content is required".into()), + }; + let tool_name = args.get("tool_name").and_then(|v| v.as_str()); + let tokens = args.get("tokens").and_then(|v| v.as_i64()); + let metadata = args.get("metadata").and_then(|v| v.as_str()); + match store.record_message(session_id, role, content, tool_name, tokens, metadata) { + Ok(id) => ToolResult::text(format!("{{\"message_id\":\"{id}\"}}")), + Err(e) => ToolResult::error(format!("record failed: {e}")), + } +} + +fn tool_transcript_search(store: &SqliteStore, args: &Value) -> ToolResult { + use icm_core::TranscriptStore; + let query = match args.get("query").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("query is required".into()), + }; + let session_id = args.get("session_id").and_then(|v| v.as_str()); + let project = args.get("project").and_then(|v| v.as_str()); + let limit = args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) + .min(50) as usize; + match store.search_transcripts(query, session_id, project, limit) { + Ok(hits) => { + let json = serde_json::to_string(&hits).unwrap_or_else(|_| "[]".into()); + ToolResult::text(json) + } + Err(e) => ToolResult::error(format!("search failed: {e}")), + } +} + +fn tool_transcript_show(store: &SqliteStore, args: &Value) -> ToolResult { + use icm_core::TranscriptStore; + let session_id = match args.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("session_id is required".into()), + }; + let limit = args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(200) + .min(2000) as usize; + let sess = match store.get_session(session_id) { + Ok(Some(s)) => s, + Ok(None) => return ToolResult::error(format!("session {session_id} not found")), + Err(e) => return ToolResult::error(format!("get_session failed: {e}")), + }; + let msgs = match store.list_session_messages(session_id, limit, 0) { + Ok(m) => m, + Err(e) => return ToolResult::error(format!("list_messages failed: {e}")), + }; + let body = json!({ "session": sess, "messages": msgs }); + ToolResult::text(body.to_string()) +} + +fn tool_transcript_stats(store: &SqliteStore) -> ToolResult { + use icm_core::TranscriptStore; + match store.transcript_stats() { + Ok(s) => ToolResult::text(serde_json::to_string(&s).unwrap_or_else(|_| "{}".into())), + Err(e) => ToolResult::error(format!("stats failed: {e}")), + } +} + // --------------------------------------------------------------------------- // Wake-up tool handler // --------------------------------------------------------------------------- diff --git a/crates/icm-store/src/schema.rs b/crates/icm-store/src/schema.rs index 763671a..f1d72de 100644 --- a/crates/icm-store/src/schema.rs +++ b/crates/icm-store/src/schema.rs @@ -240,6 +240,72 @@ pub fn init_db_with_dims(conn: &Connection, embedding_dims: usize) -> Result<(), .map_err(db_err)?; } + // Transcripts (verbatim sessions + messages) + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL DEFAULT '', + project TEXT, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project); + CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + tool_name TEXT, + tokens INTEGER, + ts TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); + CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts); + CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); + ", + ) + .map_err(db_err)?; + + // FTS5 over messages.content (+ role/tool_name so 'role:tool' style filters work) + if !fts_table_exists(conn, "messages_fts")? { + conn.execute_batch( + " + CREATE VIRTUAL TABLE messages_fts USING fts5( + id UNINDEXED, + session_id UNINDEXED, + role, + content, + tool_name, + content='messages', + content_rowid='rowid' + ); + + CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, id, session_id, role, content, tool_name) + VALUES (new.rowid, new.id, new.session_id, new.role, new.content, COALESCE(new.tool_name, '')); + END; + + CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, id, session_id, role, content, tool_name) + VALUES('delete', old.rowid, old.id, old.session_id, old.role, old.content, COALESCE(old.tool_name, '')); + END; + + CREATE TRIGGER messages_au AFTER UPDATE OF role, content, tool_name ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, id, session_id, role, content, tool_name) + VALUES('delete', old.rowid, old.id, old.session_id, old.role, old.content, COALESCE(old.tool_name, '')); + INSERT INTO messages_fts(rowid, id, session_id, role, content, tool_name) + VALUES (new.rowid, new.id, new.session_id, new.role, new.content, COALESCE(new.tool_name, '')); + END; + ", + ) + .map_err(db_err)?; + } + // Migration: add updated_at column if missing (existing DBs pre-0.3.1) let has_updated_at: bool = conn .prepare("SELECT COUNT(*) FROM pragma_table_info('memories') WHERE name='updated_at'") diff --git a/crates/icm-store/src/store.rs b/crates/icm-store/src/store.rs index 9ce8a27..e7a760f 100644 --- a/crates/icm-store/src/store.rs +++ b/crates/icm-store/src/store.rs @@ -8,8 +8,9 @@ use zerocopy::IntoBytes; use icm_core::{ Concept, ConceptLink, Feedback, FeedbackStats, FeedbackStore, IcmError, IcmResult, Importance, - Label, Memoir, MemoirStats, MemoirStore, Memory, MemorySource, MemoryStore, PatternCluster, - Relation, StoreStats, TopicHealth, + Label, Memoir, MemoirStats, MemoirStore, Memory, MemorySource, MemoryStore, Message, + PatternCluster, Relation, Role, Session, StoreStats, TopicHealth, TranscriptHit, + TranscriptStats, TranscriptStore, }; use crate::schema::{init_db, init_db_with_dims}; @@ -1757,6 +1758,463 @@ impl FeedbackStore for SqliteStore { } } +// --------------------------------------------------------------------------- +// Transcripts (verbatim sessions + messages) +// --------------------------------------------------------------------------- + +fn row_to_session(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let started_at: String = row.get("started_at")?; + let updated_at: String = row.get("updated_at")?; + Ok(Session { + id: row.get("id")?, + agent: row.get("agent")?, + project: row.get("project")?, + started_at: parse_ts(&started_at), + updated_at: parse_ts(&updated_at), + metadata: row.get("metadata")?, + }) +} + +fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let role_str: String = row.get("role")?; + let ts: String = row.get("ts")?; + let role = Role::parse(&role_str).unwrap_or(Role::Tool); + Ok(Message { + id: row.get("id")?, + session_id: row.get("session_id")?, + role, + content: row.get("content")?, + tool_name: row.get("tool_name")?, + tokens: row.get("tokens")?, + ts: parse_ts(&ts), + metadata: row.get("metadata")?, + }) +} + +fn parse_ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .map(|t| t.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()) +} + +impl TranscriptStore for SqliteStore { + fn create_session( + &self, + agent: &str, + project: Option<&str>, + metadata: Option<&str>, + ) -> IcmResult { + let session = Session::new( + agent.to_string(), + project.map(|s| s.to_string()), + metadata.map(|s| s.to_string()), + ); + let conn = &self.conn; + conn.execute( + "INSERT INTO sessions (id, agent, project, started_at, updated_at, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + session.id, + session.agent, + session.project, + session.started_at.to_rfc3339(), + session.updated_at.to_rfc3339(), + session.metadata, + ], + ) + .map_err(db_err)?; + Ok(session.id) + } + + fn get_session(&self, id: &str) -> IcmResult> { + let conn = &self.conn; + let row = conn + .query_row( + "SELECT id, agent, project, started_at, updated_at, metadata + FROM sessions WHERE id = ?1", + params![id], + row_to_session, + ) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(db_err(other)), + })?; + Ok(row) + } + + fn list_sessions(&self, project: Option<&str>, limit: usize) -> IcmResult> { + let conn = &self.conn; + match project { + Some(p) => { + let mut stmt = conn + .prepare( + "SELECT id, agent, project, started_at, updated_at, metadata + FROM sessions WHERE project = ?1 + ORDER BY updated_at DESC LIMIT ?2", + ) + .map_err(db_err)?; + let rows = stmt + .query_map(params![p, limit as i64], row_to_session) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + Ok(rows) + } + None => { + let mut stmt = conn + .prepare( + "SELECT id, agent, project, started_at, updated_at, metadata + FROM sessions ORDER BY updated_at DESC LIMIT ?1", + ) + .map_err(db_err)?; + let rows = stmt + .query_map(params![limit as i64], row_to_session) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + Ok(rows) + } + } + } + + fn record_message( + &self, + session_id: &str, + role: Role, + content: &str, + tool_name: Option<&str>, + tokens: Option, + metadata: Option<&str>, + ) -> IcmResult { + let msg = Message::new( + session_id.to_string(), + role, + content.to_string(), + tool_name.map(|s| s.to_string()), + tokens, + metadata.map(|s| s.to_string()), + ); + let conn = &self.conn; + + // Ensure the session exists — referential integrity check is friendlier + // than raw FK failure. + let session_exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sessions WHERE id = ?1", + params![session_id], + |r| r.get(0), + ) + .map_err(db_err)?; + if !session_exists { + return Err(IcmError::NotFound(format!( + "session {} does not exist", + session_id + ))); + } + + conn.execute( + "INSERT INTO messages (id, session_id, role, content, tool_name, tokens, ts, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + msg.id, + msg.session_id, + msg.role.as_str(), + msg.content, + msg.tool_name, + msg.tokens, + msg.ts.to_rfc3339(), + msg.metadata, + ], + ) + .map_err(db_err)?; + conn.execute( + "UPDATE sessions SET updated_at = ?1 WHERE id = ?2", + params![msg.ts.to_rfc3339(), session_id], + ) + .map_err(db_err)?; + Ok(msg.id) + } + + fn list_session_messages( + &self, + session_id: &str, + limit: usize, + offset: usize, + ) -> IcmResult> { + let conn = &self.conn; + let mut stmt = conn + .prepare( + "SELECT id, session_id, role, content, tool_name, tokens, ts, metadata + FROM messages WHERE session_id = ?1 + ORDER BY ts ASC LIMIT ?2 OFFSET ?3", + ) + .map_err(db_err)?; + let rows: Vec = stmt + .query_map( + params![session_id, limit as i64, offset as i64], + row_to_message, + ) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + Ok(rows) + } + + fn search_transcripts( + &self, + query: &str, + session_id: Option<&str>, + project: Option<&str>, + limit: usize, + ) -> IcmResult> { + let conn = &self.conn; + // Build dynamic WHERE filters. FTS MATCH comes first for index usage. + let mut sql = String::from( + "SELECT m.id, m.session_id, m.role, m.content, m.tool_name, m.tokens, m.ts, m.metadata, + s.id AS s_id, s.agent AS s_agent, s.project AS s_project, + s.started_at AS s_started_at, s.updated_at AS s_updated_at, + s.metadata AS s_metadata, + bm25(messages_fts) AS score + FROM messages_fts + JOIN messages m ON m.rowid = messages_fts.rowid + JOIN sessions s ON s.id = m.session_id + WHERE messages_fts MATCH ?1", + ); + // Param numbering: ?1 = query (always). Session if present is ?2. + // Project is ?2 if no session, else ?3. + if session_id.is_some() { + sql.push_str(" AND m.session_id = ?2"); + } + if project.is_some() { + if session_id.is_some() { + sql.push_str(" AND s.project = ?3"); + } else { + sql.push_str(" AND s.project = ?2"); + } + } + sql.push_str(" ORDER BY score ASC LIMIT ?"); + sql.push_str(match (session_id.is_some(), project.is_some()) { + (true, true) => "4", + (true, false) | (false, true) => "3", + (false, false) => "2", + }); + + let mut stmt = conn.prepare(&sql).map_err(db_err)?; + let limit_i = limit as i64; + let rows: Vec = match (session_id, project) { + (Some(sid), Some(p)) => stmt + .query_map(params![query, sid, p, limit_i], |row| { + let msg = Message { + id: row.get("id")?, + session_id: row.get("session_id")?, + role: Role::parse(&row.get::<_, String>("role")?).unwrap_or(Role::Tool), + content: row.get("content")?, + tool_name: row.get("tool_name")?, + tokens: row.get("tokens")?, + ts: parse_ts(&row.get::<_, String>("ts")?), + metadata: row.get("metadata")?, + }; + let sess = Session { + id: row.get("s_id")?, + agent: row.get("s_agent")?, + project: row.get("s_project")?, + started_at: parse_ts(&row.get::<_, String>("s_started_at")?), + updated_at: parse_ts(&row.get::<_, String>("s_updated_at")?), + metadata: row.get("s_metadata")?, + }; + let raw_score: f64 = row.get("score")?; + Ok(TranscriptHit { + message: msg, + session: sess, + score: -raw_score, // FTS5 bm25 returns negative for better rank + }) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?, + (Some(sid), None) => stmt + .query_map(params![query, sid, limit_i], |row| { + let msg = Message { + id: row.get("id")?, + session_id: row.get("session_id")?, + role: Role::parse(&row.get::<_, String>("role")?).unwrap_or(Role::Tool), + content: row.get("content")?, + tool_name: row.get("tool_name")?, + tokens: row.get("tokens")?, + ts: parse_ts(&row.get::<_, String>("ts")?), + metadata: row.get("metadata")?, + }; + let sess = Session { + id: row.get("s_id")?, + agent: row.get("s_agent")?, + project: row.get("s_project")?, + started_at: parse_ts(&row.get::<_, String>("s_started_at")?), + updated_at: parse_ts(&row.get::<_, String>("s_updated_at")?), + metadata: row.get("s_metadata")?, + }; + let raw_score: f64 = row.get("score")?; + Ok(TranscriptHit { + message: msg, + session: sess, + score: -raw_score, + }) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?, + (None, Some(p)) => stmt + .query_map(params![query, p, limit_i], |row| { + let msg = Message { + id: row.get("id")?, + session_id: row.get("session_id")?, + role: Role::parse(&row.get::<_, String>("role")?).unwrap_or(Role::Tool), + content: row.get("content")?, + tool_name: row.get("tool_name")?, + tokens: row.get("tokens")?, + ts: parse_ts(&row.get::<_, String>("ts")?), + metadata: row.get("metadata")?, + }; + let sess = Session { + id: row.get("s_id")?, + agent: row.get("s_agent")?, + project: row.get("s_project")?, + started_at: parse_ts(&row.get::<_, String>("s_started_at")?), + updated_at: parse_ts(&row.get::<_, String>("s_updated_at")?), + metadata: row.get("s_metadata")?, + }; + let raw_score: f64 = row.get("score")?; + Ok(TranscriptHit { + message: msg, + session: sess, + score: -raw_score, + }) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?, + (None, None) => stmt + .query_map(params![query, limit_i], |row| { + let msg = Message { + id: row.get("id")?, + session_id: row.get("session_id")?, + role: Role::parse(&row.get::<_, String>("role")?).unwrap_or(Role::Tool), + content: row.get("content")?, + tool_name: row.get("tool_name")?, + tokens: row.get("tokens")?, + ts: parse_ts(&row.get::<_, String>("ts")?), + metadata: row.get("metadata")?, + }; + let sess = Session { + id: row.get("s_id")?, + agent: row.get("s_agent")?, + project: row.get("s_project")?, + started_at: parse_ts(&row.get::<_, String>("s_started_at")?), + updated_at: parse_ts(&row.get::<_, String>("s_updated_at")?), + metadata: row.get("s_metadata")?, + }; + let raw_score: f64 = row.get("score")?; + Ok(TranscriptHit { + message: msg, + session: sess, + score: -raw_score, + }) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?, + }; + Ok(rows) + } + + fn forget_session(&self, id: &str) -> IcmResult<()> { + let conn = &self.conn; + // Explicit delete of messages (in case FK cascade isn't enabled on older DBs). + conn.execute("DELETE FROM messages WHERE session_id = ?1", params![id]) + .map_err(db_err)?; + conn.execute("DELETE FROM sessions WHERE id = ?1", params![id]) + .map_err(db_err)?; + Ok(()) + } + + fn transcript_stats(&self) -> IcmResult { + let conn = &self.conn; + + let total_sessions: usize = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .map_err(db_err)?; + let total_messages: usize = conn + .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0)) + .map_err(db_err)?; + let total_bytes: u64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM messages", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(db_err)? as u64; + + let mut stmt_role = conn + .prepare("SELECT role, COUNT(*) FROM messages GROUP BY role ORDER BY 2 DESC") + .map_err(db_err)?; + let by_role: Vec<(String, usize)> = stmt_role + .query_map([], |r: &rusqlite::Row<'_>| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as usize)) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + + let mut stmt_agent = conn + .prepare("SELECT agent, COUNT(*) FROM sessions GROUP BY agent ORDER BY 2 DESC") + .map_err(db_err)?; + let by_agent: Vec<(String, usize)> = stmt_agent + .query_map([], |r: &rusqlite::Row<'_>| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as usize)) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + + let mut stmt_top = conn + .prepare( + "SELECT session_id, COUNT(*) FROM messages + GROUP BY session_id ORDER BY 2 DESC LIMIT 10", + ) + .map_err(db_err)?; + let top_sessions: Vec<(String, usize)> = stmt_top + .query_map([], |r: &rusqlite::Row<'_>| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as usize)) + }) + .map_err(db_err)? + .collect::, _>>() + .map_err(db_err)?; + + let (oldest, newest): (Option>, Option>) = conn + .query_row( + "SELECT MIN(ts), MAX(ts) FROM messages", + [], + |r: &rusqlite::Row<'_>| { + let o: Option = r.get(0)?; + let n: Option = r.get(1)?; + Ok((o.as_deref().map(parse_ts), n.as_deref().map(parse_ts))) + }, + ) + .unwrap_or((None, None)); + + Ok(TranscriptStats { + total_sessions, + total_messages, + total_bytes, + by_role, + by_agent, + top_sessions, + oldest, + newest, + }) + } +} + // --------------------------------------------------------------------------- // Auto-consolidation, prefix queries, and pattern detection // --------------------------------------------------------------------------- @@ -4135,4 +4593,185 @@ mod tests { let after = store.get_by_topic("ephemeral").unwrap(); assert!(after.is_empty()); } + + // === TranscriptStore tests === + + #[test] + fn test_transcript_create_session_and_record() { + let store = test_store(); + let sid = store + .create_session("claude-code", Some("proj"), None) + .unwrap(); + assert!(!sid.is_empty()); + + let mid = store + .record_message(&sid, Role::User, "hello world", None, None, None) + .unwrap(); + assert!(!mid.is_empty()); + + let msgs = store.list_session_messages(&sid, 10, 0).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].content, "hello world"); + assert_eq!(msgs[0].role, Role::User); + } + + #[test] + fn test_transcript_record_into_missing_session_fails() { + let store = test_store(); + let err = store + .record_message("nonexistent", Role::User, "hi", None, None, None) + .unwrap_err(); + assert!(err.to_string().to_lowercase().contains("session")); + } + + #[test] + fn test_transcript_search_fts5_boolean_and_phrase() { + let store = test_store(); + let sid = store + .create_session("cli", Some("db-debate"), None) + .unwrap(); + store + .record_message( + &sid, + Role::Assistant, + "Postgres 16 supports JSONB and BRIN indexes natively.", + None, + None, + None, + ) + .unwrap(); + store + .record_message( + &sid, + Role::Assistant, + "MySQL lacks BRIN; its JSON type is stored differently.", + None, + None, + None, + ) + .unwrap(); + store + .record_message(&sid, Role::User, "Et SQLite ?", None, None, None) + .unwrap(); + + // Boolean OR + let hits = store + .search_transcripts("postgres OR mysql", None, None, 10) + .unwrap(); + assert_eq!(hits.len(), 2); + + // Exact phrase + let phrase_hits = store + .search_transcripts("\"BRIN indexes\"", None, None, 10) + .unwrap(); + assert_eq!(phrase_hits.len(), 1); + assert!(phrase_hits[0].message.content.contains("Postgres")); + } + + #[test] + fn test_transcript_search_scoped_by_session_and_project() { + let store = test_store(); + let s1 = store.create_session("cli", Some("alpha"), None).unwrap(); + let s2 = store.create_session("cli", Some("beta"), None).unwrap(); + store + .record_message(&s1, Role::User, "alpha wants postgres", None, None, None) + .unwrap(); + store + .record_message(&s2, Role::User, "beta wants postgres", None, None, None) + .unwrap(); + + // Global search returns both + let all = store + .search_transcripts("postgres", None, None, 10) + .unwrap(); + assert_eq!(all.len(), 2); + + // Session filter + let only_s1 = store + .search_transcripts("postgres", Some(&s1), None, 10) + .unwrap(); + assert_eq!(only_s1.len(), 1); + assert_eq!(only_s1[0].message.session_id, s1); + + // Project filter + let only_beta = store + .search_transcripts("postgres", None, Some("beta"), 10) + .unwrap(); + assert_eq!(only_beta.len(), 1); + assert_eq!(only_beta[0].session.project.as_deref(), Some("beta")); + } + + #[test] + fn test_transcript_stats_breakdown() { + let store = test_store(); + let s = store.create_session("claude-code", None, None).unwrap(); + store + .record_message(&s, Role::User, "q", None, None, None) + .unwrap(); + store + .record_message(&s, Role::Assistant, "a", None, None, None) + .unwrap(); + store + .record_message(&s, Role::Tool, "{}", Some("Bash"), Some(10), None) + .unwrap(); + + let stats = store.transcript_stats().unwrap(); + assert_eq!(stats.total_sessions, 1); + assert_eq!(stats.total_messages, 3); + assert!(stats.total_bytes > 0); + assert_eq!(stats.by_role.len(), 3); + assert!(stats.by_agent.iter().any(|(a, _)| a == "claude-code")); + assert_eq!(stats.top_sessions.len(), 1); + assert_eq!(stats.top_sessions[0].1, 3); + } + + #[test] + fn test_transcript_forget_cascade_deletes_messages() { + let store = test_store(); + let s = store.create_session("cli", None, None).unwrap(); + for i in 0..5 { + store + .record_message(&s, Role::User, &format!("msg {i}"), None, None, None) + .unwrap(); + } + + store.forget_session(&s).unwrap(); + + assert!(store.get_session(&s).unwrap().is_none()); + let msgs = store.list_session_messages(&s, 100, 0).unwrap(); + assert!(msgs.is_empty()); + } + + #[test] + fn test_transcript_list_sessions_sorted_by_updated() { + let store = test_store(); + let a = store.create_session("cli", Some("p"), None).unwrap(); + let b = store.create_session("cli", Some("p"), None).unwrap(); + // Bump `a` by recording a message (updates its updated_at) + store + .record_message(&a, Role::User, "bump", None, None, None) + .unwrap(); + + let list = store.list_sessions(Some("p"), 10).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].id, a); // most recently updated first + assert_eq!(list[1].id, b); + } + + #[test] + fn test_transcript_messages_chronological() { + let store = test_store(); + let s = store.create_session("cli", None, None).unwrap(); + let ids: Vec<_> = (0..3) + .map(|i| { + store + .record_message(&s, Role::User, &format!("{i}"), None, None, None) + .unwrap() + }) + .collect(); + + let msgs = store.list_session_messages(&s, 10, 0).unwrap(); + let got: Vec<_> = msgs.iter().map(|m| m.id.clone()).collect(); + assert_eq!(got, ids); + } }