use super::*;
use crate::shared::api::EmbedRole;
pub struct NoteRecall;
#[async_trait::async_trait]
impl Tool for NoteRecall {
fn id(&self) -> ToolId {
NOTE_RECALL_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Memory
}
fn ui_label(&self) -> &'static str {
"recall notes"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.note_recall.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": loc.t("tool.note_recall.param.query")},
"tags": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "minimum": 1}
}
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let tags = parse_tags(&args);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let notes = match query {
Some(q) => match semantic_recall(ctx, q, &tags, limit).await {
Some(n) => n,
None => list_user_notes(ctx, query, &tags, limit)?,
},
None => list_user_notes(ctx, None, &tags, limit)?,
};
let mut outcome = format_notes(¬es, ctx.loc);
if let Some(block) = related_block(ctx, ¬es) {
outcome.result.push_str(&block);
}
let ids: Vec<Uuid> = notes.iter().map(|n| n.id).collect();
if let Some(block) = cited_sources_block(ctx, &ids) {
outcome.result.push_str(&block);
}
Ok(outcome)
}
}
pub(crate) fn related_block(ctx: &ToolContext, hits: &[Note]) -> Option<String> {
let mut seen: std::collections::HashSet<Uuid> = hits.iter().map(|n| n.id).collect();
let mut lines: Vec<String> = Vec::new();
for hit in hits.iter().take(3) {
let nb = ctx
.storage
.db()
.note_neighbors(ctx.profile_id, hit.id, None)
.unwrap_or_default();
for (note, relation, outgoing) in nb {
if !seen.insert(note.id) {
continue;
}
lines.push(related_line(ctx, ¬e, &relation, outgoing));
if lines.len() >= RELATED_IN_RECALL {
break;
}
}
if lines.len() >= RELATED_IN_RECALL {
break;
}
}
if lines.is_empty() {
None
} else {
Some(format!(
"\n{}:\n{}",
ctx.loc.t("notes.block.related"),
lines.join("\n")
))
}
}
fn related_line(ctx: &ToolContext, note: &Note, relation: &str, outgoing: bool) -> String {
let arrow = if outgoing { "→" } else { "←" };
let mark = if is_self_note(note) {
format!("{} ", ctx.loc.t("notes.mark.self"))
} else {
String::new()
};
format!(
"- {arrow}{relation} {mark}(id={}) {}",
note.id, note.content
)
}
pub(crate) fn cited_sources_block(ctx: &ToolContext, note_ids: &[Uuid]) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
for id in note_ids {
let srcs = ctx
.storage
.db()
.note_cited_sources(ctx.profile_id, *id)
.unwrap_or_default();
for s in srcs {
lines.push(ctx.loc.tf(
"notes.block.cited_sources.item",
&[("id", &id.to_string()), ("s", &s)],
));
}
}
if lines.is_empty() {
None
} else {
Some(format!(
"\n{}:\n{}",
ctx.loc.t("notes.block.cited_sources"),
lines.join("\n")
))
}
}
pub(crate) fn list_user_notes(
ctx: &ToolContext,
query: Option<&str>,
tags: &[String],
limit: Option<usize>,
) -> Result<Vec<Note>> {
let mut notes = ctx
.storage
.db()
.note_list(ctx.profile_id, query, tags, None)?;
if !ctx.recall_includes_self {
notes.retain(|n| !is_self_note(n));
}
if let Some(l) = limit {
notes.truncate(l);
}
Ok(notes)
}
pub(crate) async fn semantic_recall(
ctx: &ToolContext,
query: &str,
tags: &[String],
limit: Option<usize>,
) -> Option<Vec<Note>> {
ensure_note_vectors(&ctx.storage, ctx.embedder.as_ref(), ctx.profile_id).await;
let emb = ctx
.embedder
.embed(vec![query.to_string()], EmbedRole::Query)
.await
.ok()?
.into_iter()
.next()?;
let want = limit.unwrap_or(DEFAULT_RECALL);
let cand = want.max(30);
let hits = ctx
.storage
.db()
.note_search_semantic(ctx.profile_id, &emb, cand)
.ok()?;
let mut notes: Vec<Note> = hits
.into_iter()
.map(|(n, _)| n)
.filter(|n| {
(ctx.recall_includes_self || !is_self_note(n))
&& (tags.is_empty() || tags.iter().all(|t| n.tags.contains(t)))
})
.collect();
notes.truncate(want);
if notes.is_empty() { None } else { Some(notes) }
}
pub(crate) fn format_notes(notes: &[Note], loc: &crate::shared::i18n::Locale) -> ToolOutcome {
if notes.is_empty() {
return ToolOutcome::text(loc.t("tool.note_recall.result.empty"));
}
let mut out = format!(
"{}\n",
loc.tf(
"tool.note_recall.result.header",
&[("n", ¬es.len().to_string())]
)
);
for n in notes {
let mark = if is_self_note(n) {
format!("{} ", loc.t("notes.mark.self"))
} else {
String::new()
};
out.push_str(&format!("- (id={}) {mark}{}", n.id, n.content));
let tags: Vec<&str> = n
.tags
.iter()
.filter(|t| t.as_str() != SELF_NOTE_TAG)
.map(String::as_str)
.collect();
if !tags.is_empty() {
out.push_str(&format!(" [{}]", tags.join(", ")));
}
out.push('\n');
}
ToolOutcome::text(out.trim_end().to_string())
}