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