use super::*;
use crate::shared::api::EmbedRole;
fn format_threshold(t: f32) -> String {
format!("{t:.2}")
}
pub(crate) fn build_consolidation_overview(
storage: &crate::shared::storage::Storage,
profile_id: Uuid,
loc: &crate::shared::i18n::Locale,
) -> String {
let mut active = storage
.db()
.note_list(profile_id, None, &[], None)
.unwrap_or_default();
active.retain(|n| !is_self_note(n));
if active.is_empty() {
return loc.t("notes.overview.empty").to_string();
}
let mut with_vec = storage
.db()
.notes_with_vectors(profile_id)
.unwrap_or_default();
with_vec.retain(|(n, _)| !is_self_note(n));
let links = storage.db().note_links_all(profile_id).unwrap_or_default();
let threshold = storage.db().similarity_scale().map(CONSOLIDATE_SIMILARITY);
let mut pairs: Vec<(f32, &Note, &Note)> = Vec::new();
for i in 0..with_vec.len() {
for j in (i + 1)..with_vec.len() {
let s = cosine(&with_vec[i].1, &with_vec[j].1);
if s >= threshold {
pairs.push((s, &with_vec[i].0, &with_vec[j].0));
}
}
}
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
let contradicts: Vec<&(Uuid, Uuid, String)> = links
.iter()
.filter(|(_, _, r)| r == "contradicts")
.collect();
let linked: std::collections::HashSet<Uuid> =
links.iter().flat_map(|(f, t, _)| [*f, *t]).collect();
let dangling: Vec<&Note> = active.iter().filter(|n| !linked.contains(&n.id)).collect();
let mut out = format!(
"{}\n{}\n",
loc.t("notes.overview.header"),
loc.tf(
"notes.overview.active",
&[
("n", &active.len().to_string()),
("d", &dangling.len().to_string())
]
)
);
out.push('\n');
out.push_str(&loc.tf(
"notes.overview.pairs",
&[
("sim", &format_threshold(threshold)),
("n", &pairs.len().to_string()),
],
));
out.push('\n');
for (s, a, b) in pairs.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!(
"- {s:.2} (id={}) {} ↔ (id={}) {}\n",
a.id,
clip(&a.content, 60),
b.id,
clip(&b.content, 60)
));
}
out.push('\n');
out.push_str(&loc.tf(
"notes.overview.contradicts",
&[("n", &contradicts.len().to_string())],
));
out.push('\n');
for (f, t, _) in contradicts.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!("- (id={f}) ↔ (id={t})\n"));
}
out.push('\n');
out.push_str(&loc.tf(
"notes.overview.dangling",
&[("n", &dangling.len().to_string())],
));
out.push('\n');
for n in dangling.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!("- (id={}) {}\n", n.id, clip(&n.content, 60)));
}
out.trim_end().to_string()
}
pub(crate) fn build_self_consolidation_overview(
storage: &crate::shared::storage::Storage,
profile_id: Uuid,
loc: &crate::shared::i18n::Locale,
) -> Option<String> {
let active = storage
.db()
.note_list(profile_id, None, &[SELF_NOTE_TAG.to_string()], None)
.unwrap_or_default();
if active.len() < 2 {
return None;
}
let self_ids: std::collections::HashSet<Uuid> = active.iter().map(|n| n.id).collect();
let mut with_vec = storage
.db()
.notes_with_vectors(profile_id)
.unwrap_or_default();
with_vec.retain(|(n, _)| is_self_note(n));
let links = storage.db().note_links_all(profile_id).unwrap_or_default();
let threshold = storage.db().similarity_scale().map(CONSOLIDATE_SIMILARITY);
let mut pairs: Vec<(f32, &Note, &Note)> = Vec::new();
for i in 0..with_vec.len() {
for j in (i + 1)..with_vec.len() {
let s = cosine(&with_vec[i].1, &with_vec[j].1);
if s >= threshold {
pairs.push((s, &with_vec[i].0, &with_vec[j].0));
}
}
}
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
let contradicts: Vec<&(Uuid, Uuid, String)> = links
.iter()
.filter(|(f, t, r)| r == "contradicts" && self_ids.contains(f) && self_ids.contains(t))
.collect();
let linked: std::collections::HashSet<Uuid> =
links.iter().flat_map(|(f, t, _)| [*f, *t]).collect();
let dangling: Vec<&Note> = active.iter().filter(|n| !linked.contains(&n.id)).collect();
let mut out = format!(
"{}\n{}\n",
loc.t("notes.self_overview.header"),
loc.tf(
"notes.self_overview.count",
&[
("n", &active.len().to_string()),
("d", &dangling.len().to_string())
]
)
);
out.push('\n');
out.push_str(&loc.tf(
"notes.overview.pairs",
&[
("sim", &format_threshold(threshold)),
("n", &pairs.len().to_string()),
],
));
out.push('\n');
for (s, a, b) in pairs.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!(
"- {s:.2} (id={}) {} ↔ (id={}) {}\n",
a.id,
clip(&a.content, 60),
b.id,
clip(&b.content, 60)
));
}
out.push('\n');
out.push_str(&loc.tf(
"notes.self_overview.contradicts",
&[("n", &contradicts.len().to_string())],
));
out.push('\n');
for (f, t, _) in contradicts.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!("- (id={f}) ↔ (id={t})\n"));
}
out.push('\n');
out.push_str(&loc.tf(
"notes.self_overview.dangling",
&[("n", &dangling.len().to_string())],
));
out.push('\n');
for n in dangling.iter().take(CONSOLIDATE_LIST_CAP) {
out.push_str(&format!("- (id={}) {}\n", n.id, clip(&n.content, 60)));
}
Some(out.trim_end().to_string())
}
const SUMMARY_OBS_SIMILARITY: f32 = 0.62;
const SUMMARY_PARAGRAPH_MIN_CHARS: usize = 40;
pub(crate) async fn summary_observation_overlaps(
storage: &crate::shared::storage::Storage,
embedder: &dyn crate::shared::api::Embedder,
profile_id: Uuid,
loc: &crate::shared::i18n::Locale,
) -> Option<String> {
let model = storage.db().self_model_get(profile_id).ok().flatten()?;
let summary = model.summary.trim();
if summary.is_empty() {
return None;
}
let paragraphs: Vec<String> = summary
.split("\n\n")
.map(|p| p.trim().to_string())
.filter(|p| p.chars().count() >= SUMMARY_PARAGRAPH_MIN_CHARS)
.collect();
if paragraphs.is_empty() {
return None;
}
let mut obs = storage
.db()
.notes_with_vectors(profile_id)
.unwrap_or_default();
obs.retain(|(n, _)| is_self_note(n));
if obs.is_empty() {
return None;
}
let Ok(vecs) = embedder.embed(paragraphs.clone(), EmbedRole::Passage).await else {
return None;
};
if vecs.len() != paragraphs.len() {
return None;
}
let threshold = storage.db().similarity_scale().map(SUMMARY_OBS_SIMILARITY);
let mut lines: Vec<String> = Vec::new();
for (p, pv) in paragraphs.iter().zip(&vecs) {
let mut best: Option<(f32, &Note)> = None;
for (n, nv) in &obs {
let s = cosine(pv, nv);
if s >= threshold && best.map(|(bs, _)| s > bs).unwrap_or(true) {
best = Some((s, n));
}
}
if let Some((_, n)) = best {
lines.push(format!(
"- {} ≈ (id={}) {}",
clip(p, 60),
n.id,
clip(&n.content, 60)
));
}
}
if lines.is_empty() {
return None;
}
Some(format!(
"{}\n{}",
loc.t("notes.self_overview.summary_obs"),
lines.join("\n")
))
}
pub struct ConsolidateNotes;
#[async_trait::async_trait]
impl Tool for ConsolidateNotes {
fn id(&self) -> ToolId {
CONSOLIDATE_NOTES_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Memory
}
fn ui_label(&self) -> &'static str {
"notes consolidation"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.consolidate_notes.desc").into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn invoke(&self, ctx: &ToolContext, _args: serde_json::Value) -> Result<ToolOutcome> {
let overview = build_consolidation_overview(&ctx.storage, ctx.profile_id, ctx.loc);
let out = format!(
"{overview}\n\n{}",
ctx.loc.t("tool.consolidate_notes.rubric")
);
Ok(ToolOutcome::text(out))
}
}