1use serde::{Deserialize, Serialize};
9
10#[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 pub confidence: Option<String>,
54}
55
56#[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
79pub 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
102pub 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
163pub 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
189pub 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
220pub 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
248pub 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
276pub 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(¤t).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#[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
379pub 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
442const MAX_INCLUDED_CONSOLIDATION_SESSIONS: usize = 12;
447const MAX_CONSOLIDATION_SUMMARY_CHARS_PER_SESSION: usize = 800;
448
449#[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
584pub 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
638pub 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#[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 let raw = "{\"candidates\":[{\"title\":\"T\",\"type\":\"user\",\"content\":\"C\"}]}";
856 assert!(parse_ledger_candidates(raw).is_empty());
857 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}