-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Feat/memory tags #3381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/memory tags #3381
Changes from all commits
97e749e
6d1c487
a95901d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
| } | ||
| } | ||
|
|
||
| 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( | ||
|
|
@@ -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" => { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user types 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())), | ||
|
|
@@ -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())); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If 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!( | ||
|
|
@@ -2802,6 +2846,7 @@ impl Engine { | |
| msg_range_end, | ||
| Some(&self.session.workspace), | ||
| &pinned, | ||
| memory_context.as_deref(), | ||
| ) | ||
| .await | ||
| { | ||
|
|
@@ -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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slicing the string using a byte index
pos + 1after finding whitespace can panic at runtime if the whitespace character is multi-byte (e.g., the ideographic space\u{3000}). Usingsplit_onceis completely safe from UTF-8 boundary panics and is much more idiomatic Rust.