Skip to main content

lean_ctx/tools/
ctx_agent.rs

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            // Stigmergy (#540): mark the handed-off work as Done in the field
193            // so other agents see it arithmetically, without reading messages.
194            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        // Stigmergic claim (#540): atomically claim a target (file, task,
205        // deploy unit) in the shared scent field. Fails fast when another
206        // agent's claim is still active — prevents duplicate work for ~0 tokens.
207        "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 a stigmergic claim early (done or abandoned).
225        "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        // Sub-agent context contract (GL#450): deterministic briefing pack.
239        // `message` is the task; `priority` doubles as the token budget when
240        // numeric (default 2000). Same knowledge + task ⇒ byte-identical pack.
241        "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 synthesis (GL#450): distill a sub-agent's report into
261        // recallable parent knowledge. Expects contract-formatted lines
262        // ('category/key: value'); rejects are listed, never silently dropped.
263        "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            // Stigmergy (#540): arithmetic field view — claims, stuck markers,
364            // hot files across all agents, no scratchpad reads needed.
365            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}