Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 123 additions & 9 deletions crates/tui/src/commands/groups/memory/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,76 @@ use std::path::Path;
use super::CommandResult;
use crate::tui::app::App;

const MEMORY_USAGE: &str = "/memory [show|path|clear|edit|help]";
const MEMORY_USAGE: &str = "/memory [show|path|clear|edit|tags|search <query>|search --tag <tag>|help]";

fn memory_help(path: &Path) -> String {
format!(
"Inspect or manage your persistent user-memory file.\n\n\
Usage: {MEMORY_USAGE}\n\n\
Current path: {}\n\n\
Subcommands:\n\
/memory Show the resolved path and current contents\n\
/memory show Alias for the no-arg form\n\
/memory path Print just the resolved path\n\
/memory clear Replace the file contents with an empty marker\n\
/memory edit Print the editor command for this file\n\
/memory help Show this help\n\n\
/memory Show the resolved path and current contents\n\
/memory show Alias for the no-arg form\n\
/memory path Print just the resolved path\n\
/memory clear Replace the file contents with an empty marker\n\
/memory edit Print the editor command for this file\n\
/memory tags List all tags with occurrence counts\n\
/memory search <query> Search memory by text (body + tags)\n\
/memory search --tag <t> Search memory by tag (exact match)\n\
/memory help Show this help\n\n\
Quick capture: type `# foo` in the composer to append a timestamped\n\
bullet without firing a turn.",
path.display()
)
}

/// Split the argument into subcommand and remaining args.
fn split_subcommand(arg: Option<&str>) -> (&str, Option<&str>) {
match arg {
Some(a) => {
let trimmed = a.trim();
match trimmed.find(char::is_whitespace) {
Some(pos) => {
let sub = &trimmed[..pos];
let rest = trimmed[pos..].trim_start();
if rest.is_empty() {
(sub, None)
} else {
(sub, Some(rest))
}
}
None => (trimmed, None),
}
}
None => ("show", None),
}
}
Comment on lines +50 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Slicing the string using a byte index pos + 1 after finding whitespace can panic at runtime if the whitespace character is multi-byte (e.g., the ideographic space \u{3000}). Using split_once is completely safe from UTF-8 boundary panics and is much more idiomatic Rust.

Suggested change
fn split_subcommand(arg: Option<&str>) -> (&str, Option<&str>) {
match arg {
Some(a) => {
let trimmed = a.trim();
match trimmed.find(char::is_whitespace) {
Some(pos) => (&trimmed[..pos], Some(trimmed[pos + 1..].trim_start())),
None => (trimmed, None),
}
}
None => ("show", None),
}
}
fn split_subcommand(arg: Option<&str>) -> (&str, Option<&str>) {
match arg {
Some(a) => {
let trimmed = a.trim();
match trimmed.split_once(char::is_whitespace) {
Some((sub, rest)) => (sub, Some(rest.trim_start())),
None => (trimmed, None),
}
}
None => ("show", None),
}
}


fn render_entries(entries: &[&crate::memory::MemoryEntry], prefix: &str) -> String {
let mut lines = String::new();
for entry in entries {
let _ = std::fmt::Write::write_fmt(
&mut lines,
format_args!("\n{prefix}- ({}) {}", entry.timestamp, entry.body),
);
if !entry.tags.is_empty() {
let _ = std::fmt::Write::write_fmt(
&mut lines,
format_args!(
" {}",
entry
.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
),
);
}
}
lines
}

pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult {
if !app.use_memory {
return CommandResult::error(
Expand All @@ -51,7 +101,7 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult {
}

let path = app.memory_path.clone();
let sub = arg.unwrap_or("show").trim();
let (sub, rest) = split_subcommand(arg);

match sub {
"" | "show" => {
Expand All @@ -69,6 +119,70 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult {
CommandResult::message(body)
}
"path" => CommandResult::message(path.display().to_string()),
"tags" => match fs::read_to_string(&path) {
Ok(content) => {
let tags = crate::memory::list_tags(&content);
if tags.is_empty() {
CommandResult::message("no tags found in memory file")
} else {
let mut lines = format!("Tags in {}:\n", path.display());
for (i, (tag, count)) in tags.iter().enumerate() {
let _ = std::fmt::Write::write_fmt(
&mut lines,
format_args!("\n {}. #{} ({})", i + 1, tag, count),
);
}
CommandResult::message(lines)
}
}
Err(_) => CommandResult::message(format!(
"{}\n(file does not exist yet)",
path.display()
)),
},
"search" => {
let Some(query) = rest.filter(|r| !r.is_empty()) else {
return CommandResult::error(
"Usage: /memory search <query> or /memory search --tag <tag>",
);
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
return CommandResult::message(format!(
"memory file does not exist yet at {}",
path.display()
));
}
};
let entries = crate::memory::parse_all(&content);

// Check for --tag flag
let results: Vec<&crate::memory::MemoryEntry> = if query.starts_with("--tag ") {
let tag = query.trim_start_matches("--tag ").trim();
if tag.is_empty() {
return CommandResult::error(
"Usage: /memory search --tag <tag> (tag must not be empty)",
);
}
crate::memory::search_by_tags(&entries, &[tag])
} else {
crate::memory::search_text(&entries, query)
};
Comment on lines +161 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the user types /memory search --tag without specifying a tag, or with trailing spaces but no tag name, the current implementation either searches for the literal text "--tag" or searches for an empty tag (returning no results). We should validate that the tag is not empty and return a helpful error message.

            let results: Vec<&crate::memory::MemoryEntry> = if query == "--tag" || query.starts_with("--tag ") {
                let tag = query.trim_start_matches("--tag").trim();
                if tag.is_empty() {
                    return CommandResult::error("Error: Please specify a tag to search for.");
                }
                crate::memory::search_by_tags(&entries, &[tag])
            } else {
                crate::memory::search_text(&entries, query)
            };


if results.is_empty() {
CommandResult::message(format!(
"no memory entries matching \"{query}\""
))
} else {
let body = render_entries(&results, "");
CommandResult::message(format!(
"{} matching entry(ies) for \"{query}\":{}",
results.len(),
body
))
}
}
"clear" => match fs::write(&path, "") {
Ok(()) => CommandResult::message(format!("memory cleared: {}", path.display())),
Err(err) => CommandResult::error(format!("failed to clear {}: {err}", path.display())),
Expand Down Expand Up @@ -123,7 +237,7 @@ mod tests {
let mut app = create_test_app_with_memory(&tmpdir, true);
let result = memory(&mut app, Some("help"));
let msg = result.message.expect("help should return text");
assert!(msg.contains("Usage: /memory [show|path|clear|edit|help]"));
assert!(msg.contains("Usage: /memory [show|path|clear|edit|tags|search <query>|search --tag <tag>|help]"));
assert!(msg.contains("/memory edit"));
assert!(msg.contains(app.memory_path.to_string_lossy().as_ref()));
}
Expand Down
54 changes: 53 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2784,6 +2784,50 @@ impl Engine {
.working_set
.pinned_message_indices(&self.session.messages, &self.session.workspace);

// Build memory context from user's memory file for enriched seams.
// Limit injected entries to prevent seam bloat.
const MAX_MEMORY_CONTEXT_ENTRIES: usize = 20;
let memory_context: Option<String> = if self.config.memory_enabled {
let path = &self.config.memory_path;
std::fs::read_to_string(path).ok().map(|content| {
let index = crate::memory_index::MemoryIndex::from_content(&content);
if index.is_empty() {
return String::new();
}
// Extract topic tags from the messages to be summarized
let recent_msgs: Vec<&crate::models::Message> = (0..msg_range_end)
.filter_map(|i| self.session.messages.get(i))
.collect();
let topics = crate::seam_manager::SeamManager::extract_topic_tags(&recent_msgs);

let matched: Vec<&crate::memory::MemoryEntry> = if topics.is_empty() {
// No specific topics — include recent memory entries as general context
index.entries().iter().rev().take(MAX_MEMORY_CONTEXT_ENTRIES).collect()
} else {
let topic_refs: Vec<&str> = topics.iter().map(String::as_str).collect();
let by_tag = index.search_by_tags(&topic_refs);
if by_tag.is_empty() {
index.entries().iter().rev().take(MAX_MEMORY_CONTEXT_ENTRIES).collect()
} else {
by_tag.into_iter().take(MAX_MEMORY_CONTEXT_ENTRIES).collect()
}
};
matched
.iter()
.map(|e| {
if e.tags.is_empty() {
format!("- ({}) {}", e.timestamp, e.body)
} else {
format!("- ({}) {} #{}", e.timestamp, e.body, e.tags.join(" #"))
}
})
.collect::<Vec<_>>()
.join("\n")
Comment on lines +2815 to +2825

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If e.tags is empty, e.tags.join(" #") is empty, resulting in a trailing # at the end of the formatted string (e.g., "- (timestamp) body #"). This is a formatting bug that can confuse the LLM. We should only append the tags if they are not empty.

                matched
                    .iter()
                    .map(|e| {
                        if e.tags.is_empty() {
                            format!("- ({}) {}", e.timestamp, e.body)
                        } else {
                            format!("- ({}) {} #{}", e.timestamp, e.body, e.tags.join(" #"))
                        }
                    })
                    .collect::<Vec<_>>()
                    .join("\n")

})
} else {
None
};

let _ = self
.tx_event
.send(Event::status(format!(
Expand All @@ -2802,6 +2846,7 @@ impl Engine {
msg_range_end,
Some(&self.session.workspace),
&pinned,
memory_context.as_deref(),
)
.await
{
Expand All @@ -2816,7 +2861,14 @@ impl Engine {
.filter_map(|i| self.session.messages.get(i))
.collect();
match seam_mgr
.recompact(&existing_seams, &recent, level, 0, msg_range_end)
.recompact(
&existing_seams,
&recent,
level,
0,
msg_range_end,
memory_context.as_deref(),
)
.await
{
Ok(text) => text,
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ mod lsp;
mod mcp;
mod mcp_server;
mod memory;
mod memory_index;
mod model_catalog;
mod model_inventory;
mod model_registry;
Expand Down
Loading