1use std::cmp::Reverse;
2use std::collections::HashSet;
3use std::sync::Arc;
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use futures::StreamExt;
8use tokio::sync::RwLock;
9
10use bamboo_agent_core::{Message, SessionKind};
11use bamboo_domain::reasoning::ReasoningEffort;
12use bamboo_llm::Config;
13use bamboo_llm::{LLMChunk, LLMProvider, LLMRequestOptions};
14use bamboo_llm::{ProviderModelRouter, ProviderRegistry};
15use bamboo_memory::auto_dream::{
16 build_consolidation_prompt, build_extraction_prompt, build_rebuild_consolidation_prompt,
17 derive_session_outline, normalize_dream_notebook_body, parse_candidate_scope,
18 parse_candidate_type, parse_extraction_candidates, parse_last_consolidated_at,
19 parse_last_full_rebuild_at, should_force_full_rebuild, truncate_chars,
20 ConsolidationSessionInfo, DreamCandidateInfo, DreamGenerationMode,
21};
22use bamboo_memory::memory_store::{MemoryScope, MemoryStore};
23use bamboo_storage::{SessionIndexEntry, SessionStoreV2};
24
25const DREAM_RUNTIME_SESSION_ID: &str = "__dream__";
26const DREAM_TRACING_TARGET: &str = "bamboo.auto_dream";
27const DREAM_FULL_REBUILD_INTERVAL_SECS: i64 = 60 * 60 * 24 * 30;
30const DREAM_MAX_SESSIONS: usize = 12;
31const DREAM_MAX_SUMMARY_CHARS: usize = 12_000;
32const EXTRACTION_MAX_TOPICS_PER_SESSION: usize = 4;
33const EXTRACTION_MAX_TOPIC_CHARS: usize = 1_500;
34const EXTRACTION_MAX_CANDIDATES: usize = 8;
35
36fn to_consolidation_sessions(
37 entries: &[(SessionIndexEntry, Option<String>)],
38) -> Vec<ConsolidationSessionInfo> {
39 entries
40 .iter()
41 .map(|(entry, summary)| ConsolidationSessionInfo {
42 id: entry.id.clone(),
43 title: entry.title.clone(),
44 kind: format!("{:?}", entry.kind),
45 updated_at: entry.updated_at.to_rfc3339(),
46 message_count: entry.message_count,
47 last_run_status: entry.last_run_status.clone(),
48 summary: summary.clone(),
49 })
50 .collect()
51}
52
53#[derive(Clone)]
54pub struct AutoDreamContext {
55 pub session_store: Arc<SessionStoreV2>,
56 pub storage: Arc<dyn bamboo_agent_core::storage::Storage>,
57 pub provider: Arc<dyn LLMProvider>,
58 pub config: Arc<RwLock<Config>>,
59 pub provider_registry: Arc<ProviderRegistry>,
60}
61
62fn memory_store_for_context(ctx: &AutoDreamContext) -> MemoryStore {
63 MemoryStore::new(ctx.session_store.bamboo_home_dir())
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct AutoDreamRunResult {
68 pub used_model: String,
69 pub session_count: usize,
70 pub note_path: std::path::PathBuf,
71 pub notebook_chars: usize,
72}
73
74#[derive(Debug, Clone)]
75struct CandidateSessionContext {
76 entry: SessionIndexEntry,
77 summary: Option<String>,
78 session_id: String,
79 project_key: Option<String>,
80 topics: Vec<(String, String)>,
81}
82
83#[derive(Debug, Clone)]
84struct DreamSourceWindow {
85 existing_dream: Option<String>,
86 durable_memory_index: Option<String>,
87 sessions: Vec<(SessionIndexEntry, Option<String>)>,
88}
89
90fn session_is_candidate(entry: &SessionIndexEntry, since: DateTime<Utc>) -> bool {
91 matches!(entry.kind, SessionKind::Root)
92 && entry.updated_at >= since
93 && !entry.id.trim().is_empty()
94 && entry.id != DREAM_RUNTIME_SESSION_ID
95}
96
97async fn collect_candidate_sessions(
98 ctx: &AutoDreamContext,
99 since: DateTime<Utc>,
100) -> Vec<(SessionIndexEntry, Option<String>)> {
101 let mut items = ctx.session_store.list_index_entries().await;
102 items.retain(|entry| session_is_candidate(entry, since));
103 items.sort_by_key(|e| Reverse(e.updated_at));
104
105 let mut seen_roots = HashSet::new();
106 let mut out = Vec::new();
107 for entry in items.into_iter() {
108 if !seen_roots.insert(entry.root_session_id.clone()) {
109 continue;
110 }
111 let summary = match ctx.storage.load_session(&entry.id).await {
112 Ok(Some(session)) => session
113 .conversation_summary
114 .as_ref()
115 .map(|summary| summary.content.clone())
116 .or_else(|| derive_session_outline(&session)),
117 _ => None,
118 };
119 out.push((entry, summary));
120 if out.len() >= DREAM_MAX_SESSIONS {
121 break;
122 }
123 }
124 out
125}
126
127async fn resolve_session_project_key(
128 ctx: &AutoDreamContext,
129 memory: &MemoryStore,
130 session_id: &str,
131) -> Option<String> {
132 ctx.storage
133 .load_session(session_id)
134 .await
135 .ok()
136 .flatten()
137 .and_then(|session| session.workspace_path_meta())
138 .map(std::path::PathBuf::from)
139 .map(|path| bamboo_memory::memory_store::project_key_from_path(&path))
140 .or_else(|| memory.project_key_for_session(Some(session_id)))
141}
142
143async fn collect_candidate_sessions_for_project(
144 ctx: &AutoDreamContext,
145 memory: &MemoryStore,
146 project_key: &str,
147 since: DateTime<Utc>,
148) -> Vec<(SessionIndexEntry, Option<String>)> {
149 let mut out = Vec::new();
150 for (entry, summary) in collect_candidate_sessions(ctx, since).await {
151 if resolve_session_project_key(ctx, memory, &entry.id)
152 .await
153 .as_deref()
154 != Some(project_key)
155 {
156 continue;
157 }
158 out.push((entry, summary));
159 if out.len() >= DREAM_MAX_SESSIONS {
160 break;
161 }
162 }
163 out
164}
165
166async fn collect_candidate_session_contexts_from_sessions(
167 ctx: &AutoDreamContext,
168 memory: &MemoryStore,
169 sessions: Vec<(SessionIndexEntry, Option<String>)>,
170) -> Vec<CandidateSessionContext> {
171 let mut out = Vec::new();
172 for (entry, summary) in sessions {
173 let project_key = resolve_session_project_key(ctx, memory, &entry.id).await;
174 let topics = memory
175 .read_session_topics_with_content(&entry.id)
176 .await
177 .unwrap_or_default()
178 .into_iter()
179 .take(EXTRACTION_MAX_TOPICS_PER_SESSION)
180 .map(|(topic, content)| (topic, truncate_chars(&content, EXTRACTION_MAX_TOPIC_CHARS)))
181 .collect::<Vec<_>>();
182 if topics.is_empty()
183 && summary
184 .as_deref()
185 .map(str::trim)
186 .unwrap_or_default()
187 .is_empty()
188 {
189 continue;
190 }
191 out.push(CandidateSessionContext {
192 session_id: entry.id.clone(),
193 project_key,
194 entry,
195 summary,
196 topics,
197 });
198 }
199 out
200}
201
202async fn collect_candidate_session_contexts(
203 ctx: &AutoDreamContext,
204 memory: &MemoryStore,
205 since: DateTime<Utc>,
206) -> Vec<CandidateSessionContext> {
207 collect_candidate_session_contexts_from_sessions(
208 ctx,
209 memory,
210 collect_candidate_sessions(ctx, since).await,
211 )
212 .await
213}
214
215async fn collect_candidate_session_contexts_for_project(
216 ctx: &AutoDreamContext,
217 memory: &MemoryStore,
218 project_key: &str,
219 since: DateTime<Utc>,
220) -> Vec<CandidateSessionContext> {
221 collect_candidate_session_contexts_from_sessions(
222 ctx,
223 memory,
224 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await,
225 )
226 .await
227}
228
229async fn extract_and_persist_durable_candidates(
230 provider: &Arc<dyn LLMProvider>,
231 memory: &MemoryStore,
232 model: &str,
233 sessions: &[CandidateSessionContext],
234) -> Result<usize, String> {
235 if sessions.is_empty() {
236 return Ok(0);
237 }
238
239 let candidates_info: Vec<DreamCandidateInfo> = sessions
240 .iter()
241 .map(|session| DreamCandidateInfo {
242 session_id: session.session_id.clone(),
243 title: session.entry.title.clone(),
244 project_key: session.project_key.clone(),
245 updated_at: session.entry.updated_at.to_rfc3339(),
246 summary: session.summary.clone(),
247 topics: session.topics.clone(),
248 })
249 .collect();
250 let prompt = build_extraction_prompt(&candidates_info);
251 let raw = collect_stream_text(provider.clone(), model, prompt).await?;
252 let candidates = parse_extraction_candidates(&raw)?;
253 if candidates.is_empty() {
254 return Ok(0);
255 }
256
257 let mut session_project_keys = std::collections::HashMap::new();
258 for session in sessions {
259 session_project_keys.insert(session.session_id.clone(), session.project_key.clone());
260 }
261
262 let extracted_at = Utc::now().to_rfc3339();
263 let mut writes = 0usize;
264 let mut touched_sessions = HashSet::new();
265 for candidate in candidates.into_iter().take(EXTRACTION_MAX_CANDIDATES) {
266 let Some(memory_type) = parse_candidate_type(&candidate.kind) else {
267 continue;
268 };
269 let title = candidate.title.trim();
270 let content = candidate.content.trim();
271 if title.is_empty() || content.is_empty() {
272 continue;
273 }
274 let session_id = candidate
275 .session_id
276 .as_deref()
277 .map(str::trim)
278 .filter(|value| !value.is_empty());
279 let project_key = session_id
280 .and_then(|id| session_project_keys.get(id))
281 .and_then(|value| value.as_deref())
282 .map(ToString::to_string);
283 let scope = parse_candidate_scope(&candidate, project_key.as_deref());
284 let tags = candidate.tags;
285 let _ = &candidate.confidence;
286 memory
287 .write_memory(
288 scope,
289 project_key.as_deref(),
290 memory_type,
291 title,
292 content,
293 &tags,
294 session_id,
295 "background-fast-model",
296 false,
297 None,
298 )
299 .await
300 .map_err(|error| {
301 format!(
302 "failed to persist durable extraction candidate '{}': {error}",
303 title
304 )
305 })?;
306 writes += 1;
307 if let Some(session_id) = session_id {
308 touched_sessions.insert(session_id.to_string());
309 }
310 }
311
312 for session_id in touched_sessions {
313 memory
314 .mark_session_extracted(&session_id, &extracted_at)
315 .await
316 .map_err(|error| {
317 format!("failed to update session extraction state for {session_id}: {error}")
318 })?;
319 }
320
321 Ok(writes)
322}
323
324async fn collect_stream_text(
325 provider: Arc<dyn LLMProvider>,
326 model: &str,
327 prompt: String,
328) -> Result<String, String> {
329 let messages = vec![
330 Message::system(
331 "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."
332 ),
333 Message::user(prompt),
334 ];
335 let options = LLMRequestOptions {
336 session_id: Some(DREAM_RUNTIME_SESSION_ID.to_string()),
337 reasoning_effort: Some(ReasoningEffort::High),
338 parallel_tool_calls: None,
339 responses: None,
340 request_purpose: Some("auto_dream".to_string()),
341 cache: None,
342 };
343
344 let mut stream = provider
345 .chat_stream_with_options(&messages, &[], Some(8192), model, Some(&options))
346 .await
347 .map_err(|error| format!("auto-dream provider call failed: {error}"))?;
348
349 let mut content = String::new();
350 while let Some(chunk) = stream.next().await {
351 match chunk {
352 Ok(LLMChunk::Token(text)) => content.push_str(&text),
353 Ok(LLMChunk::Done) => break,
354 Ok(_) => {}
355 Err(error) => {
356 if !content.is_empty() {
357 break;
358 }
359 return Err(format!("auto-dream stream failed: {error}"));
360 }
361 }
362 }
363
364 let trimmed = content.trim();
365 if trimmed.is_empty() {
366 return Err("auto-dream returned empty content".to_string());
367 }
368 Ok(truncate_chars(trimmed, DREAM_MAX_SUMMARY_CHARS))
369}
370
371async fn read_existing_dream_for_scope(
372 memory: &MemoryStore,
373 scope: MemoryScope,
374 project_key: Option<&str>,
375) -> Result<Option<String>, String> {
376 match scope {
377 MemoryScope::Global => memory
378 .read_dream_view()
379 .await
380 .map_err(|error| format!("failed to read Dream notebook: {error}")),
381 MemoryScope::Project => {
382 let project_key = project_key
383 .map(str::trim)
384 .filter(|value| !value.is_empty())
385 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
386 memory
387 .read_project_dream_view(project_key)
388 .await
389 .map_err(|error| {
390 format!("failed to read project Dream notebook for '{project_key}': {error}")
391 })
392 }
393 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
394 }
395}
396
397async fn read_durable_memory_index_for_scope(
398 memory: &MemoryStore,
399 scope: MemoryScope,
400 project_key: Option<&str>,
401) -> Result<Option<String>, String> {
402 memory
403 .read_memory_view(scope, project_key)
404 .await
405 .map_err(|error| format!("failed to read durable memory index view: {error}"))
406}
407
408async fn write_dream_for_scope(
409 memory: &MemoryStore,
410 scope: MemoryScope,
411 project_key: Option<&str>,
412 content: &str,
413) -> Result<std::path::PathBuf, String> {
414 match scope {
415 MemoryScope::Global => memory
416 .write_dream_view(content)
417 .await
418 .map_err(|error| format!("failed to persist Dream notebook: {error}")),
419 MemoryScope::Project => {
420 let project_key = project_key
421 .map(str::trim)
422 .filter(|value| !value.is_empty())
423 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
424 memory
425 .write_project_dream_view(project_key, content)
426 .await
427 .map_err(|error| {
428 format!("failed to persist project Dream notebook for '{project_key}': {error}")
429 })
430 }
431 MemoryScope::Session => Err("session-scoped Dream generation is not supported".to_string()),
432 }
433}
434
435async fn build_dream_notebook_body(
436 provider: &Arc<dyn LLMProvider>,
437 model: &str,
438 source_window: &DreamSourceWindow,
439 generation_mode: DreamGenerationMode,
440) -> Result<String, String> {
441 match generation_mode {
442 DreamGenerationMode::Rebuild => {
443 tracing::info!(
444 target: DREAM_TRACING_TARGET,
445 event = "rebuild_attempt",
446 model = model,
447 session_count = source_window.sessions.len(),
448 durable_memory_index_present = source_window.durable_memory_index.is_some(),
449 "Attempting full rebuild Dream synthesis"
450 );
451 let prompt = build_rebuild_consolidation_prompt(
452 source_window.durable_memory_index.as_deref(),
453 &to_consolidation_sessions(&source_window.sessions),
454 );
455 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
456 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
457 }
458 DreamGenerationMode::Incremental => {
459 let prompt =
460 build_consolidation_prompt(&to_consolidation_sessions(&source_window.sessions));
461 let raw_body = collect_stream_text(provider.clone(), model, prompt).await?;
462 normalize_dream_notebook_body(&raw_body, DREAM_MAX_SUMMARY_CHARS)
463 }
464 }
465}
466
467fn full_rebuild_marker_line(
477 force_full_rebuild: bool,
478 generation_mode: DreamGenerationMode,
479 last_full_rebuild_at: Option<chrono::DateTime<chrono::Utc>>,
480 now: chrono::DateTime<chrono::Utc>,
481) -> String {
482 if force_full_rebuild
483 || (matches!(generation_mode, DreamGenerationMode::Rebuild)
484 && last_full_rebuild_at.is_none())
485 {
486 format!("Last full rebuild at: {}\n", now.to_rfc3339())
487 } else if let Some(existing_rebuild_at) = last_full_rebuild_at {
488 format!(
489 "Last full rebuild at: {}\n",
490 existing_rebuild_at.to_rfc3339()
491 )
492 } else {
493 String::new()
494 }
495}
496
497async fn run_auto_dream_once_for_scope(
498 ctx: &AutoDreamContext,
499 memory: &MemoryStore,
500 scope: MemoryScope,
501 project_key: Option<&str>,
502 require_auto_dream_enabled: bool,
503) -> Result<Option<AutoDreamRunResult>, String> {
504 let scope_label = match scope {
505 MemoryScope::Global => "global",
506 MemoryScope::Project => "project",
507 MemoryScope::Session => "session",
508 };
509
510 let config_snapshot = ctx.config.read().await.clone();
511 let memory_cfg = config_snapshot.memory.clone().unwrap_or_default();
512 if require_auto_dream_enabled && !memory_cfg.auto_dream_enabled {
513 tracing::info!(
514 target: DREAM_TRACING_TARGET,
515 event = "run_skip",
516 reason = "auto_dream_disabled",
517 scope = scope_label,
518 project_key = project_key.unwrap_or(""),
519 "Skipping Dream generation because auto_dream is disabled"
520 );
521 return Ok(None);
522 }
523
524 let now = Utc::now();
529 let existing = read_existing_dream_for_scope(memory, scope, project_key).await?;
530 let durable_memory_index =
531 read_durable_memory_index_for_scope(memory, scope, project_key).await?;
532 let last_full_rebuild_at = existing.as_deref().and_then(parse_last_full_rebuild_at);
533 let force_full_rebuild =
534 should_force_full_rebuild(last_full_rebuild_at, now, DREAM_FULL_REBUILD_INTERVAL_SECS);
535 let since = if force_full_rebuild {
536 now - chrono::Duration::days(30)
537 } else {
538 match existing.as_deref().and_then(parse_last_consolidated_at) {
539 Some(ts) => ts,
540 None => now - chrono::Duration::hours(24),
541 }
542 };
543
544 let sessions = match scope {
545 MemoryScope::Global => collect_candidate_sessions(ctx, since).await,
546 MemoryScope::Project => {
547 let project_key = project_key
548 .map(str::trim)
549 .filter(|value| !value.is_empty())
550 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
551 collect_candidate_sessions_for_project(ctx, memory, project_key, since).await
552 }
553 MemoryScope::Session => {
554 return Err("session-scoped Dream generation is not supported".to_string())
555 }
556 };
557 if sessions.is_empty() {
558 tracing::info!(
559 target: DREAM_TRACING_TARGET,
560 event = "run_skip",
561 reason = "no_candidate_sessions",
562 scope = scope_label,
563 project_key = project_key.unwrap_or(""),
564 existing_dream_present = existing.is_some(),
565 "Skipping Dream generation because there are no candidate sessions"
566 );
567 return Ok(None);
568 }
569
570 let provider_ref_enabled = config_snapshot.features.provider_model_ref;
575 let model_ref = if provider_ref_enabled {
576 config_snapshot
577 .defaults
578 .as_ref()
579 .and_then(|d| d.memory_background.as_ref())
580 .or_else(|| {
581 config_snapshot
582 .defaults
583 .as_ref()
584 .and_then(|d| d.fast.as_ref())
585 })
586 } else {
587 None
588 };
589 let (bg_provider, model): (Arc<dyn LLMProvider>, String) = if let Some(ref mr) = model_ref {
590 let router = ProviderModelRouter::new(ctx.provider_registry.clone());
591 let routed = router.route(mr).map_err(|e| {
592 format!(
593 "[auto_dream] failed to route background model ref '{}': {}",
594 mr, e
595 )
596 })?;
597 tracing::debug!(
598 target: DREAM_TRACING_TARGET,
599 model_ref = %mr,
600 "Resolved background model via ProviderModelRef"
601 );
602 (routed, mr.model.clone())
603 } else {
604 let Some(model) = config_snapshot.get_memory_background_model() else {
605 tracing::warn!(
606 target: DREAM_TRACING_TARGET,
607 event = "run_skip",
608 reason = "no_background_model",
609 scope = scope_label,
610 project_key = project_key.unwrap_or(""),
611 "[auto_dream] skipped: no memory.background_model / provider.fast_model configured"
612 );
613 return Ok(None);
614 };
615 (ctx.provider.clone(), model)
616 };
617
618 let generation_mode = if force_full_rebuild || durable_memory_index.is_some() {
625 DreamGenerationMode::Rebuild
626 } else {
627 DreamGenerationMode::Incremental
628 };
629 tracing::info!(
630 target: DREAM_TRACING_TARGET,
631 event = "run_start",
632 scope = scope_label,
633 project_key = project_key.unwrap_or(""),
634 model = model.as_str(),
635 session_count = sessions.len(),
636 existing_dream_present = existing.is_some(),
637 durable_memory_index_present = durable_memory_index.is_some(),
638 force_full_rebuild = force_full_rebuild,
639 generation_mode = match generation_mode {
640 DreamGenerationMode::Incremental => "incremental",
641 DreamGenerationMode::Rebuild => "rebuild",
642 },
643 require_auto_dream_enabled = require_auto_dream_enabled,
644 "Starting Dream generation run"
645 );
646
647 let source_window = DreamSourceWindow {
648 existing_dream: existing,
649 durable_memory_index,
650 sessions,
651 };
652 let notebook_body =
653 build_dream_notebook_body(&bg_provider, &model, &source_window, generation_mode).await?;
654 let last_full_rebuild_line = full_rebuild_marker_line(
655 force_full_rebuild,
656 generation_mode,
657 last_full_rebuild_at,
658 now,
659 );
660 let final_note = match scope {
661 MemoryScope::Global => format!(
662 "# Bamboo Dream Notebook\n\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
663 now.to_rfc3339(),
664 last_full_rebuild_line,
665 source_window.sessions.len(),
666 model,
667 notebook_body.trim(),
668 ),
669 MemoryScope::Project => format!(
670 "# Bamboo Dream Notebook\n\nProject key: {}\nLast consolidated at: {}\n{}Sessions reviewed: {}\nModel: {}\n\n{}\n",
671 project_key.unwrap_or_default(),
672 now.to_rfc3339(),
673 last_full_rebuild_line,
674 source_window.sessions.len(),
675 model,
676 notebook_body.trim(),
677 ),
678 MemoryScope::Session => unreachable!("session scope handled above"),
679 };
680
681 let note_path = write_dream_for_scope(memory, scope, project_key, &final_note).await?;
682
683 let extraction_sessions = match scope {
684 MemoryScope::Global => collect_candidate_session_contexts(ctx, memory, since).await,
685 MemoryScope::Project => {
686 let project_key = project_key
687 .map(str::trim)
688 .filter(|value| !value.is_empty())
689 .ok_or_else(|| "project Dream generation requires a project_key".to_string())?;
690 collect_candidate_session_contexts_for_project(ctx, memory, project_key, since).await
691 }
692 MemoryScope::Session => unreachable!("session scope handled above"),
693 };
694 let extracted_count =
695 extract_and_persist_durable_candidates(&bg_provider, memory, &model, &extraction_sessions)
696 .await?;
697 let notebook_chars = final_note.chars().count();
698
699 tracing::info!(
700 target: DREAM_TRACING_TARGET,
701 event = "run_complete",
702 scope = scope_label,
703 project_key = project_key.unwrap_or(""),
704 model = model.as_str(),
705 session_count = source_window.sessions.len(),
706 existing_dream_present = source_window.existing_dream.is_some(),
707 durable_memory_index_present = source_window.durable_memory_index.is_some(),
708 generation_mode = match generation_mode {
709 DreamGenerationMode::Incremental => "incremental",
710 DreamGenerationMode::Rebuild => "rebuild",
711 },
712 notebook_chars = notebook_chars,
713 durable_candidates_persisted = extracted_count,
714 note_path = %note_path.display(),
715 "Dream generation run completed"
716 );
717
718 Ok(Some(AutoDreamRunResult {
719 used_model: model,
720 session_count: source_window.sessions.len(),
721 note_path,
722 notebook_chars,
723 }))
724}
725
726async fn run_auto_dream_once_with_store(
727 ctx: &AutoDreamContext,
728 memory: &MemoryStore,
729) -> Result<Option<AutoDreamRunResult>, String> {
730 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Global, None, true).await
731}
732
733pub async fn run_auto_dream_once(
734 ctx: &AutoDreamContext,
735) -> Result<Option<AutoDreamRunResult>, String> {
736 let memory = memory_store_for_context(ctx);
737 run_auto_dream_once_with_store(ctx, &memory).await
738}
739
740pub async fn run_project_auto_dream_once(
741 ctx: &AutoDreamContext,
742 project_key: &str,
743) -> Result<Option<AutoDreamRunResult>, String> {
744 let memory = memory_store_for_context(ctx);
745 run_project_auto_dream_once_with_store(ctx, &memory, project_key).await
746}
747
748async fn run_project_auto_dream_once_with_store(
749 ctx: &AutoDreamContext,
750 memory: &MemoryStore,
751 project_key: &str,
752) -> Result<Option<AutoDreamRunResult>, String> {
753 let project_key = project_key.trim();
754 if project_key.is_empty() {
755 return Err("project Dream generation requires a non-empty project_key".to_string());
756 }
757 run_auto_dream_once_for_scope(ctx, memory, MemoryScope::Project, Some(project_key), false).await
758}
759
760pub fn spawn_auto_dream_task(ctx: AutoDreamContext) {
761 tokio::spawn(async move {
762 let interval_secs = ctx
763 .config
764 .read()
765 .await
766 .memory
767 .as_ref()
768 .map(|memory| memory.auto_dream_interval_secs)
769 .filter(|secs| *secs > 0)
770 .unwrap_or_else(|| bamboo_config::MemoryConfig::default().auto_dream_interval_secs);
773 let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs));
774 loop {
775 ticker.tick().await;
776 if let Err(error) = run_auto_dream_once(&ctx).await {
777 tracing::warn!(
778 target: DREAM_TRACING_TARGET,
779 event = "run_failed",
780 "[auto_dream] run failed: {}",
781 error
782 );
783 }
784 }
785 });
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 use std::collections::HashMap;
793 use std::sync::{Arc, Mutex};
794
795 use async_trait::async_trait;
796 use futures::stream;
797
798 use bamboo_agent_core::storage::Storage;
799 use bamboo_llm::{LLMError, LLMStream};
800
801 #[test]
802 fn full_rebuild_marker_bootstraps_on_first_grounded_rebuild() {
803 let now = "2026-07-08T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
804
805 let line = full_rebuild_marker_line(false, DreamGenerationMode::Rebuild, None, now);
809 assert_eq!(
810 line,
811 format!("Last full rebuild at: {}\n", now.to_rfc3339())
812 );
813 }
814
815 #[test]
816 fn full_rebuild_marker_preserves_existing_on_non_forced_pass() {
817 let now = "2026-07-08T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
818 let existing = "2026-07-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
819
820 let line =
824 full_rebuild_marker_line(false, DreamGenerationMode::Rebuild, Some(existing), now);
825 assert_eq!(
826 line,
827 format!("Last full rebuild at: {}\n", existing.to_rfc3339())
828 );
829 }
830
831 #[test]
832 fn full_rebuild_marker_stamps_now_on_forced_pass() {
833 let now = "2026-07-08T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
834 let existing = "2026-06-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
835
836 let line =
838 full_rebuild_marker_line(true, DreamGenerationMode::Rebuild, Some(existing), now);
839 assert_eq!(
840 line,
841 format!("Last full rebuild at: {}\n", now.to_rfc3339())
842 );
843 }
844
845 #[test]
846 fn full_rebuild_marker_absent_when_incremental_and_no_prior_marker() {
847 let now = "2026-07-08T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
848
849 let line = full_rebuild_marker_line(false, DreamGenerationMode::Incremental, None, now);
852 assert_eq!(line, String::new());
853 }
854
855 fn test_registry() -> Arc<ProviderRegistry> {
856 Arc::new(ProviderRegistry::new(HashMap::new(), "test".to_string()))
857 }
858
859 #[derive(Clone)]
860 struct SequenceProvider {
861 responses: Arc<Mutex<Vec<String>>>,
862 prompts: Arc<Mutex<Vec<String>>>,
863 }
864
865 impl SequenceProvider {
866 fn new(responses: Vec<String>) -> Self {
867 Self {
868 responses: Arc::new(Mutex::new(responses)),
869 prompts: Arc::new(Mutex::new(Vec::new())),
870 }
871 }
872
873 fn recorded_prompts(&self) -> Vec<String> {
874 self.prompts.lock().expect("lock poisoned").clone()
875 }
876 }
877
878 #[async_trait]
879 impl LLMProvider for SequenceProvider {
880 async fn chat_stream(
881 &self,
882 messages: &[Message],
883 _tools: &[bamboo_agent_core::tools::ToolSchema],
884 _max_output_tokens: Option<u32>,
885 _model: &str,
886 ) -> Result<LLMStream, LLMError> {
887 if let Some(prompt) = messages.last().map(|message| message.content.clone()) {
888 self.prompts.lock().expect("lock poisoned").push(prompt);
889 }
890 let text = self.responses.lock().expect("lock poisoned").remove(0);
891 Ok(Box::pin(stream::iter(vec![
892 Ok(LLMChunk::Token(text)),
893 Ok(LLMChunk::Done),
894 ])))
895 }
896 }
897
898 #[test]
899 fn parse_last_consolidated_at_reads_frontmatter_line() {
900 let note = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-04-02T16:00:00Z\nSessions reviewed: 3\n";
901 let parsed = parse_last_consolidated_at(note).expect("timestamp should parse");
902 assert_eq!(parsed.to_rfc3339(), "2026-04-02T16:00:00+00:00");
903 }
904
905 #[test]
906 fn parse_extraction_candidates_accepts_fenced_json() {
907 let raw = "```json\n{\"candidates\":[{\"title\":\"User prefers terse responses\",\"type\":\"feedback\",\"scope\":\"global\",\"content\":\"The user prefers terse responses.\",\"tags\":[\"preference\"],\"session_id\":\"session-1\"}]}\n```";
908 let candidates = parse_extraction_candidates(raw).expect("candidates should parse");
909 assert_eq!(candidates.len(), 1);
910 assert_eq!(candidates[0].title, "User prefers terse responses");
911 assert_eq!(candidates[0].kind, "feedback");
912 }
913
914 #[tokio::test]
915 async fn extract_and_persist_durable_candidates_writes_memory_and_marks_session() {
916 let temp_dir = tempfile::tempdir().expect("tempdir");
917 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
918
919 let session_store = Arc::new(
920 SessionStoreV2::new(temp_dir.path().to_path_buf())
921 .await
922 .unwrap(),
923 );
924 let storage: Arc<dyn Storage> = session_store.clone();
925 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
926 "{\"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(),
927 ]));
928 let config = Arc::new(RwLock::new(Config {
929 memory: Some(bamboo_config::MemoryConfig {
930 background_model: Some("fast-model".to_string()),
931 auto_dream_enabled: true,
932 ..bamboo_config::MemoryConfig::default()
933 }),
934 ..Config::default()
935 }));
936
937 let mut session = bamboo_agent_core::Session::new("session-auto", "model");
938 session.title = "Auto memory test".to_string();
939 session.metadata.insert(
940 "workspace_path".to_string(),
941 temp_dir
942 .path()
943 .join("workspace-a")
944 .to_string_lossy()
945 .to_string(),
946 );
947 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
948 "User confirmed a stable response preference.",
949 3,
950 128,
951 ));
952 session.add_message(Message::user("Please be terse and skip the recap."));
953 storage.save_session(&session).await.expect("save session");
954
955 let memory = MemoryStore::new(temp_dir.path());
956 memory
957 .write_session_topic("session-auto", "default", "User prefers terse responses.")
958 .await
959 .expect("write session topic");
960
961 let context = AutoDreamContext {
962 session_store: session_store.clone(),
963 storage: storage.clone(),
964 provider: provider.clone(),
965 config: config.clone(),
966 provider_registry: test_registry(),
967 };
968 let contexts = collect_candidate_session_contexts(
969 &context,
970 &memory,
971 Utc::now() - chrono::Duration::hours(24),
972 )
973 .await;
974 assert_eq!(contexts.len(), 1);
975
976 let writes =
977 extract_and_persist_durable_candidates(&provider, &memory, "fast-model", &contexts)
978 .await
979 .expect("extraction should succeed");
980 assert_eq!(writes, 1);
981
982 let project_key = bamboo_memory::memory_store::project_key_from_path(
983 &temp_dir.path().join("workspace-a"),
984 );
985 let results = memory
986 .query_scope(
987 MemoryScope::Project,
988 Some(&project_key),
989 Some("terse recap"),
990 None,
991 None,
992 &bamboo_memory::memory_store::MemoryQueryOptions {
993 limit: Some(5),
994 max_chars: Some(2000),
995 cursor: None,
996 include_related: false,
997 },
998 )
999 .await
1000 .expect("query should succeed");
1001 assert_eq!(results.matched_count, 1);
1002 assert_eq!(results.items[0].title, "User prefers terse responses");
1003
1004 let state = memory
1005 .read_session_state("session-auto")
1006 .await
1007 .expect("read session state");
1008 assert!(state.last_extracted_at.is_some());
1009 }
1010
1011 #[tokio::test]
1012 async fn extract_and_persist_durable_candidates_ignores_empty_candidate_lists() {
1013 let temp_dir = tempfile::tempdir().expect("tempdir");
1014 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1015
1016 let session_store = Arc::new(
1017 SessionStoreV2::new(temp_dir.path().to_path_buf())
1018 .await
1019 .unwrap(),
1020 );
1021 let storage: Arc<dyn Storage> = session_store.clone();
1022 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1023 "{\"candidates\":[]}".to_string(),
1024 ]));
1025 let config = Arc::new(RwLock::new(Config {
1026 memory: Some(bamboo_config::MemoryConfig {
1027 background_model: Some("fast-model".to_string()),
1028 auto_dream_enabled: true,
1029 ..bamboo_config::MemoryConfig::default()
1030 }),
1031 ..Config::default()
1032 }));
1033
1034 let mut session = bamboo_agent_core::Session::new("session-empty", "model");
1035 session.metadata.insert(
1036 "workspace_path".to_string(),
1037 temp_dir.path().to_string_lossy().to_string(),
1038 );
1039 session.add_message(Message::user("This should not produce durable memory."));
1040 storage.save_session(&session).await.expect("save session");
1041
1042 let memory = MemoryStore::new(temp_dir.path());
1043 memory
1044 .write_session_topic("session-empty", "default", "ephemeral scratch")
1045 .await
1046 .expect("write session topic");
1047
1048 let context = AutoDreamContext {
1049 session_store,
1050 storage,
1051 provider,
1052 config,
1053 provider_registry: test_registry(),
1054 };
1055 let sessions = collect_candidate_session_contexts(
1056 &context,
1057 &memory,
1058 Utc::now() - chrono::Duration::hours(24),
1059 )
1060 .await;
1061 let writes = extract_and_persist_durable_candidates(
1062 &context.provider,
1063 &memory,
1064 "fast-model",
1065 &sessions,
1066 )
1067 .await
1068 .expect("empty extraction should succeed");
1069 assert_eq!(writes, 0);
1070
1071 let state = memory
1072 .read_session_state("session-empty")
1073 .await
1074 .expect("read session state");
1075 assert!(state.last_extracted_at.is_none());
1076 }
1077
1078 #[tokio::test]
1079 async fn run_auto_dream_once_updates_dream_and_persists_candidates() {
1080 let temp_dir = tempfile::tempdir().expect("tempdir");
1081 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1082
1083 let session_store = Arc::new(
1084 SessionStoreV2::new(temp_dir.path().to_path_buf())
1085 .await
1086 .unwrap(),
1087 );
1088 let storage: Arc<dyn Storage> = session_store.clone();
1089 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1090 "## 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(),
1091 "{\"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(),
1092 ]));
1093 let config = Arc::new(RwLock::new(Config {
1094 memory: Some(bamboo_config::MemoryConfig {
1095 background_model: Some("fast-model".to_string()),
1096 auto_dream_enabled: true,
1097 ..bamboo_config::MemoryConfig::default()
1098 }),
1099 ..Config::default()
1100 }));
1101
1102 let mut session = bamboo_agent_core::Session::new("session-dream-run", "model");
1103 session.title = "Dream run test".to_string();
1104 session.metadata.insert(
1105 "workspace_path".to_string(),
1106 temp_dir
1107 .path()
1108 .join("workspace-run")
1109 .to_string_lossy()
1110 .to_string(),
1111 );
1112 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1113 "Stable user preference discussed.",
1114 4,
1115 200,
1116 ));
1117 session.add_message(Message::user("Please keep answers concise."));
1118 storage.save_session(&session).await.expect("save session");
1119
1120 let memory = MemoryStore::new(temp_dir.path());
1121 memory
1122 .write_session_topic(
1123 "session-dream-run",
1124 "default",
1125 "User prefers concise answers and minimal recap.",
1126 )
1127 .await
1128 .expect("write session topic");
1129
1130 let context = AutoDreamContext {
1131 session_store,
1132 storage,
1133 provider,
1134 config,
1135 provider_registry: test_registry(),
1136 };
1137 let result = run_auto_dream_once_with_store(&context, &memory)
1138 .await
1139 .expect("auto dream run should succeed")
1140 .expect("auto dream should produce output");
1141 assert_eq!(result.used_model, "fast-model");
1142 assert_eq!(result.session_count, 1);
1143
1144 let dream = memory
1145 .read_dream_view()
1146 .await
1147 .expect("read dream view")
1148 .expect("dream should exist");
1149 assert!(dream.contains("Bamboo Dream Notebook"));
1150 assert!(dream.contains("Durable signal found"));
1151
1152 let project_key = bamboo_memory::memory_store::project_key_from_path(
1153 &temp_dir.path().join("workspace-run"),
1154 );
1155 let results = memory
1156 .query_scope(
1157 MemoryScope::Project,
1158 Some(&project_key),
1159 Some("concise answers"),
1160 None,
1161 None,
1162 &bamboo_memory::memory_store::MemoryQueryOptions {
1163 limit: Some(5),
1164 max_chars: Some(2000),
1165 cursor: None,
1166 include_related: false,
1167 },
1168 )
1169 .await
1170 .expect("query should succeed");
1171 assert_eq!(results.matched_count, 1);
1172 assert_eq!(results.items[0].title, "User prefers concise answers");
1173 }
1174
1175 #[tokio::test]
1176 async fn run_project_auto_dream_once_filters_sessions_by_project_and_writes_project_dream() {
1177 let temp_dir = tempfile::tempdir().expect("tempdir");
1178 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1179
1180 let workspace_a = temp_dir.path().join("workspace-a");
1181 let workspace_b = temp_dir.path().join("workspace-b");
1182 std::fs::create_dir_all(&workspace_a).expect("workspace a");
1183 std::fs::create_dir_all(&workspace_b).expect("workspace b");
1184 let project_key_a = bamboo_memory::memory_store::project_key_from_path(&workspace_a);
1185
1186 let session_store = Arc::new(
1187 SessionStoreV2::new(temp_dir.path().to_path_buf())
1188 .await
1189 .unwrap(),
1190 );
1191 let storage: Arc<dyn Storage> = session_store.clone();
1192 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1193 "## 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(),
1194 "{\"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(),
1195 ]));
1196 let config = Arc::new(RwLock::new(Config {
1197 memory: Some(bamboo_config::MemoryConfig {
1198 background_model: Some("fast-model".to_string()),
1199 auto_dream_enabled: true,
1200 ..bamboo_config::MemoryConfig::default()
1201 }),
1202 ..Config::default()
1203 }));
1204
1205 let mut session_a = bamboo_agent_core::Session::new("session-project-a", "model");
1206 session_a.title = "Project A session".to_string();
1207 session_a.metadata.insert(
1208 "workspace_path".to_string(),
1209 workspace_a.to_string_lossy().to_string(),
1210 );
1211 session_a.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1212 "Project A stable direction.",
1213 4,
1214 160,
1215 ));
1216 session_a.add_message(Message::user("Keep project A plans concise."));
1217 storage
1218 .save_session(&session_a)
1219 .await
1220 .expect("save session a");
1221
1222 let mut session_b = bamboo_agent_core::Session::new("session-project-b", "model");
1223 session_b.title = "Project B session".to_string();
1224 session_b.metadata.insert(
1225 "workspace_path".to_string(),
1226 workspace_b.to_string_lossy().to_string(),
1227 );
1228 session_b.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1229 "Project B unrelated direction.",
1230 4,
1231 160,
1232 ));
1233 session_b.add_message(Message::user("This is unrelated project B context."));
1234 storage
1235 .save_session(&session_b)
1236 .await
1237 .expect("save session b");
1238
1239 let memory = MemoryStore::new(temp_dir.path());
1240 memory
1241 .write_session_topic(
1242 "session-project-a",
1243 "default",
1244 "Project A planning should remain concise.",
1245 )
1246 .await
1247 .expect("write session topic a");
1248 memory
1249 .write_session_topic(
1250 "session-project-b",
1251 "default",
1252 "Project B note that should not be included.",
1253 )
1254 .await
1255 .expect("write session topic b");
1256
1257 let context = AutoDreamContext {
1258 session_store,
1259 storage,
1260 provider,
1261 config,
1262 provider_registry: test_registry(),
1263 };
1264 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key_a)
1265 .await
1266 .expect("project auto dream should succeed")
1267 .expect("project auto dream should produce output");
1268 assert_eq!(result.used_model, "fast-model");
1269 assert_eq!(result.session_count, 1);
1270
1271 let project_dream = memory
1272 .read_project_dream_view(&project_key_a)
1273 .await
1274 .expect("read project dream")
1275 .expect("project dream should exist");
1276 assert!(project_dream.contains("Bamboo Dream Notebook"));
1277 assert!(project_dream.contains("Project key: "));
1278 assert!(project_dream.contains(&project_key_a));
1279 assert!(project_dream.contains("Project A signal only"));
1280 assert!(!project_dream.contains("unrelated project B"));
1281
1282 let global_dream = memory.read_dream_view().await.expect("read global dream");
1283 assert!(global_dream.is_none());
1284
1285 let results = memory
1286 .query_scope(
1287 MemoryScope::Project,
1288 Some(&project_key_a),
1289 Some("concise planning"),
1290 None,
1291 None,
1292 &bamboo_memory::memory_store::MemoryQueryOptions {
1293 limit: Some(5),
1294 max_chars: Some(2000),
1295 cursor: None,
1296 include_related: false,
1297 },
1298 )
1299 .await
1300 .expect("query should succeed");
1301 assert_eq!(results.matched_count, 1);
1302 assert_eq!(results.items[0].title, "Project A prefers concise planning");
1303 }
1304
1305 #[tokio::test]
1306 async fn run_project_auto_dream_once_returns_none_without_target_project_sessions_and_preserves_existing_dream(
1307 ) {
1308 let temp_dir = tempfile::tempdir().expect("tempdir");
1309 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1310
1311 let workspace_other = temp_dir.path().join("workspace-other");
1312 let workspace_target = temp_dir.path().join("workspace-target");
1313 std::fs::create_dir_all(&workspace_other).expect("workspace other");
1314 std::fs::create_dir_all(&workspace_target).expect("workspace target");
1315 let target_project_key =
1316 bamboo_memory::memory_store::project_key_from_path(&workspace_target);
1317
1318 let session_store = Arc::new(
1319 SessionStoreV2::new(temp_dir.path().to_path_buf())
1320 .await
1321 .unwrap(),
1322 );
1323 let storage: Arc<dyn Storage> = session_store.clone();
1324 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1325 let config = Arc::new(RwLock::new(Config {
1326 memory: Some(bamboo_config::MemoryConfig {
1327 background_model: Some("fast-model".to_string()),
1328 auto_dream_enabled: true,
1329 ..bamboo_config::MemoryConfig::default()
1330 }),
1331 ..Config::default()
1332 }));
1333
1334 let mut other_session = bamboo_agent_core::Session::new("session-other-project", "model");
1335 other_session.title = "Other project session".to_string();
1336 other_session.metadata.insert(
1337 "workspace_path".to_string(),
1338 workspace_other.to_string_lossy().to_string(),
1339 );
1340 other_session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1341 "Other project only.",
1342 2,
1343 80,
1344 ));
1345 other_session.add_message(Message::user("Other project context only."));
1346 storage
1347 .save_session(&other_session)
1348 .await
1349 .expect("save other session");
1350
1351 let memory = MemoryStore::new(temp_dir.path());
1352 memory
1353 .write_project_dream_view(
1354 &target_project_key,
1355 "# Bamboo Dream Notebook\n\nExisting target project dream",
1356 )
1357 .await
1358 .expect("write existing project dream");
1359
1360 let context = AutoDreamContext {
1361 session_store,
1362 storage,
1363 provider,
1364 config,
1365 provider_registry: test_registry(),
1366 };
1367 let result = run_project_auto_dream_once_with_store(&context, &memory, &target_project_key)
1368 .await
1369 .expect("project auto dream without sessions should not error");
1370 assert!(result.is_none());
1371
1372 let project_dream = memory
1373 .read_project_dream_view(&target_project_key)
1374 .await
1375 .expect("read project dream")
1376 .expect("existing dream should remain");
1377 assert!(project_dream.contains("Existing target project dream"));
1378 }
1379
1380 #[tokio::test]
1381 async fn run_project_auto_dream_once_still_runs_when_auto_background_dream_is_disabled() {
1382 let temp_dir = tempfile::tempdir().expect("tempdir");
1383 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1384
1385 let workspace = temp_dir.path().join("workspace-manual-project-dream");
1386 std::fs::create_dir_all(&workspace).expect("workspace dir");
1387 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1388
1389 let session_store = Arc::new(
1390 SessionStoreV2::new(temp_dir.path().to_path_buf())
1391 .await
1392 .unwrap(),
1393 );
1394 let storage: Arc<dyn Storage> = session_store.clone();
1395 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![
1396 "## 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(),
1397 "{\"candidates\":[]}".to_string(),
1398 ]));
1399 let config = Arc::new(RwLock::new(Config {
1400 memory: Some(bamboo_config::MemoryConfig {
1401 background_model: Some("fast-model".to_string()),
1402 ..bamboo_config::MemoryConfig::default()
1403 }),
1404 ..Config::default()
1405 }));
1406
1407 let mut session = bamboo_agent_core::Session::new("session-manual-project-dream", "model");
1408 session.title = "Manual project dream session".to_string();
1409 session.metadata.insert(
1410 "workspace_path".to_string(),
1411 workspace.to_string_lossy().to_string(),
1412 );
1413 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1414 "Manual project dream summary.",
1415 3,
1416 100,
1417 ));
1418 session.add_message(Message::user("Generate a project-scoped dream manually."));
1419 storage.save_session(&session).await.expect("save session");
1420
1421 let memory = MemoryStore::new(temp_dir.path());
1422 memory
1423 .write_session_topic(
1424 "session-manual-project-dream",
1425 "default",
1426 "Manual project dream note.",
1427 )
1428 .await
1429 .expect("write session topic");
1430
1431 let context = AutoDreamContext {
1432 session_store,
1433 storage,
1434 provider,
1435 config,
1436 provider_registry: test_registry(),
1437 };
1438 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1439 .await
1440 .expect(
1441 "manual project dream should succeed even when auto background dream is disabled",
1442 )
1443 .expect("manual project dream should produce output");
1444 assert_eq!(result.session_count, 1);
1445
1446 let project_dream = memory
1447 .read_project_dream_view(&project_key)
1448 .await
1449 .expect("read project dream")
1450 .expect("project dream should exist");
1451 assert!(project_dream.contains("Manual project dream worked"));
1452 }
1453
1454 #[tokio::test]
1459 async fn run_auto_dream_once_grounds_notebook_in_durable_index_not_prior_prose() {
1460 let temp_dir = tempfile::tempdir().expect("tempdir");
1461 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1462
1463 let session_store = Arc::new(
1464 SessionStoreV2::new(temp_dir.path().to_path_buf())
1465 .await
1466 .unwrap(),
1467 );
1468 let storage: Arc<dyn Storage> = session_store.clone();
1469 let provider = SequenceProvider::new(vec![
1470 "## Current durable context\n- Grounded in durable memory\n\n## Cross-session patterns\n- Keep continuity\n\n## Active threads to remember\n- Refresh blockers\n\n## Stable constraints and preferences\n- None\n\n## Open risks or questions\n- None".to_string(),
1471 "{\"candidates\":[]}".to_string(),
1472 ]);
1473 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1474 let config = Arc::new(RwLock::new(Config {
1475 memory: Some(bamboo_config::MemoryConfig {
1476 background_model: Some("fast-model".to_string()),
1477 auto_dream_enabled: true,
1478 ..bamboo_config::MemoryConfig::default()
1479 }),
1480 ..Config::default()
1481 }));
1482
1483 let workspace = temp_dir.path().join("workspace-grounded-mode");
1484 std::fs::create_dir_all(&workspace).expect("workspace dir");
1485 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1486
1487 let mut session = bamboo_agent_core::Session::new("session-grounded-mode", "model");
1488 session.title = "Grounded mode test".to_string();
1489 session.metadata.insert(
1490 "workspace_path".to_string(),
1491 workspace.to_string_lossy().to_string(),
1492 );
1493 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1494 "Recent session summary for grounded mode.",
1495 3,
1496 120,
1497 ));
1498 session.add_message(Message::user("Update the dream from durable memory."));
1499 storage.save_session(&session).await.expect("save session");
1500
1501 let memory = MemoryStore::new(temp_dir.path());
1502 memory
1505 .write_project_dream_view(
1506 &project_key,
1507 "# 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- Stale prior notebook prose that must NOT drive the rebuild\n",
1508 )
1509 .await
1510 .expect("write existing project dream");
1511 memory
1512 .write_memory(
1513 MemoryScope::Project,
1514 Some(&project_key),
1515 bamboo_memory::memory_store::DurableMemoryType::Project,
1516 "Canonical release decision",
1517 "Release freeze starts Tuesday and all mobile changes require review.",
1518 &["release".to_string(), "mobile".to_string()],
1519 Some("session-grounded-mode"),
1520 "main-model",
1521 false,
1522 None,
1523 )
1524 .await
1525 .expect("write project durable memory");
1526
1527 let context = AutoDreamContext {
1528 session_store,
1529 storage,
1530 provider: provider_handle,
1531 config,
1532 provider_registry: test_registry(),
1533 };
1534
1535 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1536 .await
1537 .expect("grounded auto dream should succeed")
1538 .expect("dream output should be produced");
1539 assert_eq!(result.session_count, 1);
1540
1541 let prompts = provider.recorded_prompts();
1542 assert!(prompts.len() >= 2);
1543 assert!(prompts[0].contains("## Durable memory index"));
1545 assert!(prompts[0].contains("Canonical release decision"));
1546 assert!(prompts[0].contains("canonical durable memory plus recent session activity"));
1547 assert!(
1548 !prompts[0].contains("## Existing Dream notebook"),
1549 "notebook must not be rewritten from its own prior prose (Refine retired)"
1550 );
1551 assert!(!prompts[0].contains("Stale prior notebook prose"));
1552
1553 let dream = memory
1557 .read_project_dream_view(&project_key)
1558 .await
1559 .expect("read project dream")
1560 .expect("project dream should exist");
1561 assert!(dream.contains("Grounded in durable memory"));
1562 assert!(
1563 dream.contains("Last full rebuild at:"),
1564 "the first grounded Rebuild must bootstrap the full-rebuild marker (#261)"
1565 );
1566 }
1567
1568 #[tokio::test]
1569 async fn run_auto_dream_once_forces_periodic_full_rebuild_using_memory_index() {
1570 let temp_dir = tempfile::tempdir().expect("tempdir");
1571 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1572
1573 let session_store = Arc::new(
1574 SessionStoreV2::new(temp_dir.path().to_path_buf())
1575 .await
1576 .unwrap(),
1577 );
1578 let storage: Arc<dyn Storage> = session_store.clone();
1579 let provider = SequenceProvider::new(vec![
1580 "## 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(),
1581 "{\"candidates\":[]}".to_string(),
1582 ]);
1583 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1584 let config = Arc::new(RwLock::new(Config {
1585 memory: Some(bamboo_config::MemoryConfig {
1586 background_model: Some("fast-model".to_string()),
1587 auto_dream_enabled: true,
1588 ..bamboo_config::MemoryConfig::default()
1589 }),
1590 ..Config::default()
1591 }));
1592
1593 let workspace = temp_dir.path().join("workspace-rebuild-mode");
1594 std::fs::create_dir_all(&workspace).expect("workspace dir");
1595 let project_key = bamboo_memory::memory_store::project_key_from_path(&workspace);
1596
1597 let mut session = bamboo_agent_core::Session::new("session-rebuild-mode", "model");
1598 session.title = "Rebuild mode test".to_string();
1599 session.metadata.insert(
1600 "workspace_path".to_string(),
1601 workspace.to_string_lossy().to_string(),
1602 );
1603 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1604 "Recent session summary for rebuild mode.",
1605 3,
1606 120,
1607 ));
1608 session.add_message(Message::user(
1609 "Refresh the project dream from canonical memory.",
1610 ));
1611 storage.save_session(&session).await.expect("save session");
1612
1613 let memory = MemoryStore::new(temp_dir.path());
1614 memory
1615 .write_project_dream_view(
1616 &project_key,
1617 "# 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",
1618 )
1619 .await
1620 .expect("write existing project dream");
1621 memory
1622 .write_memory(
1623 MemoryScope::Project,
1624 Some(&project_key),
1625 bamboo_memory::memory_store::DurableMemoryType::Project,
1626 "Canonical release decision",
1627 "Release freeze starts Tuesday and all mobile changes require review.",
1628 &["release".to_string(), "mobile".to_string()],
1629 Some("session-rebuild-mode"),
1630 "main-model",
1631 false,
1632 None,
1633 )
1634 .await
1635 .expect("write project durable memory");
1636
1637 let context = AutoDreamContext {
1638 session_store,
1639 storage,
1640 provider: provider_handle,
1641 config,
1642 provider_registry: test_registry(),
1643 };
1644
1645 let result = run_project_auto_dream_once_with_store(&context, &memory, &project_key)
1646 .await
1647 .expect("rebuild auto dream should succeed")
1648 .expect("rebuild dream output should be produced");
1649 assert_eq!(result.session_count, 1);
1650
1651 let prompts = provider.recorded_prompts();
1652 assert!(prompts.len() >= 2);
1653 assert!(prompts[0].contains("## Durable memory index"));
1654 assert!(prompts[0].contains("Canonical release decision"));
1655 assert!(prompts[0].contains("canonical durable memory plus recent session activity"));
1656
1657 let dream = memory
1658 .read_project_dream_view(&project_key)
1659 .await
1660 .expect("read project dream")
1661 .expect("project dream should exist");
1662 assert!(dream.contains("Rebuilt from durable memory index"));
1663 assert!(dream.contains("Last full rebuild at:"));
1664 }
1665
1666 #[test]
1667 fn normalize_dream_notebook_body_strips_nested_fenced_notebook_wrapper() {
1668 let raw = r#"
1669```md
1670# Bamboo Dream Notebook
1671
1672Last consolidated at: 2026-04-10T06:28:54.680302+00:00
1673Sessions reviewed: 2
1674Model: gpt-5-mini
1675
1676## Current durable context
1677- Existing durable thread
1678
1679## Cross-session patterns
1680- Keep continuity
1681
1682## Active threads to remember
1683- Update the notebook
1684
1685## Stable constraints and preferences
1686- None
1687
1688## Open risks or questions
1689- None
1690```
1691"#;
1692
1693 let normalized = normalize_dream_notebook_body(raw, DREAM_MAX_SUMMARY_CHARS)
1694 .expect("normalization should succeed");
1695 assert!(!normalized.contains("```md"));
1696 assert!(!normalized.contains("# Bamboo Dream Notebook"));
1697 assert!(normalized.contains("## Current durable context"));
1698 assert!(normalized.contains("Existing durable thread"));
1699 }
1700
1701 #[tokio::test]
1702 async fn run_auto_dream_once_normalizes_nested_notebook_output() {
1703 let temp_dir = tempfile::tempdir().expect("tempdir");
1704 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1705
1706 let session_store = Arc::new(
1707 SessionStoreV2::new(temp_dir.path().to_path_buf())
1708 .await
1709 .unwrap(),
1710 );
1711 let storage: Arc<dyn Storage> = session_store.clone();
1712 let provider = SequenceProvider::new(vec![
1713 "```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(),
1714 "{\"candidates\":[]}".to_string(),
1715 ]);
1716 let provider_handle: Arc<dyn LLMProvider> = Arc::new(provider.clone());
1717 let config = Arc::new(RwLock::new(Config {
1718 memory: Some(bamboo_config::MemoryConfig {
1719 background_model: Some("fast-model".to_string()),
1720 auto_dream_enabled: true,
1721 ..bamboo_config::MemoryConfig::default()
1722 }),
1723 ..Config::default()
1724 }));
1725
1726 let workspace = temp_dir.path().join("workspace-refine-normalize");
1727 std::fs::create_dir_all(&workspace).expect("workspace dir");
1728
1729 let mut session = bamboo_agent_core::Session::new("session-refine-normalize", "model");
1730 session.title = "Refine normalize test".to_string();
1731 session.metadata.insert(
1732 "workspace_path".to_string(),
1733 workspace.to_string_lossy().to_string(),
1734 );
1735 session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
1736 "Recent session summary for refine normalization.",
1737 3,
1738 120,
1739 ));
1740 session.add_message(Message::user("Normalize the refined dream output."));
1741 storage.save_session(&session).await.expect("save session");
1742
1743 let memory = MemoryStore::new(temp_dir.path());
1744 memory
1745 .write_dream_view(
1746 "# 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",
1747 )
1748 .await
1749 .expect("write existing dream");
1750 memory
1751 .write_session_topic(
1752 "session-refine-normalize",
1753 "default",
1754 "Recent session note.",
1755 )
1756 .await
1757 .expect("write session topic");
1758
1759 let context = AutoDreamContext {
1760 session_store,
1761 storage,
1762 provider: provider_handle,
1763 config,
1764 provider_registry: test_registry(),
1765 };
1766
1767 let result = run_auto_dream_once_with_store(&context, &memory)
1768 .await
1769 .expect("refine normalize auto dream should succeed")
1770 .expect("dream output should be produced");
1771 assert_eq!(result.session_count, 1);
1772
1773 let dream = memory
1774 .read_dream_view()
1775 .await
1776 .expect("read dream view")
1777 .expect("dream should exist");
1778 assert!(dream.contains("Refined durable theme"));
1779 assert!(!dream.contains("```md"));
1780 assert_eq!(dream.matches("# Bamboo Dream Notebook").count(), 1);
1781 }
1782
1783 #[tokio::test]
1784 async fn run_auto_dream_once_returns_none_when_disabled() {
1785 let temp_dir = tempfile::tempdir().expect("tempdir");
1786 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1787
1788 let session_store = Arc::new(
1789 SessionStoreV2::new(temp_dir.path().to_path_buf())
1790 .await
1791 .unwrap(),
1792 );
1793 let storage: Arc<dyn Storage> = session_store.clone();
1794 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1795 let config = Arc::new(RwLock::new(Config {
1798 memory: Some(bamboo_config::MemoryConfig {
1799 background_model: Some("fast-model".to_string()),
1800 auto_dream_enabled: false,
1801 ..bamboo_config::MemoryConfig::default()
1802 }),
1803 ..Config::default()
1804 }));
1805
1806 let context = AutoDreamContext {
1807 session_store,
1808 storage,
1809 provider,
1810 config,
1811 provider_registry: test_registry(),
1812 };
1813 let result = run_auto_dream_once(&context)
1814 .await
1815 .expect("disabled auto dream should not error");
1816 assert!(result.is_none());
1817 }
1818
1819 #[tokio::test]
1820 async fn run_auto_dream_once_returns_none_without_candidate_sessions() {
1821 let temp_dir = tempfile::tempdir().expect("tempdir");
1822 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1823
1824 let session_store = Arc::new(
1825 SessionStoreV2::new(temp_dir.path().to_path_buf())
1826 .await
1827 .unwrap(),
1828 );
1829 let storage: Arc<dyn Storage> = session_store.clone();
1830 let provider: Arc<dyn LLMProvider> = Arc::new(SequenceProvider::new(vec![]));
1831 let config = Arc::new(RwLock::new(Config {
1832 memory: Some(bamboo_config::MemoryConfig {
1833 background_model: Some("fast-model".to_string()),
1834 auto_dream_enabled: true,
1835 ..bamboo_config::MemoryConfig::default()
1836 }),
1837 ..Config::default()
1838 }));
1839
1840 let context = AutoDreamContext {
1841 session_store,
1842 storage,
1843 provider,
1844 config,
1845 provider_registry: test_registry(),
1846 };
1847 let result = run_auto_dream_once(&context)
1848 .await
1849 .expect("no candidate sessions should not error");
1850 assert!(result.is_none());
1851 }
1852}