use super::*;
use crate::shared::api::EmbedRole;
pub struct NoteSave;
#[async_trait::async_trait]
impl Tool for NoteSave {
fn id(&self) -> ToolId {
"note_save".into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Memory
}
fn ui_label(&self) -> &'static str {
"save note"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.note_save.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"content": {"type": "string", "description": loc.t("tool.note_save.param.content")},
"tags": {"type": "array", "items": {"type": "string"}}
},
"required": ["content"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("notes.err.content_string")))?
.trim()
.to_string();
if content.is_empty() {
anyhow::bail!(ctx.loc.t("notes.err.content_empty"));
}
let tags = parse_tags(&args);
let note = Note::new(ctx.profile_id, content, tags);
let id = note.id;
ctx.storage.db().note_insert(¬e)?;
let mut out = ctx
.loc
.tf("tool.note_save.result.saved", &[("id", &id.to_string())]);
if let Ok(vecs) = ctx
.embedder
.embed(vec![note.content.clone()], EmbedRole::Passage)
.await
&& let Some(emb) = vecs.into_iter().next()
{
let _ = ctx
.storage
.db()
.note_vector_upsert(id, ctx.profile_id, &emb);
ensure_note_vectors(&ctx.storage, ctx.embedder.as_ref(), ctx.profile_id).await;
if let Ok(hits) = ctx
.storage
.db()
.note_search_semantic(ctx.profile_id, &emb, 4)
{
let similar: Vec<Note> = hits
.into_iter()
.map(|(n, _)| n)
.filter(|n| n.id != id && !is_self_note(n))
.take(3)
.collect();
if !similar.is_empty() {
out.push('\n');
out.push_str(ctx.loc.t("tool.note_save.gate.similar"));
for n in similar {
out.push_str(&format!("\n- (id={}) {}", n.id, n.content));
}
}
}
}
Ok(ToolOutcome::text(out).wrote())
}
}
pub(crate) async fn create_note(
ctx: &ToolContext,
content: String,
tags: Vec<String>,
) -> Result<Uuid> {
let note = Note::new(ctx.profile_id, content, tags);
let id = note.id;
ctx.storage.db().note_insert(¬e)?;
if let Ok(vecs) = ctx
.embedder
.embed(vec![note.content.clone()], EmbedRole::Passage)
.await
&& let Some(emb) = vecs.into_iter().next()
{
let _ = ctx
.storage
.db()
.note_vector_upsert(id, ctx.profile_id, &emb);
}
Ok(id)
}
pub(crate) async fn self_note_similar(
ctx: &ToolContext,
content: &str,
exclude: Uuid,
) -> Vec<Note> {
ensure_note_vectors(&ctx.storage, ctx.embedder.as_ref(), ctx.profile_id).await;
let Ok(vecs) = ctx
.embedder
.embed(vec![content.to_string()], EmbedRole::Passage)
.await
else {
return Vec::new();
};
let Some(emb) = vecs.into_iter().next() else {
return Vec::new();
};
let Ok(hits) = ctx
.storage
.db()
.note_search_semantic(ctx.profile_id, &emb, 8)
else {
return Vec::new();
};
hits.into_iter()
.map(|(n, _)| n)
.filter(|n| n.id != exclude && is_self_note(n))
.take(3)
.collect()
}
pub(crate) async fn ensure_note_vectors(
storage: &crate::shared::storage::Storage,
embedder: &dyn crate::shared::api::Embedder,
profile_id: Uuid,
) {
let missing = match storage.db().notes_missing_vectors(profile_id) {
Ok(m) => m,
Err(_) => return,
};
for chunk in missing.chunks(NOTE_BACKFILL_BATCH) {
let texts: Vec<String> = chunk.iter().map(|(_, c)| c.clone()).collect();
let Ok(vecs) = embedder.embed(texts, EmbedRole::Passage).await else {
return; };
for ((id, _), emb) in chunk.iter().zip(vecs) {
let _ = storage.db().note_vector_upsert(*id, profile_id, &emb);
}
}
}