Skip to main content

bamboo_server_tools/ledger/
mod.rs

1//! `ledger` overlay tool — the agent-facing surface of the prospective-memory
2//! record ledger (todos, events, reminders, habits).
3//!
4//! Mirrors [`crate::memory::MemoryTool`]'s shape (one action-dispatched tool)
5//! so the model transfers its habits: `upsert`/`transition`/`decompose`/
6//! `promote` mutate records, `get`/`query`/`agenda` read them. Reminder and
7//! recurrence times are synced onto real `ScheduleSpec`s through the
8//! [`LedgerScheduleBridge`] port, which the server implements over its
9//! schedule store — this crate never sees the scheduler directly.
10
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use chrono::{DateTime, NaiveDate, Utc};
16use serde::Deserialize;
17use serde_json::json;
18
19use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
20use bamboo_domain::ledger::{LedgerRecord, LedgerScope, RecordActor, RecordKind, RecordStatus};
21use bamboo_domain::schedule::ScheduleTrigger;
22use bamboo_domain::{TaskItemStatus, TaskPriority};
23use bamboo_memory::ledger_store::store::new_record_id;
24use bamboo_memory::ledger_store::{LedgerRecordDocument, LedgerStore, RecordFilter};
25use bamboo_memory::memory_store::project_key_from_path;
26use bamboo_tools::tools::workspace_state;
27
28#[cfg(test)]
29mod tests;
30
31const MAX_QUERY_LIMIT: usize = 50;
32const DEFAULT_QUERY_LIMIT: usize = 20;
33const MAX_DECOMPOSE_CHILDREN: usize = 20;
34const DEFAULT_AGENDA_HORIZON_DAYS: i64 = 7;
35
36// The schedule-bridge port lives beside the store so background maintenance
37// (the engine's ledger gardener) can reconcile schedules through the same
38// seam; re-exported here for existing callers.
39pub use bamboo_memory::ledger_store::LedgerScheduleBridge;
40
41#[derive(Clone)]
42pub struct LedgerTool {
43    session_repo: bamboo_engine::SessionRepository,
44    store: LedgerStore,
45    schedule_bridge: Option<Arc<dyn LedgerScheduleBridge>>,
46}
47
48#[derive(Debug, Deserialize)]
49struct ChildSpec {
50    title: String,
51    #[serde(default)]
52    kind: Option<String>,
53    #[serde(default)]
54    due_at: Option<String>,
55    #[serde(default)]
56    priority: Option<String>,
57    #[serde(default)]
58    body: Option<String>,
59    #[serde(default)]
60    tags: Option<Vec<String>>,
61}
62
63#[derive(Debug, Deserialize, Default)]
64struct LedgerArgs {
65    action: String,
66    #[serde(default)]
67    id: Option<String>,
68    #[serde(default)]
69    kind: Option<String>,
70    #[serde(default)]
71    title: Option<String>,
72    #[serde(default)]
73    body: Option<String>,
74    #[serde(default)]
75    status: Option<String>,
76    #[serde(default)]
77    priority: Option<String>,
78    #[serde(default)]
79    scope: Option<String>,
80    #[serde(default)]
81    project_key: Option<String>,
82    #[serde(default)]
83    due_at: Option<String>,
84    #[serde(default)]
85    starts_at: Option<String>,
86    #[serde(default)]
87    ends_at: Option<String>,
88    #[serde(default)]
89    remind_at: Option<Vec<String>>,
90    #[serde(default)]
91    recurrence: Option<serde_json::Value>,
92    #[serde(default)]
93    timezone: Option<String>,
94    #[serde(default)]
95    parent_id: Option<String>,
96    #[serde(default)]
97    depends_on: Option<Vec<String>>,
98    #[serde(default)]
99    related: Option<Vec<String>>,
100    #[serde(default)]
101    tags: Option<Vec<String>>,
102    #[serde(default)]
103    excerpt: Option<String>,
104    #[serde(default)]
105    reason: Option<String>,
106    #[serde(default)]
107    statuses: Option<Vec<String>>,
108    #[serde(default)]
109    kinds: Option<Vec<String>>,
110    #[serde(default)]
111    due_before: Option<String>,
112    #[serde(default)]
113    due_after: Option<String>,
114    #[serde(default)]
115    include_terminal: Option<bool>,
116    #[serde(default)]
117    limit: Option<usize>,
118    #[serde(default)]
119    horizon_days: Option<i64>,
120    #[serde(default)]
121    children: Option<Vec<ChildSpec>>,
122    #[serde(default)]
123    task_ids: Option<Vec<String>>,
124}
125
126fn parse_datetime(raw: &str, field: &str) -> Result<DateTime<Utc>, ToolError> {
127    let trimmed = raw.trim();
128    if let Ok(parsed) = DateTime::parse_from_rfc3339(trimmed) {
129        return Ok(parsed.with_timezone(&Utc));
130    }
131    // Date-only convenience: midnight UTC.
132    if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
133        if let Some(midnight) = date.and_hms_opt(0, 0, 0) {
134            return Ok(DateTime::from_naive_utc_and_offset(midnight, Utc));
135        }
136    }
137    Err(ToolError::InvalidArguments(format!(
138        "{field} must be RFC3339 (e.g. 2026-07-20T09:00:00Z) or YYYY-MM-DD, got: {raw}"
139    )))
140}
141
142fn parse_priority(raw: &str) -> Result<TaskPriority, ToolError> {
143    serde_json::from_value(json!(raw.trim().to_ascii_lowercase())).map_err(|_| {
144        ToolError::InvalidArguments(format!(
145            "priority must be one of low|medium|high|critical, got: {raw}"
146        ))
147    })
148}
149
150fn parse_kind(raw: &str) -> Result<RecordKind, ToolError> {
151    RecordKind::parse(raw)
152        .ok_or_else(|| ToolError::InvalidArguments("kind cannot be empty".to_string()))
153}
154
155fn parse_status(raw: &str) -> Result<RecordStatus, ToolError> {
156    RecordStatus::parse(raw).ok_or_else(|| {
157        ToolError::InvalidArguments(format!(
158            "status must be one of open|in_progress|blocked|done|cancelled|expired, got: {raw}"
159        ))
160    })
161}
162
163fn record_json(doc: &LedgerRecordDocument) -> serde_json::Value {
164    json!({
165        "record": doc.record,
166        "body": doc.body,
167    })
168}
169
170fn json_result(value: serde_json::Value) -> ToolResult {
171    ToolResult {
172        success: true,
173        result: value.to_string(),
174        display_preference: Some("json".to_string()),
175        images: Vec::new(),
176    }
177}
178
179impl LedgerTool {
180    pub fn new(
181        session_repo: bamboo_engine::SessionRepository,
182        data_dir: impl Into<std::path::PathBuf>,
183    ) -> Self {
184        Self {
185            session_repo,
186            store: LedgerStore::new(data_dir),
187            schedule_bridge: None,
188        }
189    }
190
191    pub fn with_schedule_bridge(mut self, bridge: Arc<dyn LedgerScheduleBridge>) -> Self {
192        self.schedule_bridge = Some(bridge);
193        self
194    }
195
196    async fn resolve_project_key(
197        &self,
198        explicit: Option<&str>,
199        session_id: &str,
200    ) -> Option<String> {
201        if let Some(explicit) = explicit
202            .map(str::trim)
203            .filter(|value| !value.is_empty())
204            .map(ToString::to_string)
205        {
206            return Some(explicit);
207        }
208        workspace_state::get_workspace(session_id)
209            .or_else(workspace_state::get_configured_default_workspace)
210            .map(|path| project_key_from_path(&path))
211    }
212
213    /// Find a record by id: the explicitly requested scope first, otherwise
214    /// global then the session's project scope.
215    async fn locate_record(
216        &self,
217        id: &str,
218        explicit_scope: Option<LedgerScope>,
219        project_key: Option<&str>,
220    ) -> Result<Option<LedgerRecordDocument>, ToolError> {
221        let candidates: Vec<(LedgerScope, Option<&str>)> = match explicit_scope {
222            Some(LedgerScope::Global) => vec![(LedgerScope::Global, None)],
223            Some(LedgerScope::Project) => vec![(LedgerScope::Project, project_key)],
224            None => vec![
225                (LedgerScope::Global, None),
226                (LedgerScope::Project, project_key),
227            ],
228        };
229        for (scope, key) in candidates {
230            if scope == LedgerScope::Project && key.is_none() {
231                continue;
232            }
233            if let Some(doc) = self
234                .store
235                .get_record(scope, key, id)
236                .await
237                .map_err(|error| ToolError::Execution(format!("Failed to read record: {error}")))?
238            {
239                return Ok(Some(doc));
240            }
241        }
242        Ok(None)
243    }
244
245    /// Reconcile managed schedules with the record's current state, persisting
246    /// any change to the record's `schedule_ids`. Bridge failures degrade to a
247    /// warning string instead of failing the mutation: the record write
248    /// already succeeded, and losing it over a scheduler hiccup would be worse.
249    async fn sync_schedules(
250        &self,
251        doc: LedgerRecordDocument,
252    ) -> (LedgerRecordDocument, Vec<String>) {
253        let Some(bridge) = &self.schedule_bridge else {
254            return (doc, Vec::new());
255        };
256        let mut warnings = Vec::new();
257        let record = &doc.record;
258        let wants_schedules = !record.status.is_terminal()
259            && (!record.time.remind_at.is_empty() || record.time.recurrence.is_some());
260
261        let new_ids = if wants_schedules {
262            match bridge.sync_record_schedules(record).await {
263                Ok(ids) => ids,
264                Err(error) => {
265                    warnings.push(format!("schedule sync failed: {error}"));
266                    return (doc, warnings);
267                }
268            }
269        } else {
270            if !record.schedule_ids.is_empty() {
271                if let Err(error) = bridge.release_schedules(&record.schedule_ids).await {
272                    warnings.push(format!("schedule release failed: {error}"));
273                    return (doc, warnings);
274                }
275            }
276            Vec::new()
277        };
278
279        if new_ids == record.schedule_ids {
280            return (doc, warnings);
281        }
282        let mut updated = doc.record.clone();
283        updated.schedule_ids = new_ids;
284        match self
285            .store
286            .write_record(updated, Some(doc.body.clone()))
287            .await
288        {
289            Ok(rewritten) => (rewritten, warnings),
290            Err(error) => {
291                warnings.push(format!("failed to persist schedule ids: {error}"));
292                (doc, warnings)
293            }
294        }
295    }
296
297    fn apply_time_args(record: &mut LedgerRecord, args: &LedgerArgs) -> Result<(), ToolError> {
298        if let Some(raw) = &args.due_at {
299            record.time.due_at = Some(parse_datetime(raw, "due_at")?);
300        }
301        if let Some(raw) = &args.starts_at {
302            record.time.starts_at = Some(parse_datetime(raw, "starts_at")?);
303        }
304        if let Some(raw) = &args.ends_at {
305            record.time.ends_at = Some(parse_datetime(raw, "ends_at")?);
306        }
307        if let Some(raws) = &args.remind_at {
308            let mut parsed = Vec::with_capacity(raws.len());
309            for raw in raws {
310                parsed.push(parse_datetime(raw, "remind_at")?);
311            }
312            record.time.remind_at = parsed;
313        }
314        if let Some(raw) = &args.recurrence {
315            if raw.is_null() {
316                record.time.recurrence = None;
317            } else {
318                let trigger: ScheduleTrigger =
319                    serde_json::from_value(raw.clone()).map_err(|error| {
320                        ToolError::InvalidArguments(format!(
321                            "recurrence must be a schedule trigger object \
322                             (e.g. {{\"type\":\"daily\",\"hour\":9,\"minute\":0}}): {error}"
323                        ))
324                    })?;
325                record.time.recurrence = Some(trigger);
326            }
327        }
328        if let Some(tz) = &args.timezone {
329            record.time.timezone = Some(tz.clone());
330        }
331        Ok(())
332    }
333
334    async fn handle_upsert(
335        &self,
336        args: &LedgerArgs,
337        session_id: &str,
338    ) -> Result<serde_json::Value, ToolError> {
339        let explicit_scope = args
340            .scope
341            .as_deref()
342            .map(|raw| {
343                LedgerScope::parse(raw).ok_or_else(|| {
344                    ToolError::InvalidArguments(format!(
345                        "scope must be global or project, got: {raw}"
346                    ))
347                })
348            })
349            .transpose()?;
350        let project_key = self
351            .resolve_project_key(args.project_key.as_deref(), session_id)
352            .await;
353
354        let existing = match args
355            .id
356            .as_deref()
357            .map(str::trim)
358            .filter(|id| !id.is_empty())
359        {
360            Some(id) => {
361                self.locate_record(id, explicit_scope, project_key.as_deref())
362                    .await?
363            }
364            None => None,
365        };
366
367        let mut record = match &existing {
368            Some(doc) => doc.record.clone(),
369            None => {
370                let title = args
371                    .title
372                    .as_deref()
373                    .map(str::trim)
374                    .filter(|title| !title.is_empty())
375                    .ok_or_else(|| {
376                        ToolError::InvalidArguments(
377                            "creating a record requires a title".to_string(),
378                        )
379                    })?;
380                let id = args
381                    .id
382                    .as_deref()
383                    .map(str::trim)
384                    .filter(|id| !id.is_empty())
385                    .map(ToString::to_string)
386                    .unwrap_or_else(new_record_id);
387                let kind = match args.kind.as_deref() {
388                    Some(raw) => parse_kind(raw)?,
389                    None => RecordKind::Todo,
390                };
391                let mut record = LedgerRecord::new(id, kind, title);
392                record.source.session_id = Some(session_id.to_string());
393                record.source.created_by = RecordActor::Agent;
394                match explicit_scope {
395                    Some(LedgerScope::Project) => {
396                        record.scope = LedgerScope::Project;
397                        record.project_key = Some(project_key.clone().ok_or_else(|| {
398                            ToolError::InvalidArguments(
399                                "project scope requires a project_key (or a session workspace)"
400                                    .to_string(),
401                            )
402                        })?);
403                    }
404                    _ => record.scope = LedgerScope::Global,
405                }
406                record
407            }
408        };
409
410        // Field updates apply to both paths; on update, absent args leave the
411        // existing value untouched.
412        if let Some(title) = args
413            .title
414            .as_deref()
415            .map(str::trim)
416            .filter(|t| !t.is_empty())
417        {
418            record.title = title.to_string();
419        }
420        if existing.is_some() {
421            if let Some(raw) = args.kind.as_deref() {
422                record.kind = parse_kind(raw)?;
423            }
424        }
425        if let Some(raw) = args.priority.as_deref() {
426            record.priority = parse_priority(raw)?;
427        }
428        if let Some(raw) = args.status.as_deref() {
429            let status = parse_status(raw)?;
430            record.transition_to(status, args.reason.as_deref());
431        }
432        if let Some(parent_id) = &args.parent_id {
433            record.relations.parent_id =
434                Some(parent_id.trim().to_string()).filter(|value| !value.is_empty());
435        }
436        if let Some(depends_on) = &args.depends_on {
437            record.relations.depends_on = depends_on.clone();
438        }
439        if let Some(related) = &args.related {
440            record.relations.related = related.clone();
441        }
442        if let Some(tags) = &args.tags {
443            record.tags = bamboo_memory::memory_store::normalize_tags(tags.iter());
444        }
445        if let Some(excerpt) = &args.excerpt {
446            record.source.excerpt = Some(excerpt.clone());
447        }
448        Self::apply_time_args(&mut record, args)?;
449
450        let body = args.body.clone();
451        let action = if existing.is_some() {
452            "update"
453        } else {
454            "create"
455        };
456        let doc = self
457            .store
458            .write_record(record, body)
459            .await
460            .map_err(|error| ToolError::Execution(format!("Failed to write record: {error}")))?;
461        let (doc, warnings) = self.sync_schedules(doc).await;
462
463        Ok(json!({
464            "action": "upsert",
465            "result": action,
466            "data": record_json(&doc),
467            "warnings": warnings,
468        }))
469    }
470
471    async fn handle_transition(
472        &self,
473        args: &LedgerArgs,
474        session_id: &str,
475    ) -> Result<serde_json::Value, ToolError> {
476        let id = args
477            .id
478            .as_deref()
479            .map(str::trim)
480            .filter(|id| !id.is_empty())
481            .ok_or_else(|| ToolError::InvalidArguments("transition requires an id".to_string()))?;
482        let status = parse_status(args.status.as_deref().ok_or_else(|| {
483            ToolError::InvalidArguments("transition requires a status".to_string())
484        })?)?;
485        let project_key = self
486            .resolve_project_key(args.project_key.as_deref(), session_id)
487            .await;
488        let Some(located) = self.locate_record(id, None, project_key.as_deref()).await? else {
489            return Err(ToolError::Execution(format!("record not found: {id}")));
490        };
491
492        let updated = self
493            .store
494            .transition_record(
495                located.record.scope,
496                located.record.project_key.as_deref(),
497                id,
498                status,
499                args.reason.as_deref(),
500            )
501            .await
502            .map_err(|error| ToolError::Execution(format!("Failed to transition: {error}")))?;
503        let doc = match updated {
504            Some(doc) => doc,
505            // No-op transition: report current state rather than erroring.
506            None => located,
507        };
508        let (doc, warnings) = self.sync_schedules(doc).await;
509
510        Ok(json!({
511            "action": "transition",
512            "data": record_json(&doc),
513            "warnings": warnings,
514        }))
515    }
516
517    async fn handle_get(
518        &self,
519        args: &LedgerArgs,
520        session_id: &str,
521    ) -> Result<serde_json::Value, ToolError> {
522        let id = args
523            .id
524            .as_deref()
525            .map(str::trim)
526            .filter(|id| !id.is_empty())
527            .ok_or_else(|| ToolError::InvalidArguments("get requires an id".to_string()))?;
528        let project_key = self
529            .resolve_project_key(args.project_key.as_deref(), session_id)
530            .await;
531        let Some(doc) = self.locate_record(id, None, project_key.as_deref()).await? else {
532            return Err(ToolError::Execution(format!("record not found: {id}")));
533        };
534
535        let children = self
536            .store
537            .list_records(
538                doc.record.scope,
539                doc.record.project_key.as_deref(),
540                &RecordFilter {
541                    parent_id: Some(doc.record.id.clone()),
542                    include_terminal: true,
543                    ..RecordFilter::default()
544                },
545            )
546            .await
547            .map_err(|error| ToolError::Execution(format!("Failed to list children: {error}")))?;
548
549        Ok(json!({
550            "action": "get",
551            "data": record_json(&doc),
552            "children": children.iter().map(|child| &child.record).collect::<Vec<_>>(),
553        }))
554    }
555
556    async fn handle_query(
557        &self,
558        args: &LedgerArgs,
559        session_id: &str,
560    ) -> Result<serde_json::Value, ToolError> {
561        let project_key = self
562            .resolve_project_key(args.project_key.as_deref(), session_id)
563            .await;
564        let scopes: Vec<(LedgerScope, Option<String>)> = match args.scope.as_deref() {
565            Some("global") => vec![(LedgerScope::Global, None)],
566            Some("project") => vec![(LedgerScope::Project, project_key.clone())],
567            None | Some("all") => {
568                let mut scopes = vec![(LedgerScope::Global, None)];
569                if project_key.is_some() {
570                    scopes.push((LedgerScope::Project, project_key.clone()));
571                }
572                scopes
573            }
574            Some(other) => {
575                return Err(ToolError::InvalidArguments(format!(
576                    "scope must be global, project, or all, got: {other}"
577                )))
578            }
579        };
580
581        let statuses = args
582            .statuses
583            .as_ref()
584            .map(|raws| {
585                raws.iter()
586                    .map(|raw| parse_status(raw))
587                    .collect::<Result<HashSet<_>, _>>()
588            })
589            .transpose()?;
590        let kinds = args
591            .kinds
592            .as_ref()
593            .map(|raws| {
594                raws.iter()
595                    .map(|raw| parse_kind(raw))
596                    .collect::<Result<HashSet<_>, _>>()
597            })
598            .transpose()?;
599        let filter = RecordFilter {
600            statuses,
601            kinds,
602            tags: args.tags.clone().unwrap_or_default(),
603            parent_id: args.parent_id.clone(),
604            anchor_before: args
605                .due_before
606                .as_deref()
607                .map(|raw| parse_datetime(raw, "due_before"))
608                .transpose()?,
609            anchor_after: args
610                .due_after
611                .as_deref()
612                .map(|raw| parse_datetime(raw, "due_after"))
613                .transpose()?,
614            include_terminal: args.include_terminal.unwrap_or(false),
615            limit: None,
616        };
617        let limit = args
618            .limit
619            .unwrap_or(DEFAULT_QUERY_LIMIT)
620            .clamp(1, MAX_QUERY_LIMIT);
621
622        let mut records = Vec::new();
623        for (scope, key) in &scopes {
624            if *scope == LedgerScope::Project && key.is_none() {
625                return Err(ToolError::InvalidArguments(
626                    "project scope requires a project_key (or a session workspace)".to_string(),
627                ));
628            }
629            let docs = self
630                .store
631                .list_records(*scope, key.as_deref(), &filter)
632                .await
633                .map_err(|error| ToolError::Execution(format!("Failed to query: {error}")))?;
634            records.extend(docs.into_iter().map(|doc| doc.record));
635        }
636        let total = records.len();
637        records.truncate(limit);
638
639        Ok(json!({
640            "action": "query",
641            "records": records,
642            "returned": records.len(),
643            "matched": total,
644        }))
645    }
646
647    async fn handle_agenda(
648        &self,
649        args: &LedgerArgs,
650        session_id: &str,
651    ) -> Result<serde_json::Value, ToolError> {
652        let project_key = self
653            .resolve_project_key(args.project_key.as_deref(), session_id)
654            .await;
655        let mut scopes: Vec<(LedgerScope, Option<String>)> = vec![(LedgerScope::Global, None)];
656        if project_key.is_some() {
657            scopes.push((LedgerScope::Project, project_key));
658        }
659        let horizon = args
660            .horizon_days
661            .unwrap_or(DEFAULT_AGENDA_HORIZON_DAYS)
662            .clamp(1, 31);
663        let snapshot = self
664            .store
665            .agenda(&scopes, Utc::now(), horizon)
666            .await
667            .map_err(|error| ToolError::Execution(format!("Failed to build agenda: {error}")))?;
668        Ok(json!({
669            "action": "agenda",
670            "horizon_days": horizon,
671            "agenda": snapshot,
672        }))
673    }
674
675    async fn handle_decompose(
676        &self,
677        args: &LedgerArgs,
678        session_id: &str,
679    ) -> Result<serde_json::Value, ToolError> {
680        let parent_id = args
681            .parent_id
682            .as_deref()
683            .or(args.id.as_deref())
684            .map(str::trim)
685            .filter(|id| !id.is_empty())
686            .ok_or_else(|| {
687                ToolError::InvalidArguments("decompose requires a parent_id".to_string())
688            })?;
689        let children = args
690            .children
691            .as_ref()
692            .filter(|c| !c.is_empty())
693            .ok_or_else(|| {
694                ToolError::InvalidArguments(
695                    "decompose requires a non-empty children array".to_string(),
696                )
697            })?;
698        if children.len() > MAX_DECOMPOSE_CHILDREN {
699            return Err(ToolError::InvalidArguments(format!(
700                "decompose supports at most {MAX_DECOMPOSE_CHILDREN} children per call"
701            )));
702        }
703        let project_key = self
704            .resolve_project_key(args.project_key.as_deref(), session_id)
705            .await;
706        let Some(parent) = self
707            .locate_record(parent_id, None, project_key.as_deref())
708            .await?
709        else {
710            return Err(ToolError::Execution(format!(
711                "parent record not found: {parent_id}"
712            )));
713        };
714
715        let mut created = Vec::with_capacity(children.len());
716        for child in children {
717            let kind = match child.kind.as_deref() {
718                Some(raw) => parse_kind(raw)?,
719                None => parent.record.kind.clone(),
720            };
721            let mut record = LedgerRecord::new(new_record_id(), kind, child.title.trim());
722            record.scope = parent.record.scope;
723            record.project_key = parent.record.project_key.clone();
724            record.relations.parent_id = Some(parent.record.id.clone());
725            record.source.session_id = Some(session_id.to_string());
726            record.source.created_by = RecordActor::Agent;
727            if let Some(raw) = child.due_at.as_deref() {
728                record.time.due_at = Some(parse_datetime(raw, "children[].due_at")?);
729            }
730            if let Some(raw) = child.priority.as_deref() {
731                record.priority = parse_priority(raw)?;
732            }
733            if let Some(tags) = &child.tags {
734                record.tags = bamboo_memory::memory_store::normalize_tags(tags.iter());
735            }
736            let doc = self
737                .store
738                .write_record(record, child.body.clone())
739                .await
740                .map_err(|error| {
741                    ToolError::Execution(format!("Failed to write child record: {error}"))
742                })?;
743            created.push(doc.record);
744        }
745
746        Ok(json!({
747            "action": "decompose",
748            "parent_id": parent.record.id,
749            "created": created,
750        }))
751    }
752
753    async fn handle_promote(
754        &self,
755        args: &LedgerArgs,
756        session_id: &str,
757    ) -> Result<serde_json::Value, ToolError> {
758        let session = self
759            .session_repo
760            .load(session_id)
761            .await
762            .ok_or_else(|| ToolError::Execution(format!("session not found: {session_id}")))?;
763        // The shared task list lives on the root session of the tree.
764        let root = if session.root_session_id != session.id {
765            self.session_repo
766                .load(&session.root_session_id)
767                .await
768                .unwrap_or(session)
769        } else {
770            session
771        };
772        let Some(task_list) = root
773            .task_list
774            .as_ref()
775            .filter(|list| !list.items.is_empty())
776        else {
777            return Err(ToolError::Execution(
778                "the session has no task list to promote".to_string(),
779            ));
780        };
781
782        let selected: Vec<&bamboo_domain::TaskItem> = match &args.task_ids {
783            Some(ids) => {
784                let wanted: HashSet<&str> = ids.iter().map(String::as_str).collect();
785                task_list
786                    .items
787                    .iter()
788                    .filter(|item| wanted.contains(item.id.as_str()))
789                    .collect()
790            }
791            None => task_list
792                .items
793                .iter()
794                .filter(|item| item.status != TaskItemStatus::Completed)
795                .collect(),
796        };
797        if selected.is_empty() {
798            return Err(ToolError::Execution(
799                "no matching task items to promote".to_string(),
800            ));
801        }
802
803        // Task-item ids are session-local; co-promoted parents/dependencies are
804        // remapped onto the new record ids, references to unpromoted items drop.
805        let id_map: HashMap<&str, String> = selected
806            .iter()
807            .map(|item| (item.id.as_str(), new_record_id()))
808            .collect();
809        let mut created = Vec::with_capacity(selected.len());
810        for item in &selected {
811            let mut record = LedgerRecord::new(
812                id_map[item.id.as_str()].clone(),
813                RecordKind::Todo,
814                item.description.trim(),
815            );
816            record.priority = item.priority.clone();
817            record.status = match item.status {
818                TaskItemStatus::InProgress => RecordStatus::InProgress,
819                TaskItemStatus::Blocked => RecordStatus::Blocked,
820                TaskItemStatus::Completed => RecordStatus::Done,
821                TaskItemStatus::Pending => RecordStatus::Open,
822            };
823            record.relations.parent_id = item
824                .parent_id
825                .as_deref()
826                .and_then(|parent| id_map.get(parent).cloned());
827            record.relations.depends_on = item
828                .depends_on
829                .iter()
830                .filter_map(|dep| id_map.get(dep.as_str()).cloned())
831                .collect();
832            record.source.session_id = Some(session_id.to_string());
833            record.source.created_by = RecordActor::Agent;
834            let body = (!item.notes.trim().is_empty()).then(|| item.notes.clone());
835            let doc = self
836                .store
837                .write_record(record, body)
838                .await
839                .map_err(|error| {
840                    ToolError::Execution(format!("Failed to promote task item: {error}"))
841                })?;
842            created.push(doc.record);
843        }
844
845        Ok(json!({
846            "action": "promote",
847            "created": created,
848        }))
849    }
850}
851
852#[async_trait]
853impl Tool for LedgerTool {
854    fn name(&self) -> &str {
855        "ledger"
856    }
857
858    fn description(&self) -> &str {
859        "Personal ledger of prospective records: todos, events, reminders, habits. \
860         Use this — not the session Task list — whenever the user states a commitment, \
861         deadline, appointment, or recurring routine, so it survives across sessions. \
862         Actions: upsert (create/update a record; due_at/starts_at/remind_at accept \
863         RFC3339 or YYYY-MM-DD; remind_at/recurrence become real fired reminders), \
864         transition (done/cancel/block/reopen), get, query (by status/kind/time window), \
865         agenda (overdue + next 24h + upcoming), decompose (split a record into child \
866         records), promote (lift the current session's Task list into durable records)."
867    }
868
869    fn parameters_schema(&self) -> serde_json::Value {
870        // Built in two json! expansions: one deeply-nested literal blows the
871        // macro recursion limit.
872        let children_schema = json!({
873            "type": "array",
874            "items": {
875                "type": "object",
876                "properties": {
877                    "title": {"type": "string"},
878                    "kind": {"type": "string"},
879                    "due_at": {"type": "string"},
880                    "priority": {"type": "string"},
881                    "body": {"type": "string"},
882                    "tags": {"type": "array", "items": {"type": "string"}}
883                },
884                "required": ["title"]
885            }
886        });
887        json!({
888            "type": "object",
889            "properties": {
890                "action": {
891                    "type": "string",
892                    "enum": ["upsert", "transition", "get", "query", "agenda", "decompose", "promote"]
893                },
894                "id": {"type": "string"},
895                "kind": {"type": "string", "description": "todo | event | reminder | habit | custom kind"},
896                "title": {"type": "string"},
897                "body": {"type": "string", "description": "Free markdown notes for the record"},
898                "status": {"type": "string", "enum": ["open", "in_progress", "blocked", "done", "cancelled", "expired"]},
899                "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
900                "scope": {"type": "string", "enum": ["global", "project", "all"], "description": "global = personal life (default); project = current workspace"},
901                "project_key": {"type": "string"},
902                "due_at": {"type": "string", "description": "RFC3339 or YYYY-MM-DD"},
903                "starts_at": {"type": "string"},
904                "ends_at": {"type": "string"},
905                "remind_at": {"type": "array", "items": {"type": "string"}, "description": "Reminder times; each becomes a one-shot schedule that wakes the agent"},
906                "recurrence": {"type": "object", "description": "Schedule trigger object, e.g. {\"type\":\"daily\",\"hour\":9,\"minute\":0}"},
907                "timezone": {"type": "string"},
908                "parent_id": {"type": "string"},
909                "depends_on": {"type": "array", "items": {"type": "string"}},
910                "related": {"type": "array", "items": {"type": "string"}},
911                "tags": {"type": "array", "items": {"type": "string"}},
912                "excerpt": {"type": "string", "description": "The user's sentence that spawned this record"},
913                "reason": {"type": "string"},
914                "statuses": {"type": "array", "items": {"type": "string"}},
915                "kinds": {"type": "array", "items": {"type": "string"}},
916                "due_before": {"type": "string"},
917                "due_after": {"type": "string"},
918                "include_terminal": {"type": "boolean"},
919                "limit": {"type": "integer"},
920                "horizon_days": {"type": "integer"},
921                "children": children_schema,
922                "task_ids": {"type": "array", "items": {"type": "string"}, "description": "promote: specific session task-item ids (default: all incomplete)"}
923            },
924            "required": ["action"]
925        })
926    }
927
928    fn classify(&self, args: &serde_json::Value) -> ToolClass {
929        let action = args
930            .get("action")
931            .and_then(|value| value.as_str())
932            .unwrap_or("")
933            .trim()
934            .to_ascii_lowercase();
935        match action.as_str() {
936            "get" | "query" | "agenda" => ToolClass::READONLY_PARALLEL,
937            _ => ToolClass::MUTATING_SERIAL,
938        }
939    }
940
941    async fn invoke(
942        &self,
943        args: serde_json::Value,
944        ctx: ToolCtx,
945    ) -> Result<ToolOutcome, ToolError> {
946        let session_id = ctx.session_id().ok_or_else(|| {
947            ToolError::Execution("ledger requires a session_id in tool context".to_string())
948        })?;
949        let parsed: LedgerArgs = serde_json::from_value(args).map_err(|error| {
950            ToolError::InvalidArguments(format!("Invalid ledger args: {error}"))
951        })?;
952
953        let value = match parsed.action.trim().to_ascii_lowercase().as_str() {
954            "upsert" => self.handle_upsert(&parsed, session_id).await?,
955            "transition" => self.handle_transition(&parsed, session_id).await?,
956            "get" => self.handle_get(&parsed, session_id).await?,
957            "query" => self.handle_query(&parsed, session_id).await?,
958            "agenda" => self.handle_agenda(&parsed, session_id).await?,
959            "decompose" => self.handle_decompose(&parsed, session_id).await?,
960            "promote" => self.handle_promote(&parsed, session_id).await?,
961            other => {
962                return Err(ToolError::InvalidArguments(format!(
963                    "unknown ledger action: {other}"
964                )))
965            }
966        };
967        Ok(ToolOutcome::Completed(json_result(value)))
968    }
969}