Merge pull request #518 from Hmbown/feat/489-memory-mvp
feat(memory): user-memory MVP — persistent notes, `# ` quick-add, /memory, remember tool (#489–#493)
This commit is contained in:
@@ -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
|
||||
# <user_memory> 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"
|
||||
|
||||
|
||||
@@ -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]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 `<user_memory>` block, and intercept
|
||||
/// `# foo` typed in the composer to append to that file. Default `false`.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl SnapshotsConfig {
|
||||
#[must_use]
|
||||
pub fn max_age(&self) -> std::time::Duration {
|
||||
@@ -730,6 +744,12 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub snapshots: Option<SnapshotsConfig>,
|
||||
|
||||
/// 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<MemoryConfig>,
|
||||
|
||||
/// 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),
|
||||
|
||||
@@ -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<String, String>,
|
||||
/// Whether the user-memory feature is enabled (#489). When `true` the
|
||||
/// engine reads `memory_path` on each prompt assembly and prepends a
|
||||
/// `<user_memory>` 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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<user_memory>` block, and prepend it to the system
|
||||
//! prompt alongside the existing `<project_instructions>` 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
|
||||
/// `<user_memory>` 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<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
if content.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(content)
|
||||
}
|
||||
|
||||
/// Wrap memory content in a `<user_memory>` 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<String> {
|
||||
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!(
|
||||
"<user_memory source=\"{display}\">\n{payload}\n</user_memory>"
|
||||
))
|
||||
}
|
||||
|
||||
/// Compose the `<user_memory>` 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<String> {
|
||||
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("<user_memory source=\"/tmp/m.md\">"));
|
||||
assert!(block.contains("note 1"));
|
||||
assert!(block.ends_with("</user_memory>"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ToolCapability> {
|
||||
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<ToolResult, ToolError> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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<CancellationToken>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -563,6 +563,14 @@ pub struct App {
|
||||
pub config_profile: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user