diff --git a/config.example.toml b/config.example.toml index 33c3a76d..ebb9d9a2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -56,6 +56,17 @@ notes_path = "~/.deepseek/notes.txt" memory_path = "~/.deepseek/memory.md" +# ───────────────────────────────────────────────────────────────────────────────── +# User memory (#489) — opt-in. When enabled, the TUI reads memory_path on +# startup and injects its contents into the system prompt as a +# block, intercepts `# foo` typed in the composer to append +# the line as a timestamped bullet, and registers a `remember` tool the +# model can call to add durable notes itself. +# ───────────────────────────────────────────────────────────────────────────────── +[memory] +# enabled = true # turn the feature on (default: false) +# Override the env-var equivalent: `DEEPSEEK_MEMORY=on` + # Parsed but currently unused (reserved for future versions): # tools_file = "./tools.json" diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/memory.rs new file mode 100644 index 00000000..def6b19c --- /dev/null +++ b/crates/tui/src/commands/memory.rs @@ -0,0 +1,62 @@ +//! `/memory` slash command — inspect and edit the user memory file. +//! +//! When the user-memory feature is opted-in (`[memory] enabled = true` in +//! config or `DEEPSEEK_MEMORY=on` in the environment), `/memory` shows +//! the current memory file path and contents inline. Subcommands let the +//! user clear or open the file: +//! +//! - `/memory` — show path + content +//! - `/memory show` — alias for the no-arg form +//! - `/memory clear` — replace the file contents with an empty marker +//! - `/memory path` — show only the resolved path +//! +//! Editor integration (`/memory edit`) is intentionally minimal: the +//! command prints a copy-pasteable shell line to open the file in the +//! user's `$VISUAL` / `$EDITOR`, since the in-process external editor +//! plumbing requires terminal teardown that the slash-command handler +//! doesn't have access to. + +use std::fs; + +use super::CommandResult; +use crate::tui::app::App; + +pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { + if !app.use_memory { + return CommandResult::error( + "user memory is disabled. Enable with `[memory] enabled = true` in `~/.deepseek/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.", + ); + } + + let path = app.memory_path.clone(); + let sub = arg.unwrap_or("show").trim(); + + match sub { + "" | "show" => { + let body = match fs::read_to_string(&path) { + Ok(text) if text.trim().is_empty() => format!( + "{}\n(empty — add via `# foo` from the composer or have the model use the `remember` tool)", + path.display() + ), + Ok(text) => format!("{}\n\n{}", path.display(), text.trim_end()), + Err(_) => format!( + "{}\n(file does not exist yet — add via `# foo` from the composer to create it)", + path.display() + ), + }; + CommandResult::message(body) + } + "path" => CommandResult::message(path.display().to_string()), + "clear" => match fs::write(&path, "") { + Ok(()) => CommandResult::message(format!("memory cleared: {}", path.display())), + Err(err) => CommandResult::error(format!("failed to clear {}: {err}", path.display())), + }, + "edit" => CommandResult::message(format!( + "to edit your memory file, run:\n\n ${{VISUAL:-${{EDITOR:-vi}}}} {}", + path.display() + )), + _ => CommandResult::error(format!( + "unknown subcommand `{sub}`. usage: /memory [show|path|clear|edit]" + )), + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index cddfe68f..4dcfe81a 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -12,6 +12,7 @@ mod goal; mod init; mod jobs; mod mcp; +mod memory; mod note; mod provider; mod queue; @@ -465,6 +466,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "links" | "dashboard" | "api" => core::deepseek_links(app), "home" | "stats" | "overview" => core::home_dashboard(app), "note" => note::note(app, arg), + "memory" => memory::memory(app, arg), "attach" | "image" | "media" => attachment::attach(app, arg), "task" | "tasks" => task::task(app, arg), "jobs" | "job" => jobs::jobs(app, arg), diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 6f0e2698..e354599c 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -407,6 +407,20 @@ impl Default for SnapshotsConfig { } } +/// User-level memory configuration (#489). +/// +/// Default is opt-in: when this table is absent or `enabled = false`, the +/// memory file is neither read nor written, and `# foo` quick-adds in the +/// composer fall through to the normal turn-submission path. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MemoryConfig { + /// When `true`, load the user memory file at `Config::memory_path()` + /// into the system prompt as a `` block, and intercept + /// `# foo` typed in the composer to append to that file. Default `false`. + #[serde(default)] + pub enabled: Option, +} + impl SnapshotsConfig { #[must_use] pub fn max_age(&self) -> std::time::Duration { @@ -730,6 +744,12 @@ pub struct Config { #[serde(default)] pub snapshots: Option, + /// User-level memory file (#489). Default behaviour is **opt-in**: + /// loading + injection happens only when `[memory] enabled = true` or + /// `DEEPSEEK_MEMORY=on` is set. + #[serde(default)] + pub memory: Option, + /// Post-edit LSP diagnostics injection (#136). When absent, the engine /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] @@ -1270,6 +1290,18 @@ impl Config { .unwrap_or_else(|| PathBuf::from("./memory.md")) } + /// Whether the user-memory feature is enabled. The default is **off** + /// to preserve zero-overhead behavior for users who haven't opted in. + /// Flips to `true` when `[memory] enabled = true` in `config.toml` or + /// `DEEPSEEK_MEMORY=on` is set in the environment. + #[must_use] + pub fn memory_enabled(&self) -> bool { + self.memory + .as_ref() + .and_then(|m| m.enabled) + .unwrap_or(false) + } + /// Return whether shell execution is allowed. #[must_use] pub fn allow_shell(&self) -> bool { @@ -1634,6 +1666,16 @@ fn apply_env_overrides(config: &mut Config) { if let Ok(value) = std::env::var("DEEPSEEK_MEMORY_PATH") { config.memory_path = Some(value); } + if let Ok(value) = std::env::var("DEEPSEEK_MEMORY") { + let on = matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "on" | "true" | "yes" | "y" | "enabled" + ); + config + .memory + .get_or_insert_with(MemoryConfig::default) + .enabled = Some(on); + } if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") { config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true")); } @@ -1911,6 +1953,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { network: override_cfg.network.or(base.network), skills: override_cfg.skills.or(base.skills), snapshots: override_cfg.snapshots.or(base.snapshots), + memory: override_cfg.memory.or(base.memory), lsp: override_cfg.lsp.or(base.lsp), context: ContextConfig { enabled: override_cfg.context.enabled.or(base.context.enabled), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ba4b3c19..a45f04be 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -127,6 +127,13 @@ pub struct EngineConfig { pub runtime_services: RuntimeToolServices, /// Per-role/type sub-agent model overrides already resolved from config. pub subagent_model_overrides: HashMap, + /// Whether the user-memory feature is enabled (#489). When `true` the + /// engine reads `memory_path` on each prompt assembly and prepends a + /// `` block to the system prompt. + pub memory_enabled: bool, + /// Path to the user memory file (#489). Always populated; only + /// consulted when `memory_enabled` is `true`. + pub memory_path: PathBuf, } impl Default for EngineConfig { @@ -153,6 +160,8 @@ impl Default for EngineConfig { lsp_config: None, runtime_services: RuntimeToolServices::default(), subagent_model_overrides: HashMap::new(), + memory_enabled: false, + memory_path: PathBuf::from("./memory.md"), } } } @@ -338,11 +347,14 @@ impl Engine { // Set up system prompt with project context (default to agent mode) let working_set_summary = session.working_set.summary_block(&config.workspace); + let user_memory_block = + crate::memory::compose_block(config.memory_enabled, &config.memory_path); let system_prompt = prompts::system_prompt_for_mode_with_context_and_skills( AppMode::Agent, &config.workspace, None, Some(&config.skills_dir), + user_memory_block.as_deref(), ); session.system_prompt = append_working_set_summary(Some(system_prompt), working_set_summary.as_deref()); @@ -1226,6 +1238,13 @@ impl Engine { .with_cancel_token(self.cancel_token.clone()) .with_trusted_external_paths(trusted.paths().to_vec()); + // Hand the user-memory path to tools so the model-callable + // `remember` tool can append entries (#489). `None` when the + // feature is disabled — tools short-circuit on that. + if self.config.memory_enabled { + ctx.memory_path = Some(self.config.memory_path.clone()); + } + if let Some(decider) = self.config.network_policy.as_ref() { ctx = ctx.with_network_policy(decider.clone()); } @@ -1597,11 +1616,14 @@ impl Engine { .session .working_set .summary_block(&self.config.workspace); + let user_memory_block = + crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path); let base = prompts::system_prompt_for_mode_with_context_and_skills( mode, &self.config.workspace, None, Some(&self.config.skills_dir), + user_memory_block.as_deref(), ); let stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 220d4706..d3f44195 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -47,6 +47,13 @@ impl Engine { builder = builder.with_shell_tools(); } + // Register the `remember` tool only when the user has opted in to + // user-memory (#489). Without that opt-in the tool would always + // fail; surfacing it would just waste catalog slots. + if self.config.memory_enabled { + builder = builder.with_remember_tool(); + } + builder } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a406ff32..4f8beef7 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -35,6 +35,7 @@ mod logging; mod lsp; mod mcp; mod mcp_server; +mod memory; mod models; mod network_policy; mod palette; @@ -3006,7 +3007,7 @@ async fn run_interactive( memory_path: config.memory_path(), notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), - use_memory: false, + use_memory: config.memory_enabled(), start_in_agent_mode: cli.yolo, skip_onboarding: cli.skip_onboarding, yolo: cli.yolo, // YOLO mode auto-approves all tool executions @@ -3166,6 +3167,8 @@ async fn run_exec_agent( lsp_config, runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), + memory_enabled: config.memory_enabled(), + memory_path: config.memory_path(), }; let engine_handle = spawn_engine(engine_config, config); diff --git a/crates/tui/src/memory.rs b/crates/tui/src/memory.rs new file mode 100644 index 00000000..f2158254 --- /dev/null +++ b/crates/tui/src/memory.rs @@ -0,0 +1,197 @@ +//! User-level memory file. +//! +//! v0.8.8 ships an MVP that lets the user keep a persistent personal +//! note file the model sees on every turn: +//! +//! - **Load** `~/.deepseek/memory.md` (path is configurable via +//! `memory_path` in `config.toml` and `DEEPSEEK_MEMORY_PATH` env), +//! wrap it in a `` block, and prepend it to the system +//! prompt alongside the existing `` block. +//! - **`# foo`** typed in the composer appends `foo` to the memory +//! file as a timestamped bullet — fast capture without leaving the TUI. +//! - **`/memory`** opens the memory file in `$VISUAL` / `$EDITOR`. +//! - **`remember` tool** lets the model itself append a bullet when it +//! notices a durable preference or convention worth keeping across +//! sessions. +//! +//! Default behavior is **opt-in**: load + use the memory file only when +//! `[memory] enabled = true` in `config.toml` or `DEEPSEEK_MEMORY=on`. +//! That keeps existing users on zero-overhead behavior and makes the +//! feature explicit. + +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +use chrono::Utc; + +/// Maximum size of the user memory file. Larger files are loaded but the +/// `` block carries a "(truncated)" marker so the user knows +/// the model only saw a slice. Mirrors `project_context::MAX_CONTEXT_SIZE`. +const MAX_MEMORY_SIZE: usize = 100 * 1024; + +/// Read the user memory file at `path`, returning `None` when the file +/// doesn't exist or is empty after trimming. +#[must_use] +pub fn load(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + if content.trim().is_empty() { + return None; + } + Some(content) +} + +/// Wrap memory content in a `` block ready to prepend to the +/// system prompt. The `source` value is rendered verbatim into a +/// `source="…"` attribute — pass the path so the model can see where the +/// memory came from. Returns `None` for empty content. +#[must_use] +pub fn as_system_block(content: &str, source: &Path) -> Option { + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + + let display = source.display(); + let payload = if content.len() > MAX_MEMORY_SIZE { + let mut head = content[..MAX_MEMORY_SIZE].to_string(); + head.push_str("\n…(truncated, raise [memory].max_size or trim memory.md)"); + head + } else { + trimmed.to_string() + }; + + Some(format!( + "\n{payload}\n" + )) +} + +/// Compose the `` block for the system prompt, honouring the +/// opt-in toggle. Returns `None` when the feature is disabled or the file +/// is missing / empty so the caller doesn't have to check both conditions. +/// +/// Callers that hold a `&Config` should pass `config.memory_enabled()` and +/// `config.memory_path()` directly. The split keeps this module +/// `Config`-free so it can be reused from sub-agent / engine boundaries +/// where the high-level `Config` isn't available. +#[must_use] +pub fn compose_block(enabled: bool, path: &Path) -> Option { + if !enabled { + return None; + } + let content = load(path)?; + as_system_block(&content, path) +} + +/// Append `entry` to the memory file at `path`, creating it (and its +/// parent directory) if needed. The entry is timestamped so the user can +/// later see when each note was added. The leading `#` from a `# foo` +/// quick-add is stripped so the file stays as readable Markdown. +pub fn append_entry(path: &Path, entry: &str) -> io::Result<()> { + let trimmed = entry.trim_start_matches('#').trim(); + if trimmed.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "memory entry is empty after stripping `#` prefix", + )); + } + + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + let timestamp = Utc::now().format("%Y-%m-%d %H:%M UTC"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "- ({timestamp}) {trimmed}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn load_returns_none_for_missing_file() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("never-existed.md"); + assert!(load(&path).is_none()); + } + + #[test] + fn load_returns_none_for_whitespace_only_file() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + fs::write(&path, " \n \n").unwrap(); + assert!(load(&path).is_none()); + } + + #[test] + fn load_returns_content_for_real_file() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + fs::write(&path, "remember the milk").unwrap(); + assert_eq!(load(&path).as_deref(), Some("remember the milk")); + } + + #[test] + fn as_system_block_produces_xml_wrapper() { + let block = as_system_block("note 1", Path::new("/tmp/m.md")).unwrap(); + assert!(block.contains("")); + assert!(block.contains("note 1")); + assert!(block.ends_with("")); + } + + #[test] + fn as_system_block_returns_none_for_empty_content() { + assert!(as_system_block(" ", Path::new("/tmp/m.md")).is_none()); + } + + #[test] + fn as_system_block_truncates_oversize_input() { + let big = "x".repeat(MAX_MEMORY_SIZE + 100); + let block = as_system_block(&big, Path::new("/tmp/m.md")).unwrap(); + assert!(block.contains("(truncated")); + } + + #[test] + fn append_entry_creates_file_and_writes_one_bullet() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + append_entry(&path, "# remember the milk").unwrap(); + + let body = fs::read_to_string(&path).unwrap(); + assert!(body.contains("remember the milk"), "{body}"); + assert!( + body.starts_with("- ("), + "should start with bullet + date: {body}" + ); + assert!(body.trim_end().ends_with("remember the milk")); + } + + #[test] + fn append_entry_appends_subsequent_lines() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + append_entry(&path, "# first").unwrap(); + append_entry(&path, "second").unwrap(); + let body = fs::read_to_string(&path).unwrap(); + assert!(body.contains("first")); + assert!(body.contains("second")); + // Two bullets means two lines of `- (date) entry`. + assert_eq!(body.matches("- (").count(), 2); + } + + #[test] + fn append_entry_rejects_empty_after_strip() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + let err = append_entry(&path, "###").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index d091f176..c68e8dc1 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -173,7 +173,7 @@ pub fn system_prompt_for_mode_with_context( workspace: &Path, working_set_summary: Option<&str>, ) -> SystemPrompt { - system_prompt_for_mode_with_context_and_skills(mode, workspace, working_set_summary, None) + system_prompt_for_mode_with_context_and_skills(mode, workspace, working_set_summary, None, None) } /// Get the system prompt for a specific mode with project and skills context. @@ -198,6 +198,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( workspace: &Path, working_set_summary: Option<&str>, skills_dir: Option<&Path>, + user_memory_block: Option<&str>, ) -> SystemPrompt { let mode_prompt = compose_mode_prompt(mode); @@ -217,6 +218,15 @@ pub fn system_prompt_for_mode_with_context_and_skills( ) }; + // 2.5. User memory block (#489). Goes above skills/context-management + // because it's session-stable: the memory file changes when the user + // edits it via `/memory` or `# foo` quick-add, but not turn-over-turn. + if let Some(memory_block) = user_memory_block + && !memory_block.trim().is_empty() + { + full_prompt = format!("{full_prompt}\n\n{memory_block}"); + } + // 3. Skills block. if let Some(skills_block) = skills_dir.and_then(crate::skills::render_available_skills_context) { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 6bc2f258..c4e7800c 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1563,6 +1563,8 @@ impl RuntimeThreadManager { shell_manager: None, }, subagent_model_overrides: self.config.subagent_model_overrides(), + memory_enabled: self.config.memory_enabled(), + memory_path: self.config.memory_path(), }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 9ea0826a..04d556ce 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -18,6 +18,7 @@ pub mod plan; pub mod project; pub mod recall_archive; pub mod registry; +pub mod remember; pub mod revert_turn; pub mod review; pub mod rlm; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index e1812567..f2bdb7f1 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -504,6 +504,16 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(NoteTool)) } + /// Include the `remember` tool — model-callable bullet-add into the + /// user memory file (#489). Only register when the user has opted + /// in to the memory feature; without that, the tool would surface + /// in the model's catalog but always fail with "memory disabled". + #[must_use] + pub fn with_remember_tool(self) -> Self { + use super::remember::RememberTool; + self.with_tool(Arc::new(RememberTool)) + } + /// Include MCP tools from a connected pool as first-class registry /// citizens. Each MCP tool is wrapped in a lightweight adapter that /// implements `ToolSpec`, so the unified `ToolRegistryBuilder` flow diff --git a/crates/tui/src/tools/remember.rs b/crates/tui/src/tools/remember.rs new file mode 100644 index 00000000..05b6ff5d --- /dev/null +++ b/crates/tui/src/tools/remember.rs @@ -0,0 +1,138 @@ +//! `remember` tool — model-callable bullet-add into the user memory file. +//! +//! Lets the model itself notice a durable preference, convention, or fact +//! worth keeping across sessions and write it to the user's `memory.md`. +//! The tool is auto-approved and side-effecting only on the user-owned +//! memory file (`~/.deepseek/memory.md` by default), so it doesn't get +//! gated behind the same approval flow as shell or arbitrary file writes. +//! +//! Only registered when `[memory] enabled = true` (or +//! `DEEPSEEK_MEMORY=on`). When disabled, the tool isn't surfaced to the +//! model at all, so prompts that mention `remember` simply fall through. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; + +/// Tool that appends one bullet to the user memory file. +pub struct RememberTool; + +#[async_trait] +impl ToolSpec for RememberTool { + fn name(&self) -> &'static str { + "remember" + } + + fn description(&self) -> &'static str { + "Append a durable note to the user memory file so it surfaces in \ + future sessions. Use this when the user states a preference, a \ + convention they want enforced, or a fact about themselves or \ + their workflow that you should not have to relearn next time. \ + Keep notes terse (one sentence). Don't store secrets, transient \ + tasks, or reasoning scratch — those belong in a checklist or in \ + the conversation." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "The single-sentence durable note to remember." + } + }, + "required": ["note"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::WritesFiles] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + // Memory writes are scoped to the user's own memory file; gating + // them behind the standard shell/write approval would defeat the + // point of automatic memory. + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let note = required_str(&input, "note")?; + let path = context.memory_path.as_ref().ok_or_else(|| { + ToolError::execution_failed( + "user memory is disabled — set `[memory] enabled = true` in config.toml or \ + `DEEPSEEK_MEMORY=on` in the environment to enable", + ) + })?; + + crate::memory::append_entry(path, note).map_err(|err| { + ToolError::execution_failed(format!("failed to append to {}: {err}", path.display())) + })?; + + Ok(ToolResult::success(format!( + "remembered: {}", + note.trim_start_matches('#').trim() + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::tempdir; + + fn ctx_with_memory(path: PathBuf) -> ToolContext { + let mut ctx = ToolContext::new(path.parent().unwrap_or_else(|| std::path::Path::new("."))); + ctx.memory_path = Some(path); + ctx + } + + #[tokio::test] + async fn returns_error_when_memory_disabled() { + let tmp = tempdir().unwrap(); + let mut ctx = ToolContext::new(tmp.path()); + ctx.memory_path = None; // explicitly disabled + + let tool = RememberTool; + let err = tool + .execute(json!({"note": "use 4 spaces for indentation"}), &ctx) + .await + .unwrap_err(); + assert!(err.to_string().contains("memory is disabled"), "{err}"); + } + + #[tokio::test] + async fn appends_bullet_to_memory_file() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + let ctx = ctx_with_memory(path.clone()); + + let tool = RememberTool; + let result = tool + .execute(json!({"note": "use 4 spaces for indentation"}), &ctx) + .await + .expect("ok"); + assert!(result.success); + assert!(result.content.contains("4 spaces")); + + let body = std::fs::read_to_string(&path).expect("read"); + assert!(body.contains("4 spaces")); + assert!(body.starts_with("- ("), "{body}"); + } + + #[tokio::test] + async fn rejects_missing_note_field() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("memory.md"); + let ctx = ctx_with_memory(path); + + let tool = RememberTool; + let err = tool.execute(json!({}), &ctx).await.unwrap_err(); + assert!(err.to_string().to_lowercase().contains("note"), "{err}"); + } +} diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 2209d277..87bb59ed 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -100,6 +100,11 @@ pub struct ToolContext { /// Cancellation token for the active engine turn. Tools that may wait on /// external work should observe this so UI cancel can interrupt them. pub cancel_token: Option, + /// Path to the user memory file. `None` when the user-memory feature + /// (#489) is disabled — tools that read or write the file should + /// short-circuit on `None` rather than fall back to a workspace-local + /// default. + pub memory_path: Option, } impl ToolContext { @@ -125,6 +130,7 @@ impl ToolContext { network_policy: None, runtime: RuntimeToolServices::default(), cancel_token: None, + memory_path: None, } } @@ -153,6 +159,7 @@ impl ToolContext { network_policy: None, runtime: RuntimeToolServices::default(), cancel_token: None, + memory_path: None, } } @@ -181,6 +188,7 @@ impl ToolContext { network_policy: None, runtime: RuntimeToolServices::default(), cancel_token: None, + memory_path: None, } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 084480c2..4ab4219f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -563,6 +563,14 @@ pub struct App { pub config_profile: Option, pub mcp_config_path: PathBuf, pub skills_dir: PathBuf, + /// Path to the user-memory file (#489). Always populated; only + /// consulted when `use_memory` is `true`. + pub memory_path: PathBuf, + /// Whether the user-memory feature is enabled (#489). Mirrors + /// `Config::memory_enabled()` at app boot. Used by the `# foo` + /// composer interception, the `/memory` slash command, and tool + /// registration for `remember`. + pub use_memory: bool, pub use_alt_screen: bool, pub use_mouse_capture: bool, pub use_bracketed_paste: bool, @@ -931,10 +939,10 @@ impl App { use_bracketed_paste, max_subagents, skills_dir: global_skills_dir, - memory_path: _, + memory_path, notes_path: _, mcp_config_path, - use_memory: _, + use_memory, start_in_agent_mode, skip_onboarding, yolo, @@ -1064,6 +1072,8 @@ impl App { config_profile, mcp_config_path, skills_dir, + memory_path, + use_memory, use_alt_screen, use_mouse_capture, use_bracketed_paste, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d59543d4..a6528b2f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -412,6 +412,51 @@ fn terminal_probe_timeout(config: &Config) -> Duration { Duration::from_millis(timeout_ms) } +/// Recognise composer input that is a `# foo` memory quick-add (#492). +/// +/// Returns `true` for inputs that: +/// - start with `#`, +/// - have at least one non-whitespace character after the leading `#`, +/// - are a single line (no embedded `\n`), and +/// - are not a shebang (`#!`) or Markdown heading (`## …`, `### …`). +/// +/// Multi-`#` prefixes are deliberately rejected so users can paste +/// Markdown headings into the composer without triggering the quick-add. +#[must_use] +fn is_memory_quick_add(input: &str) -> bool { + let trimmed = input.trim_start(); + if !trimmed.starts_with('#') { + return false; + } + if trimmed.starts_with("##") || trimmed.starts_with("#!") { + return false; + } + if input.contains('\n') { + return false; + } + // Require something after the `#`. + !trimmed.trim_start_matches('#').trim().is_empty() +} + +/// Persist a `# foo` quick-add to the memory file and surface a status +/// note to the user. Errors land in the same status channel so a missing +/// memory directory becomes visible without crashing the composer. +fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) { + let path = config.memory_path(); + match crate::memory::append_entry(&path, input) { + Ok(()) => { + app.status_message = Some(format!("memory: appended to {}", path.display())); + } + Err(err) => { + app.status_message = Some(format!( + "memory: failed to write {}: {}", + path.display(), + err + )); + } + } +} + fn build_engine_config(app: &App, config: &Config) -> EngineConfig { EngineConfig { model: app.model.clone(), @@ -448,6 +493,8 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { .map(crate::config::LspConfigToml::into_runtime), runtime_services: app.runtime_services.clone(), subagent_model_overrides: config.subagent_model_overrides(), + memory_enabled: config.memory_enabled(), + memory_path: config.memory_path(), } } @@ -2203,6 +2250,17 @@ async fn run_event_loop( if handle_plan_choice(app, &engine_handle, &input).await? { continue; } + // `# foo` quick-add (#492) — when memory is enabled, + // a single line starting with `#` (but not `##` / + // `#!` shebangs / Markdown headings the user might + // be pasting in) is intercepted: the text is + // appended to the user memory file and the input + // is consumed without firing a turn. Disabled + // behaviour falls through to normal turn submit. + if config.memory_enabled() && is_memory_quick_add(&input) { + handle_memory_quick_add(app, &input, config); + continue; + } if input.starts_with('/') { if execute_command_input( terminal,