返回 CodeWhale
remember.rs
根目录 / crates / tui / src / tools / remember.rs
1 //! `remember` tool — model-callable capture into the native memory store.
2 //!
3 //! Lets the model itself notice a durable preference, convention, or fact
4 //! worth keeping across sessions and write it to the native memory store
5 //! (Markdown + SQLite FTS5 under `memory/global/MEMORY.md`). The tool is
6 //! auto-approved and side-effecting only on the user-owned memory files,
7 //! so it doesn't get gated behind the same approval flow as shell or
8 //! arbitrary file writes.
9 //!
10 //! Only registered when `[memory] enabled = true` (or
11 //! `DEEPSEEK_MEMORY=on`). When disabled, the tool isn't surfaced to the
12 //! model at all, so prompts that mention `remember` simply fall through.
13
14 use async_trait::async_trait;
15 use serde_json::{Value, json};
16
17 use super::spec::{
18 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
19 };
20
21 /// Tool that appends one bullet to the user memory file.
22 pub struct RememberTool;
23
24 #[async_trait]
25 impl ToolSpec for RememberTool {
26 fn name(&self) -> &'static str {
27 "remember"
28 }
29
30 fn description(&self) -> &'static str {
31 "Append a durable note to the user memory file so it surfaces in \
32 future sessions. Use this when the user states a preference, a \
33 convention they want enforced, or a fact about themselves or \
34 their workflow that you should not have to relearn next time. \
35 Keep notes terse (one sentence). Don't store secrets, transient \
36 tasks, or reasoning scratch — those belong in a checklist or in \
37 the conversation."
38 }
39
40 fn input_schema(&self) -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "note": {
45 "type": "string",
46 "description": "The single-sentence durable note to remember."
47 },
48 "scope": {
49 "type": "string",
50 "enum": ["global", "workspace"],
51 "description": "Native backend scope; defaults to global."
52 }
53 },
54 "required": ["note"]
55 })
56 }
57
58 fn capabilities(&self) -> Vec<ToolCapability> {
59 vec![ToolCapability::WritesFiles]
60 }
61
62 fn approval_requirement(&self) -> ApprovalRequirement {
63 // Memory writes are scoped to the user's own memory file; gating
64 // them behind the standard shell/write approval would defeat the
65 // point of automatic memory.
66 ApprovalRequirement::Auto
67 }
68
69 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
70 let note = required_str(&input, "note")?;
71 let path = context.memory_path.as_ref().ok_or_else(|| {
72 ToolError::execution_failed(
73 "user memory is disabled — set `[memory] enabled = true` in config.toml or \
74 `DEEPSEEK_MEMORY=on` in the environment to enable",
75 )
76 })?;
77
78 if let Some(store) = crate::native_memory::NativeMemoryStore::from_global_path(path) {
79 let scope = match input
80 .get("scope")
81 .and_then(Value::as_str)
82 .unwrap_or("global")
83 {
84 "global" => crate::native_memory::MemoryScope::Global,
85 "workspace" => crate::native_memory::MemoryScope::Workspace,
86 other => {
87 return Err(ToolError::invalid_input(format!(
88 "unknown memory scope `{other}`; expected global or workspace"
89 )));
90 }
91 };
92 let workspace_id = if scope == crate::native_memory::MemoryScope::Workspace {
93 Some(
94 crate::native_memory::NativeMemoryStore::workspace_id(&context.workspace)
95 .map_err(|error| {
96 ToolError::execution_failed(format!(
97 "failed to resolve workspace memory scope: {error}"
98 ))
99 })?
100 .ok_or_else(|| {
101 ToolError::execution_failed(
102 "workspace memory requires a git repository with an origin",
103 )
104 })?,
105 )
106 } else {
107 None
108 };
109 let hit = store
110 .remember(scope, workspace_id.as_deref(), note)
111 .map_err(|error| {
112 ToolError::execution_failed(format!("failed to write native memory: {error}"))
113 })?;
114 return Ok(ToolResult::success(format!(
115 "remembered in native memory: {}:{}-{}",
116 hit.source.display(),
117 hit.line_start,
118 hit.line_end
119 ))
120 .with_metadata(json!({
121 "memory_backend": "native",
122 "scope": if scope == crate::native_memory::MemoryScope::Global { "global" } else { "workspace" },
123 "source": hit.source,
124 "line_start": hit.line_start,
125 "line_end": hit.line_end,
126 "untrusted": true
127 })));
128 }
129
130 Err(ToolError::execution_failed(format!(
131 "native memory store not found at {} — expected the `memory/global/MEMORY.md` layout; the legacy single-file memory path was removed in v0.9.4",
132 path.display()
133 )))
134 }
135 }
136
137 #[cfg(test)]
138 mod tests {
139 use super::*;
140 use std::path::PathBuf;
141 use tempfile::tempdir;
142
143 fn ctx_with_memory(path: PathBuf) -> ToolContext {
144 let mut ctx = ToolContext::new(path.parent().unwrap_or_else(|| std::path::Path::new(".")));
145 ctx.memory_path = Some(path);
146 ctx
147 }
148
149 #[tokio::test]
150 async fn returns_error_when_memory_disabled() {
151 let tmp = tempdir().unwrap();
152 let mut ctx = ToolContext::new(tmp.path());
153 ctx.memory_path = None; // explicitly disabled
154
155 let tool = RememberTool;
156 let err = tool
157 .execute(json!({"note": "use 4 spaces for indentation"}), &ctx)
158 .await
159 .unwrap_err();
160 assert!(err.to_string().contains("memory is disabled"), "{err}");
161 }
162
163 #[tokio::test]
164 async fn rejects_legacy_plain_file_memory_path() {
165 // The legacy single-file path (`memory.md`) was removed in v0.9.4;
166 // only the native `memory/global/MEMORY.md` layout is writable.
167 let tmp = tempdir().unwrap();
168 let path = tmp.path().join("memory.md");
169 let ctx = ctx_with_memory(path);
170
171 let tool = RememberTool;
172 let err = tool
173 .execute(json!({"note": "use 4 spaces for indentation"}), &ctx)
174 .await
175 .unwrap_err();
176 assert!(err.to_string().contains("native memory store"), "{err}");
177 }
178
179 #[tokio::test]
180 async fn native_backend_capture_updates_markdown_and_fts_index() {
181 let tmp = tempdir().unwrap();
182 let root = tmp.path().join("memory");
183 let path = root.join("global/MEMORY.md");
184 let mut ctx = ToolContext::new(tmp.path());
185 ctx.memory_path = Some(path);
186
187 let result = RememberTool
188 .execute(
189 json!({"note": "Prefer bounded receipts", "scope": "global"}),
190 &ctx,
191 )
192 .await
193 .expect("native capture should succeed");
194 assert!(result.success);
195 assert_eq!(result.metadata.unwrap()["memory_backend"], "native");
196
197 let hits = crate::native_memory::NativeMemoryStore::new(root)
198 .search("receipts", 5)
199 .expect("native capture should update index");
200 assert_eq!(hits.len(), 1);
201 assert_eq!(hits[0].text, "Prefer bounded receipts");
202 }
203
204 #[tokio::test]
205 async fn rejects_missing_note_field() {
206 let tmp = tempdir().unwrap();
207 let path = tmp.path().join("memory.md");
208 let ctx = ctx_with_memory(path);
209
210 let tool = RememberTool;
211 let err = tool.execute(json!({}), &ctx).await.unwrap_err();
212 assert!(err.to_string().to_lowercase().contains("note"), "{err}");
213 }
214 }
215
215 lines RUST