1use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use chrono::{DateTime, Utc};
22use serde::Deserialize;
23
24use bamboo_domain::ledger::{LedgerRecord, LedgerScope, RecordStatus};
25use bamboo_memory::ledger_store::{LedgerScheduleBridge, LedgerStore, RecordFilter};
26use bamboo_memory::memory_store::{DurableMemoryType, MemoryStore};
27
28use crate::auto_dream::AutoDreamContext;
29use crate::gardener::{collect_model_json, resolve_background_model};
30
31const LEDGER_GARDENER_TRACING_TARGET: &str = "bamboo.ledger_gardener";
32const EXPIRY_GRACE_HOURS: i64 = 48;
35const DISTILL_MAX_RECORDS_PER_RUN: usize = 8;
37const DISTILLED_TAG: &str = "distilled";
39
40const DISTILL_SYSTEM_INSTRUCTION: &str = "You are Bamboo's background ledger gardener. From the user's completed ledger records, extract only durable, long-term facts worth remembering (habits, recurring obligations, stable preferences, notable life events). Return only the specified JSON array. No prose, no markdown fences.";
41
42pub struct LedgerGardenerContext {
43 pub dream: AutoDreamContext,
44 pub schedule_bridge: Option<Arc<dyn LedgerScheduleBridge>>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct LedgerGardenerRunResult {
49 pub scanned: usize,
50 pub expired: usize,
51 pub schedules_released: usize,
52 pub schedules_synced: usize,
53 pub distilled_records: usize,
54 pub memories_written: usize,
55 pub failed: usize,
56}
57
58#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
59pub struct DistilledMemoryCandidate {
60 pub title: String,
61 pub content: String,
62 #[serde(default)]
63 pub r#type: Option<String>,
64 #[serde(default)]
65 pub tags: Vec<String>,
66}
67
68pub fn should_expire(record: &LedgerRecord, now: DateTime<Utc>) -> bool {
74 if record.status.is_terminal() {
75 return false;
76 }
77 if record.time.due_at.is_some() || record.time.recurrence.is_some() {
78 return false;
79 }
80 let cutoff = now - chrono::Duration::hours(EXPIRY_GRACE_HOURS);
81 if let Some(end) = record.time.ends_at.or(record.time.starts_at) {
82 return end < cutoff;
83 }
84 if !record.time.remind_at.is_empty() {
85 return record.time.remind_at.iter().all(|at| *at < cutoff);
86 }
87 false
88}
89
90pub fn parse_distilled_candidates(raw: &str) -> Vec<DistilledMemoryCandidate> {
94 let Some(start) = raw.find('[') else {
95 return Vec::new();
96 };
97 let Some(end) = raw.rfind(']') else {
98 return Vec::new();
99 };
100 if end < start {
101 return Vec::new();
102 }
103 serde_json::from_str::<Vec<DistilledMemoryCandidate>>(&raw[start..=end])
104 .unwrap_or_default()
105 .into_iter()
106 .filter(|candidate| {
107 !candidate.title.trim().is_empty() && !candidate.content.trim().is_empty()
108 })
109 .collect()
110}
111
112fn build_distillation_prompt(docs: &[(String, String)]) -> String {
113 let mut prompt = String::from(
114 "The user completed the following ledger records (todos/events/reminders).\n\
115 Extract durable long-term memories ONLY where a record reveals a lasting fact:\n\
116 a habit or routine, a recurring obligation, a stable preference, or a notable\n\
117 life event worth recalling months from now. Routine one-off chores yield nothing.\n\n\
118 Records:\n",
119 );
120 for (title, detail) in docs {
121 prompt.push_str(&format!("- {title}\n {detail}\n"));
122 }
123 prompt.push_str(
124 "\nReturn a JSON array (possibly empty), each item:\n\
125 {\"title\": \"specific descriptive title\", \"content\": \"one atomic fact\", \
126 \"type\": \"user|reference\", \"tags\": [\"...\"]}\n",
127 );
128 prompt
129}
130
131fn all_scopes(project_keys: &[String]) -> Vec<(LedgerScope, Option<String>)> {
132 let mut scopes: Vec<(LedgerScope, Option<String>)> = vec![(LedgerScope::Global, None)];
133 scopes.extend(
134 project_keys
135 .iter()
136 .map(|key| (LedgerScope::Project, Some(key.clone()))),
137 );
138 scopes
139}
140
141pub async fn run_ledger_gardener_once(
142 ctx: &LedgerGardenerContext,
143) -> Result<Option<LedgerGardenerRunResult>, String> {
144 let ledger = LedgerStore::new(ctx.dream.session_store.bamboo_home_dir());
145 let memory = ctx.dream.memory.clone();
146 run_ledger_gardener_once_with_stores(ctx, &ledger, &memory).await
147}
148
149async fn run_ledger_gardener_once_with_stores(
150 ctx: &LedgerGardenerContext,
151 ledger: &LedgerStore,
152 memory: &MemoryStore,
153) -> Result<Option<LedgerGardenerRunResult>, String> {
154 let config_snapshot = ctx.dream.config.read().await.clone();
155 let memory_cfg = config_snapshot.memory().clone().unwrap_or_default();
156 if !memory_cfg.ledger_gardener_enabled {
157 return Ok(None);
158 }
159
160 let now = Utc::now();
161 let mut result = LedgerGardenerRunResult::default();
162 let project_keys = ledger
163 .list_project_keys()
164 .await
165 .map_err(|error| format!("ledger gardener scope scan failed: {error}"))?;
166 let scopes = all_scopes(&project_keys);
167
168 for (scope, project_key) in &scopes {
170 let docs = ledger
171 .list_records(
172 *scope,
173 project_key.as_deref(),
174 &RecordFilter {
175 include_terminal: true,
176 ..RecordFilter::default()
177 },
178 )
179 .await
180 .map_err(|error| format!("ledger gardener list failed: {error}"))?;
181 result.scanned += docs.len();
182
183 for doc in docs {
184 let record = &doc.record;
185
186 if should_expire(record, now) {
188 match ledger
189 .transition_record(
190 *scope,
191 project_key.as_deref(),
192 &record.id,
193 RecordStatus::Expired,
194 Some("auto-expired by the ledger gardener"),
195 )
196 .await
197 {
198 Ok(Some(expired_doc)) => {
199 result.expired += 1;
200 if let (Some(bridge), false) =
205 (&ctx.schedule_bridge, record.schedule_ids.is_empty())
206 {
207 if bridge.release_schedules(&record.schedule_ids).await.is_ok() {
208 let mut cleared = expired_doc.record.clone();
209 cleared.schedule_ids.clear();
210 let _ = ledger
211 .write_record(cleared, Some(expired_doc.body.clone()))
212 .await;
213 result.schedules_released += record.schedule_ids.len();
214 }
215 }
216 continue;
217 }
218 Ok(None) => {}
219 Err(error) => {
220 result.failed += 1;
221 tracing::warn!(
222 target: LEDGER_GARDENER_TRACING_TARGET,
223 record_id = %record.id,
224 "[ledger-gardener] expiry transition failed: {error}"
225 );
226 continue;
227 }
228 }
229 }
230
231 let Some(bridge) = &ctx.schedule_bridge else {
233 continue;
234 };
235 if record.status.is_terminal() && !record.schedule_ids.is_empty() {
236 match bridge.release_schedules(&record.schedule_ids).await {
237 Ok(()) => {
238 let mut cleared = record.clone();
239 cleared.schedule_ids.clear();
240 if ledger
241 .write_record(cleared, Some(doc.body.clone()))
242 .await
243 .is_ok()
244 {
245 result.schedules_released += record.schedule_ids.len();
246 }
247 }
248 Err(error) => {
249 result.failed += 1;
250 tracing::warn!(
251 target: LEDGER_GARDENER_TRACING_TARGET,
252 record_id = %record.id,
253 "[ledger-gardener] schedule release failed: {error}"
254 );
255 }
256 }
257 } else if !record.status.is_terminal()
258 && record.schedule_ids.is_empty()
259 && (record.time.recurrence.is_some()
260 || record.time.remind_at.iter().any(|at| *at > now))
261 {
262 match bridge.sync_record_schedules(record).await {
263 Ok(ids) if !ids.is_empty() => {
264 let mut updated = record.clone();
265 updated.schedule_ids = ids.clone();
266 if ledger
267 .write_record(updated, Some(doc.body.clone()))
268 .await
269 .is_ok()
270 {
271 result.schedules_synced += ids.len();
272 }
273 }
274 Ok(_) => {}
275 Err(error) => {
276 result.failed += 1;
277 tracing::warn!(
278 target: LEDGER_GARDENER_TRACING_TARGET,
279 record_id = %record.id,
280 "[ledger-gardener] schedule sync failed: {error}"
281 );
282 }
283 }
284 }
285 }
286 }
287
288 if memory_cfg.ledger_distillation_enabled {
290 if let Err(error) = run_distillation_pass(ctx, ledger, memory, &scopes, &mut result).await {
291 result.failed += 1;
292 tracing::warn!(
293 target: LEDGER_GARDENER_TRACING_TARGET,
294 "[ledger-gardener] distillation failed: {error}"
295 );
296 }
297 }
298
299 tracing::info!(
300 target: LEDGER_GARDENER_TRACING_TARGET,
301 event = "run_complete",
302 scanned = result.scanned,
303 expired = result.expired,
304 schedules_released = result.schedules_released,
305 schedules_synced = result.schedules_synced,
306 distilled_records = result.distilled_records,
307 memories_written = result.memories_written,
308 failed = result.failed,
309 "[ledger-gardener] run complete"
310 );
311 Ok(Some(result))
312}
313
314async fn run_distillation_pass(
315 ctx: &LedgerGardenerContext,
316 ledger: &LedgerStore,
317 memory: &MemoryStore,
318 scopes: &[(LedgerScope, Option<String>)],
319 result: &mut LedgerGardenerRunResult,
320) -> Result<(), String> {
321 let mut pending = Vec::new();
323 for (scope, project_key) in scopes {
324 let docs = ledger
325 .list_records(
326 *scope,
327 project_key.as_deref(),
328 &RecordFilter {
329 statuses: Some([RecordStatus::Done].into_iter().collect()),
330 include_terminal: true,
331 ..RecordFilter::default()
332 },
333 )
334 .await
335 .map_err(|error| format!("distillation list failed: {error}"))?;
336 pending.extend(
337 docs.into_iter()
338 .filter(|doc| !doc.record.tags.iter().any(|tag| tag == DISTILLED_TAG)),
339 );
340 if pending.len() >= DISTILL_MAX_RECORDS_PER_RUN {
341 break;
342 }
343 }
344 pending.truncate(DISTILL_MAX_RECORDS_PER_RUN);
345 if pending.is_empty() {
346 return Ok(());
347 }
348
349 let resolved_background = {
350 let current_config = ctx.dream.config.read().await;
351 resolve_background_model(&ctx.dream, ¤t_config)?
352 };
353 let Some((provider, model)) = resolved_background else {
354 return Ok(());
357 };
358
359 let lines: Vec<(String, String)> = pending
360 .iter()
361 .map(|doc| {
362 let record = &doc.record;
363 let detail = format!(
364 "kind={}, completed={}, notes: {}",
365 record.kind.as_str(),
366 record.updated_at.format("%Y-%m-%d"),
367 doc.body
368 .chars()
369 .take(200)
370 .collect::<String>()
371 .replace('\n', " "),
372 );
373 (record.title.clone(), detail)
374 })
375 .collect();
376 let raw = collect_model_json(
377 provider,
378 &model,
379 DISTILL_SYSTEM_INSTRUCTION,
380 build_distillation_prompt(&lines),
381 )
382 .await?;
383 let candidates = parse_distilled_candidates(&raw);
384
385 for candidate in &candidates {
386 let r#type = match candidate.r#type.as_deref() {
387 Some("reference") => DurableMemoryType::Reference,
388 _ => DurableMemoryType::User,
389 };
390 match memory
391 .write_memory(
392 bamboo_memory::memory_store::MemoryScope::Global,
393 None,
394 r#type,
395 candidate.title.trim(),
396 candidate.content.trim(),
397 &candidate.tags,
398 None,
399 "ledger-gardener",
400 true, None,
402 )
403 .await
404 {
405 Ok(_) => result.memories_written += 1,
406 Err(error) => {
407 result.failed += 1;
408 tracing::warn!(
409 target: LEDGER_GARDENER_TRACING_TARGET,
410 "[ledger-gardener] distilled memory write failed: {error}"
411 );
412 }
413 }
414 }
415
416 for doc in pending {
419 let mut record = doc.record.clone();
420 record.tags.push(DISTILLED_TAG.to_string());
421 if ledger
422 .write_record(record, Some(doc.body.clone()))
423 .await
424 .is_ok()
425 {
426 result.distilled_records += 1;
427 }
428 }
429 Ok(())
430}
431
432pub fn spawn_ledger_gardener_task(ctx: LedgerGardenerContext) {
435 tokio::spawn(async move {
436 let interval_secs = {
437 let guard = ctx.dream.config.read().await;
438 guard
439 .memory()
440 .as_ref()
441 .map(|memory| memory.ledger_gardener_interval_secs)
442 .filter(|secs| *secs > 0)
443 .unwrap_or_else(|| {
444 bamboo_config::MemoryConfig::default().ledger_gardener_interval_secs
445 })
446 };
447 let poll_secs = 300.min(interval_secs.max(1));
450 let mut ticker = tokio::time::interval(Duration::from_secs(poll_secs));
451 let mut last_run: Option<Instant> = None;
452
453 loop {
454 ticker.tick().await;
455 let due = last_run
456 .map(|at| at.elapsed().as_secs() >= interval_secs)
457 .unwrap_or(true);
458 if !due {
459 continue;
460 }
461 if let Err(error) = run_ledger_gardener_once(&ctx).await {
462 tracing::warn!(
463 target: LEDGER_GARDENER_TRACING_TARGET,
464 event = "run_failed",
465 "[ledger-gardener] run failed: {error}"
466 );
467 }
468 last_run = Some(Instant::now());
469 }
470 });
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use std::collections::HashMap;
477 use std::sync::Mutex;
478
479 use async_trait::async_trait;
480 use bamboo_agent_core::Message;
481 use bamboo_domain::ledger::RecordKind;
482 use bamboo_domain::schedule::ScheduleTrigger;
483 use bamboo_llm::{Config, LLMChunk, LLMError, LLMProvider, LLMStream, ProviderRegistry};
484 use bamboo_storage::SessionStoreV2;
485 use chrono::Duration as ChronoDuration;
486 use futures::stream;
487 use tokio::sync::{Mutex as AsyncMutex, RwLock};
488
489 fn config_with_memory(memory: bamboo_config::MemoryConfig) -> Config {
490 let mut config = Config::default();
491 *config.memory_mut() = Some(memory);
492 config
493 }
494
495 fn record(kind: RecordKind, status: RecordStatus) -> LedgerRecord {
496 let mut record = LedgerRecord::new("rec_test", kind, "Test record");
497 record.status = status;
498 record
499 }
500
501 #[test]
502 fn expiry_policy_expires_only_fully_past_events_and_reminders() {
503 let now = Utc::now();
504 let past = now - ChronoDuration::hours(EXPIRY_GRACE_HOURS + 1);
505 let recent_past = now - ChronoDuration::hours(1);
506
507 let mut event = record(RecordKind::Event, RecordStatus::Open);
509 event.time.starts_at = Some(past);
510 assert!(should_expire(&event, now));
511
512 let mut recent_event = record(RecordKind::Event, RecordStatus::Open);
514 recent_event.time.starts_at = Some(recent_past);
515 assert!(!should_expire(&recent_event, now));
516
517 let mut reminder = record(RecordKind::Reminder, RecordStatus::Open);
519 reminder.time.remind_at = vec![past, past - ChronoDuration::hours(2)];
520 assert!(should_expire(&reminder, now));
521
522 let mut live_reminder = record(RecordKind::Reminder, RecordStatus::Open);
524 live_reminder.time.remind_at = vec![past, now + ChronoDuration::hours(1)];
525 assert!(!should_expire(&live_reminder, now));
526
527 let mut todo = record(RecordKind::Todo, RecordStatus::Open);
529 todo.time.due_at = Some(past);
530 assert!(!should_expire(&todo, now));
531
532 let mut habit = record(RecordKind::Habit, RecordStatus::Open);
534 habit.time.remind_at = vec![past];
535 habit.time.recurrence = Some(ScheduleTrigger::Daily {
536 hour: 9,
537 minute: 0,
538 second: 0,
539 });
540 assert!(!should_expire(&habit, now));
541
542 assert!(!should_expire(
544 &record(RecordKind::Todo, RecordStatus::Done),
545 now
546 ));
547 assert!(!should_expire(
548 &record(RecordKind::Todo, RecordStatus::Open),
549 now
550 ));
551 }
552
553 #[test]
554 fn parse_distilled_candidates_is_lenient() {
555 let wrapped = "Sure! Here you go:\n```json\n[{\"title\": \"Takes medication daily\", \"content\": \"User takes medication every morning at 9.\", \"type\": \"user\", \"tags\": [\"health\"]}]\n```";
556 let parsed = parse_distilled_candidates(wrapped);
557 assert_eq!(parsed.len(), 1);
558 assert_eq!(parsed[0].title, "Takes medication daily");
559
560 assert!(parse_distilled_candidates("no json here").is_empty());
561 assert!(parse_distilled_candidates("[]").is_empty());
562 assert!(parse_distilled_candidates("[{\"title\": \" \", \"content\": \"x\"}]").is_empty());
563 assert!(parse_distilled_candidates("[{broken").is_empty());
564 }
565
566 #[test]
567 fn distillation_prompt_lists_records_and_requests_json() {
568 let prompt = build_distillation_prompt(&[(
569 "Renew passport".to_string(),
570 "kind=todo, completed=2026-07-13".to_string(),
571 )]);
572 assert!(prompt.contains("Renew passport"));
573 assert!(prompt.contains("JSON array"));
574 }
575
576 #[derive(Clone)]
577 struct CannedProvider {
578 responses: Arc<Mutex<Vec<String>>>,
579 }
580
581 #[async_trait]
582 impl LLMProvider for CannedProvider {
583 async fn chat_stream(
584 &self,
585 _messages: &[Message],
586 _tools: &[bamboo_agent_core::tools::ToolSchema],
587 _max_output_tokens: Option<u32>,
588 _model: &str,
589 ) -> Result<LLMStream, LLMError> {
590 let mut responses = self.responses.lock().expect("lock poisoned");
591 let text = if responses.is_empty() {
592 "[]".to_string()
593 } else {
594 responses.remove(0)
595 };
596 Ok(Box::pin(stream::iter(vec![
597 Ok(LLMChunk::Token(text)),
598 Ok(LLMChunk::Done),
599 ])))
600 }
601 }
602
603 #[derive(Default)]
604 struct RecordingBridge {
605 synced: AsyncMutex<Vec<String>>,
606 released: AsyncMutex<Vec<String>>,
607 }
608
609 #[async_trait]
610 impl LedgerScheduleBridge for RecordingBridge {
611 async fn sync_record_schedules(
612 &self,
613 record: &LedgerRecord,
614 ) -> Result<Vec<String>, String> {
615 self.synced.lock().await.push(record.id.clone());
616 Ok(vec![format!("sched_for_{}", record.id)])
617 }
618
619 async fn release_schedules(&self, schedule_ids: &[String]) -> Result<(), String> {
620 self.released
621 .lock()
622 .await
623 .extend(schedule_ids.iter().cloned());
624 Ok(())
625 }
626 }
627
628 #[tokio::test]
629 async fn full_run_expires_reconciles_and_distills() {
630 let temp = tempfile::tempdir().expect("tempdir");
631 let session_store = Arc::new(
632 SessionStoreV2::new(temp.path().to_path_buf())
633 .await
634 .unwrap(),
635 );
636 let ledger = LedgerStore::new(session_store.bamboo_home_dir());
637 let jiandu_root = temp.path().join("jiandu");
638 let memory = MemoryStore::new(&jiandu_root);
639 let now = Utc::now();
640
641 let mut past_event = LedgerRecord::new("rec_event", RecordKind::Event, "Old meetup");
643 past_event.time.starts_at = Some(now - ChronoDuration::hours(EXPIRY_GRACE_HOURS + 10));
644 past_event.schedule_ids = vec!["sched_old".to_string()];
645 ledger.write_record(past_event, None).await.unwrap();
646
647 let mut drifted = LedgerRecord::new("rec_drift", RecordKind::Reminder, "Call the bank");
650 drifted.time.remind_at = vec![now + ChronoDuration::hours(5)];
651 ledger.write_record(drifted, None).await.unwrap();
652
653 let mut done = LedgerRecord::new("rec_done", RecordKind::Habit, "Morning run streak");
656 done.status = RecordStatus::Done;
657 ledger.write_record(done, None).await.unwrap();
658
659 let provider: Arc<dyn LLMProvider> = Arc::new(CannedProvider {
660 responses: Arc::new(Mutex::new(vec![
661 "[{\"title\": \"Runs every morning\", \"content\": \"User keeps a morning run habit.\", \"type\": \"user\", \"tags\": [\"health\"]}]".to_string(),
662 ])),
663 });
664 let config = Arc::new(RwLock::new(config_with_memory(
665 bamboo_config::MemoryConfig {
666 background_model: Some("fast-model".to_string()),
667 ..bamboo_config::MemoryConfig::default()
668 },
669 )));
670 let bridge = Arc::new(RecordingBridge::default());
671 let ctx = LedgerGardenerContext {
672 dream: AutoDreamContext {
673 session_store: session_store.clone(),
674 storage: session_store.clone(),
675 memory: memory.clone(),
676 provider,
677 config,
678 provider_registry: Arc::new(ProviderRegistry::new(
679 HashMap::new(),
680 "test".to_string(),
681 )),
682 },
683 schedule_bridge: Some(bridge.clone()),
684 };
685
686 let result = run_ledger_gardener_once_with_stores(&ctx, &ledger, &memory)
687 .await
688 .unwrap()
689 .expect("gardener enabled by default");
690
691 assert_eq!(result.expired, 1);
692 assert_eq!(result.schedules_released, 1);
693 assert_eq!(result.schedules_synced, 1);
694 assert_eq!(result.memories_written, 1);
695 assert_eq!(result.distilled_records, 1);
696 assert_eq!(result.failed, 0);
697
698 let expired = ledger
699 .get_record(LedgerScope::Global, None, "rec_event")
700 .await
701 .unwrap()
702 .unwrap();
703 assert_eq!(expired.record.status, RecordStatus::Expired);
704 assert!(expired.record.schedule_ids.is_empty());
705 assert!(
706 !expired.record.transitions.is_empty(),
707 "history must survive"
708 );
709 assert_eq!(*bridge.released.lock().await, vec!["sched_old".to_string()]);
710
711 let drifted = ledger
712 .get_record(LedgerScope::Global, None, "rec_drift")
713 .await
714 .unwrap()
715 .unwrap();
716 assert_eq!(drifted.record.schedule_ids, vec!["sched_for_rec_drift"]);
717
718 let done = ledger
719 .get_record(LedgerScope::Global, None, "rec_done")
720 .await
721 .unwrap()
722 .unwrap();
723 assert!(done.record.tags.iter().any(|tag| tag == DISTILLED_TAG));
724
725 let second = run_ledger_gardener_once_with_stores(&ctx, &ledger, &memory)
727 .await
728 .unwrap()
729 .unwrap();
730 assert_eq!(second.expired, 0);
731 assert_eq!(second.distilled_records, 0);
732 assert_eq!(second.memories_written, 0);
733 assert!(
734 !jiandu_root.join("ledger").exists(),
735 "Jiandu memory root must never host Bamboo Ledger data"
736 );
737 }
738}