Skip to main content

bamboo_memory/
auto_dream.rs

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