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