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