| 1 | //! One `/memory` surface for the authoritative native store. Never edits the |
| 2 | //! old Markdown anchor or reports an index-cache deletion as memory erasure. |
| 3 | use crate::commands::CommandResult; |
| 4 | use codewhale_command_contract::facets::{ |
| 5 | CommandMemoryContext, MemoryDeleteScope, MemoryGetOutcome, MemoryImportOutcome, |
| 6 | MemoryRememberTarget, |
| 7 | }; |
| 8 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 9 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 10 | use std::path::Path; |
| 11 | const HELP: &str = "Memory is reviewed context, not another instruction layer.\n\n/memory [status|path|search <query>|get <numeric-id>|remember [global|workspace] <note>|import|export|reindex|clear <scope> --confirm|help]\n\nThe `native` prefix is a compatibility alias. `show` shows store status.\nCaptures, including legacy # entry points, become candidates; review in Context Lens.\nCorrect, approve, pin and exclude through Context Lens or the operator CLI.\n`clear` scopes: global, workspace, all. Confirmation removes tracked revision families and dependent checkpoints. Backups, exports and previously sent model context are not erased.\nThe database is authoritative. Never open it in a text editor or delete it as a cache."; |
| 12 | fn split(input: &str) -> (&str, &str) { |
| 13 | input |
| 14 | .trim() |
| 15 | .split_once(char::is_whitespace) |
| 16 | .map(|(a, b)| (a, b.trim())) |
| 17 | .unwrap_or((input.trim(), "")) |
| 18 | } |
| 19 | fn normalize(input: Option<&str>) -> (&str, &str) { |
| 20 | let (command, arg) = split(input.unwrap_or("status")); |
| 21 | let (command, arg) = if command == "native" { |
| 22 | split(arg) |
| 23 | } else { |
| 24 | (command, arg) |
| 25 | }; |
| 26 | ( |
| 27 | if matches!(command, "" | "show") { |
| 28 | "status" |
| 29 | } else { |
| 30 | command |
| 31 | }, |
| 32 | arg, |
| 33 | ) |
| 34 | } |
| 35 | fn memory(workspace: &Path, memory: &dyn CommandMemoryContext, arg: Option<&str>) -> CommandResult { |
| 36 | if !memory.memory_enabled() { |
| 37 | return CommandResult::error( |
| 38 | "Memory is disabled. Enable [memory] enabled = true in user configuration, then restart. No store was opened.", |
| 39 | ); |
| 40 | } |
| 41 | let (command, arg) = normalize(arg); |
| 42 | match command { |
| 43 | "help" => CommandResult::message(HELP), |
| 44 | "status" => match memory.status() { |
| 45 | Ok(s) => CommandResult::message(format!( |
| 46 | "Native memory root: {}\nAuthoritative structured database: {}\nUse search/get to inspect entries and Context Lens to review candidates. The legacy Markdown source is not authoritative.", |
| 47 | s.root.display(), |
| 48 | s.index.display() |
| 49 | )), |
| 50 | Err(e) => CommandResult::error(format!("Memory status unavailable: {e}")), |
| 51 | }, |
| 52 | "path" => match memory.path() { |
| 53 | Ok(root) => CommandResult::message(root.display().to_string()), |
| 54 | Err(e) => CommandResult::error(format!("Memory path unavailable: {e}")), |
| 55 | }, |
| 56 | "edit" => CommandResult::message( |
| 57 | "Use Context Lens to correct a memory. The authoritative database must not be edited as Markdown.", |
| 58 | ), |
| 59 | "search" => { |
| 60 | if arg.is_empty() { |
| 61 | return CommandResult::error("Usage: /memory search <query>"); |
| 62 | } |
| 63 | match memory.search(workspace, arg, 10) { |
| 64 | Ok(rows) if rows.is_empty() => { |
| 65 | CommandResult::message("No reviewed, current memory matches in this scope.") |
| 66 | } |
| 67 | Ok(rows) => CommandResult::message( |
| 68 | rows.into_iter() |
| 69 | .map(|r| { |
| 70 | format!( |
| 71 | "[untrusted memory; store={}] {}", |
| 72 | r.source.display(), |
| 73 | r.text |
| 74 | ) |
| 75 | }) |
| 76 | .collect::<Vec<_>>() |
| 77 | .join("\n"), |
| 78 | ), |
| 79 | Err(e) => CommandResult::error(format!("Memory search failed: {e}")), |
| 80 | } |
| 81 | } |
| 82 | "get" => { |
| 83 | let Ok(id) = arg.parse::<i64>() else { |
| 84 | return CommandResult::error("Usage: /memory get <numeric-id>"); |
| 85 | }; |
| 86 | match memory.get(workspace, id) { |
| 87 | Ok(MemoryGetOutcome::Found(r)) => CommandResult::message(format!( |
| 88 | "[untrusted memory #{id}; store={}]\n{}", |
| 89 | r.source.display(), |
| 90 | r.text |
| 91 | )), |
| 92 | Ok(MemoryGetOutcome::NotFound) => { |
| 93 | CommandResult::error("Memory entry was not found in this scope.") |
| 94 | } |
| 95 | Err(e) => CommandResult::error(format!("Memory read failed: {e}")), |
| 96 | } |
| 97 | } |
| 98 | "remember" => { |
| 99 | let (first, rest) = split(arg); |
| 100 | let (target, note) = match first { |
| 101 | "workspace" => match memory.workspace_id(workspace) { |
| 102 | Ok(id) => (MemoryRememberTarget::Workspace { workspace_id: id }, rest), |
| 103 | Err(e) => return CommandResult::error(e), |
| 104 | }, |
| 105 | "global" => (MemoryRememberTarget::Global, rest), |
| 106 | _ => (MemoryRememberTarget::Global, arg), |
| 107 | }; |
| 108 | if note.is_empty() { |
| 109 | return CommandResult::error( |
| 110 | "Usage: /memory remember [global|workspace] <complete note>", |
| 111 | ); |
| 112 | } |
| 113 | match memory.remember(target, note) { |
| 114 | Ok(_) => CommandResult::message( |
| 115 | "Memory candidate saved. Review it in Context Lens before it is used as durable context.", |
| 116 | ), |
| 117 | Err(e) => CommandResult::error(format!("Memory capture failed: {e}")), |
| 118 | } |
| 119 | } |
| 120 | "import" => match memory.import() { |
| 121 | Ok(MemoryImportOutcome::Imported { destination }) => CommandResult::message(format!( |
| 122 | "Legacy notes imported as candidates into {}. The original file was not deleted.", |
| 123 | destination.display() |
| 124 | )), |
| 125 | Ok(MemoryImportOutcome::Skipped) => { |
| 126 | CommandResult::message("No new legacy candidates were imported.") |
| 127 | } |
| 128 | Err(e) => CommandResult::error(format!("Memory import failed: {e}")), |
| 129 | }, |
| 130 | "export" => match memory.export() { |
| 131 | Ok(e) => CommandResult::message(e.content), |
| 132 | Err(e) => CommandResult::error(format!("Memory export failed: {e}")), |
| 133 | }, |
| 134 | "reindex" => match memory.reindex() { |
| 135 | Ok(r) => CommandResult::message(format!( |
| 136 | "Rebuilt the derived search indexes for {} entries. The authoritative database was retained.", |
| 137 | r.entry_count |
| 138 | )), |
| 139 | Err(e) => CommandResult::error(format!("Reindex failed: {e}")), |
| 140 | }, |
| 141 | "clear" | "delete" => { |
| 142 | let (scope, confirm) = split(arg); |
| 143 | if confirm != "--confirm" || !matches!(scope, "global" | "workspace" | "all") { |
| 144 | return CommandResult::error( |
| 145 | "Usage: /memory clear <global|workspace|all> --confirm. This removes tracked revision families and dependent checkpoints, not backups, exports or already-sent provider context.", |
| 146 | ); |
| 147 | } |
| 148 | let result = match scope { |
| 149 | "global" => memory.delete(MemoryDeleteScope::Global), |
| 150 | "workspace" => memory.delete_workspace(workspace), |
| 151 | _ => memory.delete(MemoryDeleteScope::All), |
| 152 | }; |
| 153 | match result { |
| 154 | Ok(_) => CommandResult::message(format!( |
| 155 | "Completed explicit {scope} memory deletion. External copies and already-sent context are outside this operation." |
| 156 | )), |
| 157 | Err(e) => CommandResult::error(format!( |
| 158 | "Memory deletion stopped: {e}. Inspect current state before retrying." |
| 159 | )), |
| 160 | } |
| 161 | } |
| 162 | _ => CommandResult::error(HELP), |
| 163 | } |
| 164 | } |
| 165 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 166 | name: "memory", |
| 167 | aliases: &[], |
| 168 | usage: "/memory [status|path|search|get|remember|import|export|reindex|clear|help]", |
| 169 | description_key: "cmd_memory_description", |
| 170 | }; |
| 171 | pub(in crate::commands) struct MemoryCmd; |
| 172 | impl RegisterCommand<CommandResult> for MemoryCmd { |
| 173 | fn info() -> &'static CommandInfo { |
| 174 | &COMMAND_INFO |
| 175 | } |
| 176 | fn handler() -> CommandHandler<CommandResult> { |
| 177 | CommandHandler::Contextual { |
| 178 | capabilities: CommandCapabilities::WORKSPACE.union(CommandCapabilities::MEMORY), |
| 179 | handler: memory_contextual, |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | fn memory_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 184 | let parts = contexts.into_parts(); |
| 185 | let Some(workspace) = parts.workspace.as_deref() else { |
| 186 | return CommandResult::error("Command capability unavailable: workspace"); |
| 187 | }; |
| 188 | let Some(memory_ctx) = parts.memory.as_deref() else { |
| 189 | return CommandResult::error("Command capability unavailable: memory"); |
| 190 | }; |
| 191 | memory(&workspace.workspace(), memory_ctx, arg) |
| 192 | } |
| 193 | #[cfg(test)] |
| 194 | mod tests { |
| 195 | use super::*; |
| 196 | #[test] |
| 197 | fn defaults_to_structured_status() { |
| 198 | assert_eq!(normalize(None), ("status", "")); |
| 199 | } |
| 200 | #[test] |
| 201 | fn native_is_an_exact_alias() { |
| 202 | assert_eq!( |
| 203 | normalize(Some("native search multiple words")), |
| 204 | ("search", "multiple words") |
| 205 | ); |
| 206 | assert_eq!(normalize(Some("natively")), ("natively", "")); |
| 207 | } |
| 208 | #[test] |
| 209 | fn complete_note_is_preserved() { |
| 210 | assert_eq!( |
| 211 | split("workspace all the words remain"), |
| 212 | ("workspace", "all the words remain") |
| 213 | ); |
| 214 | } |
| 215 | #[test] |
| 216 | fn show_does_not_read_a_markdown_file() { |
| 217 | assert_eq!(normalize(Some("show")), ("status", "")); |
| 218 | } |
| 219 | #[test] |
| 220 | fn clear_requires_scope_and_confirmation() { |
| 221 | assert_eq!(normalize(Some("clear")), ("clear", "")); |
| 222 | assert_eq!( |
| 223 | normalize(Some("clear workspace --confirm")), |
| 224 | ("clear", "workspace --confirm") |
| 225 | ); |
| 226 | } |
| 227 | } |
| 228 |