1use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
2use crate::core::a2a::task::TaskStore;
3use crate::core::agents::{AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType};
4use crate::core::evidence_ledger::EvidenceLedgerV1;
5
6#[allow(clippy::too_many_arguments)]
7pub fn handle(
8 action: &str,
9 agent_type: Option<&str>,
10 role: Option<&str>,
11 project_root: &str,
12 current_agent_id: Option<&str>,
13 message: Option<&str>,
14 category: Option<&str>,
15 to_agent: Option<&str>,
16 status: Option<&str>,
17 privacy: Option<&str>,
18 priority: Option<&str>,
19 _ttl_hours: Option<u64>,
20 format: Option<&str>,
21 write: bool,
22 filename: Option<&str>,
23) -> String {
24 match action {
25 "register" => {
26 let atype = agent_type.unwrap_or("unknown");
27 match AgentRegistry::mutate_locked(|registry| {
28 registry.cleanup_stale(24);
29 registry.register(atype, role, project_root)
30 }) {
31 Ok((_, agent_id)) => format!(
32 "Agent registered: {agent_id} (type: {atype}, role: {})",
33 role.unwrap_or("none")
34 ),
35 Err(e) => format!("Registration failed: {e}"),
36 }
37 }
38
39 "list" => {
40 let registry = match AgentRegistry::mutate_locked(|registry| {
41 registry.cleanup_stale(24);
42 }) {
43 Ok((registry, ())) => registry,
44 Err(e) => {
45 tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
46 AgentRegistry::load_or_create()
47 }
48 };
49
50 let agents = registry.list_active(Some(project_root));
51 if agents.is_empty() {
52 return "No active agents for this project.".to_string();
53 }
54
55 let mut out = format!("Active agents ({}):\n", agents.len());
56 for a in agents {
57 let role_str = a.role.as_deref().unwrap_or("-");
58 let status_msg = a
59 .status_message
60 .as_deref()
61 .map(|m| format!(" — {m}"))
62 .unwrap_or_default();
63 let age = (chrono::Utc::now() - a.last_active).num_minutes();
64 out.push_str(&format!(
65 " {} [{}] role={} status={}{} (last active: {}m ago, pid: {})\n",
66 a.agent_id, a.agent_type, role_str, a.status, status_msg, age, a.pid
67 ));
68 }
69 out
70 }
71
72 "post" => {
73 let Some(msg) = message else {
74 return "Error: message is required for post".to_string();
75 };
76 let cat = category.unwrap_or("status");
77 let from = current_agent_id.unwrap_or("anonymous");
78 let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
79 let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
80 if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
81 return "Error: private messages require to_agent".to_string();
82 }
83 match AgentRegistry::mutate_locked(|registry| {
84 registry.post_message_full(
85 from,
86 to_agent,
87 cat,
88 msg,
89 msg_privacy,
90 msg_priority,
91 _ttl_hours,
92 )
93 }) {
94 Ok((_, msg_id)) => {
95 let target = to_agent.unwrap_or("all agents (broadcast)");
96 format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
97 }
98 Err(e) => format!("Post failed: {e}"),
99 }
100 }
101
102 "read" => {
103 let Some(agent_id) = current_agent_id else {
104 return "Error: agent must be registered first (use action=register)".to_string();
105 };
106 let messages = match AgentRegistry::mutate_locked(|registry| {
107 registry
108 .read_unread(agent_id)
109 .into_iter()
110 .cloned()
111 .collect::<Vec<_>>()
112 }) {
113 Ok((_, messages)) => messages,
114 Err(e) => {
115 tracing::warn!(
116 "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
117 );
118 return "Error: failed to read messages".to_string();
119 }
120 };
121
122 if messages.is_empty() {
123 return "No new messages.".to_string();
124 }
125
126 let mut out = format!("New messages ({}):\n", messages.len());
127 for m in &messages {
128 let age = (chrono::Utc::now() - m.timestamp).num_minutes();
129 out.push_str(&format!(
130 " [{}] from {} ({}m ago): {}\n",
131 m.category, m.from_agent, age, m.message
132 ));
133 }
134 out
135 }
136
137 "status" => {
138 let Some(agent_id) = current_agent_id else {
139 return "Error: agent must be registered first".to_string();
140 };
141 let new_status = match status {
142 Some("active") => AgentStatus::Active,
143 Some("idle") => AgentStatus::Idle,
144 Some("finished") => AgentStatus::Finished,
145 Some(other) => {
146 return format!("Unknown status: {other}. Use: active, idle, finished");
147 }
148 None => return "Error: status value is required".to_string(),
149 };
150 let status_msg = message;
151
152 match AgentRegistry::mutate_locked(|registry| {
153 registry.set_status(agent_id, new_status.clone(), status_msg);
154 }) {
155 Ok(_) => format!(
156 "Status updated: {} → {}{}",
157 agent_id,
158 new_status,
159 status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
160 ),
161 Err(e) => format!("Status set but save failed: {e}"),
162 }
163 }
164
165 "info" => {
166 let registry = AgentRegistry::load_or_create();
167 let total = registry.agents.len();
168 let active = registry
169 .agents
170 .iter()
171 .filter(|a| a.status == AgentStatus::Active)
172 .count();
173 let messages = registry.scratchpad.len();
174 format!(
175 "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
176 registry.updated_at.format("%Y-%m-%d %H:%M UTC")
177 )
178 }
179
180 "handoff" => {
181 let Some(from) = current_agent_id else {
182 return "Error: agent must be registered first".to_string();
183 };
184 let Some(target) = to_agent else {
185 return "Error: to_agent is required for handoff".to_string();
186 };
187 let summary = message.unwrap_or("(no summary provided)");
188
189 let _ = AgentRegistry::mutate_locked(|registry| {
190 registry.post_message(
191 from,
192 Some(target),
193 "handoff",
194 &format!("HANDOFF from {from}: {summary}"),
195 );
196 registry.set_status(from, AgentStatus::Finished, Some("handed off"));
197 });
198
199 crate::core::scent_field::deposit(
202 from,
203 crate::core::scent_field::ScentKind::Done,
204 summary,
205 1.0,
206 );
207
208 format!("Handoff complete: {from} → {target}\nSummary: {summary}")
209 }
210
211 "claim" => {
215 let Some(target) = message else {
216 return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
217 };
218 let agent = current_agent_id.map_or_else(
219 || crate::core::scent_field::scent_agent_id().to_string(),
220 str::to_string,
221 );
222 let normalized = crate::core::pathutil::normalize_tool_path(target);
223 match crate::core::scent_field::claim(&agent, &normalized) {
224 Ok(()) => {
225 format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
226 }
227 Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
228 }
229 }
230
231 "release" => {
233 let Some(target) = message else {
234 return "Error: message (the claim target) is required for release".to_string();
235 };
236 let agent = current_agent_id.map_or_else(
237 || crate::core::scent_field::scent_agent_id().to_string(),
238 str::to_string,
239 );
240 let normalized = crate::core::pathutil::normalize_tool_path(target);
241 crate::core::scent_field::release(&agent, &normalized);
242 format!("Released: {normalized}")
243 }
244
245 "brief" => {
249 let Some(task) = message else {
250 return "Error: message (the sub-agent task) is required for brief".to_string();
251 };
252 let budget = priority
253 .and_then(|p| p.parse::<usize>().ok())
254 .unwrap_or(2000);
255 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root)
256 else {
257 return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
258 };
259 let pack =
260 crate::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
261 match crate::core::subagent_contract::serialize_pack(&pack) {
262 Ok(json) => json,
263 Err(e) => format!("Error: {e}"),
264 }
265 }
266
267 "return" => {
271 let Some(report) = message else {
272 return "Error: message (the sub-agent report) is required for return".to_string();
273 };
274 let (facts, rejected) = crate::core::subagent_contract::parse_return_lines(report);
275 if facts.is_empty() {
276 return format!(
277 "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
278 rejected.len()
279 );
280 }
281
282 let session_label = current_agent_id.unwrap_or("subagent");
283 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
284 Ok(p) => p,
285 Err(e) => return e,
286 };
287 let count = facts.len();
288 let res = crate::core::knowledge::ProjectKnowledge::mutate_locked(
289 project_root,
290 |knowledge| {
291 for f in &facts {
292 knowledge.remember(
293 &f.category,
294 &f.key,
295 &f.value,
296 session_label,
297 0.8,
298 &policy,
299 );
300 }
301 },
302 );
303 match res {
304 Ok(_) => {
305 let mut out = format!(
306 "Return synthesis: {count} fact(s) distilled into parent knowledge"
307 );
308 if !rejected.is_empty() {
309 out.push_str(&format!(
310 "\n{} line(s) rejected (not 'category/key: value'):",
311 rejected.len()
312 ));
313 for r in rejected.iter().take(5) {
314 out.push_str(&format!("\n ✗ {r}"));
315 }
316 }
317 out
318 }
319 Err(e) => format!("Error: knowledge store update failed: {e}"),
320 }
321 }
322
323 "sync" => {
324 let registry = AgentRegistry::load_or_create();
325 let pending_count = current_agent_id.map_or(0, |id| {
326 registry
327 .scratchpad
328 .iter()
329 .filter(|e| {
330 !e.read_by.contains(&id.to_string())
331 && e.from_agent != id
332 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
333 })
334 .count()
335 });
336 let agents: Vec<&crate::core::agents::AgentEntry> = registry
337 .agents
338 .iter()
339 .filter(|a| a.status != AgentStatus::Finished && a.project_root == project_root)
340 .collect();
341
342 if agents.is_empty() {
343 return "No active agents to sync with.".to_string();
344 }
345
346 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
347 .unwrap_or_default()
348 .join("agents")
349 .join("shared");
350
351 let shared_count = if shared_dir.exists() {
352 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
353 } else {
354 0
355 };
356
357 let mut out = "Multi-Agent Sync Status:\n".to_string();
358 out.push_str(&format!(" Active agents: {}\n", agents.len()));
359 for a in &agents {
360 let role = a.role.as_deref().unwrap_or("-");
361 let age = (chrono::Utc::now() - a.last_active).num_minutes();
362 out.push_str(&format!(
363 " {} [{}] role={} ({}m ago)\n",
364 a.agent_id, a.agent_type, role, age
365 ));
366 }
367 out.push_str(&format!(" Pending messages: {pending_count}\n"));
368 out.push_str(&format!(" Shared contexts: {shared_count}\n"));
369
370 let scents = crate::core::scent_field::sync_block();
373 if !scents.is_empty() {
374 out.push_str(&scents);
375 }
376 out
377 }
378
379 "export" => {
380 let Some(agent_id) = current_agent_id else {
381 return "Error: agent must be registered first (use action=register)".to_string();
382 };
383
384 fn privacy_label(p: &PrivacyLevel) -> &'static str {
385 match p {
386 PrivacyLevel::Public => "public",
387 PrivacyLevel::Team => "team",
388 PrivacyLevel::Private => "private",
389 }
390 }
391
392 fn priority_label(p: &MessagePriority) -> &'static str {
393 match p {
394 MessagePriority::Low => "low",
395 MessagePriority::Normal => "normal",
396 MessagePriority::High => "high",
397 MessagePriority::Critical => "critical",
398 }
399 }
400
401 fn maybe_redact(s: &str, should_redact: bool) -> String {
402 if should_redact {
403 crate::core::redaction::redact_text(s)
404 } else {
405 s.to_string()
406 }
407 }
408
409 #[derive(serde::Serialize)]
410 struct ExportAgentV1 {
411 agent_id: String,
412 agent_type: String,
413 role: Option<String>,
414 status: String,
415 status_message: Option<String>,
416 started_at: String,
417 last_active: String,
418 pid: u32,
419 }
420
421 #[derive(serde::Serialize)]
422 struct ExportMessageV1 {
423 id: String,
424 from_agent: String,
425 to_agent: Option<String>,
426 category: String,
427 privacy: String,
428 priority: String,
429 message: String,
430 metadata: std::collections::BTreeMap<String, String>,
431 timestamp: String,
432 expires_at: Option<String>,
433 read_by_count: usize,
434 }
435
436 #[derive(serde::Serialize)]
437 struct ExportTaskV1 {
438 id: String,
439 from_agent: String,
440 to_agent: String,
441 state: String,
442 description: String,
443 created_at: String,
444 updated_at: String,
445 messages: usize,
446 artifacts: usize,
447 transitions: usize,
448 }
449
450 #[derive(serde::Serialize)]
451 struct ExportDiaryEntryV1 {
452 entry_type: String,
453 content: String,
454 context: Option<String>,
455 timestamp: String,
456 }
457
458 #[derive(serde::Serialize)]
459 struct ExportDiaryV1 {
460 agent_id: String,
461 agent_type: String,
462 project_root: String,
463 updated_at: String,
464 entries: Vec<ExportDiaryEntryV1>,
465 }
466
467 #[derive(serde::Serialize)]
468 struct A2ASnapshotV1 {
469 schema_version: u32,
470 created_at: String,
471 project_root: String,
472 agent_id: String,
473 agents: Vec<ExportAgentV1>,
474 messages: Vec<ExportMessageV1>,
475 tasks: Vec<ExportTaskV1>,
476 diary: Option<ExportDiaryV1>,
477 }
478
479 let privacy_mode = privacy.unwrap_or("redacted");
480 let allow_full = privacy_mode == "full"
481 && !crate::core::redaction::redaction_enabled_for_active_role();
482 let should_redact = !allow_full;
483
484 let now = chrono::Utc::now();
485 let mut registry = AgentRegistry::load_or_create();
486 registry.cleanup_stale(24);
487
488 let mut agents: Vec<ExportAgentV1> = registry
489 .list_active(Some(project_root))
490 .into_iter()
491 .map(|a| ExportAgentV1 {
492 agent_id: a.agent_id.clone(),
493 agent_type: a.agent_type.clone(),
494 role: a.role.clone(),
495 status: a.status.to_string(),
496 status_message: a.status_message.clone(),
497 started_at: a.started_at.to_rfc3339(),
498 last_active: a.last_active.to_rfc3339(),
499 pid: a.pid,
500 })
501 .collect();
502 agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
503
504 let mut messages: Vec<ExportMessageV1> = registry
505 .scratchpad
506 .iter()
507 .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
508 .take(200)
509 .map(|m| ExportMessageV1 {
510 id: m.id.clone(),
511 from_agent: m.from_agent.clone(),
512 to_agent: m.to_agent.clone(),
513 category: m.category.clone(),
514 privacy: privacy_label(&m.privacy).to_string(),
515 priority: priority_label(&m.priority).to_string(),
516 message: maybe_redact(&m.message, should_redact),
517 metadata: m
518 .metadata
519 .iter()
520 .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
521 .collect(),
522 timestamp: m.timestamp.to_rfc3339(),
523 expires_at: m.expires_at.map(|t| t.to_rfc3339()),
524 read_by_count: m.read_by.len(),
525 })
526 .collect();
527 messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
528
529 let mut task_store = TaskStore::load();
530 task_store.cleanup_old(72);
531 let mut tasks: Vec<ExportTaskV1> = task_store
532 .tasks_for_agent(agent_id)
533 .into_iter()
534 .take(200)
535 .map(|t| ExportTaskV1 {
536 id: t.id.clone(),
537 from_agent: t.from_agent.clone(),
538 to_agent: t.to_agent.clone(),
539 state: t.state.to_string(),
540 description: maybe_redact(&t.description, should_redact),
541 created_at: t.created_at.to_rfc3339(),
542 updated_at: t.updated_at.to_rfc3339(),
543 messages: t.messages.len(),
544 artifacts: t.artifacts.len(),
545 transitions: t.history.len(),
546 })
547 .collect();
548 tasks.sort_by(|a, b| {
549 b.updated_at
550 .cmp(&a.updated_at)
551 .then_with(|| a.id.cmp(&b.id))
552 });
553
554 let diary = AgentDiary::load(agent_id).map(|d| ExportDiaryV1 {
555 agent_id: d.agent_id,
556 agent_type: d.agent_type,
557 project_root: d.project_root,
558 updated_at: d.updated_at.to_rfc3339(),
559 entries: d
560 .entries
561 .iter()
562 .rev()
563 .take(25)
564 .rev()
565 .map(|e| ExportDiaryEntryV1 {
566 entry_type: e.entry_type.to_string(),
567 content: maybe_redact(&e.content, should_redact),
568 context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
569 timestamp: e.timestamp.to_rfc3339(),
570 })
571 .collect(),
572 });
573
574 let payload = A2ASnapshotV1 {
575 schema_version: crate::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
576 created_at: now.to_rfc3339(),
577 project_root: project_root.to_string(),
578 agent_id: agent_id.to_string(),
579 agents,
580 messages,
581 tasks,
582 diary,
583 };
584
585 let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
586
587 if write {
588 let proofs_dir = std::path::Path::new(project_root)
589 .join(".lean-ctx")
590 .join("proofs");
591 if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
592 return format!("Error: create proofs dir: {e}");
593 }
594
595 let name = if let Some(f) = filename {
596 let p = std::path::Path::new(f);
597 if p.components().count() != 1 {
598 return "Error: filename must be a plain file name (no directories)"
599 .to_string();
600 }
601 f.to_string()
602 } else {
603 format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
604 };
605
606 let out_path = proofs_dir.join(name);
607 if let Err(e) = std::fs::write(&out_path, &json) {
608 return format!("Error: write snapshot: {e}");
609 }
610
611 let mut ledger = EvidenceLedgerV1::load();
612 if let Err(e) = ledger.record_artifact_file(
613 "proof:a2a-snapshot-v1",
614 &out_path,
615 chrono::Utc::now(),
616 ) {
617 return format!("Snapshot written but evidence ledger record failed: {e}");
618 }
619 if let Err(e) = ledger.save() {
620 return format!("Snapshot written but evidence ledger save failed: {e}");
621 }
622
623 return format!(
624 "A2A snapshot exported: {}\n agents: {}\n messages: {}\n tasks: {}",
625 out_path.display(),
626 payload.agents.len(),
627 payload.messages.len(),
628 payload.tasks.len()
629 );
630 }
631
632 match format.unwrap_or("json") {
633 "text" => format!(
634 "A2A snapshot (v1)\n agents: {}\n messages: {}\n tasks: {}",
635 payload.agents.len(),
636 payload.messages.len(),
637 payload.tasks.len()
638 ),
639 _ => json,
640 }
641 }
642
643 "diary" => {
644 let Some(agent_id) = current_agent_id else {
645 return "Error: agent must be registered first".to_string();
646 };
647 let Some(content) = message else {
648 return "Error: message is required for diary entry".to_string();
649 };
650 let entry_type = match category.unwrap_or("progress") {
651 "discovery" | "found" => DiaryEntryType::Discovery,
652 "decision" | "decided" => DiaryEntryType::Decision,
653 "blocker" | "blocked" => DiaryEntryType::Blocker,
654 "progress" | "done" => DiaryEntryType::Progress,
655 "insight" => DiaryEntryType::Insight,
656 other => {
657 return format!(
658 "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
659 );
660 }
661 };
662 let atype = agent_type.unwrap_or("unknown");
663 let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
664 let context_str = to_agent;
665 diary.add_entry(entry_type.clone(), content, context_str);
666 match diary.save() {
667 Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
668 Err(e) => format!("Diary entry added but save failed: {e}"),
669 }
670 }
671
672 "recall_diary" | "diary_recall" => {
673 let Some(agent_id) = current_agent_id else {
674 let diaries = AgentDiary::list_all();
675 if diaries.is_empty() {
676 return "No agent diaries found.".to_string();
677 }
678 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
679 for (id, count, updated) in &diaries {
680 let age = (chrono::Utc::now() - *updated).num_minutes();
681 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
682 }
683 return out;
684 };
685 match AgentDiary::load(agent_id) {
686 Some(diary) => diary.format_summary(),
687 None => format!("No diary found for agent '{agent_id}'."),
688 }
689 }
690
691 "diaries" => {
692 let diaries = AgentDiary::list_all();
693 if diaries.is_empty() {
694 return "No agent diaries found.".to_string();
695 }
696 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
697 for (id, count, updated) in &diaries {
698 let age = (chrono::Utc::now() - *updated).num_minutes();
699 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
700 }
701 out
702 }
703
704 "share_knowledge" => {
705 let cat = category.unwrap_or("general");
706 let Some(msg_text) = message else {
707 return "Error: message required (format: key1=value1;key2=value2)".to_string();
708 };
709 let facts: Vec<(String, String)> = msg_text
710 .split(';')
711 .filter_map(|kv| {
712 let (k, v) = kv.split_once('=')?;
713 Some((k.trim().to_string(), v.trim().to_string()))
714 })
715 .collect();
716 if facts.is_empty() {
717 return "Error: no valid key=value pairs found".to_string();
718 }
719 let from = current_agent_id.unwrap_or("anonymous");
720 match AgentRegistry::mutate_locked(|registry| {
721 registry.share_knowledge(from, cat, &facts);
722 }) {
723 Ok(_) => format!("Shared {} facts in category '{}'", facts.len(), cat),
724 Err(e) => format!("Share failed: {e}"),
725 }
726 }
727
728 "receive_knowledge" => {
729 let Some(agent_id) = current_agent_id else {
730 return "Error: agent must be registered first".to_string();
731 };
732 let facts = AgentRegistry::mutate_locked(|registry| {
733 registry.receive_shared_knowledge(agent_id)
734 })
735 .map(|(_, facts)| facts)
736 .unwrap_or_default();
737 if facts.is_empty() {
738 return "No new shared knowledge.".to_string();
739 }
740 let mut out = format!("Received {} facts:\n", facts.len());
741 for f in &facts {
742 let age = (chrono::Utc::now() - f.timestamp).num_minutes();
743 out.push_str(&format!(
744 " [{}] {}={} (from {}, {}m ago)\n",
745 f.category, f.key, f.value, f.from_agent, age
746 ));
747 }
748 out
749 }
750
751 "poll_events" => {
752 let Some(agent_id) = current_agent_id else {
753 return "Error: agent must be registered first".to_string();
754 };
755 let workspace_id = to_agent.unwrap_or(project_root);
756 let channel_id = category.unwrap_or("default");
757 let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
758 let limit: usize = _ttl_hours.unwrap_or(50) as usize;
759
760 let rt = crate::core::context_os::runtime();
761 let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
762
763 let filter = crate::core::context_os::TopicFilter {
764 agent_id: Some(agent_id.to_string()),
765 kinds: privacy.and_then(|s| {
766 let kinds: Vec<_> = s
767 .split(',')
768 .map(|k| crate::core::context_os::ContextEventKindV1::parse(k.trim()))
769 .collect();
770 if kinds.is_empty() { None } else { Some(kinds) }
771 }),
772 ..Default::default()
773 };
774
775 let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
776 if filtered.is_empty() {
777 return format!("No new events since id={since_id} for {agent_id}.");
778 }
779
780 let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
781 for ev in &filtered {
782 let actor = ev.actor.as_deref().unwrap_or("-");
783 out.push_str(&format!(
784 " #{} [{}] actor={} cl={} ({})\n",
785 ev.id,
786 ev.kind,
787 actor,
788 ev.consistency_level,
789 ev.timestamp.format("%H:%M:%S")
790 ));
791 }
792 if let Some(last) = filtered.last() {
793 out.push_str(&format!("cursor={}", last.id));
794 }
795 out
796 }
797
798 _ => format!(
799 "Unknown action: {action}. Use: register, list, post, read, status, info, handoff, sync, poll_events, diary, recall_diary, diaries, share_knowledge, receive_knowledge"
800 ),
801 }
802}