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