1#![allow(missing_docs)]
10
11use brain_db_sdk::RecallAnswer;
12use brain_db_sdk::wire::types::{
13 AnswerKindWire, EncodeGraphEdge, EncodeGraphNode, EncodeResponse, EncodeStageArtifact,
14 EncodeStageGraph, EncodeStageKeywordField, EncodeStageRecord, ForgetResponse,
15 MemoryInspectResponse, MemoryListDirWire, MemoryListItem, MemoryListRequest,
16 MemoryListSortWire, MemoryListTimeAxisWire, MemoryResult, WireMemoryId,
17};
18use serde::{Deserialize, Serialize};
19
20use super::{hex_decode, hex_encode, mem_id_decimal, parse_memory_id};
21
22#[derive(Debug, Deserialize)]
24pub struct EncodeBody {
25 pub text: String,
27 #[serde(default)]
29 pub session: Option<u64>,
30 #[serde(default)]
32 pub occurred_at: Option<u64>,
33}
34
35#[derive(Debug, Serialize)]
37pub struct EncodeResponseDto {
38 pub memory_id: String,
39 pub was_deduplicated: bool,
40 pub salience: f32,
41 pub kind: u8,
42 pub created_at_unix_nanos: u64,
43 pub auto_edges_added: u32,
44}
45
46impl From<EncodeResponse> for EncodeResponseDto {
47 fn from(r: EncodeResponse) -> Self {
48 Self {
49 memory_id: r.memory_id.to_string(),
50 was_deduplicated: r.was_deduplicated,
51 salience: r.salience,
52 kind: r.kind as u8,
53 created_at_unix_nanos: r.created_at_unix_nanos,
54 auto_edges_added: r.auto_edges_added,
55 }
56 }
57}
58
59#[derive(Debug, Deserialize)]
61pub struct RecallBody {
62 #[serde(alias = "cue")]
63 pub query: String,
64 #[serde(default)]
65 pub max_results: Option<u32>,
66 #[serde(default)]
67 pub subject: Option<String>,
68 #[serde(default)]
72 pub scope: Option<String>,
73}
74
75#[derive(Debug, Serialize)]
77pub struct MemoryHit {
78 pub memory_id: String,
79 pub text: String,
80 pub similarity_score: f32,
81 pub confidence: f32,
82 pub salience: f32,
83 pub kind: u8,
84 pub created_at_unix_nanos: u64,
85}
86
87impl From<MemoryResult> for MemoryHit {
88 fn from(m: MemoryResult) -> Self {
89 Self {
90 memory_id: m.memory_id.to_string(),
91 text: m.text,
92 similarity_score: m.similarity_score,
93 confidence: m.confidence,
94 salience: m.salience,
95 kind: m.kind as u8,
96 created_at_unix_nanos: m.created_at_unix_nanos,
97 }
98 }
99}
100
101#[derive(Debug, Serialize)]
103pub struct RecallResponseDto {
104 pub answer_kind: &'static str,
106 pub memories: Vec<MemoryHit>,
107}
108
109impl From<RecallAnswer> for RecallResponseDto {
110 fn from(a: RecallAnswer) -> Self {
111 let answer_kind = match a.answer_kind {
112 AnswerKindWire::Single => "single",
113 AnswerKindWire::Many => "many",
114 AnswerKindWire::None => "none",
115 };
116 Self {
117 answer_kind,
118 memories: a.memories.into_iter().map(MemoryHit::from).collect(),
119 }
120 }
121}
122
123#[derive(Debug, Deserialize)]
125pub struct ForgetBody {
126 pub memory_id: String,
128 #[serde(default)]
129 pub hard: bool,
130}
131
132impl ForgetBody {
133 pub fn parse_id(&self) -> Result<WireMemoryId, String> {
135 parse_memory_id(&self.memory_id)
136 }
137}
138
139#[derive(Debug, Serialize)]
141pub struct ForgetResponseDto {
142 pub memory_id: String,
143 pub was_already_forgotten: bool,
144 pub edges_removed: u32,
145}
146
147impl From<ForgetResponse> for ForgetResponseDto {
148 fn from(r: ForgetResponse) -> Self {
149 Self {
150 memory_id: r.memory_id.to_string(),
151 was_already_forgotten: r.was_already_forgotten,
152 edges_removed: r.edges_removed,
153 }
154 }
155}
156
157const LIST_DEFAULT_LIMIT: u32 = 50;
161const LIST_MAX_LIMIT: u32 = 100;
162
163#[derive(Debug, Deserialize)]
168pub struct MemoryListQuery {
169 #[serde(default)]
171 pub limit: Option<u32>,
172 #[serde(default)]
174 pub cursor: Option<String>,
175 #[serde(default)]
177 pub dir: Option<String>,
178 #[serde(default)]
180 pub include_tombstoned: Option<bool>,
181}
182
183impl MemoryListQuery {
184 pub fn to_request(&self) -> Result<MemoryListRequest, String> {
186 let limit = self
187 .limit
188 .unwrap_or(LIST_DEFAULT_LIMIT)
189 .clamp(1, LIST_MAX_LIMIT);
190 let dir = match self.dir.as_deref().map(str::trim) {
191 None | Some("" | "desc") => MemoryListDirWire::Desc,
192 Some("asc") => MemoryListDirWire::Asc,
193 Some(other) => return Err(format!("dir must be asc|desc, got `{other}`")),
194 };
195 let cursor = match &self.cursor {
196 Some(c) if !c.is_empty() => hex_decode(c)?,
197 _ => Vec::new(),
198 };
199 Ok(MemoryListRequest {
200 sort: MemoryListSortWire::Created,
201 dir,
202 limit,
203 cursor,
204 kinds: Vec::new(),
205 include_tombstoned: self.include_tombstoned.unwrap_or(false),
206 time_axis: MemoryListTimeAxisWire::Created,
207 from_unix_nanos: 0,
208 to_unix_nanos: 0,
209 salience_min: 0.0,
210 salience_max: 1.0,
211 text_contains: String::new(),
212 act_as: None,
213 })
214 }
215}
216
217#[derive(Debug, Serialize)]
221pub struct MemoryListItemDto {
222 pub memory_id: String,
224 pub text: String,
225 pub kind: u8,
227 pub state: u8,
229 pub created_at_unix_nanos: u64,
230 pub occurred_at_unix_nanos: u64,
231 pub last_accessed_at_unix_nanos: u64,
232 pub salience: f32,
233 pub access_count: u32,
234 pub statement_count: u32,
235 pub entity_count: u32,
236 pub relation_count: u32,
237}
238
239impl From<MemoryListItem> for MemoryListItemDto {
240 fn from(m: MemoryListItem) -> Self {
241 Self {
242 memory_id: mem_id_decimal(&m.memory_id),
243 text: m.text,
244 kind: m.kind,
245 state: m.state,
246 created_at_unix_nanos: m.created_at_unix_nanos,
247 occurred_at_unix_nanos: m.occurred_at_unix_nanos,
248 last_accessed_at_unix_nanos: m.last_accessed_at_unix_nanos,
249 salience: m.salience,
250 access_count: m.access_count,
251 statement_count: m.statement_count,
252 entity_count: m.entity_count,
253 relation_count: m.relation_count,
254 }
255 }
256}
257
258#[derive(Debug, Serialize)]
261pub struct MemoryListPageDto {
262 pub items: Vec<MemoryListItemDto>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub next_cursor: Option<String>,
265}
266
267impl MemoryListPageDto {
268 pub fn from_frames(frames: Vec<brain_db_sdk::wire::types::MemoryListResponseFrame>) -> Self {
271 let next = frames
272 .last()
273 .map(|f| f.next_cursor.clone())
274 .filter(|c| !c.is_empty())
275 .map(|c| hex_encode(&c));
276 let items = frames
277 .into_iter()
278 .flat_map(|f| f.items)
279 .map(MemoryListItemDto::from)
280 .collect();
281 Self {
282 items,
283 next_cursor: next,
284 }
285 }
286}
287
288#[derive(Debug, Serialize)]
297pub struct MemoryInspectDto {
298 pub found: bool,
299 pub memory_id: String,
301 pub text: String,
302 pub artifact: StageArtifactDto,
303}
304
305impl From<MemoryInspectResponse> for MemoryInspectDto {
306 fn from(r: MemoryInspectResponse) -> Self {
307 Self {
308 found: r.found,
309 memory_id: mem_id_decimal(&r.memory_id),
310 text: r.text,
311 artifact: StageArtifactDto::from(r.artifact),
312 }
313 }
314}
315
316#[derive(Debug, Serialize)]
319pub struct StageArtifactDto {
320 pub vector: Vec<f32>,
322 pub record: Option<StageRecordDto>,
324 pub hype_questions: Vec<String>,
326 pub keyword_fields: Vec<StageKeywordFieldDto>,
328 pub graph: Option<StageGraphDto>,
330}
331
332impl From<EncodeStageArtifact> for StageArtifactDto {
333 fn from(a: EncodeStageArtifact) -> Self {
334 Self {
335 vector: a.vector,
336 record: a.record.map(StageRecordDto::from),
337 hype_questions: a.hype_questions,
338 keyword_fields: a
339 .keyword_fields
340 .into_iter()
341 .map(StageKeywordFieldDto::from)
342 .collect(),
343 graph: a.graph.map(StageGraphDto::from),
344 }
345 }
346}
347
348#[derive(Debug, Serialize)]
350pub struct StageRecordDto {
351 pub memory_id: String,
352 pub kind: u8,
353 pub salience: f32,
354 pub created_at_unix_nanos: u64,
355 pub occurred_at_unix_nanos: u64,
356 pub vector_dim: u32,
357 pub text_len: u32,
358 pub lsn: u64,
359}
360
361impl From<EncodeStageRecord> for StageRecordDto {
362 fn from(r: EncodeStageRecord) -> Self {
363 Self {
364 memory_id: mem_id_decimal(&r.memory_id),
365 kind: r.kind,
366 salience: r.salience,
367 created_at_unix_nanos: r.created_at_unix_nanos,
368 occurred_at_unix_nanos: r.occurred_at_unix_nanos,
369 vector_dim: r.vector_dim,
370 text_len: r.text_len,
371 lsn: r.lsn,
372 }
373 }
374}
375
376#[derive(Debug, Serialize)]
378pub struct StageKeywordFieldDto {
379 pub field: String,
380 pub terms: Vec<String>,
381}
382
383impl From<EncodeStageKeywordField> for StageKeywordFieldDto {
384 fn from(k: EncodeStageKeywordField) -> Self {
385 Self {
386 field: k.field,
387 terms: k.terms,
388 }
389 }
390}
391
392#[derive(Debug, Serialize)]
394pub struct StageGraphDto {
395 pub nodes: Vec<StageGraphNodeDto>,
396 pub edges: Vec<StageGraphEdgeDto>,
397}
398
399impl From<EncodeStageGraph> for StageGraphDto {
400 fn from(g: EncodeStageGraph) -> Self {
401 Self {
402 nodes: g.nodes.into_iter().map(StageGraphNodeDto::from).collect(),
403 edges: g.edges.into_iter().map(StageGraphEdgeDto::from).collect(),
404 }
405 }
406}
407
408#[derive(Debug, Serialize)]
409pub struct StageGraphNodeDto {
410 pub id: String,
412 pub name: String,
413 pub kind: String,
414 pub type_qname: String,
415}
416
417impl From<EncodeGraphNode> for StageGraphNodeDto {
418 fn from(n: EncodeGraphNode) -> Self {
419 Self {
420 id: hex_encode(&n.id),
421 name: n.name,
422 kind: n.kind,
423 type_qname: n.type_qname,
424 }
425 }
426}
427
428#[derive(Debug, Serialize)]
429pub struct StageGraphEdgeDto {
430 pub source: String,
432 pub target: String,
434 pub predicate: String,
435 pub kind: String,
436 pub confidence: f32,
437 #[serde(skip_serializing_if = "Option::is_none")]
441 pub event_at_unix_nanos: Option<u64>,
442}
443
444impl From<EncodeGraphEdge> for StageGraphEdgeDto {
445 fn from(e: EncodeGraphEdge) -> Self {
446 Self {
447 source: hex_encode(&e.source),
448 target: hex_encode(&e.target),
449 predicate: e.predicate,
450 kind: e.kind,
451 confidence: e.confidence,
452 event_at_unix_nanos: e.event_at_unix_nanos,
453 }
454 }
455}
456
457#[cfg(test)]
458mod memory_tests {
459 use super::*;
460 use brain_db_sdk::RecallAnswer;
461 use brain_db_sdk::wire::types::{
462 EncodeGraphEdge, EncodeGraphNode, EncodeResponse, EncodeStageArtifact, EncodeStageGraph,
463 EncodeStageKeywordField, EncodeStageRecord, ForgetResponse, MemoryInspectResponse,
464 MemoryKindWire, MemoryListItem, MemoryListResponseFrame, MemoryResult,
465 };
466
467 fn sample_result(tag: u8, text: &str) -> MemoryResult {
468 MemoryResult {
469 memory_id: u128::from(tag),
470 text: text.into(),
471 similarity_score: 0.9,
472 confidence: 0.8,
473 salience: 0.5,
474 kind: MemoryKindWire::Semantic,
475 space_id: [tag; 16],
476 session_id: 0,
477 created_at_unix_nanos: 1,
478 last_accessed_at_unix_nanos: 1,
479 edges: None,
480 contributing_retrievers: vec![],
481 fused_score: 0.7,
482 rerank_score: None,
483 salience_initial: 0.5,
484 access_count: 0,
485 lsn: 1,
486 flags: 0,
487 consolidated_at_unix_nanos: None,
488 occurred_at_unix_nanos: None,
489 edges_out_count: 0,
490 edges_in_count: 0,
491 graph: None,
492 }
493 }
494
495 #[test]
496 fn list_query_clamps_limit_defaults_dir_and_rejects_bad_dir() {
497 let over = MemoryListQuery {
499 limit: Some(9999),
500 cursor: None,
501 dir: None,
502 include_tombstoned: None,
503 };
504 let req = over.to_request().unwrap();
505 assert_eq!(req.limit, LIST_MAX_LIMIT);
506 assert!(matches!(req.dir, MemoryListDirWire::Desc)); assert_eq!(req.cursor, [] as [u8; 0]);
508 assert!(!req.include_tombstoned);
509
510 let asc = MemoryListQuery {
511 limit: None,
512 cursor: Some("2a00ff".into()),
513 dir: Some(" asc ".into()),
514 include_tombstoned: Some(true),
515 }
516 .to_request()
517 .unwrap();
518 assert_eq!(asc.limit, LIST_DEFAULT_LIMIT);
519 assert!(matches!(asc.dir, MemoryListDirWire::Asc));
520 assert_eq!(asc.cursor, vec![0x2a, 0x00, 0xff]); assert!(asc.include_tombstoned);
522
523 assert!(
524 MemoryListQuery {
525 limit: None,
526 cursor: None,
527 dir: Some("sideways".into()),
528 include_tombstoned: None,
529 }
530 .to_request()
531 .is_err()
532 );
533 assert!(
535 MemoryListQuery {
536 limit: None,
537 cursor: Some("zz".into()),
538 dir: None,
539 include_tombstoned: None,
540 }
541 .to_request()
542 .is_err()
543 );
544 }
545
546 #[test]
547 fn forget_body_parses_decimal_id_and_rejects_junk() {
548 let ok = ForgetBody {
549 memory_id: "42".into(),
550 hard: true,
551 };
552 assert_eq!(ok.parse_id().unwrap(), 42u128);
553 assert!(
554 ForgetBody {
555 memory_id: "not-a-number".into(),
556 hard: false,
557 }
558 .parse_id()
559 .is_err()
560 );
561 }
562
563 #[test]
564 fn encode_response_maps_every_field_and_kind_byte() {
565 let resp = EncodeResponse {
566 memory_id: 7,
567 was_deduplicated: true,
568 salience: 0.42,
569 auto_edges_added: 3,
570 lsn: 99,
571 space_id: [0xAB; 16],
572 session_id: 5,
573 kind: MemoryKindWire::Consolidated,
574 created_at_unix_nanos: 1_700_000_000,
575 edges_out_count: 0,
576 embedding_model_fp: [0; 16],
577 pending_stages: vec![],
578 has_active_schema: true,
579 trace: None,
580 };
581 let dto = EncodeResponseDto::from(resp);
582 assert_eq!(dto.memory_id, "7"); assert!(dto.was_deduplicated);
584 assert_eq!(dto.salience.to_bits(), 0.42f32.to_bits());
585 assert_eq!(dto.kind, 2); assert_eq!(dto.created_at_unix_nanos, 1_700_000_000);
587 assert_eq!(dto.auto_edges_added, 3);
588 }
589
590 #[test]
591 fn recall_answer_kind_maps_single_many_none() {
592 let cases = [
593 (AnswerKindWire::Single, "single"),
594 (AnswerKindWire::Many, "many"),
595 (AnswerKindWire::None, "none"),
596 ];
597 for (kind, expected) in cases {
598 let answer = RecallAnswer {
599 answer_kind: kind,
600 memories: vec![sample_result(0xAA, "hit")],
601 };
602 let dto = RecallResponseDto::from(answer);
603 assert_eq!(dto.answer_kind, expected);
604 assert_eq!(dto.memories.len(), 1);
605 assert_eq!(dto.memories[0].memory_id, "170"); assert_eq!(dto.memories[0].text, "hit");
607 assert_eq!(dto.memories[0].kind, 1); }
609 }
610
611 #[test]
612 fn forget_response_maps_fields() {
613 let dto = ForgetResponseDto::from(ForgetResponse {
614 memory_id: 42,
615 was_already_forgotten: true,
616 edges_removed: 4,
617 });
618 assert_eq!(dto.memory_id, "42");
619 assert!(dto.was_already_forgotten);
620 assert_eq!(dto.edges_removed, 4);
621 }
622
623 fn sample_list_item(tag: u8) -> MemoryListItem {
624 MemoryListItem {
625 memory_id: [tag; 16],
626 space_id: [0; 16],
627 session_id: 0,
628 text: "row".into(),
629 kind: 1,
630 state: 0,
631 created_at_unix_nanos: 10,
632 occurred_at_unix_nanos: 0,
633 last_accessed_at_unix_nanos: 11,
634 salience: 0.5,
635 access_count: 2,
636 source_request_id: [0; 16],
637 statement_count: 1,
638 entity_count: 2,
639 relation_count: 3,
640 }
641 }
642
643 #[test]
644 fn list_page_flattens_frames_and_hex_encodes_tail_cursor() {
645 let frames = vec![
647 MemoryListResponseFrame {
648 items: vec![sample_list_item(1)],
649 next_cursor: Vec::new(),
650 cumulative_count: 1,
651 is_final: false,
652 },
653 MemoryListResponseFrame {
654 items: vec![sample_list_item(2)],
655 next_cursor: vec![0x2a, 0xff],
656 cumulative_count: 2,
657 is_final: true,
658 },
659 ];
660 let dto = MemoryListPageDto::from_frames(frames);
661 assert_eq!(dto.items.len(), 2);
662 assert_eq!(dto.items[0].statement_count, 1);
663 assert_eq!(dto.items[1].relation_count, 3);
664 assert_eq!(dto.next_cursor.as_deref(), Some("2aff"));
665
666 let done = MemoryListPageDto::from_frames(vec![MemoryListResponseFrame {
668 items: vec![sample_list_item(3)],
669 next_cursor: Vec::new(),
670 cumulative_count: 1,
671 is_final: true,
672 }]);
673 assert!(done.next_cursor.is_none());
674 }
675
676 #[test]
677 fn inspect_maps_stages_and_hex_encodes_graph_ids() {
678 let resp = MemoryInspectResponse {
679 found: true,
680 memory_id: [0; 16],
681 text: "hello".into(),
682 artifact: EncodeStageArtifact {
683 vector: vec![0.1, 0.2],
684 record: Some(EncodeStageRecord {
685 memory_id: [0; 16],
686 kind: 1,
687 salience: 0.5,
688 created_at_unix_nanos: 1,
689 occurred_at_unix_nanos: 0,
690 vector_dim: 384,
691 text_len: 5,
692 lsn: 7,
693 }),
694 hype_questions: vec!["who?".into()],
695 keyword_fields: vec![EncodeStageKeywordField {
696 field: "text".into(),
697 terms: vec!["hello".into()],
698 }],
699 graph: Some(EncodeStageGraph {
700 nodes: vec![EncodeGraphNode {
701 id: [0x11; 16],
702 name: "Ada".into(),
703 kind: "entity".into(),
704 type_qname: "brain:Person".into(),
705 }],
706 edges: vec![EncodeGraphEdge {
707 source: [0x11; 16],
708 target: [0x22; 16],
709 predicate: "born_in".into(),
710 kind: "statement".into(),
711 confidence: 0.9,
712 event_at_unix_nanos: Some(123),
713 }],
714 }),
715 },
716 };
717 let dto = MemoryInspectDto::from(resp);
718 assert!(dto.found);
719 assert_eq!(dto.text, "hello");
720 assert_eq!(dto.artifact.vector, vec![0.1, 0.2]);
721 assert_eq!(dto.artifact.record.as_ref().unwrap().vector_dim, 384);
722 assert_eq!(dto.artifact.hype_questions, vec!["who?".to_string()]);
723 let graph = dto.artifact.graph.unwrap();
724 assert_eq!(graph.nodes[0].id, "11".repeat(16)); assert_eq!(graph.edges[0].source, "11".repeat(16));
726 assert_eq!(graph.edges[0].target, "22".repeat(16));
727 assert_eq!(graph.edges[0].event_at_unix_nanos, Some(123));
728 }
729}