1use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde_json::Value;
10use topodb::{
11 Db, EdgeId, NodeId, NodeRecord, Op, PropValue, Props, Scope, ScopeSet, TimeAxis, TopoError,
12};
13
14use crate::{
15 merge_required_prop, normalize_edge_type, scopes_to_scope_set, ALIAS_EDGE_TYPE, ALIAS_LABEL,
16 ALIAS_NAME_PROP, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_HASH_PROP, MEMORY_CONTENT_PROP,
17 MEMORY_FORGOTTEN_AT_PROP, MEMORY_LABEL, MEMORY_SUPERSEDED_AT_PROP,
18};
19
20pub const DEFAULT_REMEMBER_EDGE_TYPE: &str = "about";
22
23#[derive(Debug)]
26pub enum ComposeError {
27 Invalid(String),
28 Engine(TopoError),
29}
30
31impl From<TopoError> for ComposeError {
32 fn from(e: TopoError) -> Self {
33 ComposeError::Engine(e)
34 }
35}
36
37pub fn entity_dedup_key(name: &str) -> String {
47 name.split_whitespace()
48 .collect::<Vec<_>>()
49 .join(" ")
50 .to_lowercase()
51}
52
53pub fn normalize_content(content: &str) -> String {
56 content.split_whitespace().collect::<Vec<_>>().join(" ")
57}
58
59pub fn content_hash(content: &str) -> String {
63 let normalized = normalize_content(content);
64 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
65 for b in normalized.as_bytes() {
66 h ^= *b as u64;
67 h = h.wrapping_mul(0x0000_0100_0000_01b3);
68 }
69 format!("{h:016x}")
70}
71
72pub fn memory_props(content: &str, extra: Option<&Value>) -> Result<Props, String> {
79 if let Some(Value::Object(map)) = extra {
80 for reserved in [
81 MEMORY_CONTENT_HASH_PROP,
82 MEMORY_SUPERSEDED_AT_PROP,
83 MEMORY_FORGOTTEN_AT_PROP,
84 ] {
85 if map.contains_key(reserved) {
86 return Err(format!(
87 "props must not include {reserved:?}: it is maintained by the engine write path"
88 ));
89 }
90 }
91 if map.contains_key(crate::MEMORY_KIND_PROP) {
92 return Err(format!(
93 "props must not include {:?}: set it via remember's kind parameter \
94 (episodic | semantic | procedural | decision)",
95 crate::MEMORY_KIND_PROP
96 ));
97 }
98 }
99 let mut props = merge_required_prop(
100 MEMORY_CONTENT_PROP,
101 PropValue::Str(content.to_string()),
102 extra,
103 )?;
104 props.insert(
105 MEMORY_CONTENT_HASH_PROP.into(),
106 PropValue::Str(content_hash(content)),
107 );
108 Ok(props)
109}
110
111pub fn resolve_entities_by_name(
127 db: &Db,
128 scopes: &ScopeSet,
129 name: &str,
130) -> Result<Vec<NodeRecord>, TopoError> {
131 let value = PropValue::Str(name.to_string());
132 let mut out = db.nodes_by_prop_normalized(scopes, ENTITY_LABEL, ENTITY_NAME_PROP, &value)?;
133 let aliases = match db.nodes_by_prop_normalized(scopes, ALIAS_LABEL, ALIAS_NAME_PROP, &value) {
134 Ok(hits) => hits,
135 Err(TopoError::Rejected(_)) => Vec::new(),
136 Err(e) => return Err(e),
137 };
138 for alias in aliases {
139 for edge in db.edges_from(
140 scopes,
141 alias.id,
142 None,
143 Some(ALIAS_EDGE_TYPE),
144 true,
145 TimeAxis::Valid,
146 )? {
147 if let Some(canonical) = db.node(scopes, edge.to) {
148 if canonical.label == ENTITY_LABEL {
149 out.push(canonical);
150 }
151 }
152 }
153 }
154 out.sort_by_key(|n| n.id);
155 out.dedup_by_key(|n| n.id);
156 Ok(out)
157}
158
159pub fn find_existing_entity(
165 db: &Db,
166 lookup: &ScopeSet,
167 name: &str,
168) -> Result<Option<NodeRecord>, TopoError> {
169 match resolve_entities_by_name(db, lookup, name) {
170 Ok(hits) => Ok(hits.into_iter().min_by_key(|n| n.id)),
171 Err(TopoError::Rejected(_)) => Ok(None),
172 Err(e) => Err(e),
173 }
174}
175
176pub fn existing_memory(
182 db: &Db,
183 write_scope: Scope,
184 content: &str,
185) -> Result<Option<NodeId>, TopoError> {
186 let hash = content_hash(content);
187 let want = normalize_content(content);
188 let scope_set = scopes_to_scope_set(&[write_scope]);
189 let candidates = db.nodes_by_prop(
190 &scope_set,
191 MEMORY_LABEL,
192 MEMORY_CONTENT_HASH_PROP,
193 &PropValue::Str(hash),
194 )?;
195 Ok(candidates
196 .into_iter()
197 .filter(|n| {
198 crate::MEMORY_TOMBSTONE_PROPS
199 .iter()
200 .all(|p| !n.props.contains_key(*p))
201 && matches!(n.props.get(MEMORY_CONTENT_PROP), Some(PropValue::Str(c)) if normalize_content(c) == want)
202 })
203 .min_by_key(|n| n.id)
204 .map(|n| n.id))
205}
206
207pub fn plan_supersede(
213 db: &Db,
214 write_scope: Scope,
215 ids: &[String],
216 now_ms: i64,
217) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
218 let mut ops = Vec::new();
219 let mut marked = Vec::new();
220 if ids.is_empty() {
221 return Ok((ops, marked));
222 }
223 let scope_set = scopes_to_scope_set(&[write_scope]);
224 let mut seen = BTreeSet::new();
225 for raw in ids {
226 let id: NodeId = raw
227 .parse()
228 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
229 if !seen.insert(id) {
230 continue;
231 }
232 let node = db.node(&scope_set, id).ok_or_else(|| {
233 ComposeError::Invalid(format!(
234 "supersedes id {raw} is not a node in the write scope"
235 ))
236 })?;
237 if node.label != MEMORY_LABEL {
238 return Err(ComposeError::Invalid(format!(
239 "supersedes id {raw} is a {}, not a Memory",
240 node.label
241 )));
242 }
243 if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
244 continue;
245 }
246 let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
247 props.insert(
248 MEMORY_SUPERSEDED_AT_PROP.into(),
249 Some(PropValue::Int(now_ms)),
250 );
251 ops.push(Op::SetNodeProps { id, props });
252 for e in db.edges_from(&scope_set, id, None, None, true, TimeAxis::Valid)? {
253 ops.push(Op::CloseEdge {
254 id: e.id,
255 valid_to: None,
256 superseded_at: None,
257 });
258 }
259 marked.push(id.to_string());
260 }
261 Ok((ops, marked))
262}
263
264pub fn plan_forget(
271 db: &Db,
272 write_scope: Scope,
273 ids: &[String],
274 now_ms: i64,
275) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
276 if ids.is_empty() {
277 return Err(ComposeError::Invalid(
278 "forget requires at least one memory id".into(),
279 ));
280 }
281 let scope_set = scopes_to_scope_set(&[write_scope]);
282 let mut ops = Vec::new();
283 let mut forgotten = Vec::new();
284 let mut seen = BTreeSet::new();
285 for raw in ids {
286 let id: NodeId = raw
287 .parse()
288 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
289 if !seen.insert(id) {
290 continue;
291 }
292 let node = db.node(&scope_set, id).ok_or_else(|| {
293 ComposeError::Invalid(format!("forget id {raw} is not a node in the write scope"))
294 })?;
295 if node.label != MEMORY_LABEL {
296 return Err(ComposeError::Invalid(format!(
297 "forget id {raw} is a {}, not a Memory",
298 node.label
299 )));
300 }
301 if node.props.contains_key(MEMORY_FORGOTTEN_AT_PROP) {
302 return Err(ComposeError::Invalid(format!(
303 "forget id {raw} is already forgotten"
304 )));
305 }
306 if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
307 return Err(ComposeError::Invalid(format!(
308 "forget id {raw} is already superseded — it has already left recall"
309 )));
310 }
311 let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
312 props.insert(
313 MEMORY_FORGOTTEN_AT_PROP.into(),
314 Some(PropValue::Int(now_ms)),
315 );
316 ops.push(Op::SetNodeProps { id, props });
317 for e in db.edges_from(&scope_set, id, None, None, true, TimeAxis::Valid)? {
318 ops.push(Op::CloseEdge {
319 id: e.id,
320 valid_to: None,
321 superseded_at: None,
322 });
323 }
324 forgotten.push(id.to_string());
325 }
326 Ok((ops, forgotten))
327}
328
329pub struct RememberRequest {
332 pub content: String,
333 pub entities: Vec<String>,
334 pub edge_type: Option<String>,
335 pub supersedes: Vec<String>,
336 pub props: Option<Value>,
339 pub kind: Option<String>,
343}
344
345impl RememberRequest {
346 pub fn validate(&self) -> Result<String, String> {
352 let ty = normalize_edge_type(
353 self.edge_type
354 .as_deref()
355 .unwrap_or(DEFAULT_REMEMBER_EDGE_TYPE),
356 )?;
357 if self.entities.is_empty() {
358 return Err(
359 "entities must contain at least one name — use create_memory for a deliberately unlinked note".into(),
360 );
361 }
362 if self.entities.iter().any(|n| n.trim().is_empty()) {
363 return Err("entity names must be non-empty".into());
364 }
365 if let Some(kind) = &self.kind {
366 crate::validate_memory_kind(kind)?;
367 }
368 Ok(ty)
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct PlannedEntity {
374 pub name: String,
375 pub id: NodeId,
376 pub created: bool,
377}
378
379pub struct RememberPlan {
380 pub ops: Vec<Op>,
382 pub memory_id: NodeId,
383 pub deduplicated: bool,
387 pub new_memory: Option<String>,
390 pub new_entities: Vec<(NodeId, String)>,
392 pub entities: Vec<PlannedEntity>,
393 pub edge_ids: Vec<String>,
394 pub superseded: Vec<String>,
395}
396
397pub fn plan_remember(
398 db: &Db,
399 write_scope: Scope,
400 lookup: &ScopeSet,
401 now_ms: i64,
402 req: &RememberRequest,
403) -> Result<RememberPlan, ComposeError> {
404 let ty = req.validate().map_err(ComposeError::Invalid)?;
405
406 let mut memory_props_result =
408 memory_props(&req.content, req.props.as_ref()).map_err(ComposeError::Invalid)?;
409 if let Some(kind) = &req.kind {
410 memory_props_result.insert(crate::MEMORY_KIND_PROP.into(), PropValue::Str(kind.clone()));
411 }
412
413 let mut supersedes_ids = BTreeSet::new();
417 for raw in &req.supersedes {
418 let id: NodeId = raw
419 .parse()
420 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
421 supersedes_ids.insert(id);
422 }
423
424 let existing = existing_memory(db, write_scope, &req.content)?;
425 let deduplicated = existing.is_some() && !supersedes_ids.contains(&existing.unwrap());
427 let memory_id = if deduplicated {
428 existing.unwrap()
429 } else {
430 NodeId::new()
431 };
432 let (supersede_ops, superseded) = plan_supersede(db, write_scope, &req.supersedes, now_ms)?;
433
434 struct Resolved {
435 name: String,
436 id: NodeId,
437 created: bool,
438 op: Option<Op>,
439 }
440 let mut seen = BTreeSet::new();
441 let mut resolved: Vec<Resolved> = Vec::new();
442 for name in req
443 .entities
444 .iter()
445 .filter(|n| seen.insert(entity_dedup_key(n)))
446 {
447 match find_existing_entity(db, lookup, name)? {
448 Some(node) => resolved.push(Resolved {
449 name: name.clone(),
450 id: node.id,
451 created: false,
452 op: None,
453 }),
454 None => {
455 let id = NodeId::new();
456 let props =
457 merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name.clone()), None)
458 .map_err(ComposeError::Invalid)?;
459 resolved.push(Resolved {
468 name: name.clone(),
469 id,
470 created: true,
471 op: Some(Op::UpsertNode {
472 id,
473 scope: write_scope,
474 label: ENTITY_LABEL.into(),
475 key_prop: ENTITY_NAME_PROP.into(),
476 props,
477 }),
478 });
479 }
480 }
481 }
482
483 let mut ops: Vec<Op> = Vec::new();
484 let mut new_memory = None;
485 if !deduplicated {
486 ops.push(Op::CreateNode {
487 id: memory_id,
488 scope: write_scope,
489 label: MEMORY_LABEL.into(),
490 props: memory_props_result,
491 });
492 new_memory = Some(req.content.clone());
493 }
494
495 let mut seen_ids = BTreeSet::new();
497 resolved.retain(|r| seen_ids.insert(r.id));
498
499 let already_linked: HashMap<NodeId, EdgeId> = if deduplicated {
501 let scope_set = scopes_to_scope_set(&[write_scope]);
502 db.edges_from(
503 &scope_set,
504 memory_id,
505 None,
506 Some(ty.as_str()),
507 true,
508 TimeAxis::Valid,
509 )?
510 .into_iter()
511 .map(|e| (e.to, e.id))
512 .collect()
513 } else {
514 HashMap::new()
515 };
516
517 let mut entities = Vec::with_capacity(resolved.len());
518 let mut edge_ids = Vec::with_capacity(resolved.len());
519 let mut new_entities = Vec::new();
520 for r in resolved {
521 if let Some(op) = r.op {
522 new_entities.push((r.id, r.name.clone()));
523 ops.push(op);
524 }
525 let edge_id = match already_linked.get(&r.id) {
526 Some(existing_edge) => existing_edge.to_string(),
527 None => {
528 let id = EdgeId::new();
529 ops.push(Op::CreateEdge {
530 id,
531 scope: write_scope,
532 ty: ty.clone().into(),
533 from: memory_id,
534 to: r.id,
535 props: Props::new(),
536 valid_from: None,
537 recorded_at: None,
538 });
539 id.to_string()
540 }
541 };
542 edge_ids.push(edge_id);
543 entities.push(PlannedEntity {
544 name: r.name,
545 id: r.id,
546 created: r.created,
547 });
548 }
549 ops.extend(supersede_ops);
550 Ok(RememberPlan {
551 ops,
552 memory_id,
553 deduplicated,
554 new_memory,
555 new_entities,
556 entities,
557 edge_ids,
558 superseded,
559 })
560}
561
562pub fn apply_upsert_remap(entities: &mut [PlannedEntity], remap: &[(NodeId, NodeId)]) {
572 if remap.is_empty() {
573 return;
574 }
575 let map: HashMap<NodeId, NodeId> = remap.iter().copied().collect();
576 for e in entities.iter_mut() {
577 if let Some(&surviving) = map.get(&e.id) {
578 e.id = surviving;
579 e.created = false;
580 }
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use topodb::Op;
588
589 #[test]
590 fn apply_upsert_remap_rewrites_collapsed_entities_only() {
591 let planned_a = NodeId::new();
592 let surviving = NodeId::new();
593 let planned_b = NodeId::new();
594 let mut entities = vec![
595 PlannedEntity {
596 name: "A".into(),
597 id: planned_a,
598 created: true,
599 },
600 PlannedEntity {
601 name: "B".into(),
602 id: planned_b,
603 created: true,
604 },
605 ];
606 apply_upsert_remap(&mut entities, &[(planned_a, surviving)]);
608 assert_eq!(
609 entities[0].id, surviving,
610 "collapsed entity takes surviving id"
611 );
612 assert!(
613 !entities[0].created,
614 "collapsed entity is not created by this call"
615 );
616 assert_eq!(entities[1].id, planned_b, "un-remapped entity is untouched");
617 assert!(entities[1].created, "un-remapped entity keeps created=true");
618 let before = entities.clone();
620 apply_upsert_remap(&mut entities, &[]);
621 assert_eq!(entities, before);
622 }
623
624 #[test]
625 fn plan_supersede_is_public_and_stamps() {
626 let dir = tempfile::tempdir().unwrap();
627 let db = Db::open(dir.path().join("t.redb")).unwrap();
628 let id = NodeId::new();
629 db.submit(vec![Op::CreateNode {
630 id,
631 scope: Scope::Shared,
632 label: MEMORY_LABEL.into(),
633 props: memory_props("old fact", None).unwrap(),
634 }])
635 .unwrap();
636 let (ops, marked) = plan_supersede(&db, Scope::Shared, &[id.to_string()], 42).unwrap();
637 assert_eq!(marked, vec![id.to_string()]);
638 assert!(!ops.is_empty());
639 }
640}