feat(memory): dual-pool memory namespace, FTS search, and prompt injection
Add a virtual memory namespace backed by SQLite, surfaced through the fs-tools, with private (per-user) and shared (system) stores. Storage - `memory_docs` owner table + external-content FTS5 index with sync triggers. - `db/memory_docs.rs` accessor: get / upsert / list / search (bm25+snippet) / delete. Routing (tools/fs) - `classify_memory` splits paths on the raw first component; `..` clamps inside the store, never escaping to disk. - read/write/list/edit/insert/replace/search_file route `user-memory/` to the owner pool and `shared-memory/` to the system pool (a singleton captured in `register_all`); every other path stays on disk. Each tool extracts a pure transform shared between its disk and memory paths. - New `memory_search` tool over the FTS index (scope private/shared/all), with a sanitised FTS5 query. grep_files stays disk-only. Approval - `user-memory/*` allow (read+write); `shared-memory/*` reads allow, writes require approval so the agent can't silently push one person's data into shared memory. `memory_search` allowed via a path-less rule. - migrate away the old `memory/*` and blanket `shared-memory/*` rows. Prompt injection - `MessageBuilder::load_inject_memory` reads `user-memory/` (owner pool) and `shared-memory/` (system pool) inject entries from SQLite; disk paths unchanged. The system pool is threaded ChatSessionManager -> handler -> MessageBuilder. - main and project-coordinator inject `user-memory/index.md` + `shared-memory/index.md`; common/memory.md rewritten for the two stores.
This commit is contained in:
@@ -1,8 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{read_to_string, write_string};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT,
|
||||
};
|
||||
use super::{classify_memory, read_to_string, write_string, MemScope};
|
||||
|
||||
/// Applies the substring edit to `content`, returning the new content. Shared by
|
||||
/// the on-disk [`EditFile::execute`] and the `memory/` routing in `run_with`;
|
||||
/// `display` is the path used in error messages.
|
||||
fn apply_edit(content: &str, args: &Value, display: &str) -> Result<String> {
|
||||
let old = args["old"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
|
||||
|
||||
let updated = if replace_all {
|
||||
if !content.contains(old) {
|
||||
anyhow::bail!(
|
||||
"Text not found in {display}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
);
|
||||
}
|
||||
content.replace(old, new)
|
||||
} else {
|
||||
let exact_count = content.matches(old).count();
|
||||
if exact_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Text found {exact_count} times in {display}. \
|
||||
Include more surrounding context in `old` to make it unique, or set replace_all=true."
|
||||
);
|
||||
}
|
||||
if exact_count == 1 {
|
||||
content.replacen(old, new, 1)
|
||||
} else {
|
||||
let normalized_old = normalize_ws(old);
|
||||
let (start, end) = find_normalized(content, &normalized_old)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Text not found in {display}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
))?;
|
||||
format!("{}{}{}", &content[..start], new, &content[end..])
|
||||
}
|
||||
};
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn normalize_ws(s: &str) -> String {
|
||||
s.lines()
|
||||
@@ -56,10 +103,13 @@ fn find_normalized(haystack: &str, normalized_needle: &str) -> Option<(usize, us
|
||||
None
|
||||
}
|
||||
|
||||
pub struct EditFile;
|
||||
pub struct EditFile {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl EditFile {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
impl Tool for EditFile {
|
||||
@@ -102,46 +152,32 @@ impl Tool for EditFile {
|
||||
truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
|
||||
anyhow::bail!("No note at {path}");
|
||||
};
|
||||
let updated = apply_edit(&doc.content, &args, &path)?;
|
||||
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
|
||||
Ok(ToolResult::Text(format!("Edited {path}.")))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let old = args["old"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
|
||||
let content = read_to_string(user_path)?;
|
||||
|
||||
let updated = if replace_all {
|
||||
if !content.contains(old) {
|
||||
anyhow::bail!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
);
|
||||
}
|
||||
content.replace(old, new)
|
||||
} else {
|
||||
let exact_count = content.matches(old).count();
|
||||
if exact_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Text found {exact_count} times in {user_path}. \
|
||||
Include more surrounding context in `old` to make it unique, or set replace_all=true."
|
||||
);
|
||||
}
|
||||
if exact_count == 1 {
|
||||
content.replacen(old, new, 1)
|
||||
} else {
|
||||
let normalized_old = normalize_ws(old);
|
||||
let (start, end) = find_normalized(&content, &normalized_old)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
))?;
|
||||
format!("{}{}{}", &content[..start], new, &content[end..])
|
||||
}
|
||||
};
|
||||
|
||||
let updated = apply_edit(&content, &args, user_path)?;
|
||||
write_string(user_path, &updated)?;
|
||||
Ok(format!("Edited {user_path}."))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
use super::{classify_memory, read_to_string, write_string, MemScope};
|
||||
|
||||
pub struct InsertAtLine;
|
||||
pub struct InsertAtLine {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl InsertAtLine {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Inserts `content` before/after `line` in `text`, returning the new text and a
|
||||
/// result message. Shared by the on-disk `execute` and the `memory/` routing;
|
||||
/// `display` is the path used in the message.
|
||||
fn apply_insert(text: &str, args: &Value, display: &str) -> Result<(String, String)> {
|
||||
let line_num = args["line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
|
||||
let new_text = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
let placement = args["placement"].as_str().unwrap_or("after");
|
||||
|
||||
anyhow::ensure!(line_num >= 1, "line must be >= 1");
|
||||
|
||||
let mut lines: Vec<&str> = text.split('\n').collect();
|
||||
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
|
||||
let insert_idx = if placement == "before" { idx } else { idx + 1 };
|
||||
let new_lines: Vec<&str> = new_text.split('\n').collect();
|
||||
for (i, l) in new_lines.iter().enumerate() {
|
||||
lines.insert(insert_idx + i, l);
|
||||
}
|
||||
let updated = lines.join("\n");
|
||||
let msg = format!(
|
||||
"Inserted {} line(s) {} line {} in {display}.",
|
||||
new_lines.len(), placement, line_num
|
||||
);
|
||||
Ok((updated, msg))
|
||||
}
|
||||
|
||||
impl Tool for InsertAtLine {
|
||||
@@ -53,32 +89,33 @@ impl Tool for InsertAtLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
|
||||
anyhow::bail!("No note at {path}");
|
||||
};
|
||||
let (updated, msg) = apply_insert(&doc.content, &args, &path)?;
|
||||
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
|
||||
Ok(ToolResult::Text(msg))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let line_num = args["line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
|
||||
let new_text = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
let placement = args["placement"].as_str().unwrap_or("after");
|
||||
|
||||
anyhow::ensure!(line_num >= 1, "line must be >= 1");
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = text.split('\n').collect();
|
||||
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
|
||||
let insert_idx = if placement == "before" { idx } else { idx + 1 };
|
||||
let new_lines: Vec<&str> = new_text.split('\n').collect();
|
||||
for (i, l) in new_lines.iter().enumerate() {
|
||||
lines.insert(insert_idx + i, l);
|
||||
}
|
||||
let updated = lines.join("\n");
|
||||
|
||||
let (updated, msg) = apply_insert(&text, &args, user_path)?;
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Inserted {} line(s) {} line {} in {user_path}.",
|
||||
new_lines.len(), placement, line_num
|
||||
))
|
||||
Ok(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::resolve;
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT,
|
||||
};
|
||||
use super::{classify_memory, resolve, MemScope};
|
||||
|
||||
/// Directories to skip unconditionally when walking.
|
||||
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
|
||||
/// working directory) never reveals the contents of the secrets store.
|
||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
|
||||
|
||||
pub struct ListFiles;
|
||||
pub struct ListFiles {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl ListFiles {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
impl Tool for ListFiles {
|
||||
@@ -27,7 +35,8 @@ impl Tool for ListFiles {
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Skips .git, target, node_modules, .cache. \
|
||||
Returns a JSON array of paths relative to the requested directory. \
|
||||
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration."
|
||||
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration. \
|
||||
Listing under user-memory/ (private) or shared-memory/ (shared) lists your memory notes instead of disk."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -56,6 +65,32 @@ impl Tool for ListFiles {
|
||||
truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute). Memory is a
|
||||
/// flat key space, so `depth`/`dirs_only` don't apply — the whole subtree
|
||||
/// under the prefix is returned, keyed relative to the requested directory.
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = args["path"].as_str().unwrap_or("").to_string();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
// Treat `rel` as a directory prefix: match `rel/…` (or everything at
|
||||
// the root), then strip it so results are relative to what was asked.
|
||||
let prefix = if rel.is_empty() || rel.ends_with('/') { rel } else { format!("{rel}/") };
|
||||
let entries = crate::db::memory_docs::list(&pool, &prefix).await?;
|
||||
let mut paths: Vec<String> = entries.into_iter()
|
||||
.map(|e| e.path.strip_prefix(&prefix).unwrap_or(&e.path).to_string())
|
||||
.collect();
|
||||
paths.sort();
|
||||
Ok(ToolResult::Text(serde_json::to_string(&paths)?))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().unwrap_or(".");
|
||||
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! `memory_search` — full-text search over the virtual memory namespace (§5).
|
||||
//!
|
||||
//! Unlike the fs-tools, this does **not** route a path: it searches note *content*
|
||||
//! through the `memory_docs` FTS5 index (`memory_docs::search`, bm25-ranked with a
|
||||
//! highlighted snippet). `user-memory` is the caller's own pool (`ToolContext::pool`);
|
||||
//! `shared-memory` is the system pool captured at registration. Kept a distinct tool
|
||||
//! rather than folding FTS into `grep_files`: grep is regex-per-line over a tree,
|
||||
//! this is ranked keyword recall — different semantics, so different names.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::memory_docs::{self, MemoryHit};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT,
|
||||
};
|
||||
|
||||
pub struct MemorySearch {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl MemorySearch {
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Turns free text into a robust FTS5 MATCH query: each whitespace token becomes a
|
||||
/// quoted term (AND-combined), so arbitrary input — colons, dashes, punctuation —
|
||||
/// can't trip an FTS5 syntax error. Returns `None` when there are no tokens.
|
||||
fn fts_query(input: &str) -> Option<String> {
|
||||
let terms: Vec<String> = input
|
||||
.split_whitespace()
|
||||
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
|
||||
.collect();
|
||||
(!terms.is_empty()).then(|| terms.join(" "))
|
||||
}
|
||||
|
||||
fn render_hits(store: &str, hits: &[MemoryHit], out: &mut String) {
|
||||
for h in hits {
|
||||
out.push_str(&format!("[{store}] {} — {}\n", h.path, h.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for MemorySearch {
|
||||
fn name(&self) -> &str { "memory_search" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Full-text search across your memory notes by keyword, ranked by relevance. \
|
||||
Searches user-memory/ (private) and shared-memory/ (shared); set scope to narrow it. \
|
||||
Returns matching note paths with a short highlighted snippet — open one with read_file. \
|
||||
Use this to recall where you wrote something instead of listing and reading notes one by one."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Keywords to search for. Plain words; all must appear." },
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["all", "private", "shared"],
|
||||
"description": "Which store to search: 'private' (user-memory), 'shared' (shared-memory), or 'all' (default)."
|
||||
},
|
||||
"limit": { "type": "integer", "description": "Max results per store (default 10, max 50).", "default": 10 }
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let q = args["query"].as_str().unwrap_or("?");
|
||||
truncate_label(&format!("memory_search \"{q}\""), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let user_pool = Arc::clone(&ctx.pool);
|
||||
let shared_pool = Arc::clone(&self.shared_pool);
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let raw = args["query"].as_str().unwrap_or("");
|
||||
let Some(q) = fts_query(raw) else {
|
||||
anyhow::bail!("memory_search needs a non-empty query");
|
||||
};
|
||||
let scope = args["scope"].as_str().unwrap_or("all");
|
||||
let limit = args["limit"].as_u64().unwrap_or(10).clamp(1, 50) as i64;
|
||||
|
||||
let mut out = String::new();
|
||||
let mut total = 0usize;
|
||||
if scope == "all" || scope == "private" {
|
||||
let hits = memory_docs::search(&user_pool, &q, limit).await?;
|
||||
total += hits.len();
|
||||
render_hits("user-memory", &hits, &mut out);
|
||||
}
|
||||
if scope == "all" || scope == "shared" {
|
||||
let hits = memory_docs::search(&shared_pool, &q, limit).await?;
|
||||
total += hits.len();
|
||||
render_hits("shared-memory", &hits, &mut out);
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return Ok(ToolResult::Text(format!("No memory notes match {raw:?}.")));
|
||||
}
|
||||
Ok(ToolResult::Text(format!("{total} result(s):\n{out}")))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fts_query_quotes_tokens_and_survives_punctuation() {
|
||||
assert_eq!(fts_query("spesa settimana").unwrap(), "\"spesa\" \"settimana\"");
|
||||
// colons / dashes would be FTS5 operators unquoted; quoting makes them literal
|
||||
assert_eq!(fts_query("budget: 2026-07").unwrap(), "\"budget:\" \"2026-07\"");
|
||||
// an embedded quote is escaped by doubling
|
||||
assert_eq!(fts_query("say \"hi\"").unwrap(), "\"say\" \"\"\"hi\"\"\"");
|
||||
assert!(fts_query(" ").is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,18 @@ mod edit_file;
|
||||
mod grep_files;
|
||||
mod insert_at_line;
|
||||
mod list_files;
|
||||
mod memory_search;
|
||||
mod read_file;
|
||||
mod replace_lines;
|
||||
mod search_file;
|
||||
mod write_file;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
@@ -25,11 +28,65 @@ pub use edit_file::EditFile;
|
||||
pub use grep_files::GrepFiles;
|
||||
pub use insert_at_line::InsertAtLine;
|
||||
pub use list_files::ListFiles;
|
||||
pub use memory_search::MemorySearch;
|
||||
pub use read_file::ReadFile;
|
||||
pub use replace_lines::ReplaceLines;
|
||||
pub use search_file::SearchFile;
|
||||
pub use write_file::WriteFile;
|
||||
|
||||
// ── Virtual memory namespace (blueprint §5) ───────────────────────────────────
|
||||
//
|
||||
// Two sibling top-level roots, each backed by the `memory_docs` table in SQLite
|
||||
// rather than the disk. The fs-tools intercept these prefixes in `run_with` and
|
||||
// route reads/writes to the `memory_docs` accessor on the right pool, so the LLM
|
||||
// uses ordinary read/write/list against what looks like two folders.
|
||||
|
||||
/// The current user's **private** memory — routed to `ctx.pool` (`{userid}.db`,
|
||||
/// behind SQLCipher).
|
||||
pub const USER_MEMORY_ROOT: &str = "user-memory";
|
||||
|
||||
/// The instance-wide **shared** memory — routed to the system pool (`system.db`,
|
||||
/// cleartext, readable by every member).
|
||||
pub const SHARED_MEMORY_ROOT: &str = "shared-memory";
|
||||
|
||||
/// Which memory store a path resolves to.
|
||||
pub(crate) enum MemScope {
|
||||
/// `user-memory/…` → the caller's own pool (`ToolContext::pool`).
|
||||
User,
|
||||
/// `shared-memory/…` → the shared system pool.
|
||||
Shared,
|
||||
}
|
||||
|
||||
/// A path that falls inside the virtual memory namespace: the store it belongs to
|
||||
/// and the note key **relative to that store's root** (the root prefix stripped).
|
||||
pub(crate) struct MemRef {
|
||||
pub scope: MemScope,
|
||||
pub rel: String,
|
||||
}
|
||||
|
||||
/// Classifies a user-supplied path. Returns `Some` when it lands under one of the
|
||||
/// virtual memory roots — to be routed to SQLite — and `None` for an ordinary
|
||||
/// disk path.
|
||||
///
|
||||
/// The **first** component decides the store, taken raw *before* normalization, so
|
||||
/// a `..` in the tail can never drop the memory root and silently fall back to a
|
||||
/// disk path. The tail is then normalized (resolving `.`/`..`) and clamped at the
|
||||
/// store root, so a memory path stays within its store and an absolute path is
|
||||
/// always disk.
|
||||
pub(crate) fn classify_memory(user_path: &str) -> Option<MemRef> {
|
||||
let mut parts = user_path.trim_start_matches("./").splitn(2, ['/', '\\']);
|
||||
let scope = match parts.next()? {
|
||||
USER_MEMORY_ROOT => MemScope::User,
|
||||
SHARED_MEMORY_ROOT => MemScope::Shared,
|
||||
_ => return None,
|
||||
};
|
||||
// Normalize the tail within the store (empty = the root itself). `..` clamps
|
||||
// at the root rather than escaping upward.
|
||||
let tail = parts.next().unwrap_or("");
|
||||
let rel = lexical_normalize(Path::new(tail)).to_string_lossy().replace('\\', "/");
|
||||
Some(MemRef { scope, rel })
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied path:
|
||||
/// - starts with `/` → absolute path, used as-is
|
||||
/// - otherwise → relative to the process working directory (project root)
|
||||
@@ -127,13 +184,209 @@ pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))
|
||||
}
|
||||
|
||||
pub fn register_all(registry: &mut ToolRegistry) {
|
||||
registry.register(EditFile::new());
|
||||
registry.register(GrepFiles::new());
|
||||
registry.register(InsertAtLine::new());
|
||||
registry.register(ListFiles::new());
|
||||
registry.register(ReadFile::new());
|
||||
registry.register(ReplaceLines::new());
|
||||
registry.register(SearchFile::new());
|
||||
registry.register(WriteFile::new());
|
||||
/// Registers the filesystem tools. `shared_pool` is the system (`shared-memory`)
|
||||
/// pool captured once here — a global singleton — and handed to the memory-aware
|
||||
/// tools; each still resolves the per-user (`user-memory`) pool per call from the
|
||||
/// `ToolContext`.
|
||||
pub fn register_all(registry: &mut ToolRegistry, shared_pool: Arc<SqlitePool>) {
|
||||
registry.register(EditFile::new(Arc::clone(&shared_pool)));
|
||||
registry.register(GrepFiles::new()); // not memory-aware yet — see blueprint Prossimi passi
|
||||
registry.register(InsertAtLine::new(Arc::clone(&shared_pool)));
|
||||
registry.register(ListFiles::new(Arc::clone(&shared_pool)));
|
||||
registry.register(ReadFile::new(Arc::clone(&shared_pool)));
|
||||
registry.register(ReplaceLines::new(Arc::clone(&shared_pool)));
|
||||
registry.register(SearchFile::new(Arc::clone(&shared_pool)));
|
||||
registry.register(MemorySearch::new(Arc::clone(&shared_pool)));
|
||||
registry.register(WriteFile::new(shared_pool));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tools::{ExecutionOutcome, Tool, ToolContext};
|
||||
|
||||
#[test]
|
||||
fn classify_memory_splits_root_from_key() {
|
||||
let u = classify_memory("user-memory/notes/x.md").unwrap();
|
||||
assert!(matches!(u.scope, MemScope::User));
|
||||
assert_eq!(u.rel, "notes/x.md");
|
||||
|
||||
let s = classify_memory("./shared-memory/casa.md").unwrap();
|
||||
assert!(matches!(s.scope, MemScope::Shared));
|
||||
assert_eq!(s.rel, "casa.md");
|
||||
|
||||
// bare roots (with/without trailing slash) resolve to the empty key
|
||||
assert_eq!(classify_memory("user-memory").unwrap().rel, "");
|
||||
assert_eq!(classify_memory("shared-memory/").unwrap().rel, "");
|
||||
|
||||
// `..` clamps inside the store instead of falling back to a disk path
|
||||
assert_eq!(classify_memory("user-memory/../secret.md").unwrap().rel, "secret.md");
|
||||
|
||||
// ordinary, absolute, and look-alike paths are disk (None)
|
||||
assert!(classify_memory("src/main.rs").is_none());
|
||||
assert!(classify_memory("/etc/hosts").is_none());
|
||||
assert!(classify_memory("user-memoryish/x").is_none());
|
||||
}
|
||||
|
||||
/// A throwaway owner-schema pool (as `Arc`, ready for a `ToolContext`), plus its
|
||||
/// dir for cleanup. `tag` + a counter keep parallel tests off the same file.
|
||||
async fn store(tag: &str) -> (Arc<SqlitePool>, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("skald-fsmem-{}-{tag}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
|
||||
(Arc::new(pool), dir)
|
||||
}
|
||||
|
||||
/// Drives a tool through the context-aware path and returns its text result.
|
||||
async fn drive(tool: &dyn Tool, ctx: &ToolContext, args: Value) -> Result<String, String> {
|
||||
let exec = tool.run_with(ctx, args);
|
||||
match exec.wait().await {
|
||||
ExecutionOutcome::Completed(r) => Ok(r.to_wire()),
|
||||
ExecutionOutcome::Failed(e) => Err(e),
|
||||
ExecutionOutcome::Cancelled => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_tools_route_and_isolate_user_vs_shared() {
|
||||
let (user, udir) = store("user").await;
|
||||
let (shared, sdir) = store("shared").await;
|
||||
|
||||
// The shared pool is captured by the tools; the user pool arrives per call.
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let read = ReadFile::new(Arc::clone(&shared));
|
||||
let list = ListFiles::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
|
||||
// Private write lands in the user pool — and never in the shared one.
|
||||
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte\npane"}))
|
||||
.await.unwrap();
|
||||
assert!(out.starts_with("Created user-memory/spesa.md"), "{out}");
|
||||
assert!(crate::db::memory_docs::get(&user, "spesa.md").await.unwrap().is_some());
|
||||
assert!(crate::db::memory_docs::get(&shared, "spesa.md").await.unwrap().is_none(),
|
||||
"a user-memory write must not touch the shared store");
|
||||
|
||||
// Shared write lands in the shared pool — and never in the user one.
|
||||
drive(&write, &ctx, json!({"path":"shared-memory/casa.md","content":"wifi 1234"}))
|
||||
.await.unwrap();
|
||||
assert!(crate::db::memory_docs::get(&shared, "casa.md").await.unwrap().is_some());
|
||||
assert!(crate::db::memory_docs::get(&user, "casa.md").await.unwrap().is_none());
|
||||
|
||||
// Read back with 1-based line numbers; a missing note errors.
|
||||
let r = drive(&read, &ctx, json!({"path":"user-memory/spesa.md"})).await.unwrap();
|
||||
assert!(r.contains("| latte") && r.contains("| pane"), "{r}");
|
||||
assert!(drive(&read, &ctx, json!({"path":"user-memory/nope.md"})).await.is_err());
|
||||
|
||||
// A second write to the same key overwrites (and says so).
|
||||
let out = drive(&write, &ctx, json!({"path":"user-memory/spesa.md","content":"latte"}))
|
||||
.await.unwrap();
|
||||
assert!(out.starts_with("Overwrote user-memory/spesa.md"), "{out}");
|
||||
|
||||
// Listing returns keys relative to the requested directory.
|
||||
drive(&write, &ctx, json!({"path":"user-memory/notes/idee.md","content":"x"}))
|
||||
.await.unwrap();
|
||||
let l = drive(&list, &ctx, json!({"path":"user-memory"})).await.unwrap();
|
||||
assert_eq!(serde_json::from_str::<Vec<String>>(&l).unwrap(),
|
||||
vec!["notes/idee.md".to_string(), "spesa.md".to_string()]);
|
||||
let l = drive(&list, &ctx, json!({"path":"user-memory/notes"})).await.unwrap();
|
||||
assert_eq!(serde_json::from_str::<Vec<String>>(&l).unwrap(),
|
||||
vec!["idee.md".to_string()]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_edit_insert_replace_search_route_to_the_note() {
|
||||
let (user, udir) = store("edit-user").await;
|
||||
let (shared, sdir) = store("edit-shared").await;
|
||||
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let edit = EditFile::new(Arc::clone(&shared));
|
||||
let insert = InsertAtLine::new(Arc::clone(&shared));
|
||||
let replace = ReplaceLines::new(Arc::clone(&shared));
|
||||
let search = SearchFile::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
|
||||
async fn note(pool: &SqlitePool, path: &str) -> String {
|
||||
crate::db::memory_docs::get(pool, path).await.unwrap().unwrap().content
|
||||
}
|
||||
|
||||
drive(&write, &ctx, json!({"path":"user-memory/todo.md","content":"latte\npane\nuvoa"}))
|
||||
.await.unwrap();
|
||||
|
||||
// edit_file: fix the typo, in place
|
||||
let out = drive(&edit, &ctx, json!({"path":"user-memory/todo.md","old":"uvoa","new":"uova"}))
|
||||
.await.unwrap();
|
||||
assert_eq!(out, "Edited user-memory/todo.md.");
|
||||
assert_eq!(note(&user, "todo.md").await, "latte\npane\nuova");
|
||||
|
||||
// insert_at_line: add a line after line 1
|
||||
drive(&insert, &ctx, json!({"path":"user-memory/todo.md","line":1,"content":"burro","placement":"after"}))
|
||||
.await.unwrap();
|
||||
assert_eq!(note(&user, "todo.md").await, "latte\nburro\npane\nuova");
|
||||
|
||||
// replace_lines: collapse lines 2–3 into one
|
||||
drive(&replace, &ctx, json!({"path":"user-memory/todo.md","from_line":2,"to_line":3,"new":"olio"}))
|
||||
.await.unwrap();
|
||||
assert_eq!(note(&user, "todo.md").await, "latte\nolio\nuova");
|
||||
|
||||
// search_file: find a line inside the note
|
||||
let s = drive(&search, &ctx, json!({"path":"user-memory/todo.md","query":"olio"})).await.unwrap();
|
||||
assert!(s.contains("match(es) in user-memory/todo.md"), "{s}");
|
||||
assert!(s.contains("| olio"), "{s}");
|
||||
|
||||
// editing a note that doesn't exist errors, not creates
|
||||
assert!(drive(&edit, &ctx, json!({"path":"user-memory/ghost.md","old":"a","new":"b"}))
|
||||
.await.is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_search_scopes_to_user_shared_or_all() {
|
||||
let (user, udir) = store("search-user").await;
|
||||
let (shared, sdir) = store("search-shared").await;
|
||||
|
||||
let write = WriteFile::new(Arc::clone(&shared));
|
||||
let search = MemorySearch::new(Arc::clone(&shared));
|
||||
let ctx = ToolContext { session_id: 1, pool: Arc::clone(&user) };
|
||||
|
||||
// one note in each store, both mentioning "wifi"
|
||||
drive(&write, &ctx, json!({"path":"user-memory/rete.md","content":"la mia wifi privata"}))
|
||||
.await.unwrap();
|
||||
drive(&write, &ctx, json!({"path":"shared-memory/casa.md","content":"wifi di casa 1234"}))
|
||||
.await.unwrap();
|
||||
|
||||
// scope=private → only the user store
|
||||
let r = drive(&search, &ctx, json!({"query":"wifi","scope":"private"})).await.unwrap();
|
||||
assert!(r.contains("[user-memory] rete.md"), "{r}");
|
||||
assert!(!r.contains("shared-memory"), "{r}");
|
||||
|
||||
// scope=shared → only the shared store
|
||||
let r = drive(&search, &ctx, json!({"query":"wifi","scope":"shared"})).await.unwrap();
|
||||
assert!(r.contains("[shared-memory] casa.md"), "{r}");
|
||||
assert!(!r.contains("[user-memory]"), "{r}");
|
||||
|
||||
// scope=all (default) → both, and the snippet highlights the term
|
||||
let r = drive(&search, &ctx, json!({"query":"wifi"})).await.unwrap();
|
||||
assert!(r.contains("[user-memory] rete.md") && r.contains("[shared-memory] casa.md"), "{r}");
|
||||
assert!(r.contains("[wifi]"), "snippet should highlight the match: {r}");
|
||||
|
||||
// no match → a friendly message, not an error
|
||||
let r = drive(&search, &ctx, json!({"query":"inesistente"})).await.unwrap();
|
||||
assert!(r.starts_with("No memory notes match"), "{r}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
use super::{classify_memory, read_to_string, MemScope};
|
||||
|
||||
pub struct ReadFile;
|
||||
pub struct ReadFile {
|
||||
/// The `shared-memory` (system) pool. `user-memory` resolves per call from the
|
||||
/// `ToolContext`; only the shared store is a global singleton captured here.
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl ReadFile {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Render `content` with 1-based line numbers, honouring the same
|
||||
/// `start`/`end_line`/`limit` windowing as the disk path. Shared by the on-disk
|
||||
/// [`ReadFile::execute`] and the `memory/` routing in [`ReadFile::run_with`].
|
||||
fn number_lines(content: &str, start: usize, end_line: Option<usize>, limit: Option<usize>) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
|
||||
let end = match (end_line, limit) {
|
||||
(Some(e), _) => e.min(total),
|
||||
(None, Some(l)) => (start + l).min(total),
|
||||
(None, None) => total,
|
||||
};
|
||||
|
||||
if start >= total && total > 0 {
|
||||
return format!("(file has only {total} lines; start_line {} is out of range)", start + 1);
|
||||
}
|
||||
|
||||
let end = end.max(start);
|
||||
let width = total.to_string().len().max(3);
|
||||
lines[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
impl Tool for ReadFile {
|
||||
@@ -19,7 +56,8 @@ impl Tool for ReadFile {
|
||||
Use instead of cat/head/tail in the terminal. \
|
||||
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
|
||||
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
|
||||
Use limit to cap output when end_line is unknown."
|
||||
Use limit to cap output when end_line is unknown. \
|
||||
Paths under user-memory/ (private) or shared-memory/ (shared) read a note from your memory instead of disk."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -70,37 +108,35 @@ impl Tool for ReadFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
let start = args["start_line"].as_u64().map(|n| (n as usize).saturating_sub(1)).unwrap_or(0);
|
||||
let end_line = args["end_line"].as_u64().map(|n| n as usize);
|
||||
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
|
||||
anyhow::bail!("No note at {path}");
|
||||
};
|
||||
Ok(ToolResult::Text(number_lines(&doc.content, start, end_line, limit)))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let content = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
|
||||
let start = args["start_line"].as_u64().map(|n| (n as usize).saturating_sub(1)).unwrap_or(0);
|
||||
let end_line = args["end_line"].as_u64().map(|n| n as usize);
|
||||
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
|
||||
let start = args["start_line"].as_u64()
|
||||
.map(|n| (n as usize).saturating_sub(1))
|
||||
.unwrap_or(0);
|
||||
let end = match (args["end_line"].as_u64(), limit) {
|
||||
(Some(e), _) => (e as usize).min(total),
|
||||
(None, Some(l)) => (start + l).min(total),
|
||||
(None, None) => total,
|
||||
};
|
||||
|
||||
if start >= total && total > 0 {
|
||||
return Ok(format!("(file has only {total} lines; start_line {start_line} is out of range)",
|
||||
start_line = start + 1));
|
||||
}
|
||||
|
||||
let end = end.max(start);
|
||||
|
||||
let width = total.to_string().len().max(3);
|
||||
let numbered = lines[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(numbered)
|
||||
Ok(number_lines(&content, start, end_line, limit))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
use super::{classify_memory, read_to_string, write_string, MemScope};
|
||||
|
||||
pub struct ReplaceLines;
|
||||
pub struct ReplaceLines {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl ReplaceLines {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Replaces the inclusive 1-based line range with `new`, returning the new content
|
||||
/// and a result message. Shared by the on-disk `execute` and the `memory/` routing;
|
||||
/// `display` is the path used in the message.
|
||||
fn apply_replace(content: &str, args: &Value, display: &str) -> Result<(String, String)> {
|
||||
let from_line = args["from_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
|
||||
let to_line = args["to_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
|
||||
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
|
||||
|
||||
let mut lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
if from_line > total {
|
||||
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
|
||||
}
|
||||
let to_clamped = to_line.min(total);
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
lines.splice((from_line - 1)..to_clamped, new_lines);
|
||||
|
||||
let has_trailing = content.ends_with('\n');
|
||||
let mut updated = lines.join("\n");
|
||||
if has_trailing { updated.push('\n'); }
|
||||
|
||||
let msg = format!(
|
||||
"Replaced lines {from_line}–{to_clamped} in {display} with {} new lines.",
|
||||
new.lines().count()
|
||||
);
|
||||
Ok((updated, msg))
|
||||
}
|
||||
|
||||
impl Tool for ReplaceLines {
|
||||
@@ -51,38 +94,33 @@ impl Tool for ReplaceLines {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
|
||||
anyhow::bail!("No note at {path}");
|
||||
};
|
||||
let (updated, msg) = apply_replace(&doc.content, &args, &path)?;
|
||||
crate::db::memory_docs::upsert(&pool, &rel, &updated).await?;
|
||||
Ok(ToolResult::Text(msg))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let from_line = args["from_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
|
||||
let to_line = args["to_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
|
||||
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
|
||||
|
||||
let content = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
if from_line > total {
|
||||
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
|
||||
}
|
||||
let to_clamped = to_line.min(total);
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
lines.splice((from_line - 1)..to_clamped, new_lines);
|
||||
|
||||
let has_trailing = content.ends_with('\n');
|
||||
let mut updated = lines.join("\n");
|
||||
if has_trailing { updated.push('\n'); }
|
||||
|
||||
let (updated, msg) = apply_replace(&content, &args, user_path)?;
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Replaced lines {from_line}–{to_clamped} in {user_path} with {} new lines.",
|
||||
new.lines().count()
|
||||
))
|
||||
Ok(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||
};
|
||||
use super::{classify_memory, read_to_string, MemScope};
|
||||
|
||||
pub struct SearchFile;
|
||||
pub struct SearchFile {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl SearchFile {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Renders the case-insensitive substring search over `text` with context lines.
|
||||
/// Shared by the on-disk `execute` and the `memory/` routing; `display` is the
|
||||
/// path shown in the output.
|
||||
fn render_search(text: &str, args: &Value, display: &str) -> Result<String> {
|
||||
let query = args["query"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
|
||||
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
|
||||
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let lower_query = query.to_lowercase();
|
||||
let width = lines.len().to_string().len().max(3);
|
||||
|
||||
let matches: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches found for {:?} in {display}.", query));
|
||||
}
|
||||
|
||||
let mut chunks: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &matches {
|
||||
let start = m.saturating_sub(context);
|
||||
let end = (m + context).min(lines.len() - 1);
|
||||
if let Some(last) = chunks.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
chunks.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
|
||||
let mut out = format!("{} match(es) in {display}:\n", match_set.len());
|
||||
|
||||
for (ci, (start, end)) in chunks.iter().enumerate() {
|
||||
if ci > 0 { out.push_str(" ···\n"); }
|
||||
for idx in *start..=*end {
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Tool for SearchFile {
|
||||
@@ -53,48 +107,29 @@ impl Tool for SearchFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let Some(doc) = crate::db::memory_docs::get(&pool, &rel).await? else {
|
||||
anyhow::bail!("No note at {path}");
|
||||
};
|
||||
Ok(ToolResult::Text(render_search(&doc.content, &args, &path)?))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let query = args["query"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
|
||||
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let lower_query = query.to_lowercase();
|
||||
let width = lines.len().to_string().len().max(3);
|
||||
|
||||
let matches: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches found for {:?} in {user_path}.", query));
|
||||
}
|
||||
|
||||
let mut chunks: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &matches {
|
||||
let start = m.saturating_sub(context);
|
||||
let end = (m + context).min(lines.len() - 1);
|
||||
if let Some(last) = chunks.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
chunks.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
|
||||
let mut out = format!("{} match(es) in {user_path}:\n", match_set.len());
|
||||
|
||||
for (ci, (start, end)) in chunks.iter().enumerate() {
|
||||
if ci > 0 { out.push_str(" ···\n"); }
|
||||
for idx in *start..=*end {
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
render_search(&text, &args, user_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{resolve, write_string};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT,
|
||||
};
|
||||
use super::{classify_memory, resolve, write_string, MemScope};
|
||||
|
||||
pub struct WriteFile;
|
||||
pub struct WriteFile {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl WriteFile {
|
||||
pub fn new() -> Self { Self }
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
impl Tool for WriteFile {
|
||||
@@ -18,7 +27,8 @@ impl Tool for WriteFile {
|
||||
"Create a new file or fully overwrite an existing one. \
|
||||
Use instead of echo/cat heredoc in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead."
|
||||
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead. \
|
||||
Write Markdown under user-memory/ (private to you) or shared-memory/ (shared with everyone) to save a durable note in your memory instead of on disk."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -48,6 +58,30 @@ impl Tool for WriteFile {
|
||||
truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else { return self.run(args); };
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
let content = args["content"].as_str().map(str::to_string);
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let content = content.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
if rel.is_empty() {
|
||||
anyhow::bail!("{path} is a memory root, not a note — write to a path like {path}/notes.md");
|
||||
}
|
||||
let existed = crate::db::memory_docs::get(&pool, &rel).await?.is_some();
|
||||
crate::db::memory_docs::upsert(&pool, &rel, &content).await?;
|
||||
let verb = if existed { "Overwrote" } else { "Created" };
|
||||
Ok(ToolResult::Text(format!("{verb} {path} ({} bytes).", content.len())))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
|
||||
Reference in New Issue
Block a user