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