use serde_json::{json, Value};
use pensieve_memory::types::{MemoryType, RecallFilter};
use pensieve_memory::{MemoryWriter, DEFAULT_DATABASE, NODE_TABLE};
use super::memory_extract::{decide_conflict, ConflictOp, ExtractedMemory};
use super::memory_gate::{self, AddSpec, GateCtx, HitlGate, OpPayload};
use super::memory_policy::MemoryOp;
use super::state::AgentState;
use super::tools::{execute_sql, SharedToolCtx};
#[derive(Debug, Default, Clone)]
pub struct ConflictTally {
pub added: i64,
pub updated: i64,
pub noop: i64,
pub invalidated: i64,
pub gated: i64,
pub rejected_trivial: i64,
}
impl ConflictTally {
pub fn written(&self) -> i64 {
self.added + self.updated
}
pub fn merge(&mut self, other: &ConflictTally) {
self.added += other.added;
self.updated += other.updated;
self.noop += other.noop;
self.invalidated += other.invalidated;
self.gated += other.gated;
self.rejected_trivial += other.rejected_trivial;
}
pub fn to_json(&self) -> Value {
json!({
"added": self.added,
"updated": self.updated,
"noop": self.noop,
"invalidated": self.invalidated,
"gated": self.gated,
"rejected_trivial": self.rejected_trivial,
})
}
}
const NEIGHBOURS: usize = 5;
pub async fn consolidate_memory(
state: &AgentState,
shared: &SharedToolCtx,
writer: &MemoryWriter,
realm: &str,
m: &ExtractedMemory,
references: Vec<String>,
provenance: Value,
gate: Option<&HitlGate>,
activity_id: Option<&str>,
) -> ConflictTally {
let mut tally = ConflictTally::default();
let content = m.content.trim();
if content.is_empty() {
return tally;
}
let kind = MemoryType::parse(&m.kind);
let similar = nearest(shared, writer, realm, kind, content).await;
let decision = match decide_conflict(state, content, &similar).await {
Ok(d) => d,
Err(e) => {
tracing::debug!(error = %e, "conflict decision failed; defaulting to ADD");
super::memory_extract::ConflictDecision {
op: ConflictOp::Add,
target_id: None,
merged_content: None,
reason: Some("decision error".into()),
}
}
};
let add_spec = || AddSpec {
content: content.to_string(),
title: m.title.clone(),
memory_type: m.kind.clone(),
realm: realm.to_string(),
importance: m.importance.clamp(0.0, 1.0),
references: references.clone(),
valid_at: m.valid_at.clone(),
provenance: provenance.clone(),
};
let (op, payload) = match decision.op {
ConflictOp::Noop => {
tally.noop += 1;
return tally;
}
ConflictOp::Add => (MemoryOp::Add, OpPayload::Add(add_spec())),
ConflictOp::Update => match decision.target_id.as_deref() {
Some(target) => {
let new_content = decision.merged_content.as_deref().unwrap_or(content);
(
MemoryOp::Update,
OpPayload::Update {
target_id: target.to_string(),
new_content: new_content.to_string(),
},
)
}
None => (MemoryOp::Add, OpPayload::Add(add_spec())),
},
ConflictOp::Invalidate => match decision.target_id.as_deref() {
Some(target) => (
MemoryOp::Invalidate,
OpPayload::Invalidate {
target_id: target.to_string(),
replacement: add_spec(),
},
),
None => (MemoryOp::Add, OpPayload::Add(add_spec())),
},
};
let ctx = GateCtx {
op,
realm: realm.to_string(),
mem_type: Some(m.kind.clone()),
confidence: m.confidence,
reason: decision.reason.clone(),
};
let outcome = memory_gate::gate_or_apply(gate, ctx, payload.clone(), || {
memory_gate::apply_op(shared, &payload)
})
.await;
match outcome {
Ok(o) => {
if o.applied {
match op {
MemoryOp::Add => tally.added += 1,
MemoryOp::Update => tally.updated += 1,
MemoryOp::Invalidate => {
tally.added += 1; tally.invalidated += 1; }
_ => {}
}
if let (Some(activity), Some(new_id)) = (activity_id, o.created_node_id.as_deref())
{
let _ = writer
.link(
new_id,
activity,
pensieve_memory::EDGE_DERIVED_FROM,
realm,
Some(pensieve_memory::activities::ACTIVITIES_NAMESPACE),
)
.await;
}
} else {
tally.gated += 1; }
}
Err(e) => tracing::debug!(error = %e, op = ?op, "consolidate apply failed"),
}
tally
}
async fn nearest(
shared: &SharedToolCtx,
writer: &MemoryWriter,
realm: &str,
kind: MemoryType,
content: &str,
) -> Vec<(String, String)> {
let qvec = match writer.embed_one(content).await {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let filter = RecallFilter {
realms: vec![realm.to_string()],
memory_type: Some(kind),
..Default::default()
};
let sql = pensieve_memory::sql::recall_sql(NODE_TABLE, &qvec, &filter, NEIGHBOURS, None);
let res = execute_sql(shared, DEFAULT_DATABASE, &sql, NEIGHBOURS).await;
res.get("rows")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|r| {
let id = r.get("id").and_then(Value::as_str)?;
let c = r
.get("content")
.or_else(|| r.get("content_preview"))
.and_then(Value::as_str)
.unwrap_or("");
Some((id.to_string(), c.to_string()))
})
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_sums_every_field_including_rejected_trivial() {
let mut a = ConflictTally {
added: 1,
updated: 2,
noop: 3,
invalidated: 4,
gated: 5,
rejected_trivial: 6,
};
let b = ConflictTally {
added: 10,
updated: 20,
noop: 30,
invalidated: 40,
gated: 50,
rejected_trivial: 60,
};
a.merge(&b);
assert_eq!(a.added, 11);
assert_eq!(a.updated, 22);
assert_eq!(a.noop, 33);
assert_eq!(a.invalidated, 44);
assert_eq!(a.gated, 55);
assert_eq!(a.rejected_trivial, 66);
}
#[test]
fn written_excludes_rejected_trivial() {
let t = ConflictTally {
added: 2,
updated: 3,
rejected_trivial: 100,
..Default::default()
};
assert_eq!(t.written(), 5, "rejected_trivial never counts as written");
}
#[test]
fn to_json_includes_rejected_trivial_key() {
let t = ConflictTally {
rejected_trivial: 7,
..Default::default()
};
assert_eq!(t.to_json()["rejected_trivial"], json!(7));
}
}