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}
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 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 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 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 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 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 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 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 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}