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