1use 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#[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
58pub 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
126pub 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
152pub 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
183pub 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
211pub 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
239pub 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(¤t).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#[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
342pub 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
393const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
398const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
399
400#[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
567pub 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
620pub 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
647pub 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
682const 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 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#[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 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}