返回 CodeWhale
remember.rs
根目录 / crates / tui / src / tools / remember.rs
1 //! Model capture proposes candidates. It cannot review or delete user memory.
2 //! The trusted Context Lens owns approval, correction and forgetting controls.
3 use super::spec::{
4 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
5 };
6 use crate::native_memory::{MemoryScope, NativeMemoryStore};
7 use async_trait::async_trait;
8 use serde::Deserialize;
9 use serde_json::{Value, json};
10
11 #[derive(Deserialize)]
12 #[serde(deny_unknown_fields)]
13 struct Input {
14 action: Option<String>,
15 note: Option<String>,
16 replaces: Option<String>,
17 evidence: Option<String>,
18 scope: Option<String>,
19 }
20 pub struct RememberTool;
21 #[async_trait]
22 impl ToolSpec for RememberTool {
23 fn name(&self) -> &'static str {
24 "remember"
25 }
26 fn description(&self) -> &'static str {
27 "Propose a durable memory or correction for review in Context Lens. A successful capture is a candidate, not active knowledge. Do not store secrets, transient task state or private reasoning. Existing memory is never silently replaced or deleted by this tool."
28 }
29 fn input_schema(&self) -> Value {
30 json!({"type":"object","additionalProperties":false,"properties":{
31 "action":{"type":"string","enum":["append","revise"],"default":"append"},
32 "note":{"type":"string","minLength":1,"maxLength":8192},
33 "scope":{"type":"string","enum":["global","workspace"]},
34 "replaces":{"type":"string","description":"Exact current note, required for a correction."},
35 "evidence":{"type":"string","description":"Why the proposed correction is supported."}
36 },"required":["note"]})
37 }
38 fn capabilities(&self) -> Vec<ToolCapability> {
39 vec![ToolCapability::WritesFiles]
40 }
41 fn approval_requirement(&self) -> ApprovalRequirement {
42 ApprovalRequirement::Auto
43 }
44 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
45 let input: Input = serde_json::from_value(input)
46 .map_err(|_| ToolError::invalid_input("invalid memory proposal"))?;
47 let path = context
48 .memory_path
49 .clone()
50 .ok_or_else(|| ToolError::execution_failed("memory is disabled"))?;
51 let store = NativeMemoryStore::from_global_path(&path)
52 .ok_or_else(|| ToolError::execution_failed("native memory store is not configured"))?;
53 let workspace = context.workspace.clone();
54 let action = input.action.unwrap_or_else(|| "append".into());
55 if !matches!(action.as_str(), "append" | "revise") {
56 return Err(ToolError::invalid_input(
57 "Only append and revise candidates are supported. Forgetting requires the user's Context Lens control.",
58 ));
59 }
60 let note = input
61 .note
62 .filter(|s| !s.trim().is_empty())
63 .ok_or_else(|| ToolError::invalid_input("a note is required"))?;
64 let scope = match input.scope.as_deref().unwrap_or("workspace") {
65 "global" => MemoryScope::Global,
66 "workspace" => MemoryScope::Workspace,
67 _ => {
68 return Err(ToolError::invalid_input(
69 "scope must be global or workspace",
70 ));
71 }
72 };
73 let hit=tokio::task::spawn_blocking(move||{
74 let workspace_id=if scope==MemoryScope::Workspace {
75 Some(NativeMemoryStore::workspace_id(&workspace).map_err(|_|ToolError::execution_failed("workspace identity unavailable"))?
76 .ok_or_else(||ToolError::execution_failed("workspace memory needs a git origin; explicitly select global for a user preference"))?)
77 } else {None};
78 let result=if action=="revise" {
79 let from=input.replaces.as_deref().filter(|s|!s.trim().is_empty()).ok_or_else(||ToolError::invalid_input("replaces is required"))?;
80 let evidence=input.evidence.as_deref().filter(|s|!s.trim().is_empty()).ok_or_else(||ToolError::invalid_input("evidence is required"))?;
81 store.revise(scope,workspace_id.as_deref(),from,&note,evidence)
82 } else {store.remember(scope,workspace_id.as_deref(),&note)};
83 result.map_err(|_|ToolError::execution_failed("memory proposal was rejected or could not be stored; no active memory was changed"))
84 }).await.map_err(|_|ToolError::execution_failed("memory worker stopped"))??;
85 Ok(ToolResult::success(format!("Memory candidate #{} is available for review in Context Lens. It is not active context; existing knowledge is unchanged.",hit.id))
86 .with_metadata(json!({"memory_backend":"native","memory_schema":2,"memory_id":hit.id,"candidate":true,"untrusted":true})))
87 }
88 }
89 #[cfg(test)]
90 mod tests {
91 use super::*;
92 use codewhale_memory::{Access, Status};
93 #[tokio::test]
94 async fn disabled_capture_does_not_create_store() {
95 let temp = tempfile::tempdir().unwrap();
96 let mut context = ToolContext::new(temp.path());
97 context.memory_path = None;
98 assert!(
99 RememberTool
100 .execute(
101 json!({"note":"Keep constraints explicit","scope":"global"}),
102 &context
103 )
104 .await
105 .is_err()
106 );
107 assert!(!temp.path().join("memory").exists());
108 }
109 #[tokio::test]
110 async fn capture_is_reviewable_not_recalled() {
111 let temp = tempfile::tempdir().unwrap();
112 let mut context = ToolContext::new(temp.path());
113 let root = temp.path().join("memory");
114 context.memory_path = Some(root.join("global/MEMORY.md"));
115 let result = RememberTool
116 .execute(
117 json!({"note":"Keep constraints explicit","scope":"global"}),
118 &context,
119 )
120 .await
121 .unwrap();
122 assert!(result.success);
123 let native = NativeMemoryStore::new(&root);
124 assert!(native.search("constraints", 10).unwrap().is_empty());
125 let store = native.open_structured().unwrap();
126 let access = Access::operator(vec![NativeMemoryStore::owner_scope()]).unwrap();
127 assert_eq!(
128 store.list(&access, None, 10).unwrap()[0].status,
129 Status::Candidate
130 );
131 assert!(!root.join("global/MEMORY.md").exists());
132 }
133 #[tokio::test]
134 async fn forged_review_field_is_rejected() {
135 let temp = tempfile::tempdir().unwrap();
136 let context = ToolContext::new(temp.path());
137 assert!(
138 RememberTool
139 .execute(json!({"note":"claim","approved":true}), &context)
140 .await
141 .is_err()
142 );
143 }
144 }
145
145 lines RUST