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_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 [MEMORY_CONTENT_HASH_PROP, MEMORY_SUPERSEDED_AT_PROP] {
79 if map.contains_key(reserved) {
80 return Err(format!(
81 "props must not include {reserved:?}: it is maintained by the engine write path"
82 ));
83 }
84 }
85 }
86 let mut props = merge_required_prop(
87 MEMORY_CONTENT_PROP,
88 PropValue::Str(content.to_string()),
89 extra,
90 )?;
91 props.insert(
92 MEMORY_CONTENT_HASH_PROP.into(),
93 PropValue::Str(content_hash(content)),
94 );
95 Ok(props)
96}
97
98pub fn resolve_entities_by_name(
114 db: &Db,
115 scopes: &ScopeSet,
116 name: &str,
117) -> Result<Vec<NodeRecord>, TopoError> {
118 let value = PropValue::Str(name.to_string());
119 let mut out = db.nodes_by_prop_normalized(scopes, ENTITY_LABEL, ENTITY_NAME_PROP, &value)?;
120 let aliases = match db.nodes_by_prop_normalized(scopes, ALIAS_LABEL, ALIAS_NAME_PROP, &value) {
121 Ok(hits) => hits,
122 Err(TopoError::Rejected(_)) => Vec::new(),
123 Err(e) => return Err(e),
124 };
125 for alias in aliases {
126 for edge in db.edges_from(scopes, alias.id, None, Some(ALIAS_EDGE_TYPE), true)? {
127 if let Some(canonical) = db.node(scopes, edge.to) {
128 if canonical.label == ENTITY_LABEL {
129 out.push(canonical);
130 }
131 }
132 }
133 }
134 out.sort_by_key(|n| n.id);
135 out.dedup_by_key(|n| n.id);
136 Ok(out)
137}
138
139pub fn find_existing_entity(
145 db: &Db,
146 lookup: &ScopeSet,
147 name: &str,
148) -> Result<Option<NodeRecord>, TopoError> {
149 match resolve_entities_by_name(db, lookup, name) {
150 Ok(hits) => Ok(hits.into_iter().min_by_key(|n| n.id)),
151 Err(TopoError::Rejected(_)) => Ok(None),
152 Err(e) => Err(e),
153 }
154}
155
156pub fn existing_memory(
162 db: &Db,
163 write_scope: Scope,
164 content: &str,
165) -> Result<Option<NodeId>, TopoError> {
166 let hash = content_hash(content);
167 let want = normalize_content(content);
168 let scope_set = scopes_to_scope_set(&[write_scope]);
169 let candidates = db.nodes_by_prop(
170 &scope_set,
171 MEMORY_LABEL,
172 MEMORY_CONTENT_HASH_PROP,
173 &PropValue::Str(hash),
174 )?;
175 Ok(candidates
176 .into_iter()
177 .filter(|n| {
178 !n.props.contains_key(MEMORY_SUPERSEDED_AT_PROP)
179 && matches!(n.props.get(MEMORY_CONTENT_PROP), Some(PropValue::Str(c)) if normalize_content(c) == want)
180 })
181 .min_by_key(|n| n.id)
182 .map(|n| n.id))
183}
184
185fn plan_supersede(
189 db: &Db,
190 write_scope: Scope,
191 ids: &[String],
192 now_ms: i64,
193) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
194 let mut ops = Vec::new();
195 let mut marked = Vec::new();
196 if ids.is_empty() {
197 return Ok((ops, marked));
198 }
199 let scope_set = scopes_to_scope_set(&[write_scope]);
200 let mut seen = BTreeSet::new();
201 for raw in ids {
202 let id: NodeId = raw
203 .parse()
204 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
205 if !seen.insert(id) {
206 continue;
207 }
208 let node = db.node(&scope_set, id).ok_or_else(|| {
209 ComposeError::Invalid(format!(
210 "supersedes id {raw} is not a node in the write scope"
211 ))
212 })?;
213 if node.label != MEMORY_LABEL {
214 return Err(ComposeError::Invalid(format!(
215 "supersedes id {raw} is a {}, not a Memory",
216 node.label
217 )));
218 }
219 if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
220 continue;
221 }
222 let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
223 props.insert(
224 MEMORY_SUPERSEDED_AT_PROP.into(),
225 Some(PropValue::Int(now_ms)),
226 );
227 ops.push(Op::SetNodeProps { id, props });
228 for e in db.edges_from(&scope_set, id, None, None, true)? {
229 ops.push(Op::CloseEdge {
230 id: e.id,
231 valid_to: None,
232 });
233 }
234 marked.push(id.to_string());
235 }
236 Ok((ops, marked))
237}
238
239pub struct RememberRequest {
242 pub content: String,
243 pub entities: Vec<String>,
244 pub edge_type: Option<String>,
245 pub supersedes: Vec<String>,
246 pub props: Option<Value>,
249}
250
251impl RememberRequest {
252 pub fn validate(&self) -> Result<String, String> {
258 let ty = normalize_edge_type(
259 self.edge_type
260 .as_deref()
261 .unwrap_or(DEFAULT_REMEMBER_EDGE_TYPE),
262 )?;
263 if self.entities.is_empty() {
264 return Err(
265 "entities must contain at least one name — use create_memory for a deliberately unlinked note".into(),
266 );
267 }
268 if self.entities.iter().any(|n| n.trim().is_empty()) {
269 return Err("entity names must be non-empty".into());
270 }
271 Ok(ty)
272 }
273}
274
275pub struct PlannedEntity {
276 pub name: String,
277 pub id: NodeId,
278 pub created: bool,
279}
280
281pub struct RememberPlan {
282 pub ops: Vec<Op>,
284 pub memory_id: NodeId,
285 pub deduplicated: bool,
289 pub new_memory: Option<String>,
292 pub new_entities: Vec<(NodeId, String)>,
294 pub entities: Vec<PlannedEntity>,
295 pub edge_ids: Vec<String>,
296 pub superseded: Vec<String>,
297}
298
299pub fn plan_remember(
300 db: &Db,
301 write_scope: Scope,
302 lookup: &ScopeSet,
303 now_ms: i64,
304 req: &RememberRequest,
305) -> Result<RememberPlan, ComposeError> {
306 let ty = req.validate().map_err(ComposeError::Invalid)?;
307
308 let memory_props_result =
310 memory_props(&req.content, req.props.as_ref()).map_err(ComposeError::Invalid)?;
311
312 let mut supersedes_ids = BTreeSet::new();
316 for raw in &req.supersedes {
317 let id: NodeId = raw
318 .parse()
319 .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
320 supersedes_ids.insert(id);
321 }
322
323 let existing = existing_memory(db, write_scope, &req.content)?;
324 let deduplicated = existing.is_some() && !supersedes_ids.contains(&existing.unwrap());
326 let memory_id = if deduplicated {
327 existing.unwrap()
328 } else {
329 NodeId::new()
330 };
331 let (supersede_ops, superseded) = plan_supersede(db, write_scope, &req.supersedes, now_ms)?;
332
333 struct Resolved {
334 name: String,
335 id: NodeId,
336 created: bool,
337 op: Option<Op>,
338 }
339 let mut seen = BTreeSet::new();
340 let mut resolved: Vec<Resolved> = Vec::new();
341 for name in req
342 .entities
343 .iter()
344 .filter(|n| seen.insert(entity_dedup_key(n)))
345 {
346 match find_existing_entity(db, lookup, name)? {
347 Some(node) => resolved.push(Resolved {
348 name: name.clone(),
349 id: node.id,
350 created: false,
351 op: None,
352 }),
353 None => {
354 let id = NodeId::new();
355 let props =
356 merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name.clone()), None)
357 .map_err(ComposeError::Invalid)?;
358 resolved.push(Resolved {
359 name: name.clone(),
360 id,
361 created: true,
362 op: Some(Op::CreateNode {
363 id,
364 scope: write_scope,
365 label: ENTITY_LABEL.into(),
366 props,
367 }),
368 });
369 }
370 }
371 }
372
373 let mut ops: Vec<Op> = Vec::new();
374 let mut new_memory = None;
375 if !deduplicated {
376 ops.push(Op::CreateNode {
377 id: memory_id,
378 scope: write_scope,
379 label: MEMORY_LABEL.into(),
380 props: memory_props_result,
381 });
382 new_memory = Some(req.content.clone());
383 }
384
385 let mut seen_ids = BTreeSet::new();
387 resolved.retain(|r| seen_ids.insert(r.id));
388
389 let already_linked: HashMap<NodeId, EdgeId> = if deduplicated {
391 let scope_set = scopes_to_scope_set(&[write_scope]);
392 db.edges_from(&scope_set, memory_id, None, Some(ty.as_str()), true)?
393 .into_iter()
394 .map(|e| (e.to, e.id))
395 .collect()
396 } else {
397 HashMap::new()
398 };
399
400 let mut entities = Vec::with_capacity(resolved.len());
401 let mut edge_ids = Vec::with_capacity(resolved.len());
402 let mut new_entities = Vec::new();
403 for r in resolved {
404 if let Some(op) = r.op {
405 new_entities.push((r.id, r.name.clone()));
406 ops.push(op);
407 }
408 let edge_id = match already_linked.get(&r.id) {
409 Some(existing_edge) => existing_edge.to_string(),
410 None => {
411 let id = EdgeId::new();
412 ops.push(Op::CreateEdge {
413 id,
414 scope: write_scope,
415 ty: ty.clone().into(),
416 from: memory_id,
417 to: r.id,
418 props: Props::new(),
419 valid_from: None,
420 });
421 id.to_string()
422 }
423 };
424 edge_ids.push(edge_id);
425 entities.push(PlannedEntity {
426 name: r.name,
427 id: r.id,
428 created: r.created,
429 });
430 }
431 ops.extend(supersede_ops);
432 Ok(RememberPlan {
433 ops,
434 memory_id,
435 deduplicated,
436 new_memory,
437 new_entities,
438 entities,
439 edge_ids,
440 superseded,
441 })
442}