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