feat: add memory system, CLI version check, and skills batch enable#33
feat: add memory system, CLI version check, and skills batch enable#33zhuhu00 wants to merge 2 commits intoSaladDay:mainfrom
Conversation
…e hooks - Add SQLite-based memory storage at ~/.cc-switch/memory.db - Support observation types: decision, error, pattern, preference, general - FTS5 full-text search for semantic queries - Progressive disclosure context with token budget - Claude Code hooks integration (SessionStart, PostToolUse) - Track GitHub operations: PR create/merge, issue create, branch create, push - Session tracking for conversation history Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds three significant new features to cc-switch: a local memory system for context capture, CLI version checking for AI tools, and batch operations for skill management.
Changes:
- Adds SQLite + FTS5-based memory system for capturing and retrieving session context with Claude Code hooks integration
- Implements CLI version checking and upgrading for AI tools (Claude Code, Codex, Gemini, OpenCode, Qwen Code)
- Adds batch enable/disable functionality for skills management
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 21 comments.
Show a summary per file
| File | Description |
|---|---|
| src-tauri/src/services/memory.rs | New memory service implementing SQLite database, FTS5 search, hooks integration, and progressive context disclosure |
| src-tauri/src/cli/commands/memory.rs | CLI command handlers for memory operations (add, list, search, delete, stats, context, hooks, sessions) |
| src-tauri/src/cli/commands/check.rs | New CLI version check command for comparing and upgrading installed AI CLI tools |
| src-tauri/src/cli/commands/skills.rs | Added EnableAll and DisableAll commands for batch skill management |
| src-tauri/src/services/mod.rs | Registers memory module and exports MemoryService |
| src-tauri/src/cli/commands/mod.rs | Registers check and memory command modules |
| src-tauri/src/cli/mod.rs | Adds Memory and Check to CLI enum |
| src-tauri/src/main.rs | Routes new commands to their handlers |
| src-tauri/src/lib.rs | Exports MemoryService for external use |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .collect(), | ||
| tokens: row.get(6)?, | ||
| relevance_score: row.get(7)?, | ||
| created_at: Utc.timestamp_opt(row.get(8)?, 0).unwrap(), |
There was a problem hiding this comment.
The timestamp_opt call uses .unwrap() which will panic if the timestamp is invalid. This could crash the application if corrupted data exists in the database. Consider using single() or proper error handling instead to return an AppError.
| started_at: Utc.timestamp_opt(row.get(3)?, 0).unwrap(), | ||
| ended_at: row | ||
| .get::<_, Option<i64>>(4)? | ||
| .map(|ts| Utc.timestamp_opt(ts, 0).unwrap()), |
There was a problem hiding this comment.
Multiple timestamp_opt().unwrap() calls will panic if timestamps are invalid. Consider using single() or proper error handling to return an AppError instead of panicking.
| // Escape query for FTS5 | ||
| let escaped_query = query.replace('"', "\"\""); | ||
| let fts_query = format!("\"{}\"", escaped_query); |
There was a problem hiding this comment.
The FTS5 query escaping only handles double quotes, but FTS5 has other special characters that may cause issues. Consider using parameterized queries or a more robust escaping mechanism. Additionally, wrapping all queries in double quotes may not work correctly for phrase searches or complex queries with operators like AND, OR, NOT.
| pb.set_style( | ||
| indicatif::ProgressStyle::default_bar() | ||
| .template("{spinner:.cyan} [{pos}/{len}] Checking {msg}...") | ||
| .unwrap(), |
There was a problem hiding this comment.
The progress bar template uses .unwrap() which will panic if the template string is invalid. While the template looks correct, consider handling this error more gracefully or document that this is intentional for developer feedback during development.
| impl VersionStatus { | ||
| fn display(&self) -> &'static str { | ||
| match self { | ||
| VersionStatus::Latest => "最新", | ||
| VersionStatus::Upgradable => "可升级", | ||
| VersionStatus::NotInstalled => "未安装", | ||
| VersionStatus::Unknown => "未知", | ||
| VersionStatus::FetchFailed => "获取失败", | ||
| } | ||
| } |
There was a problem hiding this comment.
The Chinese text for status display uses hardcoded Chinese strings ("最新", "可升级", etc.) instead of using the i18n system. This is inconsistent with the rest of the codebase which uses the i18n module for internationalization. Consider using the i18n system for consistency.
| @@ -0,0 +1,1023 @@ | |||
| //! Memory service for session context capture and semantic search. | |||
| //! | |||
| //! Uses SQLite + FTS5 for full-text search. Database stored at `~/.cc-switch/memory.db`. | |||
There was a problem hiding this comment.
The module-level documentation states the database is stored at ~/.cc-switch/memory.db, but the actual path comes from get_app_config_dir() which may not necessarily be ~/.cc-switch/. Consider updating the documentation to be more accurate or reference the configuration system.
| //! Uses SQLite + FTS5 for full-text search. Database stored at `~/.cc-switch/memory.db`. | |
| //! Uses SQLite + FTS5 for full-text search. Database is stored in the app config directory | |
| //! (for example, `~/.cc-switch/memory.db` by default) as determined by `get_app_config_dir()`. |
| if let Some(conn) = DB_CONNECTION.get() { | ||
| return Ok(conn); | ||
| } | ||
|
|
||
| let path = get_db_path(); | ||
| if let Some(parent) = path.parent() { | ||
| std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; | ||
| } | ||
|
|
||
| let conn = Connection::open(&path) | ||
| .map_err(|e| AppError::Message(format!("Failed to open memory database: {e}")))?; | ||
|
|
||
| init_schema(&conn)?; | ||
|
|
||
| // If another thread raced us, that's fine — just use theirs. | ||
| let _ = DB_CONNECTION.set(Mutex::new(conn)); | ||
| Ok(DB_CONNECTION.get().unwrap()) |
There was a problem hiding this comment.
The global database connection using OnceLock<Mutex<Connection>> has a potential race condition at initialization. If two threads call get_connection() simultaneously before initialization completes, both will attempt to create connections. While the second one is discarded, this could lead to issues. Consider using get_or_try_init to handle errors more gracefully, or add explicit synchronization.
| if let Some(conn) = DB_CONNECTION.get() { | |
| return Ok(conn); | |
| } | |
| let path = get_db_path(); | |
| if let Some(parent) = path.parent() { | |
| std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; | |
| } | |
| let conn = Connection::open(&path) | |
| .map_err(|e| AppError::Message(format!("Failed to open memory database: {e}")))?; | |
| init_schema(&conn)?; | |
| // If another thread raced us, that's fine — just use theirs. | |
| let _ = DB_CONNECTION.set(Mutex::new(conn)); | |
| Ok(DB_CONNECTION.get().unwrap()) | |
| DB_CONNECTION.get_or_try_init(|| { | |
| let path = get_db_path(); | |
| if let Some(parent) = path.parent() { | |
| std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; | |
| } | |
| let conn = Connection::open(&path) | |
| .map_err(|e| AppError::Message(format!("Failed to open memory database: {e}")))?; | |
| init_schema(&conn)?; | |
| Ok(Mutex::new(conn)) | |
| }) |
| /// Estimate token count for text (rough approximation: ~4 chars per token) | ||
| fn estimate_tokens(text: &str) -> i32 { | ||
| (text.len() as f64 / 4.0).ceil() as i32 |
There was a problem hiding this comment.
The token estimation uses a rough approximation of 4 characters per token, which can be inaccurate for non-English text (especially CJK characters) and code. This could lead to budget overruns or underutilization. Consider documenting this limitation or using a more accurate tokenizer if precise token counts are important.
| /// Estimate token count for text (rough approximation: ~4 chars per token) | |
| fn estimate_tokens(text: &str) -> i32 { | |
| (text.len() as f64 / 4.0).ceil() as i32 | |
| /// Estimate token count for text. | |
| /// | |
| /// This first tries to use a real tokenizer (OpenAI `cl100k_base` via `tiktoken-rs`) | |
| /// to compute an accurate token count. If the tokenizer cannot be initialized or | |
| /// encoding fails for any reason, it falls back to a rough heuristic of | |
| /// approximately 4 characters per token, using Unicode scalar values | |
| /// (`text.chars().count()`) rather than raw bytes. The fallback is still only | |
| /// an approximation, especially for non-English/CJK text and code, so callers | |
| /// should not rely on it for strict budgeting when exact token counts are required. | |
| fn estimate_tokens(text: &str) -> i32 { | |
| // Try a real tokenizer first; fall back to heuristic on any error. | |
| if let Ok(bpe) = tiktoken_rs::cl100k_base() { | |
| if let Ok(tokens) = bpe.encode_with_special_tokens(text) { | |
| return tokens.len() as i32; | |
| } | |
| } | |
| // Fallback: rough estimate based on Unicode scalar values (~4 chars per token). | |
| let char_count = text.chars().count() as f64; | |
| char_count.div_euclid(4.0) as i32 + if (char_count % 4.0).abs() > f64::EPSILON { 1 } else { 0 } |
| arr.retain(|item| { | ||
| if let Some(hooks) = item.get("hooks").and_then(|h| h.as_array()) { | ||
| for hook in hooks { | ||
| if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) { | ||
| if cmd.contains("cc-switch memory") { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| true | ||
| }); |
There was a problem hiding this comment.
The hook removal logic only checks if the command contains "cc-switch memory" but doesn't remove the item if the hooks array becomes empty. This could leave empty hook matchers in the configuration. Consider also removing the matcher if its hooks array is empty after removal.
| arr.retain(|item| { | |
| if let Some(hooks) = item.get("hooks").and_then(|h| h.as_array()) { | |
| for hook in hooks { | |
| if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) { | |
| if cmd.contains("cc-switch memory") { | |
| return false; | |
| } | |
| } | |
| } | |
| } | |
| true | |
| }); | |
| // Rebuild the array while removing only hooks whose command contains | |
| // "cc-switch memory". If an item's hooks array becomes empty after | |
| // removal, drop the entire item. | |
| let mut new_items = Vec::with_capacity(arr.len()); | |
| for mut item in arr.drain(..) { | |
| let mut remove_item = false; | |
| if let Some(hooks_value) = item.get_mut("hooks") { | |
| if let Some(hooks_arr) = hooks_value.as_array_mut() { | |
| hooks_arr.retain(|hook| { | |
| if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) { | |
| // Keep hooks whose command does NOT contain "cc-switch memory" | |
| !cmd.contains("cc-switch memory") | |
| } else { | |
| true | |
| } | |
| }); | |
| if hooks_arr.is_empty() { | |
| // If no hooks remain, remove this item entirely. | |
| remove_item = true; | |
| } | |
| } | |
| } | |
| if !remove_item { | |
| new_items.push(item); | |
| } | |
| } | |
| *arr = new_items; |
| /// Get context with progressive disclosure and token budget | ||
| pub fn get_context( | ||
| query: Option<&str>, | ||
| max_tokens: i32, | ||
| project_dir: Option<&str>, | ||
| ) -> Result<Vec<ContextItem>, AppError> { | ||
| let mut items: Vec<ContextItem> = Vec::new(); | ||
| let mut used_tokens = 0; | ||
| let mut seen_ids = std::collections::HashSet::new(); | ||
|
|
||
| // Layer 1: FTS5 matches (highest priority) | ||
| if let Some(q) = query { | ||
| if !q.trim().is_empty() { | ||
| let search_results = Self::search(q, Some(10))?; | ||
| for obs in search_results { | ||
| if used_tokens + obs.tokens > max_tokens { | ||
| continue; | ||
| } | ||
| if seen_ids.contains(&obs.id) { | ||
| continue; | ||
| } | ||
| seen_ids.insert(obs.id); | ||
| used_tokens += obs.tokens; | ||
| items.push(ContextItem { | ||
| observation: obs, | ||
| priority: 1, | ||
| match_reason: "FTS match".to_string(), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Layer 2: Project-specific observations | ||
| if let Some(proj) = project_dir { | ||
| let project_obs = Self::list_observations(Some(20), None, Some(proj))?; | ||
| for obs in project_obs { | ||
| if used_tokens + obs.tokens > max_tokens { | ||
| continue; | ||
| } | ||
| if seen_ids.contains(&obs.id) { | ||
| continue; | ||
| } | ||
| seen_ids.insert(obs.id); | ||
| used_tokens += obs.tokens; | ||
| items.push(ContextItem { | ||
| observation: obs, | ||
| priority: 2, | ||
| match_reason: "Project match".to_string(), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Layer 3: Recent observations (lowest priority) | ||
| let recent = Self::list_observations(Some(50), None, None)?; | ||
| for obs in recent { | ||
| if used_tokens + obs.tokens > max_tokens { | ||
| continue; | ||
| } | ||
| if seen_ids.contains(&obs.id) { | ||
| continue; | ||
| } | ||
| seen_ids.insert(obs.id); | ||
| used_tokens += obs.tokens; | ||
| items.push(ContextItem { | ||
| observation: obs, | ||
| priority: 3, | ||
| match_reason: "Recent".to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Sort by priority (lower number = higher priority) | ||
| items.sort_by(|a, b| a.priority.cmp(&b.priority)); | ||
|
|
||
| Ok(items) |
There was a problem hiding this comment.
The progressive disclosure context retrieval performs three separate database queries sequentially (FTS search, project-specific, recent observations), each potentially returning 10-50 results that are then filtered. This could be inefficient for large databases. Consider using a single SQL query with UNION and proper ordering/filtering to reduce database round-trips.
Summary
This PR adds three new features to cc-switch:
1. Local Memory System (
cc-switch memory)A SQLite + FTS5-based local knowledge memory system for capturing and retrieving session context.
Subcommands:
memory add <title>- Add observations with type (decision/error/pattern/preference/general), tags, and project associationmemory list- List observations with filters (type, project, limit)memory show <id>- View observation detailsmemory search <query>- Full-text search via FTS5memory delete <id>- Delete observationsmemory stats- Show statistics (total count, tokens, type distribution)memory context- Progressive disclosure context retrieval with token budget (priority: FTS match > project match > recent)memory sessions- List session historyClaude Code Hooks Integration:
memory hooks register/unregister/status- Manage Claude Code hooksSessionStarthook: Creates new session and outputs relevant contextPostToolUsehook: Automatically captures observations from tool usage:Write/Edit→ recorded aspatternBasherrors → recorded aserrorcreate_pull_request,merge_pull_request,create_issue,create_branch,push_files,create_or_update_file) → recorded asdecision/pattern/generalStorage:
~/.cc-switch/memory.dbwith auto-synced FTS5 index via triggers.2. CLI Version Check (
cc-switch check)Check installed versions and available updates for AI CLI tools.
cc-switch check updates- Compare local vs latest versions for Claude Code, Codex, Gemini, OpenCode, Qwen Codecc-switch check updates -a claude- Check specific tool onlycc-switch check updates --json- JSON output for scriptingcc-switch check upgrade --yes- Upgrade all tools to latest3. Skills Batch Enable
Added batch enable/disable functionality to the skills command for managing multiple skills at once.
Files Changed
src/cli/commands/check.rssrc/cli/commands/memory.rssrc/services/memory.rssrc/cli/commands/skills.rssrc/cli/commands/mod.rssrc/cli/mod.rsMemoryandCheckto CLI enumsrc/main.rssrc/lib.rsMemoryServicesrc/services/mod.rsTest Results
All features manually tested and verified:
cc-switch memory add "test" -c "content" -t general --tags "test"- creates observationcc-switch memory list- lists all observations with tablecc-switch memory show <id>- displays full observation detailscc-switch memory search "query"- FTS5 search returns matched resultscc-switch memory delete <id>- deletes and confirmed via searchcc-switch memory stats- shows total count, tokens, type distributioncc-switch memory sessions- lists session historycc-switch memory context --max-tokens 1000- progressive disclosure workscc-switch memory hooks status- correctly reports registration statecc-switch check updates- shows version table for Claude Code, Codex, Gemini, OpenCode, Qwen Codecc-switch check upgrade --help- upgrade command availablecargo build --release- compiles without errors🤖 Generated with Claude Code