1use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde_json::Value;
10use topodb::{Db, EdgeId, NodeId, NodeRecord, Op, PropValue, Props, Scope, ScopeSet, TopoError};
11
12use crate::{
13 merge_required_prop, normalize_edge_type, scopes_to_scope_set, ALIAS_EDGE_TYPE, ALIAS_LABEL,
14 ALIAS_NAME_PROP, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_HASH_PROP, MEMORY_CONTENT_PROP,
15 MEMORY_FORGOTTEN_AT_PROP, MEMORY_LABEL, MEMORY_SUPERSEDED_AT_PROP,
16};
17
18pub const DEFAULT_REMEMBER_EDGE_TYPE: &str = "about";
20
21#[derive(Debug)]
24pub enum ComposeError {
25 Invalid(String),
26 Engine(TopoError),
27}
28
29impl From<TopoError> for ComposeError {
30 fn from(e: TopoError) -> Self {
31 ComposeError::Engine(e)
32 }
33}
34
35pub fn entity_dedup_key(name: &str) -> String {
45 name.split_whitespace()
46 .collect::<Vec<_>>()
47 .join(" ")
48 .to_lowercase()
49}
50
51pub fn normalize_content(content: &str) -> String {
54 content.split_whitespace().collect::<Vec<_>>().join(" ")
55}
56
57pub fn content_hash(content: &str) -> String {
61 let normalized = normalize_content(content);
62 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
63 for b in normalized.as_bytes() {
64 h ^= *b as u64;
65 h = h.wrapping_mul(0x0000_0100_0000_01b3);
66 }
67 format!("{h:016x}")
68}
69
70pub fn memory_props(content: &str, extra: Option<&Value>) -> Result<Props, String> {
77 if let Some(Value::Object(map)) = extra {
78 for reserved in [
79 MEMORY_CONTENT_HASH_PROP,
80 MEMORY_SUPERSEDED_AT_PROP,
81 MEMORY_FORGOTTEN_AT_PROP,
82 ] {
83 if map.contains_key(reserved) {
84 return Err(format!(
85 "props must not include {reserved:?}: it is maintained by the engine write path"
86 ));
87 }
88 }
89 if map.contains_key(crate::MEMORY_KIND_PROP) {
90 return Err(format!(
91 "props must not include {:?}: set it via remember's kind parameter \
92 (episodic | semantic | procedural)",
93 crate::MEMORY_KIND_PROP
94 ));
95 }
96 }
97 let mut props = merge_required_prop(
98 MEMORY_CONTENT_PROP,
99 PropValue::Str(content.to_string()),
100 extra,
101 )?;
102 props.insert(
103 MEMORY_CONTENT_HASH_PROP.into(),
104 PropValue::Str(content_hash(content)),
105 );
106 Ok(props)
107}
108
109pub fn resolve_entities_by_name(
125 db: &Db,
126 scopes: &ScopeSet,
127 name: &str,
128) -> Result<Vec<NodeRecord>, TopoError> {
129 let value = PropValue::Str(name.to_string());
130 let mut out = db.nodes_by_prop_normalized(scopes, ENTITY_LABEL, ENTITY_NAME_PROP, &value)?;
131 let aliases = match db.nodes_by_prop_normalized(scopes, ALIAS_LABEL, ALIAS_NAME_PROP, &value) {
132 Ok(hits) => hits,
133 Err(TopoError::Rejected(_)) => Vec::new(),
134 Err(e) => return Err(e),
135 };
136 for alias in aliases {
137 for edge in db.edges_from(scopes, alias.id, None, Some(ALIAS_EDGE_TYPE), true)? {
138 if let Some(canonical) = db.node(scopes, edge.to) {
139 if canonical.label == ENTITY_LABEL {
140 out.push(canonical);
141 }
142 }
143 }
144 }
145 out.sort_by_key(|n| n.id);
146 out.dedup_by_key(|n| n.id);
147 Ok(out)
148}
149
150pub fn find_existing_entity(
156 db: &Db,
157 lookup: &ScopeSet,
158 name: &str,
159) -> Result<Option<NodeRecord>, TopoError> {
160 match resolve_entities_by_name(db, lookup, name) {
161 Ok(hits) => Ok(hits.into_iter().min_by_key(|n| n.id)),
162 Err(TopoError::Rejected(_)) => Ok(None),
163 Err(e) => Err(e),
164 }
165}
166
167pub fn existing_memory(
173 db: &Db,
174 write_scope: Scope,
175 content: &str,
176) -> Result<Option<NodeId>, TopoError> {
177 let hash = content_hash(content);
178 let want = normalize_content(content);
179 let scope_set = scopes_to_scope_set(&[write_scope]);
180 let candidates = db.nodes_by_prop(
181 &scope_set,
182 MEMORY_LABEL,
183 MEMORY_CONTENT_HASH_PROP,
184 &PropValue::Str(hash),
185 )?;
186 Ok(candidates
187 .into_iter()
188 .filter(|n| {
189 crate::MEMORY_TOMBSTONE_PROPS
190 .iter()
191 .all(|p| !n.props.contains_key(*p))
192 && matches!(n.props.get(MEMORY_CONTENT_PROP), Some(PropValue::Str(c)) if normalize_content(c) == want)
193 })
194 .min_by_key(|n| n.id)
195 .map(|n| n.id))
196}
197
198pub fn plan_supersede(
204 db: &Db,
205 write_scope: Scope,
206 ids: &[String],
207 now_ms: i64,
208) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
209 let mut ops = Vec::new();
210 let mut marked = Vec::new();
211 if ids.is_empty() {
212 return Ok((ops, marked));
213 }
214 let scope_set = scopes_to_scope_set(&[write_scope]);
215 let mut seen = BTreeSet::new();
216 for raw in ids {
217 let id: NodeId = raw
218 .parse()
219 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
220 if !seen.insert(id) {
221 continue;
222 }
223 let node = db.node(&scope_set, id).ok_or_else(|| {
224 ComposeError::Invalid(format!(
225 "supersedes id {raw} is not a node in the write scope"
226 ))
227 })?;
228 if node.label != MEMORY_LABEL {
229 return Err(ComposeError::Invalid(format!(
230 "supersedes id {raw} is a {}, not a Memory",
231 node.label
232 )));
233 }
234 if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
235 continue;
236 }
237 let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
238 props.insert(
239 MEMORY_SUPERSEDED_AT_PROP.into(),
240 Some(PropValue::Int(now_ms)),
241 );
242 ops.push(Op::SetNodeProps { id, props });
243 for e in db.edges_from(&scope_set, id, None, None, true)? {
244 ops.push(Op::CloseEdge {
245 id: e.id,
246 valid_to: None,
247 });
248 }
249 marked.push(id.to_string());
250 }
251 Ok((ops, marked))
252}
253
254pub fn plan_forget(
261 db: &Db,
262 write_scope: Scope,
263 ids: &[String],
264 now_ms: i64,
265) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
266 if ids.is_empty() {
267 return Err(ComposeError::Invalid(
268 "forget requires at least one memory id".into(),
269 ));
270 }
271 let scope_set = scopes_to_scope_set(&[write_scope]);
272 let mut ops = Vec::new();
273 let mut forgotten = Vec::new();
274 let mut seen = BTreeSet::new();
275 for raw in ids {
276 let id: NodeId = raw
277 .parse()
278 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
279 if !seen.insert(id) {
280 continue;
281 }
282 let node = db.node(&scope_set, id).ok_or_else(|| {
283 ComposeError::Invalid(format!("forget id {raw} is not a node in the write scope"))
284 })?;
285 if node.label != MEMORY_LABEL {
286 return Err(ComposeError::Invalid(format!(
287 "forget id {raw} is a {}, not a Memory",
288 node.label
289 )));
290 }
291 if node.props.contains_key(MEMORY_FORGOTTEN_AT_PROP) {
292 return Err(ComposeError::Invalid(format!(
293 "forget id {raw} is already forgotten"
294 )));
295 }
296 if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
297 return Err(ComposeError::Invalid(format!(
298 "forget id {raw} is already superseded — it has already left recall"
299 )));
300 }
301 let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
302 props.insert(
303 MEMORY_FORGOTTEN_AT_PROP.into(),
304 Some(PropValue::Int(now_ms)),
305 );
306 ops.push(Op::SetNodeProps { id, props });
307 for e in db.edges_from(&scope_set, id, None, None, true)? {
308 ops.push(Op::CloseEdge {
309 id: e.id,
310 valid_to: None,
311 });
312 }
313 forgotten.push(id.to_string());
314 }
315 Ok((ops, forgotten))
316}
317
318pub struct RememberRequest {
321 pub content: String,
322 pub entities: Vec<String>,
323 pub edge_type: Option<String>,
324 pub supersedes: Vec<String>,
325 pub props: Option<Value>,
328 pub kind: Option<String>,
332}
333
334impl RememberRequest {
335 pub fn validate(&self) -> Result<String, String> {
341 let ty = normalize_edge_type(
342 self.edge_type
343 .as_deref()
344 .unwrap_or(DEFAULT_REMEMBER_EDGE_TYPE),
345 )?;
346 if self.entities.is_empty() {
347 return Err(
348 "entities must contain at least one name — use create_memory for a deliberately unlinked note".into(),
349 );
350 }
351 if self.entities.iter().any(|n| n.trim().is_empty()) {
352 return Err("entity names must be non-empty".into());
353 }
354 if let Some(kind) = &self.kind {
355 crate::validate_memory_kind(kind)?;
356 }
357 Ok(ty)
358 }
359}
360
361pub struct PlannedEntity {
362 pub name: String,
363 pub id: NodeId,
364 pub created: bool,
365}
366
367pub struct RememberPlan {
368 pub ops: Vec<Op>,
370 pub memory_id: NodeId,
371 pub deduplicated: bool,
375 pub new_memory: Option<String>,
378 pub new_entities: Vec<(NodeId, String)>,
380 pub entities: Vec<PlannedEntity>,
381 pub edge_ids: Vec<String>,
382 pub superseded: Vec<String>,
383}
384
385pub fn plan_remember(
386 db: &Db,
387 write_scope: Scope,
388 lookup: &ScopeSet,
389 now_ms: i64,
390 req: &RememberRequest,
391) -> Result<RememberPlan, ComposeError> {
392 let ty = req.validate().map_err(ComposeError::Invalid)?;
393
394 let mut memory_props_result =
396 memory_props(&req.content, req.props.as_ref()).map_err(ComposeError::Invalid)?;
397 if let Some(kind) = &req.kind {
398 memory_props_result.insert(crate::MEMORY_KIND_PROP.into(), PropValue::Str(kind.clone()));
399 }
400
401 let mut supersedes_ids = BTreeSet::new();
405 for raw in &req.supersedes {
406 let id: NodeId = raw
407 .parse()
408 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
409 supersedes_ids.insert(id);
410 }
411
412 let existing = existing_memory(db, write_scope, &req.content)?;
413 let deduplicated = existing.is_some() && !supersedes_ids.contains(&existing.unwrap());
415 let memory_id = if deduplicated {
416 existing.unwrap()
417 } else {
418 NodeId::new()
419 };
420 let (supersede_ops, superseded) = plan_supersede(db, write_scope, &req.supersedes, now_ms)?;
421
422 struct Resolved {
423 name: String,
424 id: NodeId,
425 created: bool,
426 op: Option<Op>,
427 }
428 let mut seen = BTreeSet::new();
429 let mut resolved: Vec<Resolved> = Vec::new();
430 for name in req
431 .entities
432 .iter()
433 .filter(|n| seen.insert(entity_dedup_key(n)))
434 {
435 match find_existing_entity(db, lookup, name)? {
436 Some(node) => resolved.push(Resolved {
437 name: name.clone(),
438 id: node.id,
439 created: false,
440 op: None,
441 }),
442 None => {
443 let id = NodeId::new();
444 let props =
445 merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name.clone()), None)
446 .map_err(ComposeError::Invalid)?;
447 resolved.push(Resolved {
448 name: name.clone(),
449 id,
450 created: true,
451 op: Some(Op::CreateNode {
452 id,
453 scope: write_scope,
454 label: ENTITY_LABEL.into(),
455 props,
456 }),
457 });
458 }
459 }
460 }
461
462 let mut ops: Vec<Op> = Vec::new();
463 let mut new_memory = None;
464 if !deduplicated {
465 ops.push(Op::CreateNode {
466 id: memory_id,
467 scope: write_scope,
468 label: MEMORY_LABEL.into(),
469 props: memory_props_result,
470 });
471 new_memory = Some(req.content.clone());
472 }
473
474 let mut seen_ids = BTreeSet::new();
476 resolved.retain(|r| seen_ids.insert(r.id));
477
478 let already_linked: HashMap<NodeId, EdgeId> = if deduplicated {
480 let scope_set = scopes_to_scope_set(&[write_scope]);
481 db.edges_from(&scope_set, memory_id, None, Some(ty.as_str()), true)?
482 .into_iter()
483 .map(|e| (e.to, e.id))
484 .collect()
485 } else {
486 HashMap::new()
487 };
488
489 let mut entities = Vec::with_capacity(resolved.len());
490 let mut edge_ids = Vec::with_capacity(resolved.len());
491 let mut new_entities = Vec::new();
492 for r in resolved {
493 if let Some(op) = r.op {
494 new_entities.push((r.id, r.name.clone()));
495 ops.push(op);
496 }
497 let edge_id = match already_linked.get(&r.id) {
498 Some(existing_edge) => existing_edge.to_string(),
499 None => {
500 let id = EdgeId::new();
501 ops.push(Op::CreateEdge {
502 id,
503 scope: write_scope,
504 ty: ty.clone().into(),
505 from: memory_id,
506 to: r.id,
507 props: Props::new(),
508 valid_from: None,
509 });
510 id.to_string()
511 }
512 };
513 edge_ids.push(edge_id);
514 entities.push(PlannedEntity {
515 name: r.name,
516 id: r.id,
517 created: r.created,
518 });
519 }
520 ops.extend(supersede_ops);
521 Ok(RememberPlan {
522 ops,
523 memory_id,
524 deduplicated,
525 new_memory,
526 new_entities,
527 entities,
528 edge_ids,
529 superseded,
530 })
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use topodb::Op;
537
538 #[test]
539 fn plan_supersede_is_public_and_stamps() {
540 let dir = tempfile::tempdir().unwrap();
541 let db = Db::open(dir.path().join("t.redb")).unwrap();
542 let id = NodeId::new();
543 db.submit(vec![Op::CreateNode {
544 id,
545 scope: Scope::Shared,
546 label: MEMORY_LABEL.into(),
547 props: memory_props("old fact", None).unwrap(),
548 }])
549 .unwrap();
550 let (ops, marked) = plan_supersede(&db, Scope::Shared, &[id.to_string()], 42).unwrap();
551 assert_eq!(marked, vec![id.to_string()]);
552 assert!(!ops.is_empty());
553 }
554}