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