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:
Hunter Bown
2026-05-03 08:18:47 -05:00
committed by GitHub
16 changed files with 588 additions and 4 deletions
+11
View File
@@ -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"
+62
View File
@@ -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]"
)),
}
}
+2
View File
@@ -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),
+43
View File
@@ -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),
+22
View File
@@ -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());
+7
View File
@@ -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
}
}
+4 -1
View File
@@ -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);
+197
View File
@@ -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);
}
}
+11 -1
View File
@@ -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)
{
+2
View File
@@ -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);
+1
View File
@@ -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;
+10
View File
@@ -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
+138
View File
@@ -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}");
}
}
+8
View File
@@ -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,
}
}
+12 -2
View File
@@ -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,
+58
View File
@@ -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,