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