diff --git a/docs/commands.md b/docs/commands.md index 6e384bf..73f855f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,7 +11,7 @@ This document is the reference for the visible slash commands available in Coven 3. [Model & Provider](#model--provider) — `/model`, `/providers`, `/connect`, `/thinking`, `/effort` 4. [Configuration & Settings](#configuration--settings) — `/config`, `/permissions`, `/hooks`, `/mcp` 5. [Code & Git](#code--git) — `/commit`, `/diff`, `/review`, `/init`, `/search` -6. [Search & Files](#search--files) +6. [Search & Files](#search--files) — `/link`, `/attach` 7. [Memory & Context](#memory--context) — `/memory`, `/usage`, `/status` 8. [Agents & Tasks](#agents--tasks) — `/familiar`, `/tasks`, `/coven goal` 9. [Planning & Review](#planning--review) — `/plan`, `ultraplan` (CLI) @@ -533,6 +533,51 @@ Analyze context window usage. Shows a breakdown of tokens consumed by system pro --- +### /link + +Save and manage links in the structured stash. Links are stored in `~/.coven-code/stash.sqlite` with title, note, tags, project, and session metadata. + +``` +/link [--title ...] [--note ...] [--tags a,b] — save a link +/link add [flags] — same as above +/link list [--tag ] [--project] — list saved links +/link search — search links +/link remove — delete a link +``` + +`/link list` shows each entry's short id, save date, title, URL, and tags. `--project` limits the listing to links saved from the current repository. The stash is a personal local store and is disabled in [hosted review mode](configuration#hosted-review-mode). + +``` +/link https://docs.rs/tokio --title Tokio docs --tags rust,async +/link list --tag rust +/link remove 1a2b3c4d +``` + +--- + +### /attach + +Save and manage file attachments in the structured stash. Files are copied into `~/.coven-code/attachments//`, so the stored copy survives even if the original file moves or is deleted. Metadata lives in `~/.coven-code/stash.sqlite` alongside saved links. + +``` +/attach [--title ...] [--note ...] [--tags a,b] — save a file +/attach add [flags] — same as above +/attach list [--tag ] [--project] — list attachments +/attach search — search attachments +/attach show — show stored/original paths +/attach remove — delete an attachment and its stored copy +``` + +Relative paths resolve against the current working directory; `~` expands to the home directory. Like `/link`, the stash is disabled in hosted review mode. + +``` +/attach ./design/spec.pdf --title API spec --tags design +/attach list --tag design +/attach show 1a2b3c4d +``` + +--- + ## Memory & Context ### /memory diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 052d56b..8bed30c 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -283,6 +283,8 @@ pub struct FamiliarCommand; pub struct SearchCommand; pub struct ForkCommand; pub struct ManagedAgentsCommand; +pub struct LinkCommand; +pub struct AttachCommand; pub struct CovenCommand; pub struct NamedCommandAdapter { pub slash_name: &'static str, @@ -10193,6 +10195,388 @@ impl SlashCommand for CovenCommand { } } +// ---- /link and /attach — structured link/attachment stash ------------------ + +/// Split an argument string into positional tokens and `--flag value...` pairs. +/// Flag values run until the next `--flag` token. `--tags` values are +/// comma-separated. +fn parse_stash_args(args: &str) -> (Vec, BTreeMap) { + let mut positional = Vec::new(); + let mut flags = BTreeMap::new(); + let mut current_flag: Option = None; + let mut current_value: Vec = Vec::new(); + + for token in args.split_whitespace() { + if let Some(name) = token.strip_prefix("--") { + if let Some(flag) = current_flag.take() { + flags.insert(flag, current_value.join(" ")); + current_value.clear(); + } + current_flag = Some(name.to_string()); + } else if current_flag.is_some() { + current_value.push(token.to_string()); + } else { + positional.push(token.to_string()); + } + } + if let Some(flag) = current_flag { + flags.insert(flag, current_value.join(" ")); + } + (positional, flags) +} + +fn stash_tags_from_flags(flags: &BTreeMap) -> Vec { + flags + .get("tags") + .map(|raw| { + raw.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// The project scope items are recorded under: the git repo root when inside +/// a repository, otherwise the working directory. +fn stash_project_scope(ctx: &CommandContext) -> String { + claurst_core::git_utils::get_repo_root(&ctx.working_dir) + .unwrap_or_else(|| ctx.working_dir.clone()) + .display() + .to_string() +} + +fn stash_hosted_refusal(ctx: &CommandContext) -> Option { + if ctx.config.hosted_review_enabled() { + Some(CommandResult::Message( + "The link/attachment stash is a personal local store and is disabled in \ + hosted review mode." + .to_string(), + )) + } else { + None + } +} + +fn format_stash_items(items: &[claurst_core::stash::StashItem], heading: &str) -> String { + if items.is_empty() { + return format!("{}: none.", heading); + } + let mut lines = vec![format!("{} ({}):", heading, items.len())]; + for item in items { + let date = + chrono::DateTime::::from_timestamp_millis(item.created_at_ms as i64) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + let mut line = format!(" {} {}", item.short_id(), date); + if let Some(ref title) = item.title { + line.push_str(&format!(" {}", title)); + } + line.push_str(&format!(" {}", item.value)); + if !item.tags.is_empty() { + line.push_str(&format!(" [{}]", item.tags.join(", "))); + } + if let Some(ref note) = item.note { + line.push_str(&format!("\n note: {}", note)); + } + lines.push(line); + } + lines.join("\n") +} + +fn open_stash_store() -> Result { + claurst_core::stash::StashStore::open_default() + .map_err(|e| format!("Could not open stash: {}", e)) +} + +fn looks_like_url(token: &str) -> bool { + token.starts_with("http://") || token.starts_with("https://") || token.starts_with("www.") +} + +#[async_trait] +impl SlashCommand for LinkCommand { + fn name(&self) -> &str { + "link" + } + fn aliases(&self) -> Vec<&str> { + vec!["links"] + } + fn description(&self) -> &str { + "Save and manage links in the structured stash" + } + fn help(&self) -> &str { + "Usage:\n\ + /link [--title ...] [--note ...] [--tags a,b] — save a link\n\ + /link add [flags] — same as above\n\ + /link list [--tag ] [--project] — list saved links\n\ + /link search — search links\n\ + /link remove — delete a link\n\n\ + Links are stored in ~/.coven-code/stash.sqlite with title, note,\n\ + tags, project, and session metadata. Use the short id shown by\n\ + /link list to remove entries.\n\n\ + Examples:\n\ + /link https://docs.rs/tokio --title Tokio docs --tags rust,async\n\ + /link list --tag rust\n\ + /link remove 1a2b3c4d" + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + if let Some(refusal) = stash_hosted_refusal(ctx) { + return refusal; + } + let (positional, flags) = parse_stash_args(args); + let sub = positional.first().map(String::as_str).unwrap_or("list"); + + match sub { + "list" => { + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let filter = claurst_core::stash::StashFilter { + kind: Some(claurst_core::stash::StashKind::Link), + tag: flags.get("tag").cloned(), + project: flags + .contains_key("project") + .then(|| stash_project_scope(ctx)), + }; + match store.list(&filter) { + Ok(items) => CommandResult::Message(format_stash_items(&items, "Links")), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "search" => { + let term = positional[1..].join(" "); + if term.is_empty() { + return CommandResult::Message("Usage: /link search ".to_string()); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.search(&term, Some(claurst_core::stash::StashKind::Link)) { + Ok(items) => CommandResult::Message(format_stash_items( + &items, + &format!("Links matching '{}'", term), + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "remove" | "rm" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /link remove ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.remove(id) { + Ok(item) => CommandResult::Message(format!( + "Removed link {} ({}).", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + _ => { + // `/link add ` or bare `/link `. + let url = if sub == "add" { + positional.get(1).cloned() + } else if looks_like_url(sub) { + Some(sub.to_string()) + } else { + None + }; + let Some(url) = url else { + return CommandResult::Message(self.help().to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let tags = stash_tags_from_flags(&flags); + let project = stash_project_scope(ctx); + match store.add_link( + &url, + flags.get("title").map(String::as_str), + flags.get("note").map(String::as_str), + &tags, + Some(&project), + Some(&ctx.session_id), + ) { + Ok(item) => CommandResult::Message(format!( + "Saved link {} — {}", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + } + } +} + +#[async_trait] +impl SlashCommand for AttachCommand { + fn name(&self) -> &str { + "attach" + } + fn aliases(&self) -> Vec<&str> { + vec!["attachments"] + } + fn description(&self) -> &str { + "Save and manage file attachments in the structured stash" + } + fn help(&self) -> &str { + "Usage:\n\ + /attach [--title ...] [--note ...] [--tags a,b] — save a file\n\ + /attach add [flags] — same as above\n\ + /attach list [--tag ] [--project] — list attachments\n\ + /attach search — search attachments\n\ + /attach show — show stored/original paths\n\ + /attach remove — delete an attachment\n\n\ + Files are copied into ~/.coven-code/attachments// so the stored\n\ + copy survives even if the original moves. Metadata lives in\n\ + ~/.coven-code/stash.sqlite alongside saved links.\n\n\ + Examples:\n\ + /attach ./design/spec.pdf --title API spec --tags design\n\ + /attach list --tag design\n\ + /attach show 1a2b3c4d" + } + + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult { + if let Some(refusal) = stash_hosted_refusal(ctx) { + return refusal; + } + let (positional, flags) = parse_stash_args(args); + let sub = positional.first().map(String::as_str).unwrap_or("list"); + + match sub { + "list" => { + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let filter = claurst_core::stash::StashFilter { + kind: Some(claurst_core::stash::StashKind::Attachment), + tag: flags.get("tag").cloned(), + project: flags + .contains_key("project") + .then(|| stash_project_scope(ctx)), + }; + match store.list(&filter) { + Ok(items) => CommandResult::Message(format_stash_items(&items, "Attachments")), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "search" => { + let term = positional[1..].join(" "); + if term.is_empty() { + return CommandResult::Message("Usage: /attach search ".to_string()); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.search(&term, Some(claurst_core::stash::StashKind::Attachment)) { + Ok(items) => CommandResult::Message(format_stash_items( + &items, + &format!("Attachments matching '{}'", term), + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + "show" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /attach show ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.get(id) { + Ok(item) => { + let mut out = + format!("Attachment {}\n stored: {}", item.short_id(), item.value); + if let Some(ref original) = item.original_path { + out.push_str(&format!("\n original: {}", original)); + } + if let Some(ref title) = item.title { + out.push_str(&format!("\n title: {}", title)); + } + if !item.tags.is_empty() { + out.push_str(&format!("\n tags: {}", item.tags.join(", "))); + } + if let Some(ref note) = item.note { + out.push_str(&format!("\n note: {}", note)); + } + CommandResult::Message(out) + } + Err(e) => CommandResult::Error(e.to_string()), + } + } + "remove" | "rm" => { + let Some(id) = positional.get(1) else { + return CommandResult::Message("Usage: /attach remove ".to_string()); + }; + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + match store.remove(id) { + Ok(item) => CommandResult::Message(format!( + "Removed attachment {} and its stored copy.", + item.short_id() + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + _ => { + let raw_path = if sub == "add" { + positional.get(1).cloned() + } else { + Some(sub.to_string()) + }; + let Some(raw_path) = raw_path else { + return CommandResult::Message(self.help().to_string()); + }; + let mut path = PathBuf::from(&raw_path); + if let Ok(stripped) = path.strip_prefix("~") { + if let Some(home) = dirs::home_dir() { + path = home.join(stripped); + } + } + if path.is_relative() { + path = ctx.working_dir.join(path); + } + let store = match open_stash_store() { + Ok(s) => s, + Err(e) => return CommandResult::Error(e), + }; + let tags = stash_tags_from_flags(&flags); + let project = stash_project_scope(ctx); + match store.add_attachment( + &path, + &claurst_core::stash::StashStore::default_attachments_dir(), + flags.get("title").map(String::as_str), + flags.get("note").map(String::as_str), + &tags, + Some(&project), + Some(&ctx.session_id), + ) { + Ok(item) => CommandResult::Message(format!( + "Saved attachment {} — stored at {}", + item.short_id(), + item.value + )), + Err(e) => CommandResult::Error(e.to_string()), + } + } + } + } +} + // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- @@ -10308,6 +10692,9 @@ static COMMANDS: Lazy>> = Lazy::new(|| { Box::new(ManagedAgentsCommand), // Durable long-running goals Box::new(GoalCommand), + // Structured link/attachment stash + Box::new(LinkCommand), + Box::new(AttachCommand), // Coven daemon control surface (sessions, harness runs, rituals) Box::new(CovenCommand), ] @@ -10636,6 +11023,51 @@ mod tests { assert!(!all_commands().is_empty()); } + #[test] + fn parse_stash_args_splits_positionals_and_flags() { + let (positional, flags) = + parse_stash_args("add https://example.com --title Example site --tags a,b --note x y"); + assert_eq!(positional, vec!["add", "https://example.com"]); + assert_eq!(flags.get("title").map(String::as_str), Some("Example site")); + assert_eq!(flags.get("tags").map(String::as_str), Some("a,b")); + assert_eq!(flags.get("note").map(String::as_str), Some("x y")); + } + + #[test] + fn parse_stash_args_handles_valueless_flags() { + let (positional, flags) = parse_stash_args("list --project"); + assert_eq!(positional, vec!["list"]); + assert_eq!(flags.get("project").map(String::as_str), Some("")); + } + + #[test] + fn stash_tags_are_trimmed_and_non_empty() { + let (_, flags) = parse_stash_args("x --tags a, b ,,c"); + assert_eq!(stash_tags_from_flags(&flags), vec!["a", "b", "c"]); + } + + #[tokio::test] + async fn link_command_refuses_hosted_mode() { + let mut ctx = make_ctx(); + ctx.config.hosted_review.enabled = true; + let result = LinkCommand.execute("list", &mut ctx).await; + match result { + CommandResult::Message(msg) => assert!(msg.contains("hosted review")), + other => panic!("expected refusal message, got {:?}", other), + } + } + + #[tokio::test] + async fn attach_command_refuses_hosted_mode() { + let mut ctx = make_ctx(); + ctx.config.hosted_review.enabled = true; + let result = AttachCommand.execute("list", &mut ctx).await; + match result { + CommandResult::Message(msg) => assert!(msg.contains("hosted review")), + other => panic!("expected refusal message, got {:?}", other), + } + } + #[test] fn test_all_commands_have_unique_names() { let mut names = std::collections::HashSet::new(); diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 1889671..3fc350e 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -50,6 +50,9 @@ pub mod claudemd; // Hosted review runtime isolation model. pub mod hosted_review; +// Structured link/attachment stash (personal local store). +pub mod stash; + // OpenClaw installation/agent detection used by Coven integrations. pub mod openclaw_agent; diff --git a/src-rust/crates/core/src/stash.rs b/src-rust/crates/core/src/stash.rs new file mode 100644 index 0000000..bcd7d36 --- /dev/null +++ b/src-rust/crates/core/src/stash.rs @@ -0,0 +1,495 @@ +//! Stash — durable, structured storage for links and attachments. +//! +//! Backs the `/link` and `/attach` slash commands. Items are stored in +//! `~/.coven-code/stash.sqlite`; attachment files are copied into +//! `~/.coven-code/attachments//` so they survive the original +//! file moving or the session ending. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StashKind { + Link, + Attachment, +} + +impl StashKind { + pub fn as_str(&self) -> &'static str { + match self { + StashKind::Link => "link", + StashKind::Attachment => "attachment", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "link" => Some(StashKind::Link), + "attachment" => Some(StashKind::Attachment), + _ => None, + } + } +} + +/// A single stashed link or attachment. +#[derive(Debug, Clone)] +pub struct StashItem { + pub id: String, + pub kind: StashKind, + /// The URL for links; the stored (copied) file path for attachments. + pub value: String, + /// Original source path for attachments (informational). + pub original_path: Option, + pub title: Option, + pub note: Option, + pub tags: Vec, + /// Project root the item was saved from. + pub project: Option, + pub session_id: Option, + pub created_at_ms: u64, +} + +impl StashItem { + /// Short display id (first 8 chars of the uuid). + pub fn short_id(&self) -> &str { + &self.id[..self.id.len().min(8)] + } +} + +/// Filters for listing stash items. +#[derive(Debug, Clone, Default)] +pub struct StashFilter { + pub kind: Option, + pub tag: Option, + pub project: Option, +} + +#[derive(Debug)] +pub enum StashError { + Db(String), + Io(String), + NotFound(String), + Ambiguous(String), +} + +impl std::fmt::Display for StashError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StashError::Db(msg) => write!(f, "stash database error: {}", msg), + StashError::Io(msg) => write!(f, "stash I/O error: {}", msg), + StashError::NotFound(id) => write!(f, "no stash item matches '{}'", id), + StashError::Ambiguous(id) => { + write!( + f, + "'{}' matches more than one stash item; use a longer id", + id + ) + } + } + } +} + +impl std::error::Error for StashError {} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +pub struct StashStore { + conn: rusqlite::Connection, +} + +impl StashStore { + /// Open (or create) a stash database. + pub fn open(db_path: &Path) -> Result { + let conn = + rusqlite::Connection::open(db_path).map_err(|e| StashError::Db(e.to_string()))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS stash_items ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + value TEXT NOT NULL, + original_path TEXT, + title TEXT, + note TEXT, + tags TEXT NOT NULL DEFAULT '', + project TEXT, + session_id TEXT, + created_at_ms INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_stash_kind ON stash_items(kind); + CREATE INDEX IF NOT EXISTS idx_stash_project ON stash_items(project);", + ) + .map_err(|e| StashError::Db(e.to_string()))?; + Ok(Self { conn }) + } + + /// Default database path: `~/.coven-code/stash.sqlite`. + pub fn default_path() -> PathBuf { + crate::config::Settings::config_dir().join("stash.sqlite") + } + + /// Default directory for stored attachment copies. + pub fn default_attachments_dir() -> PathBuf { + crate::config::Settings::config_dir().join("attachments") + } + + /// Open using the default path (creates `~/.coven-code` if needed). + pub fn open_default() -> Result { + let path = Self::default_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| StashError::Io(e.to_string()))?; + } + Self::open(&path) + } + + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + + /// Save a link. + pub fn add_link( + &self, + url: &str, + title: Option<&str>, + note: Option<&str>, + tags: &[String], + project: Option<&str>, + session_id: Option<&str>, + ) -> Result { + let item = StashItem { + id: uuid::Uuid::new_v4().simple().to_string(), + kind: StashKind::Link, + value: url.to_string(), + original_path: None, + title: title.map(str::to_string), + note: note.map(str::to_string), + tags: tags.to_vec(), + project: project.map(str::to_string), + session_id: session_id.map(str::to_string), + created_at_ms: Self::now_ms(), + }; + self.insert(&item)?; + Ok(item) + } + + /// Save an attachment: copies `src` into `attachments_dir//` + /// and records the stored copy. + // Metadata fields (title/note/tags/project/session) are individually + // optional; a params struct would just restate the StashItem shape. + #[allow(clippy::too_many_arguments)] + pub fn add_attachment( + &self, + src: &Path, + attachments_dir: &Path, + title: Option<&str>, + note: Option<&str>, + tags: &[String], + project: Option<&str>, + session_id: Option<&str>, + ) -> Result { + if !src.is_file() { + return Err(StashError::Io(format!( + "'{}' is not a readable file", + src.display() + ))); + } + let id = uuid::Uuid::new_v4().simple().to_string(); + let filename = src + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| "attachment".to_string()); + let dest_dir = attachments_dir.join(&id[..8]); + std::fs::create_dir_all(&dest_dir).map_err(|e| StashError::Io(e.to_string()))?; + let dest = dest_dir.join(&filename); + std::fs::copy(src, &dest).map_err(|e| StashError::Io(e.to_string()))?; + + let item = StashItem { + id, + kind: StashKind::Attachment, + value: dest.to_string_lossy().to_string(), + original_path: Some(src.to_string_lossy().to_string()), + title: title.map(str::to_string), + note: note.map(str::to_string), + tags: tags.to_vec(), + project: project.map(str::to_string), + session_id: session_id.map(str::to_string), + created_at_ms: Self::now_ms(), + }; + self.insert(&item)?; + Ok(item) + } + + fn insert(&self, item: &StashItem) -> Result<(), StashError> { + self.conn + .execute( + "INSERT INTO stash_items + (id, kind, value, original_path, title, note, tags, project, session_id, created_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + item.id, + item.kind.as_str(), + item.value, + item.original_path, + item.title, + item.note, + item.tags.join(","), + item.project, + item.session_id, + item.created_at_ms, + ], + ) + .map_err(|e| StashError::Db(e.to_string()))?; + Ok(()) + } + + /// List items matching the filter, newest first. + pub fn list(&self, filter: &StashFilter) -> Result, StashError> { + let mut items = self.query_all()?; + items.retain(|item| { + if let Some(kind) = filter.kind { + if item.kind != kind { + return false; + } + } + if let Some(ref tag) = filter.tag { + if !item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { + return false; + } + } + if let Some(ref project) = filter.project { + if item.project.as_deref() != Some(project.as_str()) { + return false; + } + } + true + }); + Ok(items) + } + + /// Case-insensitive substring search over value, title, note, and tags. + pub fn search( + &self, + term: &str, + kind: Option, + ) -> Result, StashError> { + let needle = term.to_lowercase(); + let mut items = self.query_all()?; + items.retain(|item| { + if let Some(k) = kind { + if item.kind != k { + return false; + } + } + item.value.to_lowercase().contains(&needle) + || item + .title + .as_deref() + .is_some_and(|t| t.to_lowercase().contains(&needle)) + || item + .note + .as_deref() + .is_some_and(|n| n.to_lowercase().contains(&needle)) + || item.tags.iter().any(|t| t.to_lowercase().contains(&needle)) + }); + Ok(items) + } + + /// Find a single item by full id or unique id prefix. + pub fn get(&self, id_prefix: &str) -> Result { + let mut matches = self + .query_all()? + .into_iter() + .filter(|item| item.id.starts_with(id_prefix)); + match (matches.next(), matches.next()) { + (None, _) => Err(StashError::NotFound(id_prefix.to_string())), + (Some(item), None) => Ok(item), + (Some(_), Some(_)) => Err(StashError::Ambiguous(id_prefix.to_string())), + } + } + + /// Remove an item by id prefix. For attachments the stored copy (and its + /// per-item directory) is deleted as well. + pub fn remove(&self, id_prefix: &str) -> Result { + let item = self.get(id_prefix)?; + self.conn + .execute("DELETE FROM stash_items WHERE id = ?1", [&item.id]) + .map_err(|e| StashError::Db(e.to_string()))?; + if item.kind == StashKind::Attachment { + let stored = Path::new(&item.value); + let _ = std::fs::remove_file(stored); + if let Some(dir) = stored.parent() { + let _ = std::fs::remove_dir(dir); + } + } + Ok(item) + } + + fn query_all(&self) -> Result, StashError> { + let mut stmt = self + .conn + .prepare( + "SELECT id, kind, value, original_path, title, note, tags, project, + session_id, created_at_ms + FROM stash_items ORDER BY created_at_ms DESC", + ) + .map_err(|e| StashError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let kind_str: String = row.get(1)?; + let tags_str: String = row.get(6)?; + Ok(StashItem { + id: row.get(0)?, + kind: StashKind::parse(&kind_str).unwrap_or(StashKind::Link), + value: row.get(2)?, + original_path: row.get(3)?, + title: row.get(4)?, + note: row.get(5)?, + tags: tags_str + .split(',') + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect(), + project: row.get(7)?, + session_id: row.get(8)?, + created_at_ms: row.get(9)?, + }) + }) + .map_err(|e| StashError::Db(e.to_string()))?; + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| StashError::Db(e.to_string()))?); + } + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store() -> (tempfile::TempDir, StashStore) { + let dir = tempfile::tempdir().unwrap(); + let store = StashStore::open(&dir.path().join("stash.sqlite")).unwrap(); + (dir, store) + } + + #[test] + fn add_and_list_links() { + let (_dir, store) = temp_store(); + store + .add_link( + "https://example.com/a", + Some("Example A"), + None, + &["docs".to_string()], + Some("/repo"), + Some("sess-1"), + ) + .unwrap(); + store + .add_link("https://example.com/b", None, None, &[], None, None) + .unwrap(); + + let all = store.list(&StashFilter::default()).unwrap(); + assert_eq!(all.len(), 2); + + let tagged = store + .list(&StashFilter { + tag: Some("docs".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(tagged.len(), 1); + assert_eq!(tagged[0].value, "https://example.com/a"); + assert_eq!(tagged[0].title.as_deref(), Some("Example A")); + } + + #[test] + fn add_attachment_copies_file_and_remove_cleans_up() { + let (dir, store) = temp_store(); + let src = dir.path().join("notes.txt"); + std::fs::write(&src, "hello").unwrap(); + let attachments = dir.path().join("attachments"); + + let item = store + .add_attachment(&src, &attachments, None, None, &[], None, None) + .unwrap(); + let stored = PathBuf::from(&item.value); + assert!(stored.exists()); + assert_eq!(std::fs::read_to_string(&stored).unwrap(), "hello"); + assert_eq!(item.original_path.as_deref(), Some(src.to_str().unwrap())); + + // Source can vanish; the stored copy remains. + std::fs::remove_file(&src).unwrap(); + assert!(stored.exists()); + + let removed = store.remove(item.short_id()).unwrap(); + assert_eq!(removed.id, item.id); + assert!(!stored.exists()); + assert!(store.list(&StashFilter::default()).unwrap().is_empty()); + } + + #[test] + fn attachment_of_missing_file_errors() { + let (dir, store) = temp_store(); + let err = store + .add_attachment( + &dir.path().join("nope.bin"), + &dir.path().join("attachments"), + None, + None, + &[], + None, + None, + ) + .unwrap_err(); + assert!(matches!(err, StashError::Io(_))); + } + + #[test] + fn search_matches_title_note_tags_and_value() { + let (_dir, store) = temp_store(); + store + .add_link( + "https://docs.rs/rusqlite", + Some("Rusqlite docs"), + Some("sqlite bindings"), + &["rust".to_string()], + None, + None, + ) + .unwrap(); + store + .add_link("https://example.com", None, None, &[], None, None) + .unwrap(); + + assert_eq!(store.search("rusqlite", None).unwrap().len(), 1); + assert_eq!(store.search("BINDINGS", None).unwrap().len(), 1); + assert_eq!(store.search("rust", None).unwrap().len(), 1); + assert_eq!(store.search("zzz", None).unwrap().len(), 0); + } + + #[test] + fn get_by_prefix_and_ambiguity() { + let (_dir, store) = temp_store(); + let a = store + .add_link("https://a.example", None, None, &[], None, None) + .unwrap(); + store + .add_link("https://b.example", None, None, &[], None, None) + .unwrap(); + + assert_eq!(store.get(&a.id[..8]).unwrap().id, a.id); + assert!(matches!(store.get("zzzzzz"), Err(StashError::NotFound(_)))); + // Empty prefix matches everything → ambiguous. + assert!(matches!(store.get(""), Err(StashError::Ambiguous(_)))); + } +} diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index e803b90..a51db4e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -56,6 +56,10 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ "accounts", "List stored accounts; `dedupe` collapses duplicate profiles", ), + ( + "attach", + "Save and manage file attachments in the structured stash", + ), ("chrome", "Browser automation via Chrome DevTools Protocol"), ("clear", "Clear the conversation transcript"), ( @@ -99,6 +103,7 @@ pub const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ "learn", "Codify the script/workflow we just built into a reusable skill", ), + ("link", "Save and manage links in the structured stash"), ("login", "Log in, switch accounts, or refresh provider auth"), ("logout", "Log out of Coven Code"), ("mcp", "Browse configured MCP servers"),