1use serde::Deserialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DreamGenerationMode {
16 Incremental,
17 Refine,
18 Rebuild,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22pub struct DurableExtractionEnvelope {
23 #[serde(default)]
24 pub candidates: Vec<DurableExtractionCandidate>,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct DurableExtractionCandidate {
29 pub title: String,
30 #[serde(rename = "type")]
31 pub kind: String,
32 pub content: String,
33 #[serde(default)]
34 pub scope: Option<String>,
35 #[serde(default)]
36 pub tags: Vec<String>,
37 #[serde(default)]
38 pub session_id: Option<String>,
39 #[serde(default)]
40 pub confidence: Option<String>,
41}
42
43pub fn strip_json_fence(raw: &str) -> &str {
48 let trimmed = raw.trim();
49 if let Some(rest) = trimmed.strip_prefix("```json") {
50 return rest.trim().trim_end_matches("```").trim();
51 }
52 if let Some(rest) = trimmed.strip_prefix("```") {
53 return rest.trim().trim_end_matches("```").trim();
54 }
55 trimmed
56}
57
58pub fn parse_extraction_candidates(raw: &str) -> Result<Vec<DurableExtractionCandidate>, String> {
59 let payload = strip_json_fence(raw);
60 let parsed: DurableExtractionEnvelope = serde_json::from_str(payload)
61 .map_err(|error| format!("failed to parse durable extraction candidates: {error}"))?;
62 Ok(parsed.candidates)
63}
64
65pub fn parse_candidate_scope(
66 candidate: &DurableExtractionCandidate,
67 project_key: Option<&str>,
68) -> crate::memory_store::MemoryScope {
69 match candidate
70 .scope
71 .as_deref()
72 .map(str::trim)
73 .map(str::to_ascii_lowercase)
74 .as_deref()
75 {
76 Some("project") if project_key.is_some() => crate::memory_store::MemoryScope::Project,
77 Some("global") => crate::memory_store::MemoryScope::Global,
78 _ if project_key.is_some() => crate::memory_store::MemoryScope::Project,
79 _ => crate::memory_store::MemoryScope::Global,
80 }
81}
82
83pub fn parse_candidate_type(kind: &str) -> Option<crate::memory_store::DurableMemoryType> {
84 match kind.trim().to_ascii_lowercase().as_str() {
85 "user" => Some(crate::memory_store::DurableMemoryType::User),
86 "feedback" => Some(crate::memory_store::DurableMemoryType::Feedback),
87 "project" => Some(crate::memory_store::DurableMemoryType::Project),
88 "reference" => Some(crate::memory_store::DurableMemoryType::Reference),
89 _ => None,
90 }
91}
92
93#[derive(serde::Deserialize)]
94struct SplitEnvelope {
95 #[serde(default)]
96 pieces: Vec<SplitPieceRaw>,
97}
98
99#[derive(serde::Deserialize)]
100struct SplitPieceRaw {
101 #[serde(default)]
102 title: String,
103 #[serde(default, rename = "type")]
104 kind: Option<String>,
105 #[serde(default)]
106 content: String,
107 #[serde(default)]
108 tags: Vec<String>,
109}
110
111pub fn parse_split_pieces(raw: &str) -> Result<Vec<crate::memory_store::MemorySplitPiece>, String> {
114 let payload = strip_json_fence(raw);
115 let parsed: SplitEnvelope = serde_json::from_str(payload)
116 .map_err(|error| format!("failed to parse split pieces: {error}"))?;
117 let pieces = parsed
118 .pieces
119 .into_iter()
120 .filter_map(|piece| {
121 let title = piece.title.trim().to_string();
122 let content = piece.content.trim().to_string();
123 if title.is_empty() || content.is_empty() {
124 return None;
125 }
126 Some(crate::memory_store::MemorySplitPiece {
127 title,
128 r#type: piece.kind.as_deref().and_then(parse_candidate_type),
129 content,
130 tags: piece.tags,
131 })
132 })
133 .collect();
134 Ok(pieces)
135}
136
137pub fn build_blob_split_prompt(title: &str, body: &str) -> String {
140 let mut prompt = String::from("# Bamboo Memory Split\n\n");
141 prompt.push_str(
142 "The durable memory below has accreted multiple facts and must be split into atomic memories.\n\n",
143 );
144 prompt.push_str("Rules:\n");
145 prompt.push_str("- Return JSON only: {\"pieces\":[{\"title\":string,\"type\":\"user\"|\"feedback\"|\"project\"|\"reference\",\"content\":string,\"tags\":string[]}]}\n");
146 prompt.push_str("- Each piece must capture exactly ONE atomic fact/decision/preference. Never combine unrelated facts.\n");
147 prompt.push_str("- The title must concisely summarize that piece's own content so it is findable by keyword search.\n");
148 prompt.push_str("- Preserve the original wording of each fact; do not invent facts. Drop only exact duplicates.\n");
149 prompt.push_str(
150 "- If the memory is actually a single coherent fact, return exactly one piece.\n\n",
151 );
152 prompt.push_str("## Memory\n");
153 prompt.push_str(&format!("- title: {title}\n"));
154 prompt.push_str("- body:\n```md\n");
155 prompt.push_str(body);
156 prompt.push_str("\n```\n");
157 prompt
158}
159
160#[derive(serde::Deserialize)]
161struct DedupDecisionRaw {
162 #[serde(default)]
163 same_fact: bool,
164 #[serde(default)]
165 merged: Option<SplitPieceRaw>,
166}
167
168pub fn parse_dedup_decision(
172 raw: &str,
173) -> Result<Option<crate::memory_store::MemorySplitPiece>, String> {
174 let payload = strip_json_fence(raw);
175 let parsed: DedupDecisionRaw = serde_json::from_str(payload)
176 .map_err(|error| format!("failed to parse dedup decision: {error}"))?;
177 if !parsed.same_fact {
178 return Ok(None);
179 }
180 let Some(piece) = parsed.merged else {
181 return Ok(None);
182 };
183 let title = piece.title.trim().to_string();
184 let content = piece.content.trim().to_string();
185 if title.is_empty() || content.is_empty() {
186 return Ok(None);
187 }
188 Ok(Some(crate::memory_store::MemorySplitPiece {
189 title,
190 r#type: piece.kind.as_deref().and_then(parse_candidate_type),
191 content,
192 tags: piece.tags,
193 }))
194}
195
196pub fn build_dedup_prompt(members: &[(String, String)]) -> String {
200 let mut prompt = String::from("# Bamboo Memory Deduplication\n\n");
201 prompt.push_str(
202 "The durable memories below were flagged as possible duplicates of each other.\n\n",
203 );
204 prompt
205 .push_str("Decide whether they all describe the SAME single fact/decision/preference.\n\n");
206 prompt.push_str("Rules:\n");
207 prompt.push_str("- Return JSON only: {\"same_fact\":boolean,\"merged\":{\"title\":string,\"type\":\"user\"|\"feedback\"|\"project\"|\"reference\",\"content\":string,\"tags\":string[]}}\n");
208 prompt.push_str("- Set same_fact=true and provide `merged` ONLY if they are genuinely the same fact. Merge them into ONE atomic memory that preserves every distinct detail, with a specific, keyword-findable title.\n");
209 prompt.push_str("- Set same_fact=false (omit `merged`) if they are merely related but distinct facts. When unsure, prefer false — never merge facts that are not the same.\n");
210 prompt.push_str(
211 "- Preserve original wording; do not invent facts. Drop only exact redundancy.\n\n",
212 );
213 prompt.push_str("## Memories\n");
214 for (index, (title, body)) in members.iter().enumerate() {
215 prompt.push_str(&format!("\n### Memory {}\n", index + 1));
216 prompt.push_str(&format!("- title: {title}\n"));
217 prompt.push_str("- body:\n```md\n");
218 prompt.push_str(body);
219 prompt.push_str("\n```\n");
220 }
221 prompt
222}
223
224pub fn truncate_chars(value: &str, max_chars: usize) -> String {
229 let mut out = String::new();
230 for (count, ch) in value.chars().enumerate() {
231 if count >= max_chars {
232 out.push_str("...");
233 return out;
234 }
235 out.push(ch);
236 }
237 out
238}
239
240pub fn strip_markdown_fence(raw: &str) -> &str {
241 let trimmed = raw.trim();
242 if let Some(rest) = trimmed.strip_prefix("```markdown") {
243 return rest.trim().trim_end_matches("```").trim();
244 }
245 if let Some(rest) = trimmed.strip_prefix("```md") {
246 return rest.trim().trim_end_matches("```").trim();
247 }
248 if let Some(rest) = trimmed.strip_prefix("```") {
249 return rest.trim().trim_end_matches("```").trim();
250 }
251 trimmed
252}
253
254pub fn strip_dream_notebook_wrapper(raw: &str) -> Option<String> {
255 let trimmed = strip_markdown_fence(raw).trim();
256 let mut lines = trimmed.lines();
257 if lines.next()?.trim() != "# Bamboo Dream Notebook" {
258 return None;
259 }
260
261 let mut body_lines = Vec::new();
262 let mut in_body = false;
263 for line in lines {
264 let trimmed_line = line.trim();
265 if !in_body {
266 if trimmed_line.is_empty() {
267 continue;
268 }
269 if trimmed_line.starts_with("Project key: ")
270 || trimmed_line.starts_with("Last consolidated at: ")
271 || trimmed_line.starts_with("Sessions reviewed: ")
272 || trimmed_line.starts_with("Model: ")
273 {
274 continue;
275 }
276 in_body = true;
277 }
278 body_lines.push(line);
279 }
280
281 let body = body_lines.join("\n").trim().to_string();
282 (!body.is_empty()).then_some(body)
283}
284
285pub fn normalize_dream_notebook_body(raw: &str, max_chars: usize) -> Result<String, String> {
286 let mut current = raw.trim().to_string();
287 if current.is_empty() {
288 return Err("auto-dream returned empty content".to_string());
289 }
290
291 for _ in 0..3 {
292 let stripped = strip_markdown_fence(¤t).trim().to_string();
293 if stripped.is_empty() {
294 return Err("auto-dream returned empty content".to_string());
295 }
296
297 if let Some(body) = strip_dream_notebook_wrapper(&stripped) {
298 current = body;
299 continue;
300 }
301
302 current = stripped;
303 break;
304 }
305
306 Ok(truncate_chars(current.trim(), max_chars))
307}
308
309#[derive(Debug, Clone)]
318pub struct DreamCandidateInfo {
319 pub session_id: String,
320 pub title: String,
321 pub project_key: Option<String>,
322 pub updated_at: String,
323 pub summary: Option<String>,
324 pub topics: Vec<(String, String)>,
325}
326
327pub fn build_extraction_prompt(candidates: &[DreamCandidateInfo]) -> String {
332 let mut prompt = String::from("# Bamboo Durable Memory Extraction\n\n");
333 prompt.push_str("Extract only durable memory candidates that should become canonical project/global memory.\n\n");
334 prompt.push_str("Rules:\n");
335 prompt.push_str("- Return JSON only, no markdown fences or commentary unless the entire response is fenced JSON.\n");
336 prompt.push_str("- Output shape: {\"candidates\":[{\"title\":string,\"type\":\"user\"|\"feedback\"|\"project\"|\"reference\",\"scope\":\"project\"|\"global\",\"content\":string,\"tags\":string[],\"session_id\":string,\"confidence\":\"high\"|\"medium\"|\"low\"}]}\n");
337 prompt.push_str("- Include at most 8 candidates total.\n");
338 prompt.push_str("- Each candidate must capture exactly ONE atomic fact/decision/preference. Never combine unrelated facts into a single candidate.\n");
339 prompt.push_str("- The title must concisely summarize THAT candidate's own content so it can be found later by keyword search; never use a generic title that does not match the content.\n");
340 prompt.push_str("- Skip transient scratch state, code/project structure derivable from tools, and anything low-confidence or secret-like.\n");
341 prompt.push_str("- Prefer project scope when the session clearly belongs to a project workspace; otherwise use global.\n\n");
342 prompt.push_str("## Candidate sessions\n\n");
343
344 for (index, session) in candidates.iter().enumerate() {
345 prompt.push_str(&format!(
346 "### Session {}\n- id: {}\n- title: {}\n- project_key: {}\n- updated_at: {}\n",
347 index + 1,
348 session.session_id,
349 session.title,
350 session.project_key.as_deref().unwrap_or("(none)"),
351 session.updated_at,
352 ));
353 if let Some(summary) = session
354 .summary
355 .as_deref()
356 .map(str::trim)
357 .filter(|value| !value.is_empty())
358 {
359 prompt.push_str("- summary:\n```md\n");
360 prompt.push_str(summary);
361 prompt.push_str("\n```\n");
362 }
363 if !session.topics.is_empty() {
364 prompt.push_str("- session topics:\n");
365 for (topic, content) in &session.topics {
366 prompt.push_str(&format!(" - {}:\n", topic));
367 prompt.push_str(" ```md\n");
368 prompt.push_str(content);
369 prompt.push_str("\n ```\n");
370 }
371 }
372 prompt.push('\n');
373 }
374
375 prompt
376}
377
378const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
383const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
384
385#[derive(Debug, Clone)]
389pub struct ConsolidationSessionInfo {
390 pub id: String,
391 pub title: String,
392 pub kind: String,
393 pub updated_at: String,
394 pub message_count: usize,
395 pub last_run_status: Option<String>,
396 pub summary: Option<String>,
397}
398
399fn build_consolidation_prompt_prefix() -> String {
400 let mut prompt = String::from("# Bamboo Dream Consolidation\n\n");
401 prompt
402 .push_str("You are performing a lightweight reflective consolidation pass for Bamboo.\n\n");
403 prompt.push_str(
404 "Your job is to synthesize durable cross-session signal from recent session activity into a concise notebook entry for future work.\n\n"
405 );
406 prompt.push_str("Requirements:\n");
407 prompt.push_str("- Focus on durable facts, recurring goals, stable constraints, user preferences, active project directions, and unresolved blockers\n");
408 prompt.push_str("- Only fold a fact into an existing memory when it is the same fact. Keep unrelated facts as separate memories; never join unrelated facts with separators.\n");
409 prompt.push_str("- Prefer cross-session patterns over one-off chatter\n");
410 prompt.push_str("- Do not include secrets, tokens, or highly transient details\n");
411 prompt.push_str("- Separate active ongoing threads from completed or obsolete items\n");
412 prompt.push_str("- Keep the final result compact and operational\n\n");
413 prompt.push_str("Return markdown with these sections exactly:\n");
414 prompt.push_str("1. ## Current durable context\n");
415 prompt.push_str("2. ## Cross-session patterns\n");
416 prompt.push_str("3. ## Active threads to remember\n");
417 prompt.push_str("4. ## Stable constraints and preferences\n");
418 prompt.push_str("5. ## Open risks or questions\n\n");
419 prompt
420}
421
422fn append_markdown_reference_section(
423 prompt: &mut String,
424 heading: &str,
425 content: Option<&str>,
426 empty_placeholder: &str,
427) {
428 prompt.push_str(heading);
429 prompt.push_str("\n\n");
430 if let Some(content) = content.map(str::trim).filter(|value| !value.is_empty()) {
431 prompt.push_str("```md\n");
432 prompt.push_str(content);
433 prompt.push_str("\n```\n\n");
434 } else {
435 prompt.push_str(empty_placeholder);
436 prompt.push_str("\n\n");
437 }
438}
439
440fn append_consolidation_recent_sessions_section(
441 prompt: &mut String,
442 sessions: &[ConsolidationSessionInfo],
443) {
444 prompt.push_str("## Recent sessions\n\n");
445 if sessions.is_empty() {
446 prompt.push_str("_(no recent sessions supplied)_\n");
447 return;
448 }
449
450 for (index, session) in sessions
451 .iter()
452 .take(MAX_INCLUDED_CONSOLIDATION_SESSIONS)
453 .enumerate()
454 {
455 prompt.push_str(&format!(
456 "### Session {}\n- id: {}\n- title: {}\n- kind: {}\n- updated_at: {}\n- message_count: {}\n",
457 index + 1,
458 session.id,
459 session.title,
460 session.kind,
461 session.updated_at,
462 session.message_count,
463 ));
464 if let Some(status) = session
465 .last_run_status
466 .as_deref()
467 .filter(|v| !v.trim().is_empty())
468 {
469 prompt.push_str(&format!("- last_run_status: {}\n", status));
470 }
471 if let Some(summary) = session
472 .summary
473 .as_deref()
474 .map(str::trim)
475 .filter(|v| !v.is_empty())
476 {
477 prompt.push_str("- summary:\n```md\n");
478 prompt.push_str(&truncate_chars(
479 summary,
480 MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION,
481 ));
482 prompt.push_str("\n```\n");
483 }
484 prompt.push('\n');
485 }
486
487 if sessions.len() > MAX_INCLUDED_CONSOLIDATION_SESSIONS {
488 prompt.push_str(&format!(
489 "_Only the most recent {} sessions are included in this pass out of {} candidates._\n",
490 MAX_INCLUDED_CONSOLIDATION_SESSIONS,
491 sessions.len()
492 ));
493 }
494}
495
496pub fn build_consolidation_prompt(sessions: &[ConsolidationSessionInfo]) -> String {
497 let mut prompt = build_consolidation_prompt_prefix();
498 append_consolidation_recent_sessions_section(&mut prompt, sessions);
499 prompt
500}
501
502pub fn build_consolidation_prompt_with_existing_dream(
503 existing_dream: Option<&str>,
504 sessions: &[ConsolidationSessionInfo],
505) -> String {
506 build_refine_consolidation_prompt(existing_dream, None, sessions)
507}
508
509pub fn build_refine_consolidation_prompt(
510 existing_dream: Option<&str>,
511 recent_durable_memory: Option<&str>,
512 sessions: &[ConsolidationSessionInfo],
513) -> String {
514 let mut prompt = build_consolidation_prompt_prefix();
515 prompt.push_str(
516 "When an existing Dream notebook is provided, start from it and preserve still-valid durable context while updating active threads based on recent sessions and recent durable memory updates. Remove obsolete items only when the recent evidence justifies it.\n\n",
517 );
518 append_markdown_reference_section(
519 &mut prompt,
520 "## Existing Dream notebook",
521 existing_dream,
522 "_(no existing Dream notebook supplied; fall back to synthesizing from recent sessions only)_",
523 );
524 append_markdown_reference_section(
525 &mut prompt,
526 "## Recent durable memory updates",
527 recent_durable_memory,
528 "_(no recent durable memory updates supplied)_",
529 );
530 append_consolidation_recent_sessions_section(&mut prompt, sessions);
531 prompt
532}
533
534pub fn build_rebuild_consolidation_prompt(
535 durable_memory_index: Option<&str>,
536 sessions: &[ConsolidationSessionInfo],
537) -> String {
538 let mut prompt = build_consolidation_prompt_prefix();
539 prompt.push_str(
540 "You are rebuilding the Dream notebook from canonical durable memory plus recent session activity. Use the durable memory index as the primary long-lived signal, and use recent sessions to refresh active threads, current priorities, and unresolved questions.\n\n",
541 );
542 append_markdown_reference_section(
543 &mut prompt,
544 "## Durable memory index",
545 durable_memory_index,
546 "_(no durable memory index supplied)_",
547 );
548 append_consolidation_recent_sessions_section(&mut prompt, sessions);
549 prompt
550}
551
552pub fn derive_session_outline(session: &bamboo_agent_core::Session) -> Option<String> {
561 use bamboo_agent_core::Role;
562
563 let mut parts = Vec::new();
564
565 if let Some(task_list) = session.task_list.as_ref() {
566 let rendered = task_list.format_for_prompt();
567 if !rendered.trim().is_empty() {
568 parts.push(rendered);
569 }
570 }
571
572 if parts.is_empty() {
573 let recent_messages = session
574 .messages
575 .iter()
576 .rev()
577 .filter(|message| !matches!(message.role, Role::System))
578 .take(6)
579 .collect::<Vec<_>>();
580 if recent_messages.is_empty() {
581 return None;
582 }
583 let mut rendered = String::new();
584 for message in recent_messages.into_iter().rev() {
585 let role = match message.role {
586 Role::User => "User",
587 Role::Assistant => "Assistant",
588 Role::Tool => "Tool",
589 Role::System => continue,
590 };
591 rendered.push_str(&format!(
592 "**{}**: {}\n\n",
593 role,
594 truncate_chars(message.content.trim(), 300)
595 ));
596 }
597 if !rendered.trim().is_empty() {
598 parts.push(rendered.trim().to_string());
599 }
600 }
601
602 (!parts.is_empty()).then(|| parts.join("\n\n---\n\n"))
603}
604
605pub fn normalize_existing_dream_for_prompt(
609 existing_dream: Option<&str>,
610 model: &str,
611 session_count: usize,
612 max_summary_chars: usize,
613) -> Option<String> {
614 existing_dream.and_then(|dream| {
615 match normalize_dream_notebook_body(dream, max_summary_chars) {
616 Ok(body) => Some(body),
617 Err(error) => {
618 tracing::warn!(
619 target: "bamboo.auto_dream",
620 event = "existing_input_normalization_failed",
621 model = model,
622 session_count = session_count,
623 "[auto_dream] failed to normalize existing Dream input; omitting prior Dream context: {}",
624 error
625 );
626 None
627 }
628 }
629 })
630}
631
632pub fn should_use_dream_refine_mode(memory_cfg: &bamboo_config::MemoryConfig) -> bool {
637 memory_cfg.dream_refine_mode
638}
639
640pub fn should_force_full_rebuild(
641 last_full_rebuild_at: Option<chrono::DateTime<chrono::Utc>>,
642 now: chrono::DateTime<chrono::Utc>,
643 rebuild_interval_secs: i64,
644) -> bool {
645 match last_full_rebuild_at {
646 Some(timestamp) => (now - timestamp) >= chrono::Duration::seconds(rebuild_interval_secs),
647 None => false,
648 }
649}
650
651pub fn parse_last_full_rebuild_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
652 note.lines()
653 .find_map(|line| line.trim().strip_prefix("Last full rebuild at: "))
654 .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
655 .map(|dt| dt.with_timezone(&chrono::Utc))
656}
657
658pub fn parse_last_consolidated_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
659 note.lines()
660 .find_map(|line| line.trim().strip_prefix("Last consolidated at: "))
661 .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
662 .map(|dt| dt.with_timezone(&chrono::Utc))
663}
664
665#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn truncate_chars_reports_truncation() {
675 let result = truncate_chars("abcde", 3);
676 assert_eq!(result, "abc...");
677 }
678
679 #[test]
680 fn truncate_chars_keeps_short_text() {
681 let result = truncate_chars("abc", 10);
682 assert_eq!(result, "abc");
683 }
684
685 #[test]
686 fn strip_json_fence_removes_fences() {
687 assert_eq!(strip_json_fence("```json\n{}\n```"), "{}");
688 assert_eq!(strip_json_fence("```\n{}\n```"), "{}");
689 assert_eq!(strip_json_fence("{}"), "{}");
690 }
691
692 #[test]
693 fn strip_markdown_fence_handles_variants() {
694 assert_eq!(strip_markdown_fence("```markdown\nhi\n```"), "hi");
695 assert_eq!(strip_markdown_fence("```md\nhi\n```"), "hi");
696 assert_eq!(strip_markdown_fence("```\nhi\n```"), "hi");
697 assert_eq!(strip_markdown_fence("hi"), "hi");
698 }
699
700 #[test]
701 fn parse_extraction_candidates_accepts_fenced_json() {
702 let input = "```json\n{\"candidates\":[{\"title\":\"T\",\"type\":\"user\",\"scope\":\"global\",\"content\":\"C\",\"tags\":[]}]}\n```";
703 let candidates = parse_extraction_candidates(input).expect("should parse");
704 assert_eq!(candidates.len(), 1);
705 assert_eq!(candidates[0].title, "T");
706 }
707
708 #[test]
709 fn parse_candidate_scope_defaults_to_project_when_key_available() {
710 let candidate = DurableExtractionCandidate {
711 title: "T".to_string(),
712 kind: "user".to_string(),
713 content: "C".to_string(),
714 scope: None,
715 tags: vec![],
716 session_id: None,
717 confidence: None,
718 };
719 assert_eq!(
720 parse_candidate_scope(&candidate, Some("proj-1")),
721 crate::memory_store::MemoryScope::Project
722 );
723 }
724
725 #[test]
726 fn parse_candidate_type_maps_known_types() {
727 assert!(parse_candidate_type("user").is_some());
728 assert!(parse_candidate_type("feedback").is_some());
729 assert!(parse_candidate_type("project").is_some());
730 assert!(parse_candidate_type("reference").is_some());
731 assert!(parse_candidate_type("unknown").is_none());
732 }
733
734 #[test]
735 fn strip_dream_notebook_wrapper_extracts_body() {
736 let input = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-01-01T00:00:00Z\nSessions reviewed: 1\nModel: test\n\n## Body\ncontent";
737 let body = strip_dream_notebook_wrapper(input).expect("should extract");
738 assert!(body.contains("## Body"));
739 assert!(!body.contains("Bamboo Dream Notebook"));
740 assert!(!body.contains("Last consolidated"));
741 }
742
743 #[test]
744 fn normalize_dream_notebook_body_strips_wrapper() {
745 let input = "# Bamboo Dream Notebook\n\nModel: test\n\n## Section\ndata\n";
746 let result = normalize_dream_notebook_body(input, 10000).expect("should normalize");
747 assert!(result.contains("## Section"));
748 assert!(!result.contains("Bamboo Dream Notebook"));
749 }
750
751 #[test]
752 fn normalize_dream_notebook_body_rejects_empty() {
753 assert!(normalize_dream_notebook_body("", 10000).is_err());
754 }
755
756 #[test]
757 fn build_extraction_prompt_includes_candidates() {
758 let candidates = vec![DreamCandidateInfo {
759 session_id: "s-1".to_string(),
760 title: "Title 1".to_string(),
761 project_key: Some("proj-a".to_string()),
762 updated_at: "2026-04-01T00:00:00Z".to_string(),
763 summary: Some("Important summary".to_string()),
764 topics: vec![("topic-a".to_string(), "content-a".to_string())],
765 }];
766 let prompt = build_extraction_prompt(&candidates);
767 assert!(prompt.contains("Bamboo Durable Memory Extraction"));
768 assert!(prompt.contains("s-1"));
769 assert!(prompt.contains("Title 1"));
770 assert!(prompt.contains("proj-a"));
771 assert!(prompt.contains("Important summary"));
772 assert!(prompt.contains("topic-a"));
773 }
774
775 #[test]
776 fn build_extraction_prompt_handles_empty_candidates() {
777 let prompt = build_extraction_prompt(&[]);
778 assert!(prompt.contains("Bamboo Durable Memory Extraction"));
779 assert!(prompt.contains("Candidate sessions"));
780 }
781
782 fn sample_consolidation_session(id: &str) -> ConsolidationSessionInfo {
783 ConsolidationSessionInfo {
784 id: id.to_string(),
785 title: format!("Title for {id}"),
786 kind: "Root".to_string(),
787 updated_at: "2026-04-01T00:00:00Z".to_string(),
788 message_count: 10,
789 last_run_status: Some("completed".to_string()),
790 summary: Some("Important summary".to_string()),
791 }
792 }
793
794 #[test]
795 fn consolidation_prompt_includes_session_metadata_and_summary() {
796 let prompt = build_consolidation_prompt(&[sample_consolidation_session("session-1")]);
797 assert!(prompt.contains("Bamboo Dream Consolidation"));
798 assert!(prompt.contains("session-1"));
799 assert!(prompt.contains("Important summary"));
800 assert!(prompt.contains("## Current durable context"));
801 }
802
803 #[test]
804 fn refine_consolidation_prompt_includes_existing_dream() {
805 let prompt = build_refine_consolidation_prompt(
806 Some("## Current durable context\n- Existing durable thread"),
807 Some("# Recent Memory Updates\n\n- `mem-1` User prefers concise plans"),
808 &[sample_consolidation_session("session-2")],
809 );
810 assert!(prompt.contains("## Existing Dream notebook"));
811 assert!(prompt.contains("Existing durable thread"));
812 assert!(prompt.contains("## Recent durable memory updates"));
813 assert!(prompt.contains("User prefers concise plans"));
814 assert!(prompt.contains("start from it and preserve still-valid durable context"));
815 assert!(prompt.contains("session-2"));
816 }
817
818 #[test]
819 fn rebuild_consolidation_prompt_includes_durable_memory_index() {
820 let prompt = build_rebuild_consolidation_prompt(
821 Some("# Bamboo Memory Index\n\n- `mem-1` Release freeze decision"),
822 &[sample_consolidation_session("session-3")],
823 );
824 assert!(prompt.contains("## Durable memory index"));
825 assert!(prompt.contains("Release freeze decision"));
826 assert!(prompt.contains("canonical durable memory plus recent session activity"));
827 assert!(prompt.contains("session-3"));
828 }
829
830 use std::sync::Mutex;
835
836 use async_trait::async_trait;
837 use futures::stream;
838
839 use bamboo_agent_core::storage::Storage;
840 use bamboo_llm::{LLMError, LLMStream};
841}