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