use crate::provider::ToolSpec;
use crate::tools::Tool;
use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sapphire_workspace::{FtsQuery, VectorQuery, WorkspaceState};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::Path;
use std::sync::{Arc, Mutex};
fn lock(state: &Mutex<WorkspaceState>) -> std::sync::MutexGuard<'_, WorkspaceState> {
state.lock().expect("WorkspaceState mutex poisoned")
}
fn validate_segment(kind: &str, value: &str) -> Result<()> {
if value.is_empty() {
anyhow::bail!("{kind} must not be empty");
}
if value == "." || value == ".." {
anyhow::bail!("{kind} must not be '.' or '..'");
}
if value.starts_with('.') {
anyhow::bail!("{kind} must not start with '.'");
}
if value.contains('/') || value.contains('\\') || value.contains('\0') {
anyhow::bail!("{kind} must not contain path separators: {value:?}");
}
Ok(())
}
const MEMORY_CATEGORY_GUIDE: &str = "Category is mandatory and free-form, but prefer these conventions: \
'daily' (date-stamped daily logs, slug = YYYY-MM-DD), \
'dictionary' (short term/definition lookups for names, acronyms, jargon), \
'knowledge' (longer-form facts, procedures, decisions, learnings — default when unsure). \
Other categories (e.g. 'recipe', 'project') may be introduced freely.";
fn memory_entry_path(
state: &Mutex<WorkspaceState>,
category: &str,
slug: &str,
) -> Result<(String, std::path::PathBuf)> {
validate_segment("category", category)?;
validate_segment("slug", slug)?;
let rel = format!("memory/{category}/{slug}.md");
let abs = lock(state).workspace.root.join(&rel);
Ok((rel, abs))
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct MemoryMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
updated_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_read_at: Option<DateTime<Utc>>,
#[serde(default)]
read_count: u64,
}
fn parse_memory_file(raw: &str) -> (MemoryMeta, String) {
match crate::frontmatter::split(raw) {
Some((fm, body)) => {
let meta: MemoryMeta = serde_yaml::from_str(fm).unwrap_or_default();
let body = body
.trim_start_matches(|c: char| c == '\n' || c == '\r')
.to_string();
(meta, body)
}
None => (MemoryMeta::default(), raw.to_string()),
}
}
fn serialize_memory_file(meta: &MemoryMeta, body: &str) -> Result<String> {
let fm = serde_yaml::to_string(meta).context("failed to serialize memory frontmatter")?;
let body_trimmed = body.trim_start_matches(|c: char| c == '\n' || c == '\r');
Ok(format!("---\n{fm}---\n\n{body_trimmed}"))
}
fn memory_entry_schema(include_content: bool) -> serde_json::Value {
let mut props = serde_json::Map::new();
props.insert(
"category".into(),
json!({
"type": "string",
"description": "Category directory under memory/, e.g. \"knowledge\", \"dictionary\", \"daily\"."
}),
);
props.insert(
"slug".into(),
json!({
"type": "string",
"description": "File stem (without .md), e.g. \"sapphire-agent-memory-design\" or \"2026-04-10\"."
}),
);
let mut required = vec!["category", "slug"];
if include_content {
props.insert(
"content".into(),
json!({
"type": "string",
"description": "Full file content."
}),
);
required.push("content");
}
json!({
"type": "object",
"properties": props,
"required": required,
})
}
pub struct MemoryAddTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl MemoryAddTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
let description = format!(
"Create a new memory entry at memory/<category>/<slug>.md. \
Fails if the file already exists (use memory_update to overwrite). \
Each file holds one self-contained entry retrievable via workspace_search. {MEMORY_CATEGORY_GUIDE}"
);
Self {
state,
spec: ToolSpec {
name: "memory_add".into(),
description: description.into(),
input_schema: memory_entry_schema(true),
},
}
}
}
#[async_trait]
impl Tool for MemoryAddTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let category = input["category"].as_str().context("missing 'category'")?;
let slug = input["slug"].as_str().context("missing 'slug'")?;
let content = input["content"].as_str().context("missing 'content'")?;
let (rel, abs) = memory_entry_path(&self.state, category, slug)?;
if abs.exists() {
anyhow::bail!("{rel} already exists; use memory_update to overwrite");
}
let now = Utc::now();
let meta = MemoryMeta {
created_at: Some(now),
updated_at: Some(now),
last_read_at: None,
read_count: 0,
};
let serialized = serialize_memory_file(&meta, content)?;
lock(&self.state)
.write_file(Path::new(&rel), &serialized)
.with_context(|| format!("Failed to write {rel}"))?;
Ok(format!("Created {rel}"))
}
}
pub struct MemoryUpdateTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl MemoryUpdateTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "memory_update".into(),
description: "Overwrite an existing memory entry at \
memory/<category>/<slug>.md. Fails if the file does not exist \
(use memory_add to create it)."
.into(),
input_schema: memory_entry_schema(true),
},
}
}
}
#[async_trait]
impl Tool for MemoryUpdateTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let category = input["category"].as_str().context("missing 'category'")?;
let slug = input["slug"].as_str().context("missing 'slug'")?;
let content = input["content"].as_str().context("missing 'content'")?;
let (rel, abs) = memory_entry_path(&self.state, category, slug)?;
if !abs.exists() {
anyhow::bail!("{rel} does not exist; use memory_add to create it");
}
let raw = std::fs::read_to_string(&abs).with_context(|| format!("Failed to read {rel}"))?;
let (mut meta, _old_body) = parse_memory_file(&raw);
let now = Utc::now();
meta.updated_at = Some(now);
if meta.created_at.is_none() {
meta.created_at = Some(now);
}
let serialized = serialize_memory_file(&meta, content)?;
lock(&self.state)
.write_file(Path::new(&rel), &serialized)
.with_context(|| format!("Failed to write {rel}"))?;
Ok(format!("Updated {rel}"))
}
}
pub struct MemoryAppendTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl MemoryAppendTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "memory_append".into(),
description: "Append content to the end of a memory entry at \
memory/<category>/<slug>.md, creating the file if it does \
not exist. Cheaper than memory_read + memory_update when you \
just want to tack a new observation onto an existing note. \
A blank line is inserted between the existing body and the \
new content; add your own Markdown heading if you want a \
section break. Frontmatter counters (updated_at) are \
maintained automatically."
.into(),
input_schema: memory_entry_schema(true),
},
}
}
}
#[async_trait]
impl Tool for MemoryAppendTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let category = input["category"].as_str().context("missing 'category'")?;
let slug = input["slug"].as_str().context("missing 'slug'")?;
let content = input["content"].as_str().context("missing 'content'")?;
let (rel, abs) = memory_entry_path(&self.state, category, slug)?;
let now = Utc::now();
let (meta, new_body, created) = if abs.exists() {
let raw =
std::fs::read_to_string(&abs).with_context(|| format!("Failed to read {rel}"))?;
let (mut meta, old_body) = parse_memory_file(&raw);
meta.updated_at = Some(now);
if meta.created_at.is_none() {
meta.created_at = Some(now);
}
let trimmed = old_body.trim_end_matches(|c: char| c == '\n' || c == '\r');
let new_body = if trimmed.is_empty() {
content.to_string()
} else {
format!("{trimmed}\n\n{content}")
};
(meta, new_body, false)
} else {
let meta = MemoryMeta {
created_at: Some(now),
updated_at: Some(now),
last_read_at: None,
read_count: 0,
};
(meta, content.to_string(), true)
};
let serialized = serialize_memory_file(&meta, &new_body)?;
lock(&self.state)
.write_file(Path::new(&rel), &serialized)
.with_context(|| format!("Failed to write {rel}"))?;
Ok(if created {
format!("Created {rel}")
} else {
format!("Appended to {rel}")
})
}
}
pub struct MemoryReadTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl MemoryReadTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "memory_read".into(),
description: "Read a memory entry at memory/<category>/<slug>.md \
and return its body. Side effect: updates the file's \
frontmatter (last_read_at = now, read_count += 1) so that \
recency and frequency can inform future weighting. \
Use file_read instead for a non-tracking read."
.into(),
input_schema: memory_entry_schema(false),
},
}
}
}
#[async_trait]
impl Tool for MemoryReadTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let category = input["category"].as_str().context("missing 'category'")?;
let slug = input["slug"].as_str().context("missing 'slug'")?;
let (rel, abs) = memory_entry_path(&self.state, category, slug)?;
if !abs.exists() {
anyhow::bail!("{rel} does not exist");
}
let raw = std::fs::read_to_string(&abs).with_context(|| format!("Failed to read {rel}"))?;
let (mut meta, body) = parse_memory_file(&raw);
meta.last_read_at = Some(Utc::now());
meta.read_count = meta.read_count.saturating_add(1);
let serialized = serialize_memory_file(&meta, &body)?;
if let Err(e) = lock(&self.state).write_file(Path::new(&rel), &serialized) {
tracing::warn!("memory_read: failed to persist counters for {rel}: {e:#}");
}
Ok(body)
}
}
pub struct MemoryRemoveTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl MemoryRemoveTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "memory_remove".into(),
description: "Delete a memory entry at memory/<category>/<slug>.md.".into(),
input_schema: memory_entry_schema(false),
},
}
}
}
#[async_trait]
impl Tool for MemoryRemoveTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let category = input["category"].as_str().context("missing 'category'")?;
let slug = input["slug"].as_str().context("missing 'slug'")?;
let (rel, abs) = memory_entry_path(&self.state, category, slug)?;
if !abs.exists() {
anyhow::bail!("{rel} does not exist");
}
std::fs::remove_file(&abs).with_context(|| format!("Failed to remove {rel}"))?;
Ok(format!("Removed {rel}"))
}
}
pub struct WorkspaceSearchTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl WorkspaceSearchTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "workspace_search".into(),
description: "Search across all indexed files in the workspace. \
Two modes are available: \
'fts' (full-text / BM25, always available) and \
'semantic' (vector similarity, requires an embedder to be configured — falls back to fts if unavailable). \
Returns matching file titles and paths.".into(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query."
},
"mode": {
"type": "string",
"description": "Search mode: 'fts' (full-text, default) or 'semantic' (vector similarity).",
"enum": ["fts", "semantic"],
"default": "fts"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default 10).",
"default": 10
}
},
"required": ["query"]
}),
},
}
}
}
#[async_trait]
impl Tool for WorkspaceSearchTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, input: &serde_json::Value) -> Result<String> {
let query = input["query"].as_str().context("missing 'query'")?;
let limit = input["limit"].as_u64().unwrap_or(10) as usize;
let mode = input["mode"].as_str().unwrap_or("fts");
let state = lock(&self.state);
if mode == "semantic" {
if let Some(embedder) = state.embedder() {
let vq = VectorQuery::new(query, embedder).limit(limit);
let results = state
.retrieve_db()
.search_similar(&vq)
.context("Vector similarity search failed")?;
if results.is_empty() {
return Ok("No results found.".to_string());
}
let lines: Vec<String> = results
.iter()
.map(|r| format!("- {} [score: {:.4}]", r.path, r.score))
.collect();
return Ok(format!("[semantic]\n{}", lines.join("\n")));
}
}
let fq = FtsQuery::new(query).limit(limit);
let results = state
.retrieve_db()
.search_fts(&fq)
.context("FTS search failed")?;
if results.is_empty() {
return Ok("No results found.".to_string());
}
let header = if mode == "semantic" {
"[fts — semantic fallback: no embedder configured]\n"
} else {
"[fts]\n"
};
let lines: Vec<String> = results.iter().map(|r| format!("- {}", r.path)).collect();
Ok(format!("{}{}", header, lines.join("\n")))
}
}
pub struct WorkspaceSyncTool {
state: Arc<Mutex<WorkspaceState>>,
spec: ToolSpec,
}
impl WorkspaceSyncTool {
pub fn new(state: Arc<Mutex<WorkspaceState>>) -> Self {
Self {
state,
spec: ToolSpec {
name: "workspace_sync".into(),
description: "Sync the workspace: index all files and, if a git \
remote is configured, commit and push changes."
.into(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
}
}
}
#[async_trait]
impl Tool for WorkspaceSyncTool {
fn spec(&self) -> &ToolSpec {
&self.spec
}
async fn execute(&self, _input: &serde_json::Value) -> Result<String> {
let state = lock(&self.state);
let (upserted, removed) = state.periodic_sync().context("Failed to sync workspace")?;
Ok(format!(
"Synced: {upserted} files indexed, {removed} removed."
))
}
}