Skip to main content

bamboo_memory/
auto_dream.rs

1//! Auto-dream pure helpers: extraction/consolidation types, response parsing,
2//! prompt construction, and Dream-notebook normalization.
3//!
4//! These are infrastructure-free building blocks consumed by the live Dream
5//! orchestration in `bamboo_engine::auto_dream`. The orchestration itself
6//! (LLM provider / session-store driven runs) lives in the engine, not here.
7
8use serde::Deserialize;
9
10// ---------------------------------------------------------------------------
11// Extraction types
12// ---------------------------------------------------------------------------
13
14/// How the Dream Notebook VIEW is (re)synthesized. The notebook is a derived
15/// view of durable memory, never a source of truth — so it is either bootstrapped
16/// from recent sessions (`Incremental`, before any durable memory exists) or
17/// grounded in the canonical durable memory index (`Rebuild`). The old `Refine`
18/// mode — rewriting the notebook from its own prior prose — was retired in L3: a
19/// self-referential narrative rewrite drifts from durable truth and silently
20/// over-merges. See `zenith/docs/memory-redesign.md`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DreamGenerationMode {
23    Incremental,
24    Rebuild,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct DurableExtractionEnvelope {
29    #[serde(default)]
30    pub candidates: Vec<DurableExtractionCandidate>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct DurableExtractionCandidate {
35    pub title: String,
36    #[serde(rename = "type")]
37    pub kind: String,
38    pub content: String,
39    #[serde(default)]
40    pub scope: Option<String>,
41    #[serde(default)]
42    pub tags: Vec<String>,
43    #[serde(default)]
44    pub session_id: Option<String>,
45    #[serde(default)]
46    pub confidence: Option<String>,
47}
48
49// ---------------------------------------------------------------------------
50// Parsing helpers
51// ---------------------------------------------------------------------------
52
53pub fn strip_json_fence(raw: &str) -> &str {
54    let trimmed = raw.trim();
55    if let Some(rest) = trimmed.strip_prefix("```json") {
56        return rest.trim().trim_end_matches("```").trim();
57    }
58    if let Some(rest) = trimmed.strip_prefix("```") {
59        return rest.trim().trim_end_matches("```").trim();
60    }
61    trimmed
62}
63
64pub fn parse_extraction_candidates(raw: &str) -> Result<Vec<DurableExtractionCandidate>, String> {
65    let payload = strip_json_fence(raw);
66    let parsed: DurableExtractionEnvelope = serde_json::from_str(payload)
67        .map_err(|error| format!("failed to parse durable extraction candidates: {error}"))?;
68    Ok(parsed.candidates)
69}
70
71pub fn parse_candidate_scope(
72    candidate: &DurableExtractionCandidate,
73    project_key: Option<&str>,
74) -> crate::memory_store::MemoryScope {
75    match candidate
76        .scope
77        .as_deref()
78        .map(str::trim)
79        .map(str::to_ascii_lowercase)
80        .as_deref()
81    {
82        Some("project") if project_key.is_some() => crate::memory_store::MemoryScope::Project,
83        Some("global") => crate::memory_store::MemoryScope::Global,
84        _ if project_key.is_some() => crate::memory_store::MemoryScope::Project,
85        _ => crate::memory_store::MemoryScope::Global,
86    }
87}
88
89pub fn parse_candidate_type(kind: &str) -> Option<crate::memory_store::DurableMemoryType> {
90    match kind.trim().to_ascii_lowercase().as_str() {
91        "user" => Some(crate::memory_store::DurableMemoryType::User),
92        "feedback" => Some(crate::memory_store::DurableMemoryType::Feedback),
93        "project" => Some(crate::memory_store::DurableMemoryType::Project),
94        "reference" => Some(crate::memory_store::DurableMemoryType::Reference),
95        _ => None,
96    }
97}
98
99#[derive(serde::Deserialize)]
100struct SplitEnvelope {
101    #[serde(default)]
102    pieces: Vec<SplitPieceRaw>,
103}
104
105#[derive(serde::Deserialize)]
106struct SplitPieceRaw {
107    #[serde(default)]
108    title: String,
109    #[serde(default, rename = "type")]
110    kind: Option<String>,
111    #[serde(default)]
112    content: String,
113    #[serde(default)]
114    tags: Vec<String>,
115}
116
117/// Parse the background model's blob-split JSON into atomic split pieces.
118/// Pure; skips pieces missing a title or content.
119pub fn parse_split_pieces(raw: &str) -> Result<Vec<crate::memory_store::MemorySplitPiece>, String> {
120    let payload = strip_json_fence(raw);
121    let parsed: SplitEnvelope = serde_json::from_str(payload)
122        .map_err(|error| format!("failed to parse split pieces: {error}"))?;
123    let pieces = parsed
124        .pieces
125        .into_iter()
126        .filter_map(|piece| {
127            let title = piece.title.trim().to_string();
128            let content = piece.content.trim().to_string();
129            if title.is_empty() || content.is_empty() {
130                return None;
131            }
132            Some(crate::memory_store::MemorySplitPiece {
133                title,
134                r#type: piece.kind.as_deref().and_then(parse_candidate_type),
135                content,
136                tags: piece.tags,
137            })
138        })
139        .collect();
140    Ok(pieces)
141}
142
143/// Build the prompt asking the background model to split a multi-topic "blob"
144/// memory into atomic pieces. Pure — formats text only.
145pub fn build_blob_split_prompt(title: &str, body: &str) -> String {
146    let mut prompt = String::from("# Bamboo Memory Split\n\n");
147    prompt.push_str(
148        "The durable memory below has accreted multiple facts and must be split into atomic memories.\n\n",
149    );
150    prompt.push_str("Rules:\n");
151    prompt.push_str("- Return JSON only: {\"pieces\":[{\"title\":string,\"type\":\"user\"|\"feedback\"|\"project\"|\"reference\",\"content\":string,\"tags\":string[]}]}\n");
152    prompt.push_str("- Each piece must capture exactly ONE atomic fact/decision/preference. Never combine unrelated facts.\n");
153    prompt.push_str("- The title must concisely summarize that piece's own content so it is findable by keyword search.\n");
154    prompt.push_str("- Preserve the original wording of each fact; do not invent facts. Drop only exact duplicates.\n");
155    prompt.push_str(
156        "- If the memory is actually a single coherent fact, return exactly one piece.\n\n",
157    );
158    prompt.push_str("## Memory\n");
159    prompt.push_str(&format!("- title: {title}\n"));
160    prompt.push_str("- body:\n```md\n");
161    prompt.push_str(body);
162    prompt.push_str("\n```\n");
163    prompt
164}
165
166#[derive(serde::Deserialize)]
167struct DedupDecisionRaw {
168    #[serde(default)]
169    same_fact: bool,
170    #[serde(default)]
171    merged: Option<SplitPieceRaw>,
172}
173
174/// Parse the background model's dedup decision. Returns `Some(piece)` ONLY when the
175/// model judged the cluster to be the same fact AND returned a usable merged memory;
176/// `None` means "leave them separate" (distinct facts, or unusable output). Pure.
177pub fn parse_dedup_decision(
178    raw: &str,
179) -> Result<Option<crate::memory_store::MemorySplitPiece>, String> {
180    let payload = strip_json_fence(raw);
181    let parsed: DedupDecisionRaw = serde_json::from_str(payload)
182        .map_err(|error| format!("failed to parse dedup decision: {error}"))?;
183    if !parsed.same_fact {
184        return Ok(None);
185    }
186    let Some(piece) = parsed.merged else {
187        return Ok(None);
188    };
189    let title = piece.title.trim().to_string();
190    let content = piece.content.trim().to_string();
191    if title.is_empty() || content.is_empty() {
192        return Ok(None);
193    }
194    Ok(Some(crate::memory_store::MemorySplitPiece {
195        title,
196        r#type: piece.kind.as_deref().and_then(parse_candidate_type),
197        content,
198        tags: piece.tags,
199    }))
200}
201
202/// Build the prompt asking the background model to judge whether a cluster of
203/// near-duplicate memories is the SAME fact and, if so, consolidate them into one
204/// atomic memory. Pure — formats text only.
205pub fn build_dedup_prompt(members: &[(String, String)]) -> String {
206    let mut prompt = String::from("# Bamboo Memory Deduplication\n\n");
207    prompt.push_str(
208        "The durable memories below were flagged as possible duplicates of each other.\n\n",
209    );
210    prompt
211        .push_str("Decide whether they all describe the SAME single fact/decision/preference.\n\n");
212    prompt.push_str("Rules:\n");
213    prompt.push_str("- Return JSON only: {\"same_fact\":boolean,\"merged\":{\"title\":string,\"type\":\"user\"|\"feedback\"|\"project\"|\"reference\",\"content\":string,\"tags\":string[]}}\n");
214    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");
215    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");
216    prompt.push_str(
217        "- Preserve original wording; do not invent facts. Drop only exact redundancy.\n\n",
218    );
219    prompt.push_str("## Memories\n");
220    for (index, (title, body)) in members.iter().enumerate() {
221        prompt.push_str(&format!("\n### Memory {}\n", index + 1));
222        prompt.push_str(&format!("- title: {title}\n"));
223        prompt.push_str("- body:\n```md\n");
224        prompt.push_str(body);
225        prompt.push_str("\n```\n");
226    }
227    prompt
228}
229
230// ---------------------------------------------------------------------------
231// Normalization helpers
232// ---------------------------------------------------------------------------
233
234pub fn truncate_chars(value: &str, max_chars: usize) -> String {
235    let mut out = String::new();
236    for (count, ch) in value.chars().enumerate() {
237        if count >= max_chars {
238            out.push_str("...");
239            return out;
240        }
241        out.push(ch);
242    }
243    out
244}
245
246pub fn strip_markdown_fence(raw: &str) -> &str {
247    let trimmed = raw.trim();
248    if let Some(rest) = trimmed.strip_prefix("```markdown") {
249        return rest.trim().trim_end_matches("```").trim();
250    }
251    if let Some(rest) = trimmed.strip_prefix("```md") {
252        return rest.trim().trim_end_matches("```").trim();
253    }
254    if let Some(rest) = trimmed.strip_prefix("```") {
255        return rest.trim().trim_end_matches("```").trim();
256    }
257    trimmed
258}
259
260pub fn strip_dream_notebook_wrapper(raw: &str) -> Option<String> {
261    let trimmed = strip_markdown_fence(raw).trim();
262    let mut lines = trimmed.lines();
263    if lines.next()?.trim() != "# Bamboo Dream Notebook" {
264        return None;
265    }
266
267    let mut body_lines = Vec::new();
268    let mut in_body = false;
269    for line in lines {
270        let trimmed_line = line.trim();
271        if !in_body {
272            if trimmed_line.is_empty() {
273                continue;
274            }
275            if trimmed_line.starts_with("Project key: ")
276                || trimmed_line.starts_with("Last consolidated at: ")
277                || trimmed_line.starts_with("Sessions reviewed: ")
278                || trimmed_line.starts_with("Model: ")
279            {
280                continue;
281            }
282            in_body = true;
283        }
284        body_lines.push(line);
285    }
286
287    let body = body_lines.join("\n").trim().to_string();
288    (!body.is_empty()).then_some(body)
289}
290
291pub fn normalize_dream_notebook_body(raw: &str, max_chars: usize) -> Result<String, String> {
292    let mut current = raw.trim().to_string();
293    if current.is_empty() {
294        return Err("auto-dream returned empty content".to_string());
295    }
296
297    for _ in 0..3 {
298        let stripped = strip_markdown_fence(&current).trim().to_string();
299        if stripped.is_empty() {
300            return Err("auto-dream returned empty content".to_string());
301        }
302
303        if let Some(body) = strip_dream_notebook_wrapper(&stripped) {
304            current = body;
305            continue;
306        }
307
308        current = stripped;
309        break;
310    }
311
312    Ok(truncate_chars(current.trim(), max_chars))
313}
314
315// ---------------------------------------------------------------------------
316// Config helpers
317// ---------------------------------------------------------------------------
318
319/// Value type for passing session info to `build_extraction_prompt`.
320///
321/// Decouples the prompt builder from `SessionIndexEntry` and other
322/// infrastructure types.
323#[derive(Debug, Clone)]
324pub struct DreamCandidateInfo {
325    pub session_id: String,
326    pub title: String,
327    pub project_key: Option<String>,
328    pub updated_at: String,
329    pub summary: Option<String>,
330    pub topics: Vec<(String, String)>,
331}
332
333/// Build the durable memory extraction prompt from candidate session info.
334///
335/// Pure function — formats the prompt text used to extract durable memory
336/// candidates from recent session activity.
337pub fn build_extraction_prompt(candidates: &[DreamCandidateInfo]) -> String {
338    let mut prompt = String::from("# Bamboo Durable Memory Extraction\n\n");
339    prompt.push_str("Extract only durable memory candidates that should become canonical project/global memory.\n\n");
340    prompt.push_str("Rules:\n");
341    prompt.push_str("- Return JSON only, no markdown fences or commentary unless the entire response is fenced JSON.\n");
342    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");
343    prompt.push_str("- Include at most 8 candidates total.\n");
344    prompt.push_str("- Each candidate must capture exactly ONE atomic fact/decision/preference. Never combine unrelated facts into a single candidate.\n");
345    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");
346    prompt.push_str("- Skip transient scratch state, code/project structure derivable from tools, and anything low-confidence or secret-like.\n");
347    prompt.push_str("- Prefer project scope when the session clearly belongs to a project workspace; otherwise use global.\n\n");
348    prompt.push_str("## Candidate sessions\n\n");
349
350    for (index, session) in candidates.iter().enumerate() {
351        prompt.push_str(&format!(
352            "### Session {}\n- id: {}\n- title: {}\n- project_key: {}\n- updated_at: {}\n",
353            index + 1,
354            session.session_id,
355            session.title,
356            session.project_key.as_deref().unwrap_or("(none)"),
357            session.updated_at,
358        ));
359        if let Some(summary) = session
360            .summary
361            .as_deref()
362            .map(str::trim)
363            .filter(|value| !value.is_empty())
364        {
365            prompt.push_str("- summary:\n```md\n");
366            prompt.push_str(summary);
367            prompt.push_str("\n```\n");
368        }
369        if !session.topics.is_empty() {
370            prompt.push_str("- session topics:\n");
371            for (topic, content) in &session.topics {
372                prompt.push_str(&format!("  - {}:\n", topic));
373                prompt.push_str("    ```md\n");
374                prompt.push_str(content);
375                prompt.push_str("\n    ```\n");
376            }
377        }
378        prompt.push('\n');
379    }
380
381    prompt
382}
383
384// ---------------------------------------------------------------------------
385// Consolidation prompt builders
386// ---------------------------------------------------------------------------
387
388const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
389const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
390
391/// Value type for passing session info to consolidation prompt builders.
392///
393/// Decouples from `SessionIndexEntry` so the crate stays infrastructure-free.
394#[derive(Debug, Clone)]
395pub struct ConsolidationSessionInfo {
396    pub id: String,
397    pub title: String,
398    pub kind: String,
399    pub updated_at: String,
400    pub message_count: usize,
401    pub last_run_status: Option<String>,
402    pub summary: Option<String>,
403}
404
405fn build_consolidation_prompt_prefix() -> String {
406    let mut prompt = String::from("# Bamboo Dream Consolidation\n\n");
407    prompt
408        .push_str("You are performing a lightweight reflective consolidation pass for Bamboo.\n\n");
409    prompt.push_str(
410        "Your job is to synthesize durable cross-session signal from recent session activity into a concise notebook entry for future work.\n\n"
411    );
412    prompt.push_str("Requirements:\n");
413    prompt.push_str("- Focus on durable facts, recurring goals, stable constraints, user preferences, active project directions, and unresolved blockers\n");
414    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");
415    prompt.push_str("- Prefer cross-session patterns over one-off chatter\n");
416    prompt.push_str("- Do not include secrets, tokens, or highly transient details\n");
417    prompt.push_str("- Separate active ongoing threads from completed or obsolete items\n");
418    prompt.push_str("- Keep the final result compact and operational\n\n");
419    prompt.push_str("Return markdown with these sections exactly:\n");
420    prompt.push_str("1. ## Current durable context\n");
421    prompt.push_str("2. ## Cross-session patterns\n");
422    prompt.push_str("3. ## Active threads to remember\n");
423    prompt.push_str("4. ## Stable constraints and preferences\n");
424    prompt.push_str("5. ## Open risks or questions\n\n");
425    prompt
426}
427
428fn append_markdown_reference_section(
429    prompt: &mut String,
430    heading: &str,
431    content: Option<&str>,
432    empty_placeholder: &str,
433) {
434    prompt.push_str(heading);
435    prompt.push_str("\n\n");
436    if let Some(content) = content.map(str::trim).filter(|value| !value.is_empty()) {
437        prompt.push_str("```md\n");
438        prompt.push_str(content);
439        prompt.push_str("\n```\n\n");
440    } else {
441        prompt.push_str(empty_placeholder);
442        prompt.push_str("\n\n");
443    }
444}
445
446fn append_consolidation_recent_sessions_section(
447    prompt: &mut String,
448    sessions: &[ConsolidationSessionInfo],
449) {
450    prompt.push_str("## Recent sessions\n\n");
451    if sessions.is_empty() {
452        prompt.push_str("_(no recent sessions supplied)_\n");
453        return;
454    }
455
456    for (index, session) in sessions
457        .iter()
458        .take(MAX_INCLUDED_CONSOLIDATION_SESSIONS)
459        .enumerate()
460    {
461        prompt.push_str(&format!(
462            "### Session {}\n- id: {}\n- title: {}\n- kind: {}\n- updated_at: {}\n- message_count: {}\n",
463            index + 1,
464            session.id,
465            session.title,
466            session.kind,
467            session.updated_at,
468            session.message_count,
469        ));
470        if let Some(status) = session
471            .last_run_status
472            .as_deref()
473            .filter(|v| !v.trim().is_empty())
474        {
475            prompt.push_str(&format!("- last_run_status: {}\n", status));
476        }
477        if let Some(summary) = session
478            .summary
479            .as_deref()
480            .map(str::trim)
481            .filter(|v| !v.is_empty())
482        {
483            prompt.push_str("- summary:\n```md\n");
484            prompt.push_str(&truncate_chars(
485                summary,
486                MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION,
487            ));
488            prompt.push_str("\n```\n");
489        }
490        prompt.push('\n');
491    }
492
493    if sessions.len() > MAX_INCLUDED_CONSOLIDATION_SESSIONS {
494        prompt.push_str(&format!(
495            "_Only the most recent {} sessions are included in this pass out of {} candidates._\n",
496            MAX_INCLUDED_CONSOLIDATION_SESSIONS,
497            sessions.len()
498        ));
499    }
500}
501
502pub fn build_consolidation_prompt(sessions: &[ConsolidationSessionInfo]) -> String {
503    let mut prompt = build_consolidation_prompt_prefix();
504    append_consolidation_recent_sessions_section(&mut prompt, sessions);
505    prompt
506}
507
508pub fn build_rebuild_consolidation_prompt(
509    durable_memory_index: Option<&str>,
510    sessions: &[ConsolidationSessionInfo],
511) -> String {
512    let mut prompt = build_consolidation_prompt_prefix();
513    prompt.push_str(
514        "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",
515    );
516    append_markdown_reference_section(
517        &mut prompt,
518        "## Durable memory index",
519        durable_memory_index,
520        "_(no durable memory index supplied)_",
521    );
522    append_consolidation_recent_sessions_section(&mut prompt, sessions);
523    prompt
524}
525
526// ---------------------------------------------------------------------------
527// Session outline and dream normalization
528// ---------------------------------------------------------------------------
529
530/// Derive a brief text outline from a session for dream extraction context.
531///
532/// Uses the task list if available, otherwise falls back to the 6 most recent
533/// non-system messages (truncated to 300 chars each).
534pub fn derive_session_outline(session: &bamboo_agent_core::Session) -> Option<String> {
535    use bamboo_agent_core::Role;
536
537    let mut parts = Vec::new();
538
539    if let Some(task_list) = session.task_list.as_ref() {
540        let rendered = task_list.format_for_prompt();
541        if !rendered.trim().is_empty() {
542            parts.push(rendered);
543        }
544    }
545
546    if parts.is_empty() {
547        let recent_messages = session
548            .messages
549            .iter()
550            .rev()
551            .filter(|message| !matches!(message.role, Role::System))
552            .take(6)
553            .collect::<Vec<_>>();
554        if recent_messages.is_empty() {
555            return None;
556        }
557        let mut rendered = String::new();
558        for message in recent_messages.into_iter().rev() {
559            let role = match message.role {
560                Role::User => "User",
561                Role::Assistant => "Assistant",
562                Role::Tool => "Tool",
563                Role::System => continue,
564            };
565            rendered.push_str(&format!(
566                "**{}**: {}\n\n",
567                role,
568                truncate_chars(message.content.trim(), 300)
569            ));
570        }
571        if !rendered.trim().is_empty() {
572            parts.push(rendered.trim().to_string());
573        }
574    }
575
576    (!parts.is_empty()).then(|| parts.join("\n\n---\n\n"))
577}
578
579// ---------------------------------------------------------------------------
580// Config helpers
581// ---------------------------------------------------------------------------
582
583pub fn should_force_full_rebuild(
584    last_full_rebuild_at: Option<chrono::DateTime<chrono::Utc>>,
585    now: chrono::DateTime<chrono::Utc>,
586    rebuild_interval_secs: i64,
587) -> bool {
588    match last_full_rebuild_at {
589        Some(timestamp) => (now - timestamp) >= chrono::Duration::seconds(rebuild_interval_secs),
590        None => false,
591    }
592}
593
594pub fn parse_last_full_rebuild_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
595    note.lines()
596        .find_map(|line| line.trim().strip_prefix("Last full rebuild at: "))
597        .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
598        .map(|dt| dt.with_timezone(&chrono::Utc))
599}
600
601pub fn parse_last_consolidated_at(note: &str) -> Option<chrono::DateTime<chrono::Utc>> {
602    note.lines()
603        .find_map(|line| line.trim().strip_prefix("Last consolidated at: "))
604        .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw.trim()).ok())
605        .map(|dt| dt.with_timezone(&chrono::Utc))
606}
607
608// ---------------------------------------------------------------------------
609// Tests
610// ---------------------------------------------------------------------------
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn truncate_chars_reports_truncation() {
618        let result = truncate_chars("abcde", 3);
619        assert_eq!(result, "abc...");
620    }
621
622    #[test]
623    fn truncate_chars_keeps_short_text() {
624        let result = truncate_chars("abc", 10);
625        assert_eq!(result, "abc");
626    }
627
628    #[test]
629    fn strip_json_fence_removes_fences() {
630        assert_eq!(strip_json_fence("```json\n{}\n```"), "{}");
631        assert_eq!(strip_json_fence("```\n{}\n```"), "{}");
632        assert_eq!(strip_json_fence("{}"), "{}");
633    }
634
635    #[test]
636    fn strip_markdown_fence_handles_variants() {
637        assert_eq!(strip_markdown_fence("```markdown\nhi\n```"), "hi");
638        assert_eq!(strip_markdown_fence("```md\nhi\n```"), "hi");
639        assert_eq!(strip_markdown_fence("```\nhi\n```"), "hi");
640        assert_eq!(strip_markdown_fence("hi"), "hi");
641    }
642
643    #[test]
644    fn parse_extraction_candidates_accepts_fenced_json() {
645        let input = "```json\n{\"candidates\":[{\"title\":\"T\",\"type\":\"user\",\"scope\":\"global\",\"content\":\"C\",\"tags\":[]}]}\n```";
646        let candidates = parse_extraction_candidates(input).expect("should parse");
647        assert_eq!(candidates.len(), 1);
648        assert_eq!(candidates[0].title, "T");
649    }
650
651    #[test]
652    fn parse_candidate_scope_defaults_to_project_when_key_available() {
653        let candidate = DurableExtractionCandidate {
654            title: "T".to_string(),
655            kind: "user".to_string(),
656            content: "C".to_string(),
657            scope: None,
658            tags: vec![],
659            session_id: None,
660            confidence: None,
661        };
662        assert_eq!(
663            parse_candidate_scope(&candidate, Some("proj-1")),
664            crate::memory_store::MemoryScope::Project
665        );
666    }
667
668    #[test]
669    fn parse_candidate_type_maps_known_types() {
670        assert!(parse_candidate_type("user").is_some());
671        assert!(parse_candidate_type("feedback").is_some());
672        assert!(parse_candidate_type("project").is_some());
673        assert!(parse_candidate_type("reference").is_some());
674        assert!(parse_candidate_type("unknown").is_none());
675    }
676
677    #[test]
678    fn strip_dream_notebook_wrapper_extracts_body() {
679        let input = "# Bamboo Dream Notebook\n\nLast consolidated at: 2026-01-01T00:00:00Z\nSessions reviewed: 1\nModel: test\n\n## Body\ncontent";
680        let body = strip_dream_notebook_wrapper(input).expect("should extract");
681        assert!(body.contains("## Body"));
682        assert!(!body.contains("Bamboo Dream Notebook"));
683        assert!(!body.contains("Last consolidated"));
684    }
685
686    #[test]
687    fn normalize_dream_notebook_body_strips_wrapper() {
688        let input = "# Bamboo Dream Notebook\n\nModel: test\n\n## Section\ndata\n";
689        let result = normalize_dream_notebook_body(input, 10000).expect("should normalize");
690        assert!(result.contains("## Section"));
691        assert!(!result.contains("Bamboo Dream Notebook"));
692    }
693
694    #[test]
695    fn normalize_dream_notebook_body_rejects_empty() {
696        assert!(normalize_dream_notebook_body("", 10000).is_err());
697    }
698
699    #[test]
700    fn build_extraction_prompt_includes_candidates() {
701        let candidates = vec![DreamCandidateInfo {
702            session_id: "s-1".to_string(),
703            title: "Title 1".to_string(),
704            project_key: Some("proj-a".to_string()),
705            updated_at: "2026-04-01T00:00:00Z".to_string(),
706            summary: Some("Important summary".to_string()),
707            topics: vec![("topic-a".to_string(), "content-a".to_string())],
708        }];
709        let prompt = build_extraction_prompt(&candidates);
710        assert!(prompt.contains("Bamboo Durable Memory Extraction"));
711        assert!(prompt.contains("s-1"));
712        assert!(prompt.contains("Title 1"));
713        assert!(prompt.contains("proj-a"));
714        assert!(prompt.contains("Important summary"));
715        assert!(prompt.contains("topic-a"));
716    }
717
718    #[test]
719    fn build_extraction_prompt_handles_empty_candidates() {
720        let prompt = build_extraction_prompt(&[]);
721        assert!(prompt.contains("Bamboo Durable Memory Extraction"));
722        assert!(prompt.contains("Candidate sessions"));
723    }
724
725    fn sample_consolidation_session(id: &str) -> ConsolidationSessionInfo {
726        ConsolidationSessionInfo {
727            id: id.to_string(),
728            title: format!("Title for {id}"),
729            kind: "Root".to_string(),
730            updated_at: "2026-04-01T00:00:00Z".to_string(),
731            message_count: 10,
732            last_run_status: Some("completed".to_string()),
733            summary: Some("Important summary".to_string()),
734        }
735    }
736
737    #[test]
738    fn consolidation_prompt_includes_session_metadata_and_summary() {
739        let prompt = build_consolidation_prompt(&[sample_consolidation_session("session-1")]);
740        assert!(prompt.contains("Bamboo Dream Consolidation"));
741        assert!(prompt.contains("session-1"));
742        assert!(prompt.contains("Important summary"));
743        assert!(prompt.contains("## Current durable context"));
744    }
745
746    #[test]
747    fn rebuild_consolidation_prompt_includes_durable_memory_index() {
748        let prompt = build_rebuild_consolidation_prompt(
749            Some("# Bamboo Memory Index\n\n- `mem-1` Release freeze decision"),
750            &[sample_consolidation_session("session-3")],
751        );
752        assert!(prompt.contains("## Durable memory index"));
753        assert!(prompt.contains("Release freeze decision"));
754        assert!(prompt.contains("canonical durable memory plus recent session activity"));
755        assert!(prompt.contains("session-3"));
756    }
757
758    // -----------------------------------------------------------------------
759    // Orchestration tests
760    // -----------------------------------------------------------------------
761
762    use std::sync::Mutex;
763
764    use async_trait::async_trait;
765    use futures::stream;
766
767    use bamboo_agent_core::storage::Storage;
768    use bamboo_llm::{LLMError, LLMStream};
769}