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