use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
use crate::memory::{EmbeddingProvider, MemoryEntry, NoopEmbedding, NoopVectorStore, VectorStore};
use crate::tools::Tool;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub id: String,
#[serde(default)]
pub tags: Vec<String>,
pub text: String,
pub ts: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MemoryStore {
pub notes: Vec<Note>,
}
impl MemoryStore {
fn load(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
name: "memory".into(),
message: format!("failed to read memory file: {e}"),
})?;
serde_json::from_str(&raw).map_err(|e| Error::Tool {
name: "memory".into(),
message: format!("malformed memory file: {e}"),
})
}
fn save(&self, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
name: "memory".into(),
message: format!("failed to create memory directory: {e}"),
})?;
}
let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
name: "memory".into(),
message: format!("failed to serialize memory: {e}"),
})?;
std::fs::write(path, raw).map_err(|e| Error::Tool {
name: "memory".into(),
message: format!("failed to write memory file: {e}"),
})?;
Ok(())
}
fn next_id(&self) -> String {
let max = self
.notes
.iter()
.filter_map(|n| n.id.strip_prefix('N'))
.filter_map(|s| s.parse::<u32>().ok())
.max()
.unwrap_or(0);
format!("N{}", max + 1)
}
fn add(&mut self, text: String, tags: Vec<String>) -> String {
let id = self.next_id();
let ts = chrono_now_rfc3339();
self.notes.push(Note {
id: id.clone(),
tags,
text,
ts,
});
id
}
fn remove(&mut self, id: &str) -> bool {
let before = self.notes.len();
self.notes.retain(|n| n.id != id);
self.notes.len() < before
}
fn search(&self, query: Option<&str>, tag: Option<&str>, limit: usize) -> Vec<&Note> {
let mut results: Vec<&Note> = self
.notes
.iter()
.filter(|n| {
let matches_query = query.map_or(true, |q| {
let q_lower = q.to_lowercase();
n.text.to_lowercase().contains(&q_lower)
|| n.tags.iter().any(|t| t.to_lowercase().contains(&q_lower))
});
let matches_tag = tag.map_or(true, |t| n.tags.iter().any(|nt| nt == t));
matches_query && matches_tag
})
.collect();
results.reverse();
results.truncate(limit);
results
}
}
fn chrono_now_rfc3339() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
let days = secs / 86400;
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let seconds = time_secs % 60;
let (year, month, day) = days_to_date(days);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
let mut year: u64 = 1970;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let months_days: [u64; 12] = if is_leap(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month: u64 = 1;
for &md in &months_days {
if days < md {
break;
}
days -= md;
month += 1;
}
let day = days + 1; (year, month, day)
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
pub fn memory_path(workspace: &std::path::Path) -> PathBuf {
if std::env::var("RECURSIVE_MEMORY_GLOBAL").as_deref() == Ok("1") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(".recursive").join("memory.json");
}
}
workspace.join(".recursive").join("memory.json")
}
pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
let path = memory_path(workspace);
MemoryStore::load(&path)
}
pub fn memory_summary(workspace: &std::path::Path, limit: usize) -> String {
let store = match load_memory(workspace) {
Ok(s) => s,
Err(_) => return String::new(),
};
if store.notes.is_empty() {
return String::new();
}
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"# Memory (top {} most recent notes; use `recall` for more)",
limit
));
let mut notes: Vec<&Note> = store.notes.iter().collect();
notes.reverse();
for note in notes.iter().take(limit) {
let tags_str = if note.tags.is_empty() {
String::new()
} else {
format!(" [{}]", note.tags.join(","))
};
let text_preview = if note.text.len() > 120 {
format!("{}...", crate::truncate_str(¬e.text, 117))
} else {
note.text.clone()
};
lines.push(format!("- {}{} {}", note.id, tags_str, text_preview));
}
lines.join("\n")
}
pub struct Remember {
workspace: PathBuf,
lock: Mutex<()>,
vector_store: Arc<dyn VectorStore>,
embedding_provider: Arc<dyn EmbeddingProvider>,
}
impl Remember {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
lock: Mutex::new(()),
vector_store: Arc::new(NoopVectorStore::new()),
embedding_provider: Arc::new(NoopEmbedding),
}
}
pub fn with_vector_store(
mut self,
store: Arc<dyn VectorStore>,
embedding: Arc<dyn EmbeddingProvider>,
) -> Self {
self.vector_store = store;
self.embedding_provider = embedding;
self
}
}
#[async_trait]
impl Tool for Remember {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "remember".into(),
description: "Save a note to persistent memory. The note will be available in future sessions via `recall` or injected into the system prompt.".into(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The note text to remember"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags for categorising the note"
}
},
"required": ["text"]
}),
}
}
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::Mutating
}
async fn execute(&self, arguments: Value) -> Result<String> {
let text = arguments["text"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "remember".into(),
message: "missing required parameter: text".to_string(),
})?
.to_string();
let tags: Vec<String> = arguments["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let _guard = self.lock.lock().unwrap();
let path = memory_path(&self.workspace);
let mut file_store = MemoryStore::load(&path)?;
let id = file_store.add(text.clone(), tags.clone());
file_store.save(&path)?;
drop(_guard);
let ts = chrono::Utc::now().to_rfc3339();
let entry = MemoryEntry {
id: id.clone(),
text: text.clone(),
tags,
ts,
};
let vector = self.embedding_provider.embed(&text).await;
if let Err(e) = self.vector_store.upsert(&entry, vector).await {
tracing::warn!(error = %e, note_id = %id, "remember: vector upsert failed");
}
Ok(format!("saved note {id}"))
}
}
pub struct Recall {
workspace: PathBuf,
vector_store: Arc<dyn VectorStore>,
embedding_provider: Arc<dyn EmbeddingProvider>,
}
impl Recall {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
vector_store: Arc::new(NoopVectorStore::new()),
embedding_provider: Arc::new(NoopEmbedding),
}
}
pub fn with_vector_store(
mut self,
store: Arc<dyn VectorStore>,
embedding: Arc<dyn EmbeddingProvider>,
) -> Self {
self.vector_store = store;
self.embedding_provider = embedding;
self
}
}
#[async_trait]
impl Tool for Recall {
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::ReadOnly
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "recall".into(),
description: "Search persistent memory for notes matching a query or tag. Returns up to `limit` results, most recent first.".into(),
parameters: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Case-insensitive substring to search for in note text or tags"
},
"tag": {
"type": "string",
"description": "Exact tag to filter by"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default 10)",
"default": 10
}
}
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let query = arguments["query"].as_str().unwrap_or("");
let tag = arguments["tag"].as_str();
let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
let query_vec = self.embedding_provider.embed(query).await;
let use_vector = !query_vec.is_empty();
if use_vector || tag.is_none() {
match self.vector_store.search(query_vec, query, limit).await {
Ok(entries) if !entries.is_empty() => {
let filtered: Vec<_> = entries
.iter()
.filter(|e| tag.map_or(true, |t| e.tags.iter().any(|et| et == t)))
.take(limit)
.collect();
if !filtered.is_empty() {
let lines: Vec<String> = filtered
.iter()
.map(|e| {
let tags_str = if e.tags.is_empty() {
String::new()
} else {
format!(" [{}]", e.tags.join(","))
};
format!("{}{} {}", e.id, tags_str, e.text)
})
.collect();
return Ok(lines.join("\n"));
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "recall: vector search failed, falling back to file");
}
}
}
let path = memory_path(&self.workspace);
let file_store = MemoryStore::load(&path)?;
let query_opt = if query.is_empty() { None } else { Some(query) };
let results = file_store.search(query_opt, tag, limit);
if results.is_empty() {
return Ok("no matching notes found".to_string());
}
let lines: Vec<String> = results
.iter()
.map(|n| {
let tags_str = if n.tags.is_empty() {
String::new()
} else {
format!(" [{}]", n.tags.join(","))
};
format!("{}{} {}", n.id, tags_str, n.text)
})
.collect();
Ok(lines.join("\n"))
}
}
pub struct Forget {
workspace: PathBuf,
lock: Mutex<()>,
}
impl Forget {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
lock: Mutex::new(()),
}
}
}
#[async_trait]
impl Tool for Forget {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "forget".into(),
description: "Remove a note from persistent memory by its ID.".into(),
parameters: json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the note to remove (e.g. N3)"
}
},
"required": ["id"]
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let id = arguments["id"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "forget".into(),
message: "missing required parameter: id".to_string(),
})?
.to_string();
let _guard = self.lock.lock().unwrap();
let path = memory_path(&self.workspace);
let mut store = MemoryStore::load(&path)?;
if store.remove(&id) {
store.save(&path)?;
Ok(format!("removed {id}"))
} else {
Ok(format!("no such id: {id}"))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScratchpadEntry {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Scratchpad {
pub entries: Vec<ScratchpadEntry>,
}
impl Scratchpad {
fn load(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
name: "scratchpad".into(),
message: format!("failed to read scratchpad file: {e}"),
})?;
serde_json::from_str(&raw).map_err(|e| Error::Tool {
name: "scratchpad".into(),
message: format!("malformed scratchpad file: {e}"),
})
}
fn save(&self, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
name: "scratchpad".into(),
message: format!("failed to create scratchpad directory: {e}"),
})?;
}
let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
name: "scratchpad".into(),
message: format!("failed to serialize scratchpad: {e}"),
})?;
std::fs::write(path, raw).map_err(|e| Error::Tool {
name: "scratchpad".into(),
message: format!("failed to write scratchpad file: {e}"),
})?;
Ok(())
}
fn set(&mut self, key: String, value: String) {
if let Some(existing) = self.entries.iter_mut().find(|e| e.key == key) {
existing.value = value;
} else {
self.entries.push(ScratchpadEntry { key, value });
}
}
fn get(&self, key: &str) -> Option<&str> {
self.entries
.iter()
.find(|e| e.key == key)
.map(|e| e.value.as_str())
}
fn delete(&mut self, key: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.key != key);
self.entries.len() < before
}
fn keys(&self) -> Vec<&str> {
self.entries.iter().map(|e| e.key.as_str()).collect()
}
}
pub fn scratchpad_path(workspace: &std::path::Path) -> PathBuf {
crate::paths::user_scratchpad_path(workspace)
.unwrap_or_else(|_| workspace.join(".recursive").join("scratchpad.json"))
}
pub fn load_scratchpad(workspace: &std::path::Path) -> Result<Scratchpad> {
let path = scratchpad_path(workspace);
Scratchpad::load(&path)
}
pub fn scratchpad_summary(workspace: &std::path::Path) -> String {
let pad = match load_scratchpad(workspace) {
Ok(p) => p,
Err(_) => return String::new(),
};
if pad.entries.is_empty() {
return String::new();
}
let mut lines: Vec<String> = Vec::new();
lines.push("# Working Memory (scratchpad)".to_string());
for entry in &pad.entries {
let value_preview = if entry.value.len() > 200 {
format!("{}...", crate::truncate_str(&entry.value, 197))
} else {
entry.value.clone()
};
lines.push(format!("- {}: {}", entry.key, value_preview));
}
lines.join("\n")
}
pub fn migrate_scratchpad(_workspace: &std::path::Path) -> Result<()> {
Ok(())
}
pub struct WorkingMemoryTool {
workspace: PathBuf,
lock: Mutex<()>,
}
impl WorkingMemoryTool {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
lock: Mutex::new(()),
}
}
}
#[async_trait]
impl Tool for WorkingMemoryTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "scratchpad_set".into(),
description: "Store a value in working memory (scratchpad) under a key. Use this to remember intermediate results, decisions, or context across steps. The scratchpad contents are injected into the system prompt.".into(),
parameters: json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to store under"
},
"value": {
"type": "string",
"description": "The value to store"
}
},
"required": ["key", "value"]
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let key = arguments["key"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "scratchpad_set".into(),
message: "missing required parameter: key".to_string(),
})?
.to_string();
let value = arguments["value"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "scratchpad_set".into(),
message: "missing required parameter: value".to_string(),
})?
.to_string();
let _guard = self.lock.lock().unwrap();
let path = scratchpad_path(&self.workspace);
let mut pad = Scratchpad::load(&path)?;
pad.set(key.clone(), value);
pad.save(&path)?;
Ok(format!("scratchpad key '{key}' set"))
}
}
pub struct ScratchpadGet {
workspace: PathBuf,
}
impl ScratchpadGet {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
}
}
}
#[async_trait]
impl Tool for ScratchpadGet {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "scratchpad_get".into(),
description: "Retrieve a value from working memory (scratchpad) by key.".into(),
parameters: json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to retrieve"
}
},
"required": ["key"]
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let key = arguments["key"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "scratchpad_get".into(),
message: "missing required parameter: key".to_string(),
})?
.to_string();
let path = scratchpad_path(&self.workspace);
let pad = Scratchpad::load(&path)?;
match pad.get(&key) {
Some(value) => Ok(value.to_string()),
None => Ok(format!("no such key: {key}")),
}
}
}
pub struct ScratchpadDelete {
workspace: PathBuf,
lock: Mutex<()>,
}
impl ScratchpadDelete {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
lock: Mutex::new(()),
}
}
}
#[async_trait]
impl Tool for ScratchpadDelete {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "scratchpad_delete".into(),
description: "Delete a key from working memory (scratchpad).".into(),
parameters: json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to delete"
}
},
"required": ["key"]
}),
}
}
async fn execute(&self, arguments: Value) -> Result<String> {
let key = arguments["key"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "scratchpad_delete".into(),
message: "missing required parameter: key".to_string(),
})?
.to_string();
let _guard = self.lock.lock().unwrap();
let path = scratchpad_path(&self.workspace);
let mut pad = Scratchpad::load(&path)?;
if pad.delete(&key) {
pad.save(&path)?;
Ok(format!("scratchpad key '{key}' deleted"))
} else {
Ok(format!("no such key: {key}"))
}
}
}
pub struct ScratchpadList {
workspace: PathBuf,
}
impl ScratchpadList {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
}
}
}
#[async_trait]
impl Tool for ScratchpadList {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "scratchpad_list".into(),
description: "List all keys currently stored in working memory (scratchpad).".into(),
parameters: json!({
"type": "object",
"properties": {}
}),
}
}
async fn execute(&self, _arguments: Value) -> Result<String> {
let path = scratchpad_path(&self.workspace);
let pad = Scratchpad::load(&path)?;
let keys = pad.keys();
if keys.is_empty() {
return Ok("scratchpad is empty".to_string());
}
Ok(keys.join("\n"))
}
}