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