use serde::{Deserialize, Serialize};
use serde_json::Value;
use pensieve_memory::MemoryWriter;
use super::SharedToolCtx;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexEntry {
pub title: String,
pub file: String,
pub hook: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum FileAction {
WriteMemoryFile {
file: String,
content: String,
node_id: String,
content_hash: String,
},
ArchiveFile {
file: String,
reason: String,
node_id: Option<String>,
},
SetIndex { entries: Vec<IndexEntry> },
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct CurationOutcome {
pub promoted: usize,
pub refreshed: usize,
pub archived_files: usize,
pub merged: usize,
pub index_entries: usize,
pub llm_reviewed: usize,
}
#[derive(Debug, Clone)]
pub struct CurationConfig {
pub promote: bool,
pub promote_max: usize,
pub promote_min_importance: f32,
pub dry_run: bool,
}
impl Default for CurationConfig {
fn default() -> Self {
CurationConfig {
promote: true,
promote_max: 15,
promote_min_importance: 0.6,
dry_run: false,
}
}
}
impl CurationConfig {
pub fn from_env() -> Self {
let d = CurationConfig::default();
let get = |k: &str| std::env::var(k).ok();
CurationConfig {
promote: get("PENSIEVE_CC_PROMOTE").map_or(d.promote, |v| v != "0"),
promote_max: get("PENSIEVE_CC_PROMOTE_MAX")
.and_then(|v| v.parse().ok())
.unwrap_or(d.promote_max),
promote_min_importance: get("PENSIEVE_CC_PROMOTE_MIN_IMPORTANCE")
.and_then(|v| v.parse().ok())
.unwrap_or(d.promote_min_importance),
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct GuardStamp {
pub action: usize,
pub node_id: String,
pub set: Vec<(String, Value)>,
pub remove: Vec<String>,
}
pub async fn commit_guard_stamps(
shared: &SharedToolCtx,
writer: &MemoryWriter,
stamps: &[GuardStamp],
applied: &[bool],
now: &str,
) -> anyhow::Result<usize> {
let mut committed = 0;
for s in stamps {
if !applied.get(s.action).copied().unwrap_or(false) {
continue;
}
stamp(writer, shared, &s.node_id, now, |p| {
for (k, v) in &s.set {
p.insert(k.clone(), v.clone());
}
for k in &s.remove {
p.remove(k.as_str());
}
})
.await?;
committed += 1;
}
Ok(committed)
}
#[derive(Debug, Clone)]
pub struct CurationInput<'a> {
pub realm: &'a str,
pub path_slug: &'a str,
pub now: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CurationOp {
Keep,
Archive,
Refresh,
}
#[derive(Debug, Clone)]
pub struct CurationDecision {
pub op: CurationOp,
pub refreshed_description: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LlmCurationConfig {
pub stale_days: i64,
pub dup_band: (f64, f64),
pub dry_run: bool,
}
impl Default for LlmCurationConfig {
fn default() -> Self {
LlmCurationConfig {
stale_days: 90,
dup_band: (0.90, 0.97),
dry_run: false,
}
}
}
impl LlmCurationConfig {
pub fn from_env() -> Self {
let d = LlmCurationConfig::default();
LlmCurationConfig {
stale_days: std::env::var("PENSIEVE_CC_STALE_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d.stale_days),
dup_band: (
d.dup_band.0,
std::env::var("PENSIEVE_CC_DUP_COSINE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d.dup_band.1),
),
dry_run: false,
}
}
}
const CURATION_SYSTEM: &str = r#"You curate an AI agent's long-term memory for relevance. Given one MEMORY (with its age and type), decide whether it still earns a slot in the agent's always-loaded context. Return STRICT JSON:
{ "op": "KEEP | ARCHIVE | REFRESH", "refreshed_description": "rewritten one-line description (for REFRESH)", "reason": "one short sentence" }
Choose:
- KEEP: still accurate and useful as written.
- ARCHIVE: stale, no longer relevant, or clearly superseded. Archiving is reversible — but prefer KEEP when uncertain.
- REFRESH: still relevant but the description reads outdated — supply refreshed_description.
Output ONLY the JSON object."#;
#[derive(Debug, serde::Deserialize)]
struct RawCuration {
#[serde(default)]
op: String,
#[serde(default)]
refreshed_description: Option<String>,
#[serde(default)]
reason: Option<String>,
}
pub fn parse_curation_decision(text: &str) -> CurationDecision {
let keep = CurationDecision {
op: CurationOp::Keep,
refreshed_description: None,
reason: None,
};
let Some(cleaned) = super::memory_extract::extract_json_object(text) else {
return keep;
};
let Ok(raw) = serde_json::from_str::<RawCuration>(&cleaned) else {
return keep;
};
let op = match raw.op.trim().to_ascii_uppercase().as_str() {
"ARCHIVE" => CurationOp::Archive,
"REFRESH" => CurationOp::Refresh,
_ => CurationOp::Keep,
};
CurationDecision {
op,
refreshed_description: raw.refreshed_description.filter(|s| !s.trim().is_empty()),
reason: raw.reason.filter(|s| !s.trim().is_empty()),
}
}
pub(crate) fn cosine(a: &[f32], b: &[f32]) -> f64 {
let dot: f64 = a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum();
let norm = |v: &[f32]| v.iter().map(|x| f64::from(*x).powi(2)).sum::<f64>().sqrt();
let (na, nb) = (norm(a), norm(b));
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na * nb)
}
pub(crate) fn is_stale(
updated_at: &str,
reviewed_at: Option<&str>,
now: &str,
stale_days: i64,
) -> bool {
let parse = |s: &str| chrono::DateTime::parse_from_rfc3339(s).ok();
let Some(now_t) = parse(now) else {
return false;
};
let cutoff = now_t - chrono::Duration::days(stale_days);
if parse(updated_at).is_none_or(|t| t >= cutoff) {
return false;
}
match reviewed_at.and_then(parse) {
Some(reviewed) => reviewed < cutoff,
None => true,
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn llm_curation_pass(
shared: &SharedToolCtx,
writer: &MemoryWriter,
engine: Option<&super::AgentState>,
input: &CurationInput<'_>,
cfg: &LlmCurationConfig,
actions: &mut Vec<FileAction>,
stamps: &mut Vec<GuardStamp>,
outcome: &mut CurationOutcome,
) -> anyhow::Result<()> {
let Some(state) = engine else {
return Ok(());
};
let usable = matches!(
state.engines.get().await,
Ok(cfg) if cfg.kind != super::engine::EngineKind::ClaudeCli
);
if !usable {
return Ok(());
}
let nodes = realm_nodes(shared, input.realm).await;
let file_prefix = format!("{}{}/", pensieve_ccmem::TOPIC_KEY_PREFIX, input.path_slug);
let managed_file = |n: &Node| -> Option<String> {
n.prov_str("cc_file")
.or_else(|| n.prov_str("cc_promoted_file"))
.map(str::to_string)
};
let candidates: Vec<&Node> = nodes
.iter()
.filter(|n| {
!n.dead()
&& !n.prov_flag("cc_user_owned")
&& (n.topic_key.starts_with(&file_prefix)
|| n.prov_str("cc_promoted_file").is_some())
&& is_stale(
&n.updated_at,
n.prov_str("cc_reviewed_at"),
input.now,
cfg.stale_days,
)
})
.collect();
for n in candidates {
let item = format!(
"MEMORY (type={}, title={:?}, last_updated={}):\n{}",
n.memory_type, n.title, n.updated_at, n.content
);
let Ok(text) = super::runner::run_oneshot(
state,
"pensieve-memory-curator",
"Reviews stale memories: KEEP / ARCHIVE / REFRESH.",
CURATION_SYSTEM,
&item,
)
.await
else {
continue; };
let d = parse_curation_decision(&text);
outcome.llm_reviewed += 1;
match d.op {
CurationOp::Keep => {
if !cfg.dry_run {
stamp(writer, shared, &n.id, input.now, |p| {
p.insert("cc_reviewed_at".into(), serde_json::json!(input.now));
})
.await?;
}
}
CurationOp::Archive => {
if let Some(file) = managed_file(n) {
actions.push(FileAction::ArchiveFile {
file,
reason: d
.reason
.clone()
.unwrap_or_else(|| "stale (llm review)".to_string()),
node_id: Some(n.id.clone()),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: n.id.clone(),
set: vec![("cc_file_archived".to_string(), serde_json::json!(true))],
remove: vec![
"cc_promoted_file".to_string(),
"cc_content_hash".to_string(),
],
});
outcome.archived_files += 1;
}
if !cfg.dry_run {
archive_stale(writer, shared, n, d.reason.as_deref(), input.now).await?;
}
}
CurationOp::Refresh => {
if cfg.dry_run {
} else if let Some(desc) = d.refreshed_description {
refresh_title(writer, shared, &n.id, &desc, input.now).await?;
} else {
stamp(writer, shared, &n.id, input.now, |p| {
p.insert("cc_reviewed_at".into(), serde_json::json!(input.now));
})
.await?;
}
}
}
}
let live: Vec<&Node> = nodes
.iter()
.filter(|n| n.topic_key.starts_with(&file_prefix) && !n.dead())
.collect();
adjudicate_near_dups(shared, writer, state, input, cfg, &live, actions, stamps, outcome).await
}
#[allow(clippy::too_many_arguments)]
async fn adjudicate_near_dups(
shared: &SharedToolCtx,
writer: &MemoryWriter,
state: &super::AgentState,
input: &CurationInput<'_>,
cfg: &LlmCurationConfig,
live: &[&Node],
actions: &mut Vec<FileAction>,
stamps: &mut Vec<GuardStamp>,
outcome: &mut CurationOutcome,
) -> anyhow::Result<()> {
use super::memory_extract::ConflictOp;
if live.len() < 2 {
return Ok(());
}
let mut vecs: Vec<Vec<f32>> = Vec::with_capacity(live.len());
for n in live {
match writer.embed_one(&n.content).await {
Ok(v) => vecs.push(v),
Err(_) => return Ok(()), }
}
for i in 0..live.len() {
for j in (i + 1)..live.len() {
let sim = cosine(&vecs[i], &vecs[j]);
if sim < cfg.dup_band.0 || sim >= cfg.dup_band.1 {
continue;
}
let (a, b) = if live[i].id <= live[j].id {
(live[i], live[j])
} else {
(live[j], live[i])
};
let seen = a
.prov
.get("cc_dup_distinct")
.and_then(Value::as_array)
.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(b.id.as_str())));
if seen {
continue;
}
let Ok(d) = super::memory_extract::decide_conflict(
state,
&a.content,
&[(b.id.clone(), b.content.clone())],
)
.await
else {
continue;
};
outcome.llm_reviewed += 1;
if matches!(d.op, ConflictOp::Noop | ConflictOp::Update) {
let (winner, loser) = if a.importance >= b.importance { (a, b) } else { (b, a) };
if !cfg.dry_run {
archive_as_duplicate(writer, shared, loser, winner, input.now).await?;
writer
.link(
&loser.id,
&winner.id,
pensieve_memory::EDGE_MERGED_INTO,
input.realm,
None,
)
.await
.map_err(|e| anyhow::anyhow!("merge edge: {e}"))?;
}
if let Some(file) = loser.prov_str("cc_file") {
actions.push(FileAction::ArchiveFile {
file: file.to_string(),
reason: format!("duplicate of {}", winner.display_title()),
node_id: Some(loser.id.clone()),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: loser.id.clone(),
set: vec![("cc_file_archived".to_string(), serde_json::json!(true))],
remove: vec![],
});
outcome.archived_files += 1;
}
outcome.merged += 1;
} else if !cfg.dry_run {
let b_id = b.id.clone();
stamp(writer, shared, &a.id, input.now, move |p| {
let list = p
.entry("cc_dup_distinct")
.or_insert_with(|| serde_json::json!([]));
if let Some(arr) = list.as_array_mut() {
arr.push(serde_json::json!(b_id));
}
})
.await?;
}
}
}
Ok(())
}
async fn archive_stale(
writer: &MemoryWriter,
shared: &SharedToolCtx,
node: &Node,
reason: Option<&str>,
now: &str,
) -> anyhow::Result<()> {
let Some(mut row) = fetch_full_row(shared, &node.id).await else {
return Ok(());
};
let mut prov = node.prov.clone();
if let Some(obj) = prov.as_object_mut() {
obj.insert(
"cc_archived_reason".into(),
serde_json::json!(reason.unwrap_or("stale (llm review)")),
);
obj.insert("cc_archived_at".into(), serde_json::json!(now));
}
if let Some(obj) = row.as_object_mut() {
obj.insert("status".into(), serde_json::json!("archived"));
obj.insert("invalid_at".into(), serde_json::json!(now));
obj.insert("updated_at".into(), serde_json::json!(now));
obj.insert("provenance".into(), serde_json::json!(prov.to_string()));
}
writer
.append_node_rows(vec![row])
.await
.map_err(|e| anyhow::anyhow!("archiving stale {}: {e}", node.id))
}
async fn refresh_title(
writer: &MemoryWriter,
shared: &SharedToolCtx,
node_id: &str,
new_title: &str,
now: &str,
) -> anyhow::Result<()> {
let Some(mut row) = fetch_full_row(shared, node_id).await else {
return Ok(());
};
let mut prov = row
.get("provenance")
.and_then(Value::as_str)
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = prov.as_object_mut() {
obj.insert("cc_reviewed_at".into(), serde_json::json!(now));
obj.remove("cc_content_hash"); }
if let Some(obj) = row.as_object_mut() {
obj.insert("title".into(), serde_json::json!(new_title));
obj.insert("updated_at".into(), serde_json::json!(now));
obj.insert("provenance".into(), serde_json::json!(prov.to_string()));
}
writer
.append_node_rows(vec![row])
.await
.map_err(|e| anyhow::anyhow!("refreshing {node_id}: {e}"))
}
#[derive(Debug, Clone)]
struct Node {
id: String,
memory_type: String,
title: Option<String>,
content: String,
importance: f64,
status: String,
invalid_at: String,
topic_key: String,
updated_at: String,
prov: Value,
}
impl Node {
fn from_row(row: &Value) -> Option<Node> {
let get = |k: &str| row.get(k).and_then(Value::as_str).map(str::to_string);
Some(Node {
id: get("id")?,
memory_type: get("memory_type").unwrap_or_default(),
title: get("title").filter(|t| !t.is_empty()),
content: get("content").unwrap_or_default(),
importance: row.get("importance").and_then(Value::as_f64).unwrap_or(0.5),
status: get("status").unwrap_or_else(|| "active".to_string()),
invalid_at: get("invalid_at").unwrap_or_default(),
topic_key: get("topic_key").unwrap_or_default(),
updated_at: get("updated_at").unwrap_or_default(),
prov: get("provenance")
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({})),
})
}
fn prov_str(&self, key: &str) -> Option<&str> {
self.prov.get(key).and_then(Value::as_str)
}
fn prov_flag(&self, key: &str) -> bool {
self.prov.get(key).and_then(Value::as_bool) == Some(true)
}
fn dead(&self) -> bool {
self.status == "archived" || !self.invalid_at.is_empty()
}
fn display_title(&self) -> String {
self.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| synthesize_title(&self.content))
}
}
const TITLE_MAX_LEN: usize = 72;
pub(crate) fn synthesize_title(content: &str) -> String {
let trimmed = content.trim();
let first_line = trimmed.lines().next().unwrap_or(trimmed).trim();
let sentence_end = first_line
.char_indices()
.find(|(_, c)| matches!(c, '.' | '!' | '?'))
.map(|(i, _)| i);
let candidate = match sentence_end {
Some(end) if end >= 8 => first_line[..end].trim(),
_ => first_line,
};
if candidate.is_empty() {
return "Untitled memory".to_string();
}
if candidate.chars().count() <= TITLE_MAX_LEN {
return candidate.to_string();
}
let mut out = String::new();
for word in candidate.split_whitespace() {
let would_be = out.chars().count() + usize::from(!out.is_empty()) + word.chars().count();
if would_be > TITLE_MAX_LEN.saturating_sub(1) {
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
if out.is_empty() {
clip(candidate, TITLE_MAX_LEN)
} else {
out.push('…');
out
}
}
const ALL_COLS: &str = "id, labels, realm, memory_type, title, content, content_preview, tags, \
importance, status, source_session_id, source_run_id, embedding, created_at, updated_at, \
valid_at, invalid_at, superseded_by, provenance, topic_key";
#[allow(clippy::too_many_lines)]
pub async fn plan_curation(
shared: &SharedToolCtx,
writer: &MemoryWriter,
input: &CurationInput<'_>,
cfg: &CurationConfig,
) -> anyhow::Result<(Vec<FileAction>, Vec<GuardStamp>, CurationOutcome)> {
let mut actions: Vec<FileAction> = Vec::new();
let mut stamps: Vec<GuardStamp> = Vec::new();
let mut outcome = CurationOutcome::default();
let nodes = realm_nodes(shared, input.realm).await;
let file_prefix = format!("{}{}/", pensieve_ccmem::TOPIC_KEY_PREFIX, input.path_slug);
for n in nodes.iter().filter(|n| n.topic_key.starts_with(&file_prefix)) {
if !n.dead()
|| n.prov_str("cc_archived_reason") == Some("file_deleted")
|| n.prov_flag("cc_file_archived")
{
continue;
}
let Some(file) = n.prov_str("cc_file").map(str::to_string) else {
continue;
};
actions.push(FileAction::ArchiveFile {
file,
reason: "superseded in pensieve".to_string(),
node_id: Some(n.id.clone()),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: n.id.clone(),
set: vec![("cc_file_archived".to_string(), serde_json::json!(true))],
remove: vec![],
});
outcome.archived_files += 1;
}
{
use std::collections::HashMap;
let mut by_content: HashMap<String, Vec<&Node>> = HashMap::new();
for n in nodes
.iter()
.filter(|n| n.topic_key.starts_with(&file_prefix) && !n.dead())
{
by_content
.entry(n.content.trim().to_string())
.or_default()
.push(n);
}
let mut groups: Vec<Vec<&Node>> =
by_content.into_values().filter(|g| g.len() > 1).collect();
groups.sort_by(|a, b| a[0].topic_key.cmp(&b[0].topic_key));
for mut group in groups {
group.sort_by(|a, b| {
b.importance
.total_cmp(&a.importance)
.then_with(|| a.topic_key.cmp(&b.topic_key))
});
let winner = group[0];
for loser in &group[1..] {
if !cfg.dry_run {
archive_as_duplicate(writer, shared, loser, winner, input.now).await?;
writer
.link(
&loser.id,
&winner.id,
pensieve_memory::EDGE_MERGED_INTO,
input.realm,
None,
)
.await
.map_err(|e| anyhow::anyhow!("merge edge: {e}"))?;
}
if let Some(file) = loser.prov_str("cc_file") {
actions.push(FileAction::ArchiveFile {
file: file.to_string(),
reason: format!("duplicate of {}", winner.display_title()),
node_id: Some(loser.id.clone()),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: loser.id.clone(),
set: vec![("cc_file_archived".to_string(), serde_json::json!(true))],
remove: vec![],
});
outcome.archived_files += 1;
}
outcome.merged += 1;
}
}
}
if cfg.promote {
promote(shared, input, cfg, &nodes, &mut actions, &mut stamps, &mut outcome).await;
}
Ok((actions, stamps, outcome))
}
#[allow(clippy::too_many_lines)]
async fn promote(
shared: &SharedToolCtx,
input: &CurationInput<'_>,
cfg: &CurationConfig,
nodes: &[Node],
actions: &mut Vec<FileAction>,
stamps: &mut Vec<GuardStamp>,
outcome: &mut CurationOutcome,
) {
let floor = f64::from(cfg.promote_min_importance);
let is_file_born = |n: &Node| n.topic_key.starts_with(pensieve_ccmem::TOPIC_KEY_PREFIX);
let never_promotes =
|n: &Node| matches!(n.memory_type.as_str(), "summary" | "entity") || is_file_born(n);
let user_owned: Vec<&Node> = nodes
.iter()
.filter(|n| n.prov_flag("cc_user_owned") && n.prov_str("cc_promoted_file").is_some())
.collect();
let prev_promoted: Vec<&Node> = nodes
.iter()
.filter(|n| {
n.prov_str("cc_promoted_file").is_some()
&& !n.prov_flag("cc_user_owned")
&& !is_file_born(n)
})
.collect();
let mut demoted: Vec<(&Node, &str)> = Vec::new();
let mut kept_prev: Vec<&Node> = Vec::new();
for n in &prev_promoted {
if n.dead() {
demoted.push((n, "invalidated in pensieve"));
} else if n.importance < floor * 0.8 || never_promotes(n) {
demoted.push((n, "demoted (importance fell)"));
} else {
kept_prev.push(n);
}
}
let refs = reference_counts(shared, input.realm).await;
let score = |n: &Node| -> f64 {
#[allow(clippy::cast_precision_loss)] let refs_n = refs.get(&n.id).copied().unwrap_or(0) as f64;
0.45 * n.importance
+ 0.20 * recency(&n.updated_at, input.now)
+ 0.25 * type_weight(&n.memory_type)
+ 0.10 * (refs_n / 3.0).min(1.0)
};
let slots = cfg.promote_max.saturating_sub(user_owned.len());
kept_prev.sort_by(|a, b| score(b).total_cmp(&score(a)));
while kept_prev.len() > slots {
let n = kept_prev.pop().expect("non-empty");
demoted.push((n, "demoted (over index cap)"));
}
let mut candidates: Vec<&Node> = nodes
.iter()
.filter(|n| {
!n.dead()
&& !never_promotes(n)
&& n.importance >= floor
&& n.prov_str("cc_promoted_file").is_none()
&& !n.prov_flag("cc_user_owned")
})
.collect();
candidates.sort_by(|a, b| score(b).total_cmp(&score(a)).then_with(|| a.id.cmp(&b.id)));
let mut selected: Vec<&Node> = kept_prev;
for c in candidates {
if selected.len() >= slots {
break;
}
selected.push(c);
}
selected.sort_by(|a, b| score(b).total_cmp(&score(a)).then_with(|| a.id.cmp(&b.id)));
for (n, reason) in demoted {
if let Some(file) = n.prov_str("cc_promoted_file").map(str::to_string) {
actions.push(FileAction::ArchiveFile {
file,
reason: reason.to_string(),
node_id: Some(n.id.clone()),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: n.id.clone(),
set: vec![],
remove: vec!["cc_promoted_file".to_string(), "cc_content_hash".to_string()],
});
outcome.archived_files += 1;
}
}
let related = related_names(shared, input.realm, &selected).await;
let mut used_files: std::collections::HashSet<String> = user_owned
.iter()
.filter_map(|n| n.prov_str("cc_promoted_file").map(str::to_string))
.collect();
let mut entries: Vec<IndexEntry> = Vec::new();
for n in &selected {
let title = n.display_title();
let file = match n.prov_str("cc_promoted_file") {
Some(f) => f.to_string(),
None => unique_file(&pensieve_ccmem::slug::memory_filename(&title), &used_files),
};
used_files.insert(file.clone());
let name = file.trim_end_matches(".md").to_string();
let cc_type = cc_type_for(&n.memory_type);
let body = build_body(&n.content, related.get(&n.id).map_or(&[][..], |v| v));
let hash = pensieve_ccmem::hash::content_hash(&name, Some(cc_type), &body);
let unchanged = n.prov_str("cc_promoted_file") == Some(file.as_str())
&& n.prov_str("cc_content_hash") == Some(hash.as_str());
if !unchanged {
let rendered = pensieve_ccmem::frontmatter::render(&pensieve_ccmem::frontmatter::MemoryFile {
front: pensieve_ccmem::frontmatter::Frontmatter {
name: Some(name.clone()),
description: Some(clip(&title, 140)),
cc_type: Some(cc_type.to_string()),
source: Some(pensieve_ccmem::PENSIEVE_SOURCE_MARKER.to_string()),
pensieve_memory_id: Some(n.id.clone()),
content_hash: Some(hash.clone()),
..pensieve_ccmem::frontmatter::Frontmatter::default()
},
body: body.clone(),
});
if n.prov_str("cc_promoted_file").is_none() {
outcome.promoted += 1;
} else {
outcome.refreshed += 1;
}
actions.push(FileAction::WriteMemoryFile {
file: file.clone(),
content: rendered,
node_id: n.id.clone(),
content_hash: hash.clone(),
});
stamps.push(GuardStamp {
action: actions.len() - 1,
node_id: n.id.clone(),
set: vec![
("cc_promoted_file".to_string(), serde_json::json!(file)),
("cc_content_hash".to_string(), serde_json::json!(hash)),
("cc_promoted_at".to_string(), serde_json::json!(input.now)),
],
remove: vec![],
});
}
entries.push(IndexEntry {
title: title.clone(),
file,
hook: clip(&title, 80),
});
}
for n in &user_owned {
let Some(file) = n.prov_str("cc_promoted_file") else {
continue;
};
let title = n.display_title();
entries.push(IndexEntry {
title: title.clone(),
file: file.to_string(),
hook: clip(&title, 80),
});
}
outcome.index_entries = entries.len();
actions.push(FileAction::SetIndex { entries });
}
pub fn cc_type_for(memory_type: &str) -> &'static str {
match memory_type {
"preference" => "user",
"learning" => "feedback",
"decision" => "project",
_ => "reference",
}
}
fn type_weight(t: &str) -> f64 {
match t {
"decision" | "preference" | "procedure" => 1.0,
"learning" => 0.8,
"fact" => 0.6,
_ => 0.0,
}
}
fn recency(updated_at: &str, now: &str) -> f64 {
let parse = |s: &str| chrono::DateTime::parse_from_rfc3339(s).ok();
let (Some(u), Some(n)) = (parse(updated_at), parse(now)) else {
return 0.5;
};
#[allow(clippy::cast_precision_loss)] let age_days = (n - u).num_seconds().max(0) as f64 / 86_400.0;
(-age_days / pensieve_memory::HALF_LIFE_DAYS).exp2()
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn unique_file(base: &str, used: &std::collections::HashSet<String>) -> String {
if !used.contains(base) {
return base.to_string();
}
let stem = base.trim_end_matches(".md");
for i in 2.. {
let candidate = format!("{stem}-{i}.md");
if !used.contains(&candidate) {
return candidate;
}
}
unreachable!()
}
fn build_body(content: &str, related: &[String]) -> String {
let mut body = content.trim_end().to_string();
if !related.is_empty() {
body.push_str("\n\nRelated: ");
let links: Vec<String> = related
.iter()
.map(|n| pensieve_ccmem::wikilink::to_wikilink(n))
.collect();
body.push_str(&links.join(", "));
}
body.push_str("\n\n<!-- managed by pensieve — edit freely; pensieve pulls your edits back -->\n");
body
}
async fn realm_nodes(shared: &SharedToolCtx, realm: &str) -> Vec<Node> {
let q = format!(
"WITH latest AS (SELECT *, \
row_number() OVER (PARTITION BY id ORDER BY updated_at DESC) AS rn FROM {nt}) \
SELECT id, memory_type, title, content, importance, status, updated_at, \
invalid_at, topic_key, provenance \
FROM latest WHERE rn = 1 AND realm = {r}",
nt = pensieve_memory::NODE_TABLE,
r = pensieve_memory::sql::sql_str(realm),
);
let res = super::execute_sql(shared, pensieve_memory::DEFAULT_DATABASE, &q, 100_000).await;
res.get("rows")
.and_then(Value::as_array)
.map(|rows| rows.iter().filter_map(Node::from_row).collect())
.unwrap_or_default()
}
async fn reference_counts(
shared: &SharedToolCtx,
realm: &str,
) -> std::collections::HashMap<String, i64> {
let q = format!(
"SELECT src, COUNT(DISTINCT id) AS c FROM {et} \
WHERE type = 'REFERENCES' AND realm = {r} GROUP BY src",
et = pensieve_memory::EDGE_TABLE,
r = pensieve_memory::sql::sql_str(realm),
);
let res = super::execute_sql(shared, pensieve_memory::DEFAULT_DATABASE, &q, 100_000).await;
res.get("rows")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|r| {
Some((
r.get("src")?.as_str()?.to_string(),
r.get("c").and_then(Value::as_i64).unwrap_or(0),
))
})
.collect()
})
.unwrap_or_default()
}
async fn related_names(
shared: &SharedToolCtx,
realm: &str,
selected: &[&Node],
) -> std::collections::HashMap<String, Vec<String>> {
use std::collections::HashMap;
let mut out: HashMap<String, Vec<String>> = HashMap::new();
if selected.len() < 2 {
return out;
}
let stems: HashMap<&str, String> = selected
.iter()
.filter_map(|n| {
n.prov_str("cc_promoted_file")
.map(|f| (n.id.as_str(), f.trim_end_matches(".md").to_string()))
})
.collect();
let q = format!(
"SELECT DISTINCT src, dst FROM {et} \
WHERE type IN ('RELATES_TO', 'REFERENCES') AND realm = {r}",
et = pensieve_memory::EDGE_TABLE,
r = pensieve_memory::sql::sql_str(realm),
);
let res = super::execute_sql(shared, pensieve_memory::DEFAULT_DATABASE, &q, 100_000).await;
let Some(rows) = res.get("rows").and_then(Value::as_array) else {
return out;
};
for row in rows {
let (Some(src), Some(dst)) = (
row.get("src").and_then(Value::as_str),
row.get("dst").and_then(Value::as_str),
) else {
continue;
};
for (a, b) in [(src, dst), (dst, src)] {
if let Some(stem) = stems.get(b) {
let v = out.entry(a.to_string()).or_default();
if v.len() < 3 && !v.contains(stem) {
v.push(stem.clone());
}
}
}
}
for v in out.values_mut() {
v.sort();
}
out
}
async fn stamp(
writer: &MemoryWriter,
shared: &SharedToolCtx,
node_id: &str,
now: &str,
f: impl FnOnce(&mut serde_json::Map<String, Value>),
) -> anyhow::Result<()> {
let Some(mut row) = fetch_full_row(shared, node_id).await else {
return Ok(());
};
let mut prov = row
.get("provenance")
.and_then(Value::as_str)
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = prov.as_object_mut() {
f(obj);
}
if let Some(obj) = row.as_object_mut() {
obj.insert("provenance".into(), serde_json::json!(prov.to_string()));
obj.insert("updated_at".into(), serde_json::json!(now));
}
writer
.append_node_rows(vec![row])
.await
.map_err(|e| anyhow::anyhow!("stamping {node_id}: {e}"))
}
async fn archive_as_duplicate(
writer: &MemoryWriter,
shared: &SharedToolCtx,
loser: &Node,
winner: &Node,
now: &str,
) -> anyhow::Result<()> {
let Some(mut row) = fetch_full_row(shared, &loser.id).await else {
return Ok(());
};
let mut prov = loser.prov.clone();
if let Some(obj) = prov.as_object_mut() {
obj.insert("cc_archived_reason".into(), serde_json::json!("duplicate"));
obj.insert("cc_archived_at".into(), serde_json::json!(now));
}
if let Some(obj) = row.as_object_mut() {
obj.insert("status".into(), serde_json::json!("archived"));
obj.insert("invalid_at".into(), serde_json::json!(now));
obj.insert("superseded_by".into(), serde_json::json!(winner.id));
obj.insert("updated_at".into(), serde_json::json!(now));
obj.insert("provenance".into(), serde_json::json!(prov.to_string()));
}
writer
.append_node_rows(vec![row])
.await
.map_err(|e| anyhow::anyhow!("archiving duplicate {}: {e}", loser.id))
}
async fn fetch_full_row(shared: &SharedToolCtx, node_id: &str) -> Option<Value> {
let q = format!(
"WITH latest AS (SELECT *, \
row_number() OVER (PARTITION BY id ORDER BY updated_at DESC) AS rn FROM {nt}) \
SELECT {ALL_COLS} FROM latest WHERE rn = 1 AND id = {id} LIMIT 1",
nt = pensieve_memory::NODE_TABLE,
id = pensieve_memory::sql::sql_str(node_id),
);
let res = super::execute_sql(shared, pensieve_memory::DEFAULT_DATABASE, &q, 1).await;
res.get("rows")
.and_then(Value::as_array)
.and_then(|a| a.first())
.cloned()
}