1use std::collections::HashSet;
8use std::sync::Arc;
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use futures::StreamExt;
13use serde::Deserialize;
14use tokio::sync::RwLock;
15
16use bamboo_agent_core::{Message, SessionKind};
17use bamboo_domain::reasoning::ReasoningEffort;
18use bamboo_infrastructure::Config;
19use bamboo_infrastructure::{LLMChunk, LLMProvider, LLMRequestOptions};
20use bamboo_infrastructure::{ProviderModelRouter, ProviderRegistry};
21use bamboo_infrastructure::{SessionIndexEntry, SessionStoreV2};
22
23use crate::memory_store::{MemoryScope, MemoryStore};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DreamGenerationMode {
31 Incremental,
32 Refine,
33 Rebuild,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct DurableExtractionEnvelope {
38 #[serde(default)]
39 pub candidates: Vec<DurableExtractionCandidate>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct DurableExtractionCandidate {
44 pub title: String,
45 #[serde(rename = "type")]
46 pub kind: String,
47 pub content: String,
48 #[serde(default)]
49 pub scope: Option<String>,
50 #[serde(default)]
51 pub tags: Vec<String>,
52 #[serde(default)]
53 pub session_id: Option<String>,
54 #[serde(default)]
55 pub confidence: Option<String>,
56}
57
58pub fn strip_json_fence(raw: &str) -> &str {
63 let trimmed = raw.trim();
64 if let Some(rest) = trimmed.strip_prefix("```json") {
65 return rest.trim().trim_end_matches("```").trim();
66 }
67 if let Some(rest) = trimmed.strip_prefix("```") {
68 return rest.trim().trim_end_matches("```").trim();
69 }
70 trimmed
71}
72
73pub fn parse_extraction_candidates(raw: &str) -> Result<Vec<DurableExtractionCandidate>, String> {
74 let payload = strip_json_fence(raw);
75 let parsed: DurableExtractionEnvelope = serde_json::from_str(payload)
76 .map_err(|error| format!("failed to parse durable extraction candidates: {error}"))?;
77 Ok(parsed.candidates)
78}
79
80pub fn parse_candidate_scope(
81 candidate: &DurableExtractionCandidate,
82 project_key: Option<&str>,
83) -> crate::memory_store::MemoryScope {
84 match candidate
85 .scope
86 .as_deref()
87 .map(str::trim)
88 .map(str::to_ascii_lowercase)
89 .as_deref()
90 {
91 Some("project") if project_key.is_some() => crate::memory_store::MemoryScope::Project,
92 Some("global") => crate::memory_store::MemoryScope::Global,
93 _ if project_key.is_some() => crate::memory_store::MemoryScope::Project,
94 _ => crate::memory_store::MemoryScope::Global,
95 }
96}
97
98pub fn parse_candidate_type(kind: &str) -> Option<crate::memory_store::DurableMemoryType> {
99 match kind.trim().to_ascii_lowercase().as_str() {
100 "user" => Some(crate::memory_store::DurableMemoryType::User),
101 "feedback" => Some(crate::memory_store::DurableMemoryType::Feedback),
102 "project" => Some(crate::memory_store::DurableMemoryType::Project),
103 "reference" => Some(crate::memory_store::DurableMemoryType::Reference),
104 _ => None,
105 }
106}
107
108pub fn truncate_chars(value: &str, max_chars: usize) -> String {
113 let mut out = String::new();
114 for (count, ch) in value.chars().enumerate() {
115 if count >= max_chars {
116 out.push_str("...");
117 return out;
118 }
119 out.push(ch);
120 }
121 out
122}
123
124pub fn strip_markdown_fence(raw: &str) -> &str {
125 let trimmed = raw.trim();
126 if let Some(rest) = trimmed.strip_prefix("```markdown") {
127 return rest.trim().trim_end_matches("```").trim();
128 }
129 if let Some(rest) = trimmed.strip_prefix("```md") {
130 return rest.trim().trim_end_matches("```").trim();
131 }
132 if let Some(rest) = trimmed.strip_prefix("```") {
133 return rest.trim().trim_end_matches("```").trim();
134 }
135 trimmed
136}
137
138pub fn strip_dream_notebook_wrapper(raw: &str) -> Option<String> {
139 let trimmed = strip_markdown_fence(raw).trim();
140 let mut lines = trimmed.lines();
141 if lines.next()?.trim() != "# Bamboo Dream Notebook" {
142 return None;
143 }
144
145 let mut body_lines = Vec::new();
146 let mut in_body = false;
147 for line in lines {
148 let trimmed_line = line.trim();
149 if !in_body {
150 if trimmed_line.is_empty() {
151 continue;
152 }
153 if trimmed_line.starts_with("Project key: ")
154 || trimmed_line.starts_with("Last consolidated at: ")
155 || trimmed_line.starts_with("Sessions reviewed: ")
156 || trimmed_line.starts_with("Model: ")
157 {
158 continue;
159 }
160 in_body = true;
161 }
162 body_lines.push(line);
163 }
164
165 let body = body_lines.join("\n").trim().to_string();
166 (!body.is_empty()).then_some(body)
167}
168
169pub fn normalize_dream_notebook_body(raw: &str, max_chars: usize) -> Result<String, String> {
170 let mut current = raw.trim().to_string();
171 if current.is_empty() {
172 return Err("auto-dream returned empty content".to_string());
173 }
174
175 for _ in 0..3 {
176 let stripped = strip_markdown_fence(¤t).trim().to_string();
177 if stripped.is_empty() {
178 return Err("auto-dream returned empty content".to_string());
179 }
180
181 if let Some(body) = strip_dream_notebook_wrapper(&stripped) {
182 current = body;
183 continue;
184 }
185
186 current = stripped;
187 break;
188 }
189
190 Ok(truncate_chars(current.trim(), max_chars))
191}
192
193#[derive(Debug, Clone)]
202pub struct DreamCandidateInfo {
203 pub session_id: String,
204 pub title: String,
205 pub project_key: Option<String>,
206 pub updated_at: String,
207 pub summary: Option<String>,
208 pub topics: Vec<(String, String)>,
209}
210
211pub fn build_extraction_prompt(candidates: &[DreamCandidateInfo]) -> String {
216 let mut prompt = String::from("# Bamboo Durable Memory Extraction\n\n");
217 prompt.push_str("Extract only durable memory candidates that should become canonical project/global memory.\n\n");
218 prompt.push_str("Rules:\n");
219 prompt.push_str("- Return JSON only, no markdown fences or commentary unless the entire response is fenced JSON.\n");
220 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");
221 prompt.push_str("- Include at most 8 candidates total.\n");
222 prompt.push_str("- Skip transient scratch state, code/project structure derivable from tools, and anything low-confidence or secret-like.\n");
223 prompt.push_str("- Prefer project scope when the session clearly belongs to a project workspace; otherwise use global.\n\n");
224 prompt.push_str("## Candidate sessions\n\n");
225
226 for (index, session) in candidates.iter().enumerate() {
227 prompt.push_str(&format!(
228 "### Session {}\n- id: {}\n- title: {}\n- project_key: {}\n- updated_at: {}\n",
229 index + 1,
230 session.session_id,
231 session.title,
232 session.project_key.as_deref().unwrap_or("(none)"),
233 session.updated_at,
234 ));
235 if let Some(summary) = session
236 .summary
237 .as_deref()
238 .map(str::trim)
239 .filter(|value| !value.is_empty())
240 {
241 prompt.push_str("- summary:\n```md\n");
242 prompt.push_str(summary);
243 prompt.push_str("\n```\n");
244 }
245 if !session.topics.is_empty() {
246 prompt.push_str("- session topics:\n");
247 for (topic, content) in &session.topics {
248 prompt.push_str(&format!(" - {}:\n", topic));
249 prompt.push_str(" ```md\n");
250 prompt.push_str(content);
251 prompt.push_str("\n ```\n");
252 }
253 }
254 prompt.push('\n');
255 }
256
257 prompt
258}
259
260const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
265const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
266
267#[derive(Debug, Clone)]
271pub struct ConsolidationSessionInfo {
272 pub id: String,
273 pub title: String,
274 pub kind: String,
275 pub updated_at: String,
276 pub message_count: usize,
277 pub last_run_status: Option<String>,
278 pub summary: Option<String>,
279}
280
281fn build_consolidation_prompt_prefix() -> String {
282 let mut prompt = String::from("# Bamboo Dream Consolidation\n\n");
283 prompt
284 .push_str("You are performing a lightweight reflective consolidation pass for Bamboo.\n\n");
285 prompt.push_str(
286 "Your job is to synthesize durable cross-session signal from recent session activity into a concise notebook entry for future work.\n\n"
287 );
288 prompt.push_str("Requirements:\n");
289 prompt.push_str("- Focus on durable facts, recurring goals, stable constraints, user preferences, active project directions, and unresolved blockers\n");
290 prompt.push_str("- Prefer cross-session patterns over one-off chatter\n");
291 prompt.push_str("- Do not include secrets, tokens, or highly transient details\n");
292 prompt.push_str("- Separate active ongoing threads from completed or obsolete items\n");
293 prompt.push_str("- Keep the final result compact and operational\n\n");
294 prompt.push_str("Return markdown with these sections exactly:\n");
295 prompt.push_str("1. ## Current durable context\n");
296 prompt.push_str("2. ## Cross-session patterns\n");
297 prompt.push_str("3. ## Active threads to remember\n");
298 prompt.push_str("4. ## Stable constraints and preferences\n");
299 prompt.push_str("5. ## Open risks or questions\n\n");
300 prompt
301}
302
303fn append_markdown_reference_section(
304 prompt: &mut String,
305 heading: &str,
306 content: Option<&str>,
307 empty_placeholder: &str,
308) {
309 prompt.push_str(heading);
310 prompt.push_str("\n\n");
311 if let Some(content) = content.map(str::trim).filter(|value| !value.is_empty()) {
312 prompt.push_str("```md\n");
313 prompt.push_str(content);
314 prompt.push_str("\n```\n\n");
315 } else {
316 prompt.push_str(empty_placeholder);
317 prompt.push_str("\n\n");
318 }
319}
320
321fn append_consolidation_recent_sessions_section(
322 prompt: &mut String,
323 sessions: &[ConsolidationSessionInfo],
324) {
325 prompt.push_str("## Recent sessions\n\n");
326 if sessions.is_empty() {
327 prompt.push_str("_(no recent sessions supplied)_\n");
328 return;
329 }
330
331 for (index, session) in sessions
332 .iter()
333 .take(MAX_INCLUDED_CONSOLIDATION_SESSIONS)
334 .enumerate()
335 {
336 prompt.push_str(&format!(
337 "### Session {}\n- id: {}\n- title: {}\n- kind: {}\n- updated_at: {}\n- message_count: {}\n",
338 index + 1,
339 session.id,
340 session.title,
341 session.kind,
342 session.updated_at,
343 session.message_count,
344 ));
345 if let Some(status) = session
346 .last_run_status
347 .as_deref()
348 .filter(|v| !v.trim().is_empty())
349 {
350 prompt.push_str(&format!("- last_run_status: {}\n", status));
351 }
352 if let Some(summary) = session
353 .summary
354 .as_deref()
355 .map(str::trim)
356 .filter(|v| !v.is_empty())
357 {
358 prompt.push_str("- summary:\n```md\n");
359 prompt.push_str(&truncate_chars(
360 summary,
361 MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION,
362 ));
363 prompt.push_str("\n```\n");
364 }
365 prompt.push('\n');
366 }
367
368 if sessions.len() > MAX_INCLUDED_CONSOLIDATION_SESSIONS {
369 prompt.push_str(&format!(
370 "_Only the most recent {} sessions are included in this pass out of {} candidates._\n",
371 MAX_INCLUDED_CONSOLIDATION_SESSIONS,
372 sessions.len()
373 ));
374 }
375}
376
377pub fn build_consolidation_prompt(sessions: &[ConsolidationSessionInfo]) -> String {
378 let mut prompt = build_consolidation_prompt_prefix();
379 append_consolidation_recent_sessions_section(&mut prompt, sessions);
380 prompt
381}
382
383pub fn build_consolidation_prompt_with_existing_dream(
384 existing_dream: Option<&str>,
385 sessions: &[ConsolidationSessionInfo],
386) -> String {
387 build_refine_consolidation_prompt(existing_dream, None, sessions)
388}
389
390pub fn build_refine_consolidation_prompt(
391 existing_dream: Option<&str>,
392 recent_durable_memory: Option<&str>,
393 sessions: &[ConsolidationSessionInfo],
394) -> String {
395 let mut prompt = build_consolidation_prompt_prefix();
396 prompt.push_str(
397 "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",
398 );
399 append_markdown_reference_section(
400 &mut prompt,
401 "## Existing Dream notebook",
402 existing_dream,
403 "_(no existing Dream notebook supplied; fall back to synthesizing from recent sessions only)_",
404 );
405 append_markdown_reference_section(
406 &mut prompt,
407 "## Recent durable memory updates",
408 recent_durable_memory,
409 "_(no recent durable memory updates supplied)_",
410 );
411 append_consolidation_recent_sessions_section(&mut prompt, sessions);
412 prompt
413}
414
415pub fn build_rebuild_consolidation_prompt(
416 durable_memory_index: Option<&str>,
417 sessions: &[ConsolidationSessionInfo],
418) -> String {
419 let mut prompt = build_consolidation_prompt_prefix();
420 prompt.push_str(
421 "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",
422 );
423 append_markdown_reference_section(
424 &mut prompt,
425 "## Durable memory index",
426 durable_memory_index,
427 "_(no durable memory index supplied)_",
428 );
429 append_consolidation_recent_sessions_section(&mut prompt, sessions);
430 prompt
431}
432
433pub fn derive_session_outline(session: &bamboo_agent_core::Session) -> Option<String> {
442 use bamboo_agent_core::Role;
443
444 let mut parts = Vec::new();
445
446 if let Some(task_list) = session.task_list.as_ref() {
447 let rendered = task_list.format_for_prompt();
448 if !rendered.trim().is_empty() {
449 parts.push(rendered);
450 }
451 }
452
453 if parts.is_empty() {
454 let recent_messages = session
455 .messages
456 .iter()
457 .rev()
458 .filter(|message| !matches!(message.role, Role::System))
459 .take(6)
460 .collect::<Vec<_>>();
461 if recent_messages.is_empty() {
462 return None;
463 }
464 let mut rendered = String::new();
465 for message in recent_messages.into_iter().rev() {
466 let role = match message.role {
467 Role::User => "User",
468 Role::Assistant => "Assistant",
469 Role::Tool => "Tool",
470 Role::System => continue,
471 };
472 rendered.push_str(&format!(
473 "**{}**: {}\n\n",
474 role,
475 truncate_chars(message.content.trim(), 300)
476 ));
477 }
478 if !rendered.trim().is_empty() {
479 parts.push(rendered.trim().to_string());
480 }
481 }
482
483 (!parts.is_empty()).then(|| parts.join("\n\n---\n\n"))
484}
485
486pub fn normalize_existing_dream_for_prompt(
490 existing_dream: Option<&str>,
491 model: &str,
492 session_count: usize,
493 max_summary_chars: usize,
494) -> Option<String> {
495 existing_dream.and_then(|dream| {
496 match normalize_dream_notebook_body(dream, max_summary_chars) {
497 Ok(body) => Some(body),
498 Err(error) => {
499 tracing::warn!(
500 target: "bamboo.auto_dream",
501 event = "existing_input_normalization_failed",
502 model = model,
503 session_count = session_count,
504 "[auto_dream] failed to normalize existing Dream input; omitting prior Dream context: {}",
505 error
506 );
507 None
508 }
509 }
510 })
511}
512
513pub fn should_use_dream_refine_mode(
518 memory_cfg: &bamboo_infrastructure::config::MemoryConfig,
519) -> bool {
520 memory_cfg.dream_refine_mode
521}
522
523pub fn should_force_full_rebuild(
524 last_full_rebuild_at: Option<chrono::DateTime<chrono::Utc>>,
525 now: chrono::DateTime<chrono::Utc>,
526 rebuild_interval_secs: i64,
527) -> bool {
528 match last_full_rebuild_at {
529 Some(timestamp) => (now - timestamp) >= chrono::Duration::seconds(rebuild_interval_secs),
530 None => false,
531 }
532}
533
534pub fn parse_last_full_rebuild_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
535 note.lines()
536 .find_map(|line| line.trim().strip_prefix("Last full rebuild at: "))
537 .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
538 .map(|dt| dt.with_timezone(&chrono::Utc))
539}
540
541pub fn parse_last_consolidated_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
542 note.lines()
543 .find_map(|line| line.trim().strip_prefix("Last consolidated at: "))
544 .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
545 .map(|dt| dt.with_timezone(&chrono::Utc))
546}
547
548const DREAM_RUNTIME_SESSION_ID: &str = "__dream__";
553const DREAM_TRACING_TARGET: &str = "bamboo.auto_dream";
554const DREAM_INTERVAL_SECS: u64 = 60 * 30;
555const DREAM_FULL_REBUILD_INTERVAL_SECS: i64 = 60 * 60 * 24 * 30;
556const DREAM_MAX_SESSIONS: usize = 12;
557const DREAM_MAX_SUMMARY_CHARS: usize = 12_000;
558const EXTRACTION_MAX_TOPICS_PER_SESSION: usize = 4;
559const EXTRACTION_MAX_TOPIC_CHARS: usize = 1_500;
560const EXTRACTION_MAX_CANDIDATES: usize = 8;
561
562fn to_consolidation_sessions(
563 entries: &[(SessionIndexEntry, Option<String>)],
564) -> Vec<ConsolidationSessionInfo> {
565 entries
566 .iter()
567 .map(|(entry, summary)| ConsolidationSessionInfo {
568 id: entry.id.clone(),
569 title: entry.title.clone(),
570 kind: format!("{:?}", entry.kind),
571 updated_at: entry.updated_at.to_rfc3339(),
572 message_count: entry.message_count,
573 last_run_status: entry.last_run_status.clone(),
574 summary: summary.clone(),
575 })
576 .collect()
577}
578
579#[derive(Clone)]
580pub struct AutoDreamContext {
581 pub session_store: Arc<SessionStoreV2>,
582 pub storage: Arc<dyn bamboo_agent_core::storage::Storage>,
583 pub provider: Arc<dyn LLMProvider>,
584 pub config: Arc<RwLock<Config>>,
585 pub provider_registry: Arc<ProviderRegistry>,
586}
587
588fn memory_store_for_context(ctx: &AutoDreamContext) -> MemoryStore {
589 MemoryStore::new(ctx.session_store.bamboo_home_dir())
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct AutoDreamRunResult {
594 pub used_model: String,
595 pub session_count: usize,
596 pub note_path: std::path::PathBuf,
597 pub notebook_chars: usize,
598}
599
600#[derive(Debug, Clone)]
601struct CandidateSessionContext {
602 entry: SessionIndexEntry,
603 summary: Option<String>,
604 session_id: String,
605 project_key: Option<String>,
606 topics: Vec<(String, String)>,
607}
608
609#[derive(Debug, Clone)]
610struct DreamSourceWindow {
611 existing_dream: Option<String>,
612 recent_durable_memory: Option<String>,
613 durable_memory_index: Option<String>,
614 sessions: Vec<(SessionIndexEntry, Option<String>)>,
615}
616
617fn session_is_candidate(entry: &SessionIndexEntry, since: DateTime<Utc>) -> bool {
618 matches!(entry.kind, SessionKind::Root)
619 && entry.updated_at >= since
620 && !entry.id.trim().is_empty()
621 && entry.id != DREAM_RUNTIME_SESSION_ID
622}
623
624async fn collect_candidate_sessions(
625 ctx: &AutoDreamContext,
626 since: DateTime<Utc>,
627) -> Vec<(SessionIndexEntry, Option<String>)> {
628 let mut items = ctx.session_store.list_index_entries().await;
629 items.retain(|entry| session_is_candidate(entry, since));
630 items.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at));
631
632 let mut seen_roots = HashSet::new();
633 let mut out = Vec::new();
634 for entry in items.into_iter() {
635 if !seen_roots.insert(entry.root_session_id.clone()) {
636 continue;
637 }
638 let summary = match ctx.storage.load_session(&entry.id).await {
639 Ok(Some(session)) => session
640 .conversation_summary
641 .as_ref()
642 .map(|summary| summary.content.clone())
643 .or_else(|| derive_session_outline(&session)),
644 _ => None,
645 };
646 out.push((entry, summary));
647 if out.len() >= DREAM_MAX_SESSIONS {
648 break;
649 }
650 }
651 out
652}
653
654async fn resolve_session_project_key(
655 ctx: &AutoDreamContext,
656 memory: &MemoryStore,
657 session_id: &str,
658) -> Option<String> {
659 ctx.storage
660 .load_session(session_id)
661 .await
662 .ok()
663 .flatten()
664 .and_then(|session| session.metadata.get("workspace_path").cloned())
665 .map(std::path::PathBuf::from)
666 .map(|path| crate::memory_store::project_key_from_path(&path))
667 .or_else(|| memory.project_key_for_session(Some(session_id)))
668}
669
670async fn collect_candidate_sessions_for_project(
671 ctx: &AutoDreamContext,
672 memory: &MemoryStore,
673 project_key: &str,
674 since: DateTime<Utc>,
675) -> Vec<(SessionIndexEntry, Option<String>)> {
676 let mut out = Vec::new();
677 for (entry, summary) in collect_candidate_sessions(ctx, since).await {
678 if resolve_session_project_key(ctx, memory, &entry.id)
679 .await
680 .as_deref()
681 != Some(project_key)
682 {
683 continue;
684 }
685 out.push((entry, summary));
686 if out.len() >= DREAM_MAX_SESSIONS {
687 break;
688 }
689 }
690 out
691}
692
693async fn collect_candidate_session_contexts_from_sessions(
694 ctx: &AutoDreamContext,
695 memory: &MemoryStore,
696 sessions: Vec<(SessionIndexEntry, Option<String>)>,
697) -> Vec<CandidateSessionContext> {
698 let mut out = Vec::new();
699 for (entry, summary) in sessions {
700 let project_key = resolve_session_project_key(ctx, memory, &entry.id).await;
701 let topics = memory
702 .read_session_topics_with_content(&entry.id)
703 .await
704 .unwrap_or_default()
705 .into_iter()
706 .take(EXTRACTION_MAX_TOPICS_PER_SESSION)
707 .map(|(topic, content)| (topic, truncate_chars(&content, EXTRACTION_MAX_TOPIC_CHARS)))
708 .collect::<Vec<_>>();
709 if topics.is_empty()
710 && summary
711 .as_deref()
712 .map(str::trim)
713 .unwrap_or_default()
714 .is_empty()
715 {
716 continue;
717 }
718 out.push(CandidateSessionContext {
719 session_id: entry.id.clone(),
720 project_key,
721 entry,
722 summary,
723 topics,
724 });
725 }
726 out
727}
728
729async fn collect_candidate_session_contexts(
730 ctx: &AutoDreamContext,
731 memory: &MemoryStore,
732 since: DateTime<Utc>,
733) -> Vec<CandidateSessionContext> {
734 collect_candidate_session_contexts_from_sessions(
735 ctx,
736 memory,
737 collect_candidate_sessions(ctx, since).await,
738 )
739 .await
740}
741
742async fn collect_candidate_session_contexts_for_project(
743 ctx: &AutoDreamContext,
744 memory: &MemoryStore,
745 project_key: &str,
746 since: DateTime<Utc>,
747) -> Vec<CandidateSessionContext> {
748 collect_candidate_session_contexts_from_sessions(
749 ctx,
750 memory,
751 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await,
752 )
753 .await
754}
755
756async fn extract_and_persist_durable_candidates(
757 provider: &Arc<dyn LLMProvider>,
758 memory: &MemoryStore,
759 model: &str,
760 sessions: &[CandidateSessionContext],
761) -> Result<usize, String> {
762 if sessions.is_empty() {
763 return Ok(0);
764 }
765
766 let candidates_info: Vec<DreamCandidateInfo> = sessions
767 .iter()
768 .map(|session| DreamCandidateInfo {
769 session_id: session.session_id.clone(),
770 title: session.entry.title.clone(),
771 project_key: session.project_key.clone(),
772 updated_at: session.entry.updated_at.to_rfc3339(),
773 summary: session.summary.clone(),
774 topics: session.topics.clone(),
775 })
776 .collect();
777 let prompt = build_extraction_prompt(&candidates_info);
778 let raw = collect_stream_text(provider.clone(), model, prompt).await?;
779 let candidates = parse_extraction_candidates(&raw)?;
780 if candidates.is_empty() {
781 return Ok(0);
782 }
783
784 let mut session_project_keys = std::collections::HashMap::new();
785 for session in sessions {
786 session_project_keys.insert(session.session_id.clone(), session.project_key.clone());
787 }
788
789 let extracted_at = Utc::now().to_rfc3339();
790 let mut writes = 0usize;
791 let mut touched_sessions = HashSet::new();
792 for candidate in candidates.into_iter().take(EXTRACTION_MAX_CANDIDATES) {
793 let Some(memory_type) = parse_candidate_type(&candidate.kind) else {
794 continue;
795 };
796 let title = candidate.title.trim();
797 let content = candidate.content.trim();
798 if title.is_empty() || content.is_empty() {
799 continue;
800 }
801 let session_id = candidate
802 .session_id
803 .as_deref()
804 .map(str::trim)
805 .filter(|value| !value.is_empty());
806 let project_key = session_id
807 .and_then(|id| session_project_keys.get(id))
808 .and_then(|value| value.as_deref())
809 .map(ToString::to_string);
810 let scope = parse_candidate_scope(&candidate, project_key.as_deref());
811 let tags = candidate.tags;
812 let _ = &candidate.confidence;
813 memory
814 .write_memory(
815 scope,
816 project_key.as_deref(),
817 memory_type,
818 title,
819 content,
820 &tags,
821 session_id,
822 "background-fast-model",
823 true,
824 )
825 .await
826 .map_err(|error| {
827 format!(
828 "failed to persist durable extraction candidate '{}': {error}",
829 title
830 )
831 })?;
832 writes += 1;
833 if let Some(session_id) = session_id {
834 touched_sessions.insert(session_id.to_string());
835 }
836 }
837
838 for session_id in touched_sessions {
839 memory
840 .mark_session_extracted(&session_id, &extracted_at)
841 .await
842 .map_err(|error| {
843 format!("failed to update session extraction state for {session_id}: {error}")
844 })?;
845 }
846
847 Ok(writes)
848}
849
850async fn collect_stream_text(
851 provider: Arc<dyn LLMProvider>,
852 model: &str,
853 prompt: String,
854) -> Result<String, String> {
855 let messages = vec![
856 Message::system(
857 "You are Bamboo's background Dream consolidator. Return only the Dream notebook body sections as plain markdown. Do not return an outer '# Bamboo Dream Notebook' title, metadata lines, or markdown fences."
858 ),
859 Message::user(prompt),
860 ];
861 let options = LLMRequestOptions {
862 session_id: Some(DREAM_RUNTIME_SESSION_ID.to_string()),
863 reasoning_effort: Some(ReasoningEffort::High),
864 parallel_tool_calls: None,
865 responses: None,
866 request_purpose: Some("auto_dream".to_string()),
867 cache: None,
868 };
869
870 let mut stream = provider
871 .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
872 .await
873 .map_err(|error| format!("auto-dream provider call failed: {error}"))?;
874
875 let mut content = String::new();
876 while let Some(chunk) = stream.next().await {
877 match chunk {
878 Ok(LLMChunk::Token(text)) => content.push_str(&text),
879 Ok(LLMChunk::Done) => break,
880 Ok(_) => {}
881 Err(error) => {
882 if !content.is_empty() {
883 break;
884 }
885 return Err(format!("auto-dream stream failed: {error}"));
886 }
887 }
888 }
889
890 let trimmed = content.trim();
891 if trimmed.is_empty() {
892 return Err("auto-dream returned empty content".to_string());
893 }
894 Ok(truncate_chars(trimmed, DREAM_MAX_SUMMARY_CHARS))
895}
896
897async fn read_existing_dream_for_scope(
898 memory: &MemoryStore,
899 scope: MemoryScope,
900 project_key: Option<&str>,
901) -> Result<Option<String>, String> {
902 match scope {
903 MemoryScope::Global => memory
904 .read_dream_view()
905 .await
906 .map_err(|error| format!("failed to read Dream notebook: {error}")),
907 MemoryScope::Project => {
908 let project_key = project_key
909 .map(str::trim)
910 .filter(|value| !value.is_empty())
911 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
912 memory
913 .read_project_dream_view(project_key)
914 .await
915 .map_err(|error| {
916 format!("failed to read project Dream notebook for '{project_key}': {error}")
917 })
918 }
919 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
920 }
921}
922
923async fn read_recent_durable_memory_for_scope(
924 memory: &MemoryStore,
925 scope: MemoryScope,
926 project_key: Option<&str>,
927) -> Result<Option<String>, String> {
928 memory
929 .read_recent_view(scope, project_key)
930 .await
931 .map_err(|error| format!("failed to read recent durable memory view: {error}"))
932}
933
934async fn read_durable_memory_index_for_scope(
935 memory: &MemoryStore,
936 scope: MemoryScope,
937 project_key: Option<&str>,
938) -> Result<Option<String>, String> {
939 memory
940 .read_memory_view(scope, project_key)
941 .await
942 .map_err(|error| format!("failed to read durable memory index view: {error}"))
943}
944
945async fn write_dream_for_scope(
946 memory: &MemoryStore,
947 scope: MemoryScope,
948 project_key: Option<&str>,
949 content: &str,
950) -> Result<std::path::PathBuf, String> {
951 match scope {
952 MemoryScope::Global => memory
953 .write_dream_view(content)
954 .await
955 .map_err(|error| format!("failed to persist Dream notebook: {error}")),
956 MemoryScope::Project => {
957 let project_key = project_key
958 .map(str::trim)
959 .filter(|value| !value.is_empty())
960 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
961 memory
962 .write_project_dream_view(project_key, content)
963 .await
964 .map_err(|error| {
965 format!("failed to persist project Dream notebook for '{project_key}': {error}")
966 })
967 }
968 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
969 }
970}
971
972async fn build_dream_notebook_body(
973 provider: &Arc<dyn LLMProvider>,
974 model: &str,
975 source_window: &DreamSourceWindow,
976 generation_mode: DreamGenerationMode,
977) -> Result<String, String> {
978 match generation_mode {
979 DreamGenerationMode::Refine => {
980 tracing::info!(
981 target: DREAM_TRACING_TARGET,
982 event = "refine_attempt",
983 model = model,
984 session_count = source_window.sessions.len(),
985 existing_dream_present = source_window.existing_dream.is_some(),
986 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
987 "Attempting refine-mode Dream synthesis"
988 );
989
990 let existing_dream_for_prompt = normalize_existing_dream_for_prompt(
991 source_window.existing_dream.as_deref(),
992 model,
993 source_window.sessions.len(),
994 DREAM_MAX_SUMMARY_CHARS,
995 );
996 let refine_prompt = build_refine_consolidation_prompt(
997 existing_dream_for_prompt.as_deref(),
998 source_window.recent_durable_memory.as_deref(),
999 &to_consolidation_sessions(&source_window.sessions),
1000 );
1001 match collect_stream_text(provider.clone(), model, refine_prompt).await {
1002 Ok(raw_body) => {
1003 match normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS) {
1004 Ok(body) => {
1005 tracing::info!(
1006 target: DREAM_TRACING_TARGET,
1007 event = "refine_success",
1008 model = model,
1009 session_count = source_window.sessions.len(),
1010 notebook_body_chars = body.chars().count(),
1011 existing_dream_present = source_window.existing_dream.is_some(),
1012 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
1013 "Refine-mode Dream synthesis succeeded"
1014 );
1015 Ok(body)
1016 }
1017 Err(error) => {
1018 tracing::warn!(
1019 target: DREAM_TRACING_TARGET,
1020 event = "refine_output_normalization_failed",
1021 model = model,
1022 session_count = source_window.sessions.len(),
1023 existing_dream_present = source_window.existing_dream.is_some(),
1024 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
1025 "[auto_dream] refine-mode Dream output normalization failed; falling back to incremental prompt: {}",
1026 error
1027 );
1028 let prompt = build_consolidation_prompt(&to_consolidation_sessions(
1029 &source_window.sessions,
1030 ));
1031 let raw_body =
1032 collect_stream_text(provider.clone(), model, prompt).await?;
1033 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
1034 }
1035 }
1036 }
1037 Err(error) => {
1038 tracing::warn!(
1039 target: DREAM_TRACING_TARGET,
1040 event = "refine_provider_failed",
1041 model = model,
1042 session_count = source_window.sessions.len(),
1043 existing_dream_present = source_window.existing_dream.is_some(),
1044 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
1045 "[auto_dream] refine-mode Dream synthesis failed; falling back to incremental prompt: {}",
1046 error
1047 );
1048 let prompt = build_consolidation_prompt(&to_consolidation_sessions(
1049 &source_window.sessions,
1050 ));
1051 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
1052 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
1053 }
1054 }
1055 }
1056 DreamGenerationMode::Rebuild => {
1057 tracing::info!(
1058 target: DREAM_TRACING_TARGET,
1059 event = "rebuild_attempt",
1060 model = model,
1061 session_count = source_window.sessions.len(),
1062 durable_memory_index_present = source_window.durable_memory_index.is_some(),
1063 "Attempting full rebuild Dream synthesis"
1064 );
1065 let prompt = build_rebuild_consolidation_prompt(
1066 source_window.durable_memory_index.as_deref(),
1067 &to_consolidation_sessions(&source_window.sessions),
1068 );
1069 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
1070 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
1071 }
1072 DreamGenerationMode::Incremental => {
1073 let prompt =
1074 build_consolidation_prompt(&to_consolidation_sessions(&source_window.sessions));
1075 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
1076 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
1077 }
1078 }
1079}
1080
1081async fn run_auto_dream_once_for_scope(
1082 ctx: &AutoDreamContext,
1083 memory: &MemoryStore,
1084 scope: MemoryScope,
1085 project_key: Option<&str>,
1086 require_auto_dream_enabled: bool,
1087) -> Result<Option<AutoDreamRunResult>, String> {
1088 let scope_label = match scope {
1089 MemoryScope::Global => "global",
1090 MemoryScope::Project => "project",
1091 MemoryScope::Session => "session",
1092 };
1093
1094 let config_snapshot = ctx.config.read().await.clone();
1095 let memory_cfg = config_snapshot.memory.clone().unwrap_or_default();
1096 if require_auto_dream_enabled && !memory_cfg.auto_dream_enabled {
1097 tracing::info!(
1098 target: DREAM_TRACING_TARGET,
1099 event = "run_skip",
1100 reason = "auto_dream_disabled",
1101 scope = scope_label,
1102 project_key = project_key.unwrap_or(""),
1103 "Skipping Dream generation because auto_dream is disabled"
1104 );
1105 return Ok(None);
1106 }
1107
1108 let provider_ref_enabled = config_snapshot.features.provider_model_ref;
1110 let model_ref = if provider_ref_enabled {
1111 config_snapshot
1112 .defaults
1113 .as_ref()
1114 .and_then(|d| d.memory_background.as_ref())
1115 .or_else(|| {
1116 config_snapshot
1117 .defaults
1118 .as_ref()
1119 .and_then(|d| d.fast.as_ref())
1120 })
1121 } else {
1122 None
1123 };
1124
1125 let (bg_provider, model): (Arc<dyn LLMProvider>, String) = if let Some(ref mr) = model_ref {
1126 let router = ProviderModelRouter::new(ctx.provider_registry.clone());
1127 let routed = router.route(mr).map_err(|e| {
1128 format!(
1129 "[auto_dream] failed to route background model ref '{}': {}",
1130 mr, e
1131 )
1132 })?;
1133 tracing::debug!(
1134 target: DREAM_TRACING_TARGET,
1135 model_ref = %mr,
1136 "Resolved background model via ProviderModelRef"
1137 );
1138 (routed, mr.model.clone())
1139 } else {
1140 let Some(model) = config_snapshot.get_memory_background_model() else {
1141 tracing::warn!(
1142 target: DREAM_TRACING_TARGET,
1143 event = "run_skip",
1144 reason = "no_background_model",
1145 scope = scope_label,
1146 project_key = project_key.unwrap_or(""),
1147 "[auto_dream] skipped: no memory.background_model / provider.fast_model configured"
1148 );
1149 return Ok(None);
1150 };
1151 (ctx.provider.clone(), model)
1152 };
1153
1154 let now = Utc::now();
1155 let existing = read_existing_dream_for_scope(memory, scope, project_key).await?;
1156 let recent_durable_memory =
1157 read_recent_durable_memory_for_scope(memory, scope, project_key).await?;
1158 let durable_memory_index =
1159 read_durable_memory_index_for_scope(memory, scope, project_key).await?;
1160 let last_full_rebuild_at = existing.as_deref().and_then(parse_last_full_rebuild_at);
1161 let force_full_rebuild =
1162 should_force_full_rebuild(last_full_rebuild_at, now, DREAM_FULL_REBUILD_INTERVAL_SECS);
1163 let since = if force_full_rebuild {
1164 now - chrono::Duration::days(30)
1165 } else {
1166 match existing.as_deref().and_then(parse_last_consolidated_at) {
1167 Some(ts) => ts,
1168 None => now - chrono::Duration::hours(24),
1169 }
1170 };
1171
1172 let sessions = match scope {
1173 MemoryScope::Global => collect_candidate_sessions(ctx, since).await,
1174 MemoryScope::Project => {
1175 let project_key = project_key
1176 .map(str::trim)
1177 .filter(|value| !value.is_empty())
1178 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
1179 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await
1180 }
1181 MemoryScope::Session => {
1182 return Err("session-scoped Dream generation is not supported".to_string())
1183 }
1184 };
1185 if sessions.is_empty() {
1186 tracing::info!(
1187 target: DREAM_TRACING_TARGET,
1188 event = "run_skip",
1189 reason = "no_candidate_sessions",
1190 scope = scope_label,
1191 project_key = project_key.unwrap_or(""),
1192 model = model.as_str(),
1193 existing_dream_present = existing.is_some(),
1194 "Skipping Dream generation because there are no candidate sessions"
1195 );
1196 return Ok(None);
1197 }
1198
1199 let generation_mode = if force_full_rebuild {
1200 DreamGenerationMode::Rebuild
1201 } else if should_use_dream_refine_mode(&memory_cfg) && existing.is_some() {
1202 DreamGenerationMode::Refine
1203 } else {
1204 DreamGenerationMode::Incremental
1205 };
1206 tracing::info!(
1207 target: DREAM_TRACING_TARGET,
1208 event = "run_start",
1209 scope = scope_label,
1210 project_key = project_key.unwrap_or(""),
1211 model = model.as_str(),
1212 session_count = sessions.len(),
1213 existing_dream_present = existing.is_some(),
1214 recent_durable_memory_present = recent_durable_memory.is_some(),
1215 durable_memory_index_present = durable_memory_index.is_some(),
1216 generation_mode = match generation_mode {
1217 DreamGenerationMode::Incremental => "incremental",
1218 DreamGenerationMode::Refine => "refine",
1219 DreamGenerationMode::Rebuild => "rebuild",
1220 },
1221 require_auto_dream_enabled = require_auto_dream_enabled,
1222 "Starting Dream generation run"
1223 );
1224
1225 let source_window = DreamSourceWindow {
1226 existing_dream: existing,
1227 recent_durable_memory,
1228 durable_memory_index,
1229 sessions,
1230 };
1231 let notebook_body =
1232 build_dream_notebook_body(&bg_provider, &model, &source_window, generation_mode).await?;
1233 let last_full_rebuild_line = if matches!(generation_mode, DreamGenerationMode::Rebuild) {
1234 format!("Last full rebuild at: {}\n", now.to_rfc3339())
1235 } else if let Some(existing_rebuild_at) = last_full_rebuild_at {
1236 format!(
1237 "Last full rebuild at: {}\n",
1238 existing_rebuild_at.to_rfc3339()
1239 )
1240 } else {
1241 String::new()
1242 };
1243 let final_note = match scope {
1244 MemoryScope::Global => format!(
1245 "# Bamboo Dream Notebook\n\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
1246 now.to_rfc3339(),
1247 last_full_rebuild_line,
1248 source_window.sessions.len(),
1249 model,
1250 notebook_body.trim(),
1251 ),
1252 MemoryScope::Project => format!(
1253 "# Bamboo Dream Notebook\n\nProject key: {}\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
1254 project_key.unwrap_or_default(),
1255 now.to_rfc3339(),
1256 last_full_rebuild_line,
1257 source_window.sessions.len(),
1258 model,
1259 notebook_body.trim(),
1260 ),
1261 MemoryScope::Session => unreachable!("session scope handled above"),
1262 };
1263
1264 let note_path = write_dream_for_scope(memory, scope, project_key, &final_note).await?;
1265
1266 let extraction_sessions = match scope {
1267 MemoryScope::Global => collect_candidate_session_contexts(ctx, memory, since).await,
1268 MemoryScope::Project => {
1269 let project_key = project_key
1270 .map(str::trim)
1271 .filter(|value| !value.is_empty())
1272 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
1273 collect_candidate_session_contexts_for_project(ctx, memory, project_key, since).await
1274 }
1275 MemoryScope::Session => unreachable!("session scope handled above"),
1276 };
1277 let extracted_count =
1278 extract_and_persist_durable_candidates(&bg_provider, memory, &model, &extraction_sessions)
1279 .await?;
1280 let notebook_chars = final_note.chars().count();
1281
1282 tracing::info!(
1283 target: DREAM_TRACING_TARGET,
1284 event = "run_complete",
1285 scope = scope_label,
1286 project_key = project_key.unwrap_or(""),
1287 model = model.as_str(),
1288 session_count = source_window.sessions.len(),
1289 existing_dream_present = source_window.existing_dream.is_some(),
1290 recent_durable_memory_present = source_window.recent_durable_memory.is_some(),
1291 durable_memory_index_present = source_window.durable_memory_index.is_some(),
1292 generation_mode = match generation_mode {
1293 DreamGenerationMode::Incremental => "incremental",
1294 DreamGenerationMode::Refine => "refine",
1295 DreamGenerationMode::Rebuild => "rebuild",
1296 },
1297 notebook_chars = notebook_chars,
1298 durable_candidates_persisted = extracted_count,
1299 note_path = %note_path.display(),
1300 "Dream generation run completed"
1301 );
1302
1303 Ok(Some(AutoDreamRunResult {
1304 used_model: model,
1305 session_count: source_window.sessions.len(),
1306 note_path,
1307 notebook_chars,
1308 }))
1309}
1310
1311async fn run_auto_dream_once_with_store(
1312 ctx: &AutoDreamContext,
1313 memory: &MemoryStore,
1314) -> Result<Option<AutoDreamRunResult>, String> {
1315 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Global, None, true).await
1316}
1317
1318pub async fn run_auto_dream_once(
1319 ctx: &AutoDreamContext,
1320) -> Result<Option<AutoDreamRunResult>, String> {
1321 let memory = memory_store_for_context(ctx);
1322 run_auto_dream_once_with_store(ctx, &memory).await
1323}
1324
1325pub async fn run_project_auto_dream_once(
1326 ctx: &AutoDreamContext,
1327 project_key: &str,
1328) -> Result<Option<AutoDreamRunResult>, String> {
1329 let memory = memory_store_for_context(ctx);
1330 run_project_auto_dream_once_with_store(ctx, &memory, project_key).await
1331}
1332
1333async fn run_project_auto_dream_once_with_store(
1334 ctx: &AutoDreamContext,
1335 memory: &MemoryStore,
1336 project_key: &str,
1337) -> Result<Option<AutoDreamRunResult>, String> {
1338 let project_key = project_key.trim();
1339 if project_key.is_empty() {
1340 return Err("project Dream generation requires a non-empty project_key".to_string());
1341 }
1342 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Project, Some(project_key), false).await
1343}
1344
1345pub fn spawn_auto_dream_task(ctx: AutoDreamContext) {
1346 tokio::spawn(async move {
1347 let mut ticker = tokio::time::interval(Duration::from_secs(DREAM_INTERVAL_SECS));
1348 loop {
1349 ticker.tick().await;
1350 if let Err(error) = run_auto_dream_once(&ctx).await {
1351 tracing::warn!(
1352 target: DREAM_TRACING_TARGET,
1353 event = "run_failed",
1354 "[auto_dream] run failed: {}",
1355 error
1356 );
1357 }
1358 }
1359 });
1360}
1361
1362#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 use std::collections::HashMap;
1371
1372 fn test_registry() -> Arc<ProviderRegistry> {
1373 Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()))
1374 }
1375
1376 #[test]
1377 fn truncate_chars_reports_truncation() {
1378 let result = truncate_chars("abcde", 3);
1379 assert_eq!(result, "abc...");
1380 }
1381
1382 #[test]
1383 fn truncate_chars_keeps_short_text() {
1384 let result = truncate_chars("abc", 10);
1385 assert_eq!(result, "abc");
1386 }
1387
1388 #[test]
1389 fn strip_json_fence_removes_fences() {
1390 assert_eq!(strip_json_fence("```json\n{}\n```"), "{}");
1391 assert_eq!(strip_json_fence("```\n{}\n```"), "{}");
1392 assert_eq!(strip_json_fence("{}"), "{}");
1393 }
1394
1395 #[test]
1396 fn strip_markdown_fence_handles_variants() {
1397 assert_eq!(strip_markdown_fence("```markdown\nhi\n```"), "hi");
1398 assert_eq!(strip_markdown_fence("```md\nhi\n```"), "hi");
1399 assert_eq!(strip_markdown_fence("```\nhi\n```"), "hi");
1400 assert_eq!(strip_markdown_fence("hi"), "hi");
1401 }
1402
1403 #[test]
1404 fn parse_extraction_candidates_accepts_fenced_json() {
1405 let input = "```json\n{\"candidates\":[{\"title\":\"T\",\"type\":\"user\",\"scope\":\"global\",\"content\":\"C\",\"tags\":[]}]}\n```";
1406 let candidates = parse_extraction_candidates(input).expect("should parse");
1407 assert_eq!(candidates.len(), 1);
1408 assert_eq!(candidates[0].title, "T");
1409 }
1410
1411 #[test]
1412 fn parse_candidate_scope_defaults_to_project_when_key_available() {
1413 let candidate = DurableExtractionCandidate {
1414 title: "T".to_string(),
1415 kind: "user".to_string(),
1416 content: "C".to_string(),
1417 scope: None,
1418 tags: vec![],
1419 session_id: None,
1420 confidence: None,
1421 };
1422 assert_eq!(
1423 parse_candidate_scope(&candidate, Some("proj-1")),
1424 crate::memory_store::MemoryScope::Project
1425 );
1426 }
1427
1428 #[test]
1429 fn parse_candidate_type_maps_known_types() {
1430 assert!(parse_candidate_type("user").is_some());
1431 assert!(parse_candidate_type("feedback").is_some());
1432 assert!(parse_candidate_type("project").is_some());
1433 assert!(parse_candidate_type("reference").is_some());
1434 assert!(parse_candidate_type("unknown").is_none());
1435 }
1436
1437 #[test]
1438 fn strip_dream_notebook_wrapper_extracts_body() {
1439 let input = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-01-01T00:00:00Z\nSessions reviewed: 1\nModel: test\n\n## Body\ncontent";
1440 let body = strip_dream_notebook_wrapper(input).expect("should extract");
1441 assert!(body.contains("## Body"));
1442 assert!(!body.contains("Bamboo Dream Notebook"));
1443 assert!(!body.contains("Last consolidated"));
1444 }
1445
1446 #[test]
1447 fn normalize_dream_notebook_body_strips_wrapper() {
1448 let input = "# Bamboo Dream Notebook\n\nModel: test\n\n## Section\ndata\n";
1449 let result = normalize_dream_notebook_body(input, 10000).expect("should normalize");
1450 assert!(result.contains("## Section"));
1451 assert!(!result.contains("Bamboo Dream Notebook"));
1452 }
1453
1454 #[test]
1455 fn normalize_dream_notebook_body_rejects_empty() {
1456 assert!(normalize_dream_notebook_body("", 10000).is_err());
1457 }
1458
1459 #[test]
1460 fn build_extraction_prompt_includes_candidates() {
1461 let candidates = vec![DreamCandidateInfo {
1462 session_id: "s-1".to_string(),
1463 title: "Title 1".to_string(),
1464 project_key: Some("proj-a".to_string()),
1465 updated_at: "2026-04-01T00:00:00Z".to_string(),
1466 summary: Some("Important summary".to_string()),
1467 topics: vec![("topic-a".to_string(), "content-a".to_string())],
1468 }];
1469 let prompt = build_extraction_prompt(&candidates);
1470 assert!(prompt.contains("Bamboo Durable Memory Extraction"));
1471 assert!(prompt.contains("s-1"));
1472 assert!(prompt.contains("Title 1"));
1473 assert!(prompt.contains("proj-a"));
1474 assert!(prompt.contains("Important summary"));
1475 assert!(prompt.contains("topic-a"));
1476 }
1477
1478 #[test]
1479 fn build_extraction_prompt_handles_empty_candidates() {
1480 let prompt = build_extraction_prompt(&[]);
1481 assert!(prompt.contains("Bamboo Durable Memory Extraction"));
1482 assert!(prompt.contains("Candidate sessions"));
1483 }
1484
1485 fn sample_consolidation_session(id: &str) -> ConsolidationSessionInfo {
1486 ConsolidationSessionInfo {
1487 id: id.to_string(),
1488 title: format!("Title for {id}"),
1489 kind: "Root".to_string(),
1490 updated_at: "2026-04-01T00:00:00Z".to_string(),
1491 message_count: 10,
1492 last_run_status: Some("completed".to_string()),
1493 summary: Some("Important summary".to_string()),
1494 }
1495 }
1496
1497 #[test]
1498 fn consolidation_prompt_includes_session_metadata_and_summary() {
1499 let prompt = build_consolidation_prompt(&[sample_consolidation_session("session-1")]);
1500 assert!(prompt.contains("Bamboo Dream Consolidation"));
1501 assert!(prompt.contains("session-1"));
1502 assert!(prompt.contains("Important summary"));
1503 assert!(prompt.contains("## Current durable context"));
1504 }
1505
1506 #[test]
1507 fn refine_consolidation_prompt_includes_existing_dream() {
1508 let prompt = build_refine_consolidation_prompt(
1509 Some("## Current durable context\n- Existing durable thread"),
1510 Some("# Recent Memory Updates\n\n- `mem-1` User prefers concise plans"),
1511 &[sample_consolidation_session("session-2")],
1512 );
1513 assert!(prompt.contains("## Existing Dream notebook"));
1514 assert!(prompt.contains("Existing durable thread"));
1515 assert!(prompt.contains("## Recent durable memory updates"));
1516 assert!(prompt.contains("User prefers concise plans"));
1517 assert!(prompt.contains("start from it and preserve still-valid durable context"));
1518 assert!(prompt.contains("session-2"));
1519 }
1520
1521 #[test]
1522 fn rebuild_consolidation_prompt_includes_durable_memory_index() {
1523 let prompt = build_rebuild_consolidation_prompt(
1524 Some("# Bamboo Memory Index\n\n- `mem-1` Release freeze decision"),
1525 &[sample_consolidation_session("session-3")],
1526 );
1527 assert!(prompt.contains("## Durable memory index"));
1528 assert!(prompt.contains("Release freeze decision"));
1529 assert!(prompt.contains("canonical durable memory plus recent session activity"));
1530 assert!(prompt.contains("session-3"));
1531 }
1532
1533 use std::sync::Mutex;
1538
1539 use async_trait::async_trait;
1540 use futures::stream;
1541
1542 use bamboo_agent_core::storage::Storage;
1543 use bamboo_infrastructure::{LLMError, LLMStream};
1544
1545 #[derive(Debug, Clone)]
1546 enum SequenceStep {
1547 Response(String),
1548 Fail(String),
1549 }
1550
1551 #[derive(Clone)]
1552 struct SequenceProvider {
1553 steps: Arc<Mutex<Vec<SequenceStep>>>,
1554 prompts: Arc<Mutex<Vec<String>>>,
1555 }
1556
1557 impl SequenceProvider {
1558 fn new(responses: Vec<String>) -> Self {
1559 Self::from_steps(responses.into_iter().map(SequenceStep::Response).collect())
1560 }
1561
1562 fn from_steps(steps: Vec<SequenceStep>) -> Self {
1563 Self {
1564 steps: Arc::new(Mutex::new(steps)),
1565 prompts: Arc::new(Mutex::new(Vec::new())),
1566 }
1567 }
1568
1569 fn recorded_prompts(&self) -> Vec<String> {
1570 self.prompts.lock().expect("lock poisoned").clone()
1571 }
1572 }
1573
1574 #[async_trait]
1575 impl LLMProvider for SequenceProvider {
1576 async fn chat_stream(
1577 &self,
1578 messages: &[Message],
1579 _tools: &[bamboo_agent_core::tools::ToolSchema],
1580 _max_output_tokens: Option<u32>,
1581 _model: &str,
1582 ) -> Result<LLMStream, LLMError> {
1583 if let Some(prompt) = messages.last().map(|message| message.content.clone()) {
1584 self.prompts.lock().expect("lock poisoned").push(prompt);
1585 }
1586 let next = self.steps.lock().expect("lock poisoned").remove(0);
1587 match next {
1588 SequenceStep::Response(text) => Ok(Box::pin(stream::iter(vec![
1589 Ok(LLMChunk::Token(text)),
1590 Ok(LLMChunk::Done),
1591 ]))),
1592 SequenceStep::Fail(error) => Err(LLMError::Stream(error)),
1593 }
1594 }
1595 }
1596
1597 #[test]
1598 fn parse_last_consolidated_at_reads_frontmatter_line() {
1599 let note = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 3\n";
1600 let parsed = parse_last_consolidated_at(note).expect("timestamp should parse");
1601 assert_eq!(parsed.to_rfc3339(), "2026-04-02T16:00:00+00:00");
1602 }
1603
1604 #[tokio::test]
1605 async fn extract_and_persist_durable_candidates_writes_memory_and_marks_session() {
1606 let temp_dir = tempfile::tempdir().expect("tempdir");
1607 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1608
1609 let session_store = Arc::new(
1610 SessionStoreV2::new(temp_dir.path().to_path_buf())
1611 .await
1612 .unwrap(),
1613 );
1614 let storage: Arc<dyn Storage> = session_store.clone();
1615 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1616 "{\"candidates\":[{\"title\":\"User prefers terse responses\",\"type\":\"feedback\",\"scope\":\"project\",\"content\":\"The user prefers terse responses and no recap.\",\"tags\":[\"preference\",\"style\"],\"session_id\":\"session-auto\",\"confidence\":\"high\"}]}".to_string(),
1617 ]));
1618 let config = Arc::new(RwLock::new(Config {
1619 memory: Some(bamboo_infrastructure::config::MemoryConfig {
1620 background_model: Some("fast-model".to_string()),
1621 auto_dream_enabled: true,
1622 ..bamboo_infrastructure::config::MemoryConfig::default()
1623 }),
1624 ..Config::default()
1625 }));
1626
1627 let mut session = bamboo_agent_core::Session::new("session-auto", "model");
1628 session.title = "Auto memory test".to_string();
1629 session.metadata.insert(
1630 "workspace_path".to_string(),
1631 temp_dir
1632 .path()
1633 .join("workspace-a")
1634 .to_string_lossy()
1635 .to_string(),
1636 );
1637 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1638 "User confirmed a stable response preference.",
1639 3,
1640 128,
1641 ));
1642 session.add_message(Message::user("Please be terse and skip the recap."));
1643 storage.save_session(&session).await.expect("save session");
1644
1645 let memory = MemoryStore::new(temp_dir.path());
1646 memory
1647 .write_session_topic("session-auto", "default", "User prefers terse responses.")
1648 .await
1649 .expect("write session topic");
1650
1651 let context = AutoDreamContext {
1652 session_store: session_store.clone(),
1653 storage: storage.clone(),
1654 provider: provider.clone(),
1655 config: config.clone(),
1656 provider_registry: test_registry(),
1657 };
1658 let contexts = collect_candidate_session_contexts(
1659 &context,
1660 &memory,
1661 Utc::now() - chrono::Duration::hours(24),
1662 )
1663 .await;
1664 assert_eq!(contexts.len(), 1);
1665
1666 let writes =
1667 extract_and_persist_durable_candidates(&provider, &memory, "fast-model", &contexts)
1668 .await
1669 .expect("extraction should succeed");
1670 assert_eq!(writes, 1);
1671
1672 let project_key =
1673 crate::memory_store::project_key_from_path(&temp_dir.path().join("workspace-a"));
1674 let results = memory
1675 .query_scope(
1676 MemoryScope::Project,
1677 Some(&project_key),
1678 Some("terse recap"),
1679 None,
1680 None,
1681 &crate::memory_store::MemoryQueryOptions {
1682 limit: Some(5),
1683 max_chars: Some(2000),
1684 cursor: None,
1685 include_related: false,
1686 },
1687 )
1688 .await
1689 .expect("query should succeed");
1690 assert_eq!(results.matched_count, 1);
1691 assert_eq!(results.items[0].title, "User prefers terse responses");
1692
1693 let state = memory
1694 .read_session_state("session-auto")
1695 .await
1696 .expect("read session state");
1697 assert!(state.last_extracted_at.is_some());
1698 }
1699
1700 #[tokio::test]
1701 async fn extract_and_persist_durable_candidates_ignores_empty_candidate_lists() {
1702 let temp_dir = tempfile::tempdir().expect("tempdir");
1703 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1704
1705 let session_store = Arc::new(
1706 SessionStoreV2::new(temp_dir.path().to_path_buf())
1707 .await
1708 .unwrap(),
1709 );
1710 let storage: Arc<dyn Storage> = session_store.clone();
1711 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1712 "{\"candidates\":[]}".to_string(),
1713 ]));
1714 let config = Arc::new(RwLock::new(Config {
1715 memory: Some(bamboo_infrastructure::config::MemoryConfig {
1716 background_model: Some("fast-model".to_string()),
1717 auto_dream_enabled: true,
1718 ..bamboo_infrastructure::config::MemoryConfig::default()
1719 }),
1720 ..Config::default()
1721 }));
1722
1723 let mut session = bamboo_agent_core::Session::new("session-empty", "model");
1724 session.metadata.insert(
1725 "workspace_path".to_string(),
1726 temp_dir.path().to_string_lossy().to_string(),
1727 );
1728 session.add_message(Message::user("This should not produce durable memory."));
1729 storage.save_session(&session).await.expect("save session");
1730
1731 let memory = MemoryStore::new(temp_dir.path());
1732 memory
1733 .write_session_topic("session-empty", "default", "ephemeral scratch")
1734 .await
1735 .expect("write session topic");
1736
1737 let context = AutoDreamContext {
1738 session_store,
1739 storage,
1740 provider,
1741 config,
1742 provider_registry: test_registry(),
1743 };
1744 let sessions = collect_candidate_session_contexts(
1745 &context,
1746 &memory,
1747 Utc::now() - chrono::Duration::hours(24),
1748 )
1749 .await;
1750 let writes = extract_and_persist_durable_candidates(
1751 &context.provider,
1752 &memory,
1753 "fast-model",
1754 &sessions,
1755 )
1756 .await
1757 .expect("empty extraction should succeed");
1758 assert_eq!(writes, 0);
1759
1760 let state = memory
1761 .read_session_state("session-empty")
1762 .await
1763 .expect("read session state");
1764 assert!(state.last_extracted_at.is_none());
1765 }
1766
1767 #[tokio::test]
1768 async fn run_auto_dream_once_updates_dream_and_persists_candidates() {
1769 let temp_dir = tempfile::tempdir().expect("tempdir");
1770 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1771
1772 let session_store = Arc::new(
1773 SessionStoreV2::new(temp_dir.path().to_path_buf())
1774 .await
1775 .unwrap(),
1776 );
1777 let storage: Arc<dyn Storage> = session_store.clone();
1778 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1779 "## Current durable context\n- Durable signal found\n\n## Cross-session patterns\n- Prefer concise answers\n\n## Active threads to remember\n- Memory extraction\n\n## Stable constraints and preferences\n- Terse replies\n\n## Open risks or questions\n- None".to_string(),
1780 "{\"candidates\":[{\"title\":\"User prefers concise answers\",\"type\":\"feedback\",\"scope\":\"project\",\"content\":\"The user prefers concise answers and minimal recap.\",\"tags\":[\"preference\"],\"session_id\":\"session-dream-run\"}]}".to_string(),
1781 ]));
1782 let config = Arc::new(RwLock::new(Config {
1783 memory: Some(bamboo_infrastructure::config::MemoryConfig {
1784 background_model: Some("fast-model".to_string()),
1785 auto_dream_enabled: true,
1786 ..bamboo_infrastructure::config::MemoryConfig::default()
1787 }),
1788 ..Config::default()
1789 }));
1790
1791 let mut session = bamboo_agent_core::Session::new("session-dream-run", "model");
1792 session.title = "Dream run test".to_string();
1793 session.metadata.insert(
1794 "workspace_path".to_string(),
1795 temp_dir
1796 .path()
1797 .join("workspace-run")
1798 .to_string_lossy()
1799 .to_string(),
1800 );
1801 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1802 "Stable user preference discussed.",
1803 4,
1804 200,
1805 ));
1806 session.add_message(Message::user("Please keep answers concise."));
1807 storage.save_session(&session).await.expect("save session");
1808
1809 let memory = MemoryStore::new(temp_dir.path());
1810 memory
1811 .write_session_topic(
1812 "session-dream-run",
1813 "default",
1814 "User prefers concise answers and minimal recap.",
1815 )
1816 .await
1817 .expect("write session topic");
1818
1819 let context = AutoDreamContext {
1820 session_store,
1821 storage,
1822 provider,
1823 config,
1824 provider_registry: test_registry(),
1825 };
1826 let result = run_auto_dream_once_with_store(&context, &memory)
1827 .await
1828 .expect("auto dream run should succeed")
1829 .expect("auto dream should produce output");
1830 assert_eq!(result.used_model, "fast-model");
1831 assert_eq!(result.session_count, 1);
1832
1833 let dream = memory
1834 .read_dream_view()
1835 .await
1836 .expect("read dream view")
1837 .expect("dream should exist");
1838 assert!(dream.contains("Bamboo Dream Notebook"));
1839 assert!(dream.contains("Durable signal found"));
1840
1841 let project_key =
1842 crate::memory_store::project_key_from_path(&temp_dir.path().join("workspace-run"));
1843 let results = memory
1844 .query_scope(
1845 MemoryScope::Project,
1846 Some(&project_key),
1847 Some("concise answers"),
1848 None,
1849 None,
1850 &crate::memory_store::MemoryQueryOptions {
1851 limit: Some(5),
1852 max_chars: Some(2000),
1853 cursor: None,
1854 include_related: false,
1855 },
1856 )
1857 .await
1858 .expect("query should succeed");
1859 assert_eq!(results.matched_count, 1);
1860 assert_eq!(results.items[0].title, "User prefers concise answers");
1861 }
1862
1863 #[tokio::test]
1864 async fn run_project_auto_dream_once_filters_sessions_by_project_and_writes_project_dream() {
1865 let temp_dir = tempfile::tempdir().expect("tempdir");
1866 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1867
1868 let workspace_a = temp_dir.path().join("workspace-a");
1869 let workspace_b = temp_dir.path().join("workspace-b");
1870 std::fs::create_dir_all(&workspace_a).expect("workspace a");
1871 std::fs::create_dir_all(&workspace_b).expect("workspace b");
1872 let project_key_a = crate::memory_store::project_key_from_path(&workspace_a);
1873
1874 let session_store = Arc::new(
1875 SessionStoreV2::new(temp_dir.path().to_path_buf())
1876 .await
1877 .unwrap(),
1878 );
1879 let storage: Arc<dyn Storage> = session_store.clone();
1880 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1881 "## Current durable context\n- Project A signal only\n\n## Cross-session patterns\n- Focus on project A\n\n## Active threads to remember\n- Ship project A\n\n## Stable constraints and preferences\n- Keep scope isolated\n\n## Open risks or questions\n- None".to_string(),
1882 "{\"candidates\":[{\"title\":\"Project A prefers concise planning\",\"type\":\"project\",\"scope\":\"project\",\"content\":\"Project A plans should stay concise and scoped.\",\"tags\":[\"planning\"],\"session_id\":\"session-project-a\"}]}".to_string(),
1883 ]));
1884 let config = Arc::new(RwLock::new(Config {
1885 memory: Some(bamboo_infrastructure::config::MemoryConfig {
1886 background_model: Some("fast-model".to_string()),
1887 auto_dream_enabled: true,
1888 ..bamboo_infrastructure::config::MemoryConfig::default()
1889 }),
1890 ..Config::default()
1891 }));
1892
1893 let mut session_a = bamboo_agent_core::Session::new("session-project-a", "model");
1894 session_a.title = "Project A session".to_string();
1895 session_a.metadata.insert(
1896 "workspace_path".to_string(),
1897 workspace_a.to_string_lossy().to_string(),
1898 );
1899 session_a.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1900 "Project A stable direction.",
1901 4,
1902 160,
1903 ));
1904 session_a.add_message(Message::user("Keep project A plans concise."));
1905 storage
1906 .save_session(&session_a)
1907 .await
1908 .expect("save session a");
1909
1910 let mut session_b = bamboo_agent_core::Session::new("session-project-b", "model");
1911 session_b.title = "Project B session".to_string();
1912 session_b.metadata.insert(
1913 "workspace_path".to_string(),
1914 workspace_b.to_string_lossy().to_string(),
1915 );
1916 session_b.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1917 "Project B unrelated direction.",
1918 4,
1919 160,
1920 ));
1921 session_b.add_message(Message::user("This is unrelated project B context."));
1922 storage
1923 .save_session(&session_b)
1924 .await
1925 .expect("save session b");
1926
1927 let memory = MemoryStore::new(temp_dir.path());
1928 memory
1929 .write_session_topic(
1930 "session-project-a",
1931 "default",
1932 "Project A planning should remain concise.",
1933 )
1934 .await
1935 .expect("write session topic a");
1936 memory
1937 .write_session_topic(
1938 "session-project-b",
1939 "default",
1940 "Project B note that should not be included.",
1941 )
1942 .await
1943 .expect("write session topic b");
1944
1945 let context = AutoDreamContext {
1946 session_store,
1947 storage,
1948 provider,
1949 config,
1950 provider_registry: test_registry(),
1951 };
1952 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key_a)
1953 .await
1954 .expect("project auto dream should succeed")
1955 .expect("project auto dream should produce output");
1956 assert_eq!(result.used_model, "fast-model");
1957 assert_eq!(result.session_count, 1);
1958
1959 let project_dream = memory
1960 .read_project_dream_view(&project_key_a)
1961 .await
1962 .expect("read project dream")
1963 .expect("project dream should exist");
1964 assert!(project_dream.contains("Bamboo Dream Notebook"));
1965 assert!(project_dream.contains("Project key: "));
1966 assert!(project_dream.contains(&project_key_a));
1967 assert!(project_dream.contains("Project A signal only"));
1968 assert!(!project_dream.contains("unrelated project B"));
1969
1970 let global_dream = memory.read_dream_view().await.expect("read global dream");
1971 assert!(global_dream.is_none());
1972
1973 let results = memory
1974 .query_scope(
1975 MemoryScope::Project,
1976 Some(&project_key_a),
1977 Some("concise planning"),
1978 None,
1979 None,
1980 &crate::memory_store::MemoryQueryOptions {
1981 limit: Some(5),
1982 max_chars: Some(2000),
1983 cursor: None,
1984 include_related: false,
1985 },
1986 )
1987 .await
1988 .expect("query should succeed");
1989 assert_eq!(results.matched_count, 1);
1990 assert_eq!(results.items[0].title, "Project A prefers concise planning");
1991 }
1992
1993 #[tokio::test]
1994 async fn run_project_auto_dream_once_returns_none_without_target_project_sessions_and_preserves_existing_dream(
1995 ) {
1996 let temp_dir = tempfile::tempdir().expect("tempdir");
1997 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1998
1999 let workspace_other = temp_dir.path().join("workspace-other");
2000 let workspace_target = temp_dir.path().join("workspace-target");
2001 std::fs::create_dir_all(&workspace_other).expect("workspace other");
2002 std::fs::create_dir_all(&workspace_target).expect("workspace target");
2003 let target_project_key = crate::memory_store::project_key_from_path(&workspace_target);
2004
2005 let session_store = Arc::new(
2006 SessionStoreV2::new(temp_dir.path().to_path_buf())
2007 .await
2008 .unwrap(),
2009 );
2010 let storage: Arc<dyn Storage> = session_store.clone();
2011 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
2012 let config = Arc::new(RwLock::new(Config {
2013 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2014 background_model: Some("fast-model".to_string()),
2015 auto_dream_enabled: true,
2016 ..bamboo_infrastructure::config::MemoryConfig::default()
2017 }),
2018 ..Config::default()
2019 }));
2020
2021 let mut other_session = bamboo_agent_core::Session::new("session-other-project", "model");
2022 other_session.title = "Other project session".to_string();
2023 other_session.metadata.insert(
2024 "workspace_path".to_string(),
2025 workspace_other.to_string_lossy().to_string(),
2026 );
2027 other_session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2028 "Other project only.",
2029 2,
2030 80,
2031 ));
2032 other_session.add_message(Message::user("Other project context only."));
2033 storage
2034 .save_session(&other_session)
2035 .await
2036 .expect("save other session");
2037
2038 let memory = MemoryStore::new(temp_dir.path());
2039 memory
2040 .write_project_dream_view(
2041 &target_project_key,
2042 "# Bamboo Dream Notebook\n\nExisting target project dream",
2043 )
2044 .await
2045 .expect("write existing project dream");
2046
2047 let context = AutoDreamContext {
2048 session_store,
2049 storage,
2050 provider,
2051 config,
2052 provider_registry: test_registry(),
2053 };
2054 let result = run_project_auto_dream_once_with_store(&context, &memory, &target_project_key)
2055 .await
2056 .expect("project auto dream without sessions should not error");
2057 assert!(result.is_none());
2058
2059 let project_dream = memory
2060 .read_project_dream_view(&target_project_key)
2061 .await
2062 .expect("read project dream")
2063 .expect("existing dream should remain");
2064 assert!(project_dream.contains("Existing target project dream"));
2065 }
2066
2067 #[tokio::test]
2068 async fn run_project_auto_dream_once_still_runs_when_auto_background_dream_is_disabled() {
2069 let temp_dir = tempfile::tempdir().expect("tempdir");
2070 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2071
2072 let workspace = temp_dir.path().join("workspace-manual-project-dream");
2073 std::fs::create_dir_all(&workspace).expect("workspace dir");
2074 let project_key = crate::memory_store::project_key_from_path(&workspace);
2075
2076 let session_store = Arc::new(
2077 SessionStoreV2::new(temp_dir.path().to_path_buf())
2078 .await
2079 .unwrap(),
2080 );
2081 let storage: Arc<dyn Storage> = session_store.clone();
2082 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
2083 "## Current durable context\n- Manual project dream worked\n\n## Cross-session patterns\n- None\n\n## Active threads to remember\n- None\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
2084 "{\"candidates\":[]}".to_string(),
2085 ]));
2086 let config = Arc::new(RwLock::new(Config {
2087 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2088 background_model: Some("fast-model".to_string()),
2089 ..bamboo_infrastructure::config::MemoryConfig::default()
2090 }),
2091 ..Config::default()
2092 }));
2093
2094 let mut session = bamboo_agent_core::Session::new("session-manual-project-dream", "model");
2095 session.title = "Manual project dream session".to_string();
2096 session.metadata.insert(
2097 "workspace_path".to_string(),
2098 workspace.to_string_lossy().to_string(),
2099 );
2100 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2101 "Manual project dream summary.",
2102 3,
2103 100,
2104 ));
2105 session.add_message(Message::user("Generate a project-scoped dream manually."));
2106 storage.save_session(&session).await.expect("save session");
2107
2108 let memory = MemoryStore::new(temp_dir.path());
2109 memory
2110 .write_session_topic(
2111 "session-manual-project-dream",
2112 "default",
2113 "Manual project dream note.",
2114 )
2115 .await
2116 .expect("write session topic");
2117
2118 let context = AutoDreamContext {
2119 session_store,
2120 storage,
2121 provider,
2122 config,
2123 provider_registry: test_registry(),
2124 };
2125 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
2126 .await
2127 .expect(
2128 "manual project dream should succeed even when auto background dream is disabled",
2129 )
2130 .expect("manual project dream should produce output");
2131 assert_eq!(result.session_count, 1);
2132
2133 let project_dream = memory
2134 .read_project_dream_view(&project_key)
2135 .await
2136 .expect("read project dream")
2137 .expect("project dream should exist");
2138 assert!(project_dream.contains("Manual project dream worked"));
2139 }
2140
2141 #[tokio::test]
2142 async fn run_auto_dream_once_refine_mode_includes_existing_dream_in_prompt() {
2143 let temp_dir = tempfile::tempdir().expect("tempdir");
2144 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2145
2146 let session_store = Arc::new(
2147 SessionStoreV2::new(temp_dir.path().to_path_buf())
2148 .await
2149 .unwrap(),
2150 );
2151 let storage: Arc<dyn Storage> = session_store.clone();
2152 let provider = SequenceProvider::new(vec![
2153 "## Current durable context\n- Refined durable theme\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
2154 "{\"candidates\":[]}".to_string(),
2155 ]);
2156 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
2157 let config = Arc::new(RwLock::new(Config {
2158 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2159 background_model: Some("fast-model".to_string()),
2160 auto_dream_enabled: true,
2161 dream_refine_mode: true,
2162 ..bamboo_infrastructure::config::MemoryConfig::default()
2163 }),
2164 ..Config::default()
2165 }));
2166
2167 let workspace = temp_dir.path().join("workspace-refine-mode");
2168 std::fs::create_dir_all(&workspace).expect("workspace dir");
2169
2170 let mut session = bamboo_agent_core::Session::new("session-refine-mode", "model");
2171 session.title = "Refine mode test".to_string();
2172 session.metadata.insert(
2173 "workspace_path".to_string(),
2174 workspace.to_string_lossy().to_string(),
2175 );
2176 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2177 "Recent session summary for refine mode.",
2178 3,
2179 120,
2180 ));
2181 session.add_message(Message::user("Update the dream with the latest thread."));
2182 storage.save_session(&session).await.expect("save session");
2183
2184 let memory = MemoryStore::new(temp_dir.path());
2185 memory
2186 .write_dream_view(
2187 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
2188 )
2189 .await
2190 .expect("write existing dream");
2191 memory
2192 .write_session_topic("session-refine-mode", "default", "Recent session note.")
2193 .await
2194 .expect("write session topic");
2195
2196 let context = AutoDreamContext {
2197 session_store,
2198 storage,
2199 provider: provider_handle,
2200 config,
2201 provider_registry: test_registry(),
2202 };
2203
2204 let result = run_auto_dream_once_with_store(&context, &memory)
2205 .await
2206 .expect("refine-mode auto dream should succeed")
2207 .expect("dream output should be produced");
2208 assert_eq!(result.session_count, 1);
2209
2210 let prompts = provider.recorded_prompts();
2211 assert!(prompts.len() >= 2);
2212 assert!(prompts[0].contains("## Existing Dream notebook"));
2213 assert!(prompts[0].contains("Existing durable thread"));
2214 assert!(prompts[0].contains("## Recent durable memory updates"));
2215 assert!(prompts[0].contains("start from it and preserve still-valid durable context"));
2216 }
2217
2218 #[tokio::test]
2219 async fn run_auto_dream_once_refine_mode_includes_recent_durable_memory_in_prompt() {
2220 let temp_dir = tempfile::tempdir().expect("tempdir");
2221 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2222
2223 let session_store = Arc::new(
2224 SessionStoreV2::new(temp_dir.path().to_path_buf())
2225 .await
2226 .unwrap(),
2227 );
2228 let storage: Arc<dyn Storage> = session_store.clone();
2229 let provider = SequenceProvider::new(vec![
2230 "## Current durable context\n- Refined from durable memory\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
2231 "{\"candidates\":[]}".to_string(),
2232 ]);
2233 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
2234 let config = Arc::new(RwLock::new(Config {
2235 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2236 background_model: Some("fast-model".to_string()),
2237 auto_dream_enabled: true,
2238 dream_refine_mode: true,
2239 ..bamboo_infrastructure::config::MemoryConfig::default()
2240 }),
2241 ..Config::default()
2242 }));
2243
2244 let workspace = temp_dir.path().join("workspace-refine-recent-memory");
2245 std::fs::create_dir_all(&workspace).expect("workspace dir");
2246 let project_key = crate::memory_store::project_key_from_path(&workspace);
2247
2248 let mut session = bamboo_agent_core::Session::new("session-refine-recent-memory", "model");
2249 session.title = "Refine recent memory test".to_string();
2250 session.metadata.insert(
2251 "workspace_path".to_string(),
2252 workspace.to_string_lossy().to_string(),
2253 );
2254 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2255 "Recent session summary for refine recent memory.",
2256 3,
2257 120,
2258 ));
2259 session.add_message(Message::user(
2260 "Update the dream with recent durable memory.",
2261 ));
2262 storage.save_session(&session).await.expect("save session");
2263
2264 let memory = MemoryStore::new(temp_dir.path());
2265 memory
2266 .write_project_dream_view(
2267 &project_key,
2268 "# Bamboo Dream Notebook\n\nProject key: project\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
2269 )
2270 .await
2271 .expect("write existing project dream");
2272 memory
2273 .write_memory(
2274 MemoryScope::Project,
2275 Some(&project_key),
2276 crate::memory_store::DurableMemoryType::Project,
2277 "Release freeze rule",
2278 "The release freeze starts on Tuesday for mobile.",
2279 &["release".to_string(), "freeze".to_string()],
2280 Some("session-refine-recent-memory"),
2281 "main-model",
2282 false,
2283 )
2284 .await
2285 .expect("write recent durable memory");
2286
2287 let context = AutoDreamContext {
2288 session_store,
2289 storage,
2290 provider: provider_handle,
2291 config,
2292 provider_registry: test_registry(),
2293 };
2294
2295 let _ = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
2296 .await
2297 .expect("refine recent-memory auto dream should succeed")
2298 .expect("dream output should be produced");
2299
2300 let prompts = provider.recorded_prompts();
2301 assert!(prompts.len() >= 2);
2302 assert!(prompts[0].contains("## Recent durable memory updates"));
2303 assert!(prompts[0].contains("Release freeze rule"));
2304 assert!(prompts[0].contains("Recent Memory Updates"));
2305 }
2306
2307 #[tokio::test]
2308 async fn run_auto_dream_once_forces_periodic_full_rebuild_using_memory_index() {
2309 let temp_dir = tempfile::tempdir().expect("tempdir");
2310 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2311
2312 let session_store = Arc::new(
2313 SessionStoreV2::new(temp_dir.path().to_path_buf())
2314 .await
2315 .unwrap(),
2316 );
2317 let storage: Arc<dyn Storage> = session_store.clone();
2318 let provider = SequenceProvider::new(vec![
2319 "## Current durable context\n- Rebuilt from durable memory index\n\n## Cross-session patterns\n- Canonical project history\n\n## Active threads to remember\n- Refresh active blockers\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
2320 "{\"candidates\":[]}".to_string(),
2321 ]);
2322 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
2323 let config = Arc::new(RwLock::new(Config {
2324 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2325 background_model: Some("fast-model".to_string()),
2326 auto_dream_enabled: true,
2327 dream_refine_mode: true,
2328 ..bamboo_infrastructure::config::MemoryConfig::default()
2329 }),
2330 ..Config::default()
2331 }));
2332
2333 let workspace = temp_dir.path().join("workspace-rebuild-mode");
2334 std::fs::create_dir_all(&workspace).expect("workspace dir");
2335 let project_key = crate::memory_store::project_key_from_path(&workspace);
2336
2337 let mut session = bamboo_agent_core::Session::new("session-rebuild-mode", "model");
2338 session.title = "Rebuild mode test".to_string();
2339 session.metadata.insert(
2340 "workspace_path".to_string(),
2341 workspace.to_string_lossy().to_string(),
2342 );
2343 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2344 "Recent session summary for rebuild mode.",
2345 3,
2346 120,
2347 ));
2348 session.add_message(Message::user(
2349 "Refresh the project dream from canonical memory.",
2350 ));
2351 storage.save_session(&session).await.expect("save session");
2352
2353 let memory = MemoryStore::new(temp_dir.path());
2354 memory
2355 .write_project_dream_view(
2356 &project_key,
2357 "# Bamboo Dream Notebook\n\nProject key: project\nLast consolidated at: 2026-02-02T16:00:00Z\nLast full rebuild at: 2026-02-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing project dream\n",
2358 )
2359 .await
2360 .expect("write existing project dream");
2361 memory
2362 .write_memory(
2363 MemoryScope::Project,
2364 Some(&project_key),
2365 crate::memory_store::DurableMemoryType::Project,
2366 "Canonical release decision",
2367 "Release freeze starts Tuesday and all mobile changes require review.",
2368 &["release".to_string(), "mobile".to_string()],
2369 Some("session-rebuild-mode"),
2370 "main-model",
2371 false,
2372 )
2373 .await
2374 .expect("write project durable memory");
2375
2376 let context = AutoDreamContext {
2377 session_store,
2378 storage,
2379 provider: provider_handle,
2380 config,
2381 provider_registry: test_registry(),
2382 };
2383
2384 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
2385 .await
2386 .expect("rebuild auto dream should succeed")
2387 .expect("rebuild dream output should be produced");
2388 assert_eq!(result.session_count, 1);
2389
2390 let prompts = provider.recorded_prompts();
2391 assert!(prompts.len() >= 2);
2392 assert!(prompts[0].contains("## Durable memory index"));
2393 assert!(prompts[0].contains("Canonical release decision"));
2394 assert!(prompts[0].contains("canonical durable memory plus recent session activity"));
2395
2396 let dream = memory
2397 .read_project_dream_view(&project_key)
2398 .await
2399 .expect("read project dream")
2400 .expect("project dream should exist");
2401 assert!(dream.contains("Rebuilt from durable memory index"));
2402 assert!(dream.contains("Last full rebuild at:"));
2403 }
2404
2405 #[test]
2406 fn normalize_dream_notebook_body_strips_nested_fenced_notebook_wrapper() {
2407 let raw = r#"
2408```md
2409# Bamboo Dream Notebook
2410
2411Last consolidated at: 2026-04-10T06:28:54.680302+00:00
2412Sessions reviewed: 2
2413Model: gpt-5-mini
2414
2415## Current durable context
2416- Existing durable thread
2417
2418## Cross-session patterns
2419- Keep continuity
2420
2421## Active threads to remember
2422- Update the notebook
2423
2424## Stable constraints and preferences
2425- None
2426
2427## Open risks or questions
2428- None
2429```
2430"#;
2431
2432 let normalized = normalize_dream_notebook_body(raw, DREAM_MAX_SUMMARY_CHARS)
2433 .expect("normalization should succeed");
2434 assert!(!normalized.contains("```md"));
2435 assert!(!normalized.contains("# Bamboo Dream Notebook"));
2436 assert!(normalized.contains("## Current durable context"));
2437 assert!(normalized.contains("Existing durable thread"));
2438 }
2439
2440 #[tokio::test]
2441 async fn run_auto_dream_once_refine_mode_normalizes_nested_notebook_output() {
2442 let temp_dir = tempfile::tempdir().expect("tempdir");
2443 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2444
2445 let session_store = Arc::new(
2446 SessionStoreV2::new(temp_dir.path().to_path_buf())
2447 .await
2448 .unwrap(),
2449 );
2450 let storage: Arc<dyn Storage> = session_store.clone();
2451 let provider = SequenceProvider::new(vec![
2452 "```md\n# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-10T06:28:54.680302+00:00\nSessions reviewed: 2\nModel: gpt-5-mini\n\n## Current durable context\n- Refined durable theme\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Update the notebook\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None\n```".to_string(),
2453 "{\"candidates\":[]}".to_string(),
2454 ]);
2455 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
2456 let config = Arc::new(RwLock::new(Config {
2457 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2458 background_model: Some("fast-model".to_string()),
2459 auto_dream_enabled: true,
2460 dream_refine_mode: true,
2461 ..bamboo_infrastructure::config::MemoryConfig::default()
2462 }),
2463 ..Config::default()
2464 }));
2465
2466 let workspace = temp_dir.path().join("workspace-refine-normalize");
2467 std::fs::create_dir_all(&workspace).expect("workspace dir");
2468
2469 let mut session = bamboo_agent_core::Session::new("session-refine-normalize", "model");
2470 session.title = "Refine normalize test".to_string();
2471 session.metadata.insert(
2472 "workspace_path".to_string(),
2473 workspace.to_string_lossy().to_string(),
2474 );
2475 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2476 "Recent session summary for refine normalization.",
2477 3,
2478 120,
2479 ));
2480 session.add_message(Message::user("Normalize the refined dream output."));
2481 storage.save_session(&session).await.expect("save session");
2482
2483 let memory = MemoryStore::new(temp_dir.path());
2484 memory
2485 .write_dream_view(
2486 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
2487 )
2488 .await
2489 .expect("write existing dream");
2490 memory
2491 .write_session_topic(
2492 "session-refine-normalize",
2493 "default",
2494 "Recent session note.",
2495 )
2496 .await
2497 .expect("write session topic");
2498
2499 let context = AutoDreamContext {
2500 session_store,
2501 storage,
2502 provider: provider_handle,
2503 config,
2504 provider_registry: test_registry(),
2505 };
2506
2507 let result = run_auto_dream_once_with_store(&context, &memory)
2508 .await
2509 .expect("refine normalize auto dream should succeed")
2510 .expect("dream output should be produced");
2511 assert_eq!(result.session_count, 1);
2512
2513 let dream = memory
2514 .read_dream_view()
2515 .await
2516 .expect("read dream view")
2517 .expect("dream should exist");
2518 assert!(dream.contains("Refined durable theme"));
2519 assert!(!dream.contains("```md"));
2520 assert_eq!(dream.matches("# Bamboo Dream Notebook").count(), 1);
2521 }
2522
2523 #[tokio::test]
2524 async fn run_auto_dream_once_refine_mode_falls_back_to_legacy_prompt_on_failure() {
2525 let temp_dir = tempfile::tempdir().expect("tempdir");
2526 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2527
2528 let session_store = Arc::new(
2529 SessionStoreV2::new(temp_dir.path().to_path_buf())
2530 .await
2531 .unwrap(),
2532 );
2533 let storage: Arc<dyn Storage> = session_store.clone();
2534 let provider = SequenceProvider::from_steps(vec![
2535 SequenceStep::Fail("refine prompt failed".to_string()),
2536 SequenceStep::Response(
2537 "## Current durable context\n- Legacy fallback result\n\n## Cross-session patterns\n- None\n\n## Active threads to remember\n- None\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
2538 ),
2539 SequenceStep::Response("{\"candidates\":[]}".to_string()),
2540 ]);
2541 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
2542 let config = Arc::new(RwLock::new(Config {
2543 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2544 background_model: Some("fast-model".to_string()),
2545 auto_dream_enabled: true,
2546 dream_refine_mode: true,
2547 ..bamboo_infrastructure::config::MemoryConfig::default()
2548 }),
2549 ..Config::default()
2550 }));
2551
2552 let workspace = temp_dir.path().join("workspace-refine-fallback");
2553 std::fs::create_dir_all(&workspace).expect("workspace dir");
2554
2555 let mut session = bamboo_agent_core::Session::new("session-refine-fallback", "model");
2556 session.title = "Refine fallback test".to_string();
2557 session.metadata.insert(
2558 "workspace_path".to_string(),
2559 workspace.to_string_lossy().to_string(),
2560 );
2561 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
2562 "Recent summary for fallback mode.",
2563 3,
2564 120,
2565 ));
2566 session.add_message(Message::user("Please update the dream safely."));
2567 storage.save_session(&session).await.expect("save session");
2568
2569 let memory = MemoryStore::new(temp_dir.path());
2570 memory
2571 .write_dream_view(
2572 "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 2\nModel: fast-model\n\n## Current durable context\n- Existing durable thread\n",
2573 )
2574 .await
2575 .expect("write existing dream");
2576 memory
2577 .write_session_topic("session-refine-fallback", "default", "Recent session note.")
2578 .await
2579 .expect("write session topic");
2580
2581 let context = AutoDreamContext {
2582 session_store,
2583 storage,
2584 provider: provider_handle,
2585 config,
2586 provider_registry: test_registry(),
2587 };
2588
2589 let result = run_auto_dream_once_with_store(&context, &memory)
2590 .await
2591 .expect("fallback auto dream should succeed")
2592 .expect("dream output should be produced");
2593 assert_eq!(result.session_count, 1);
2594
2595 let prompts = provider.recorded_prompts();
2596 assert!(prompts.len() >= 3);
2597 assert!(prompts[0].contains("## Existing Dream notebook"));
2598 assert!(!prompts[1].contains("## Existing Dream notebook"));
2599 assert!(prompts[1].contains("## Recent sessions"));
2600
2601 let dream = memory
2602 .read_dream_view()
2603 .await
2604 .expect("read dream view")
2605 .expect("dream should exist after fallback");
2606 assert!(dream.contains("Legacy fallback result"));
2607 }
2608
2609 #[tokio::test]
2610 async fn run_auto_dream_once_returns_none_when_disabled() {
2611 let temp_dir = tempfile::tempdir().expect("tempdir");
2612 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2613
2614 let session_store = Arc::new(
2615 SessionStoreV2::new(temp_dir.path().to_path_buf())
2616 .await
2617 .unwrap(),
2618 );
2619 let storage: Arc<dyn Storage> = session_store.clone();
2620 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
2621 let config = Arc::new(RwLock::new(Config {
2622 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2623 background_model: Some("fast-model".to_string()),
2624 ..bamboo_infrastructure::config::MemoryConfig::default()
2625 }),
2626 ..Config::default()
2627 }));
2628
2629 let context = AutoDreamContext {
2630 session_store,
2631 storage,
2632 provider,
2633 config,
2634 provider_registry: test_registry(),
2635 };
2636 let result = run_auto_dream_once(&context)
2637 .await
2638 .expect("disabled auto dream should not error");
2639 assert!(result.is_none());
2640 }
2641
2642 #[tokio::test]
2643 async fn run_auto_dream_once_returns_none_without_candidate_sessions() {
2644 let temp_dir = tempfile::tempdir().expect("tempdir");
2645 bamboo_infrastructure::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
2646
2647 let session_store = Arc::new(
2648 SessionStoreV2::new(temp_dir.path().to_path_buf())
2649 .await
2650 .unwrap(),
2651 );
2652 let storage: Arc<dyn Storage> = session_store.clone();
2653 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
2654 let config = Arc::new(RwLock::new(Config {
2655 memory: Some(bamboo_infrastructure::config::MemoryConfig {
2656 background_model: Some("fast-model".to_string()),
2657 auto_dream_enabled: true,
2658 ..bamboo_infrastructure::config::MemoryConfig::default()
2659 }),
2660 ..Config::default()
2661 }));
2662
2663 let context = AutoDreamContext {
2664 session_store,
2665 storage,
2666 provider,
2667 config,
2668 provider_registry: test_registry(),
2669 };
2670 let result = run_auto_dream_once(&context)
2671 .await
2672 .expect("no candidate sessions should not error");
2673 assert!(result.is_none());
2674 }
2675}