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