1use 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
36pub 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 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 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 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 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 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 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 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 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}