Skip to main content

bamboo_engine/
auto_dream.rs

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