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