1use serde::Deserialize;
9
10#[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#[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
77pub 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
99pub 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
157pub 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
183pub 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
214pub 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
242pub 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
270pub 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(¤t).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#[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
373pub 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
433const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
438const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
439
440#[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
575pub 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
628pub 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#[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 let raw = "{\"candidates\":[{\"title\":\"T\",\"type\":\"user\",\"content\":\"C\"}]}";
804 assert!(parse_ledger_candidates(raw).is_empty());
805 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 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}