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;
5use crate::core::ocla::capsule::global_capsule_store;
6use crate::core::ocla::registry::OclaRegistry;
7use crate::core::ocla::types::{AGENT_ENVELOPE_SCHEMA_VERSION, AgentEnvelope, OclaRequestContext};
8
9fn presence_ttl_hours() -> u64 {
10    crate::core::config::Config::load()
11        .agents
12        .presence_ttl_hours
13}
14
15fn scratchpad_default_ttl_hours() -> u64 {
16    crate::core::config::Config::load()
17        .agents
18        .scratchpad_default_ttl_hours
19}
20
21#[allow(clippy::too_many_arguments)]
22pub fn handle(
23    action: &str,
24    agent_type: Option<&str>,
25    role: Option<&str>,
26    project_root: &str,
27    current_agent_id: Option<&str>,
28    message: Option<&str>,
29    category: Option<&str>,
30    to_agent: Option<&str>,
31    status: Option<&str>,
32    privacy: Option<&str>,
33    priority: Option<&str>,
34    _ttl_hours: Option<u64>,
35    format: Option<&str>,
36    write: bool,
37    filename: Option<&str>,
38) -> String {
39    match action {
40        "register" => {
41            let atype = agent_type.unwrap_or("unknown");
42            match AgentRegistry::mutate_locked(|registry| {
43                registry.cleanup_stale(presence_ttl_hours());
44                registry.register(atype, role, project_root)
45            }) {
46                Ok((_, agent_id)) => format!(
47                    "Agent registered: {agent_id} (type: {atype}, role: {})",
48                    role.unwrap_or("none")
49                ),
50                Err(e) => format!("Registration failed: {e}"),
51            }
52        }
53
54        "list" => {
55            let registry = match AgentRegistry::mutate_locked(|registry| {
56                registry.cleanup_stale(presence_ttl_hours());
57            }) {
58                Ok((registry, ())) => registry,
59                Err(e) => {
60                    tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
61                    AgentRegistry::load_or_create()
62                }
63            };
64
65            let agents = registry.list_active(Some(project_root));
66            if agents.is_empty() {
67                return "No active agents for this project.".to_string();
68            }
69
70            let mut out = format!("Active agents ({}):\n", agents.len());
71            for a in agents {
72                let role_str = a.role.as_deref().unwrap_or("-");
73                let status_msg = a
74                    .status_message
75                    .as_deref()
76                    .map(|m| format!(" — {m}"))
77                    .unwrap_or_default();
78                let age = (chrono::Utc::now() - a.last_active).num_minutes();
79                out.push_str(&format!(
80                    "  {} [{}] role={} status={}{} (last active: {}m ago, pid: {})\n",
81                    a.agent_id, a.agent_type, role_str, a.status, status_msg, age, a.pid
82                ));
83            }
84            out
85        }
86
87        "post" => {
88            let Some(msg) = message else {
89                return "Error: message is required for post".to_string();
90            };
91            let cat = category.unwrap_or("status");
92            let from = current_agent_id.unwrap_or("anonymous");
93            let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
94            let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
95            let ttl_hours = _ttl_hours.or(Some(scratchpad_default_ttl_hours()));
96            if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
97                return "Error: private messages require to_agent".to_string();
98            }
99            match OclaRegistry::global().agent_gateway.route_message(
100                from,
101                to_agent,
102                cat,
103                msg,
104                msg_privacy,
105                msg_priority,
106                ttl_hours,
107            ) {
108                Ok(msg_id) => {
109                    let target = to_agent.unwrap_or("all agents (broadcast)");
110                    format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
111                }
112                Err(e) => format!("Post failed: {e}"),
113            }
114        }
115
116        "read" => {
117            let Some(agent_id) = current_agent_id else {
118                return "Error: agent must be registered first (use action=register)".to_string();
119            };
120            let messages = match AgentRegistry::mutate_locked(|registry| {
121                registry
122                    .read_unread(agent_id)
123                    .into_iter()
124                    .cloned()
125                    .collect::<Vec<_>>()
126            }) {
127                Ok((_, messages)) => messages,
128                Err(e) => {
129                    tracing::warn!(
130                        "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
131                    );
132                    return "Error: failed to read messages".to_string();
133                }
134            };
135
136            if messages.is_empty() {
137                return "No new messages.".to_string();
138            }
139
140            let mut out = format!("New messages ({}):\n", messages.len());
141            for m in &messages {
142                let age = (chrono::Utc::now() - m.timestamp).num_minutes();
143                out.push_str(&format!(
144                    "  [{}] from {} ({}m ago): {}\n",
145                    m.category, m.from_agent, age, m.message
146                ));
147            }
148            out
149        }
150
151        "status" => {
152            let Some(agent_id) = current_agent_id else {
153                return "Error: agent must be registered first".to_string();
154            };
155            let new_status = match status {
156                Some("active") => AgentStatus::Active,
157                Some("idle") => AgentStatus::Idle,
158                Some("finished") => AgentStatus::Finished,
159                Some(other) => {
160                    return format!("Unknown status: {other}. Use: active, idle, finished");
161                }
162                None => return "Error: status value is required".to_string(),
163            };
164            let status_msg = message;
165
166            match AgentRegistry::mutate_locked(|registry| {
167                registry.set_status(agent_id, new_status.clone(), status_msg);
168            }) {
169                Ok(_) => format!(
170                    "Status updated: {} → {}{}",
171                    agent_id,
172                    new_status,
173                    status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
174                ),
175                Err(e) => format!("Status set but save failed: {e}"),
176            }
177        }
178
179        "info" => {
180            let registry = AgentRegistry::load_or_create();
181            let total = registry.agents.len();
182            let active = registry
183                .agents
184                .iter()
185                .filter(|a| a.status == AgentStatus::Active)
186                .count();
187            let messages = registry.scratchpad.len();
188            format!(
189                "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
190                registry.updated_at.format("%Y-%m-%d %H:%M UTC")
191            )
192        }
193
194        "handoff" => {
195            let Some(from) = current_agent_id else {
196                return "Error: agent must be registered first".to_string();
197            };
198            let Some(target) = to_agent else {
199                return "Error: to_agent is required for handoff".to_string();
200            };
201            let summary = message.unwrap_or("(no summary provided)");
202            let scratchpad_ttl = scratchpad_default_ttl_hours();
203
204            let _ = AgentRegistry::mutate_locked(|registry| {
205                registry.post_message_full(
206                    from,
207                    Some(target),
208                    "handoff",
209                    &format!("HANDOFF from {from}: {summary}"),
210                    PrivacyLevel::Team,
211                    MessagePriority::Normal,
212                    Some(scratchpad_ttl),
213                );
214                registry.set_status(from, AgentStatus::Finished, Some("handed off"));
215            });
216
217            // Route through OCLA AgentGateway for cross-process visibility.
218            let handoff_message = format!("HANDOFF: {summary}");
219            let capsule_ref = global_capsule_store().register(handoff_message.as_bytes());
220            let mut envelope = AgentEnvelope {
221                schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
222                relay_id: String::new(),
223                context: OclaRequestContext::new(
224                    format!("handoff:{capsule_ref}"),
225                    project_root.to_string(),
226                    from.to_string(),
227                    capsule_ref.clone(),
228                    None,
229                    Some(format!("handoff:{from}:{target}")),
230                ),
231                from_agent_id: from.to_string(),
232                to_agent_id: target.to_string(),
233                capsule_ref,
234                budget_tokens: handoff_message.len().max(1) as u64,
235            };
236            if let Err(error) = envelope.assign_relay_id() {
237                tracing::debug!("OCLA relay setup failed (non-fatal): {error}");
238            } else if let Err(error) = OclaRegistry::global().agent_gateway.relay_agent(envelope) {
239                tracing::debug!("OCLA relay failed (non-fatal): {error}");
240            }
241
242            // Stigmergy (#540): mark the handed-off work as Done in the field
243            // so other agents see it arithmetically, without reading messages.
244            crate::core::scent_field::deposit(
245                from,
246                crate::core::scent_field::ScentKind::Done,
247                summary,
248                1.0,
249            );
250
251            format!("Handoff complete: {from} → {target}\nSummary: {summary}")
252        }
253
254        // Stigmergic claim (#540): atomically claim a target (file, task,
255        // deploy unit) in the shared scent field. Fails fast when another
256        // agent's claim is still active — prevents duplicate work for ~0 tokens.
257        "claim" => {
258            let Some(target) = message else {
259                return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
260            };
261            let agent = current_agent_id.map_or_else(
262                || crate::core::scent_field::scent_agent_id().to_string(),
263                str::to_string,
264            );
265            let normalized = crate::core::pathutil::normalize_tool_path(target);
266            match crate::core::scent_field::claim(&agent, &normalized) {
267                Ok(()) => {
268                    format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
269                }
270                Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
271            }
272        }
273
274        // Release a stigmergic claim early (done or abandoned).
275        "release" => {
276            let Some(target) = message else {
277                return "Error: message (the claim target) is required for release".to_string();
278            };
279            let agent = current_agent_id.map_or_else(
280                || crate::core::scent_field::scent_agent_id().to_string(),
281                str::to_string,
282            );
283            let normalized = crate::core::pathutil::normalize_tool_path(target);
284            crate::core::scent_field::release(&agent, &normalized);
285            format!("Released: {normalized}")
286        }
287
288        // Sub-agent context contract (GL#450): deterministic briefing pack.
289        // `message` is the task; `priority` doubles as the token budget when
290        // numeric (default 2000). Same knowledge + task ⇒ byte-identical pack.
291        "brief" => {
292            let Some(task) = message else {
293                return "Error: message (the sub-agent task) is required for brief".to_string();
294            };
295            let budget = priority
296                .and_then(|p| p.parse::<usize>().ok())
297                .unwrap_or(2000);
298            let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root)
299            else {
300                return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
301            };
302            let pack =
303                crate::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
304            match crate::core::subagent_contract::serialize_pack(&pack) {
305                Ok(json) => json,
306                Err(e) => format!("Error: {e}"),
307            }
308        }
309
310        // Return synthesis (GL#450): distill a sub-agent's report into
311        // recallable parent knowledge. Expects contract-formatted lines
312        // ('category/key: value'); rejects are listed, never silently dropped.
313        "return" => {
314            let Some(report) = message else {
315                return "Error: message (the sub-agent report) is required for return".to_string();
316            };
317            let (facts, rejected) = crate::core::subagent_contract::parse_return_lines(report);
318            if facts.is_empty() {
319                return format!(
320                    "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
321                    rejected.len()
322                );
323            }
324
325            let session_label = current_agent_id.unwrap_or("subagent");
326            let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
327                Ok(p) => p,
328                Err(e) => return e,
329            };
330            let count = facts.len();
331            let res = crate::core::knowledge::ProjectKnowledge::mutate_locked(
332                project_root,
333                |knowledge| {
334                    for f in &facts {
335                        knowledge.remember(
336                            &f.category,
337                            &f.key,
338                            &f.value,
339                            session_label,
340                            0.8,
341                            &policy,
342                        );
343                    }
344                },
345            );
346            match res {
347                Ok(_) => {
348                    let mut out = format!(
349                        "Return synthesis: {count} fact(s) distilled into parent knowledge"
350                    );
351                    if !rejected.is_empty() {
352                        out.push_str(&format!(
353                            "\n{} line(s) rejected (not 'category/key: value'):",
354                            rejected.len()
355                        ));
356                        for r in rejected.iter().take(5) {
357                            out.push_str(&format!("\n  ✗ {r}"));
358                        }
359                    }
360                    out
361                }
362                Err(e) => format!("Error: knowledge store update failed: {e}"),
363            }
364        }
365
366        "sync" => {
367            let registry = AgentRegistry::load_or_create();
368            let pending_count = current_agent_id.map_or(0, |id| {
369                registry
370                    .scratchpad
371                    .iter()
372                    .filter(|e| {
373                        !e.read_by.contains(&id.to_string())
374                            && e.from_agent != id
375                            && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
376                    })
377                    .count()
378            });
379            let agents: Vec<&crate::core::agents::AgentEntry> = registry
380                .agents
381                .iter()
382                .filter(|a| a.status != AgentStatus::Finished && a.project_root == project_root)
383                .collect();
384
385            if agents.is_empty() {
386                return "No active agents to sync with.".to_string();
387            }
388
389            let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
390                .unwrap_or_default()
391                .join("agents")
392                .join("shared");
393
394            let shared_count = if shared_dir.exists() {
395                std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
396            } else {
397                0
398            };
399
400            let mut out = "Multi-Agent Sync Status:\n".to_string();
401            out.push_str(&format!("  Active agents: {}\n", agents.len()));
402            for a in &agents {
403                let role = a.role.as_deref().unwrap_or("-");
404                let age = (chrono::Utc::now() - a.last_active).num_minutes();
405                out.push_str(&format!(
406                    "    {} [{}] role={} ({}m ago)\n",
407                    a.agent_id, a.agent_type, role, age
408                ));
409            }
410            out.push_str(&format!("  Pending messages: {pending_count}\n"));
411            out.push_str(&format!("  Shared contexts: {shared_count}\n"));
412
413            // Stigmergy (#540): arithmetic field view — claims, stuck markers,
414            // hot files across all agents, no scratchpad reads needed.
415            let scents = crate::core::scent_field::sync_block();
416            if !scents.is_empty() {
417                out.push_str(&scents);
418            }
419            out
420        }
421
422        "export" => {
423            let Some(agent_id) = current_agent_id else {
424                return "Error: agent must be registered first (use action=register)".to_string();
425            };
426
427            fn privacy_label(p: &PrivacyLevel) -> &'static str {
428                match p {
429                    PrivacyLevel::Public => "public",
430                    PrivacyLevel::Team => "team",
431                    PrivacyLevel::Private => "private",
432                }
433            }
434
435            fn priority_label(p: &MessagePriority) -> &'static str {
436                match p {
437                    MessagePriority::Low => "low",
438                    MessagePriority::Normal => "normal",
439                    MessagePriority::High => "high",
440                    MessagePriority::Critical => "critical",
441                }
442            }
443
444            fn maybe_redact(s: &str, should_redact: bool) -> String {
445                if should_redact {
446                    crate::core::redaction::redact_text(s)
447                } else {
448                    s.to_string()
449                }
450            }
451
452            #[derive(serde::Serialize)]
453            struct ExportAgentV1 {
454                agent_id: String,
455                agent_type: String,
456                role: Option<String>,
457                status: String,
458                status_message: Option<String>,
459                started_at: String,
460                last_active: String,
461                pid: u32,
462            }
463
464            #[derive(serde::Serialize)]
465            struct ExportMessageV1 {
466                id: String,
467                from_agent: String,
468                to_agent: Option<String>,
469                category: String,
470                privacy: String,
471                priority: String,
472                message: String,
473                metadata: std::collections::BTreeMap<String, String>,
474                timestamp: String,
475                expires_at: Option<String>,
476                read_by_count: usize,
477            }
478
479            #[derive(serde::Serialize)]
480            struct ExportTaskV1 {
481                id: String,
482                from_agent: String,
483                to_agent: String,
484                state: String,
485                description: String,
486                created_at: String,
487                updated_at: String,
488                messages: usize,
489                artifacts: usize,
490                transitions: usize,
491            }
492
493            #[derive(serde::Serialize)]
494            struct ExportDiaryEntryV1 {
495                entry_type: String,
496                content: String,
497                context: Option<String>,
498                timestamp: String,
499            }
500
501            #[derive(serde::Serialize)]
502            struct ExportDiaryV1 {
503                agent_id: String,
504                agent_type: String,
505                project_root: String,
506                updated_at: String,
507                entries: Vec<ExportDiaryEntryV1>,
508            }
509
510            #[derive(serde::Serialize)]
511            struct A2ASnapshotV1 {
512                schema_version: u32,
513                created_at: String,
514                project_root: String,
515                agent_id: String,
516                agents: Vec<ExportAgentV1>,
517                messages: Vec<ExportMessageV1>,
518                tasks: Vec<ExportTaskV1>,
519                diary: Option<ExportDiaryV1>,
520            }
521
522            let privacy_mode = privacy.unwrap_or("redacted");
523            let allow_full = privacy_mode == "full"
524                && !crate::core::redaction::redaction_enabled_for_active_role();
525            let should_redact = !allow_full;
526
527            let now = chrono::Utc::now();
528            let mut registry = AgentRegistry::load_or_create();
529            registry.cleanup_stale(presence_ttl_hours());
530
531            let mut agents: Vec<ExportAgentV1> = registry
532                .list_active(Some(project_root))
533                .into_iter()
534                .map(|a| ExportAgentV1 {
535                    agent_id: a.agent_id.clone(),
536                    agent_type: a.agent_type.clone(),
537                    role: a.role.clone(),
538                    status: a.status.to_string(),
539                    status_message: a.status_message.clone(),
540                    started_at: a.started_at.to_rfc3339(),
541                    last_active: a.last_active.to_rfc3339(),
542                    pid: a.pid,
543                })
544                .collect();
545            agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
546
547            let mut messages: Vec<ExportMessageV1> = registry
548                .scratchpad
549                .iter()
550                .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
551                .take(200)
552                .map(|m| ExportMessageV1 {
553                    id: m.id.clone(),
554                    from_agent: m.from_agent.clone(),
555                    to_agent: m.to_agent.clone(),
556                    category: m.category.clone(),
557                    privacy: privacy_label(&m.privacy).to_string(),
558                    priority: priority_label(&m.priority).to_string(),
559                    message: maybe_redact(&m.message, should_redact),
560                    metadata: m
561                        .metadata
562                        .iter()
563                        .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
564                        .collect(),
565                    timestamp: m.timestamp.to_rfc3339(),
566                    expires_at: m.expires_at.map(|t| t.to_rfc3339()),
567                    read_by_count: m.read_by.len(),
568                })
569                .collect();
570            messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
571
572            let mut task_store = TaskStore::load();
573            task_store.cleanup_old(72);
574            let mut tasks: Vec<ExportTaskV1> = task_store
575                .tasks_for_agent(agent_id)
576                .into_iter()
577                .take(200)
578                .map(|t| ExportTaskV1 {
579                    id: t.id.clone(),
580                    from_agent: t.from_agent.clone(),
581                    to_agent: t.to_agent.clone(),
582                    state: t.state.to_string(),
583                    description: maybe_redact(&t.description, should_redact),
584                    created_at: t.created_at.to_rfc3339(),
585                    updated_at: t.updated_at.to_rfc3339(),
586                    messages: t.messages.len(),
587                    artifacts: t.artifacts.len(),
588                    transitions: t.history.len(),
589                })
590                .collect();
591            tasks.sort_by(|a, b| {
592                b.updated_at
593                    .cmp(&a.updated_at)
594                    .then_with(|| a.id.cmp(&b.id))
595            });
596
597            let diary = AgentDiary::load(agent_id).map(|d| ExportDiaryV1 {
598                agent_id: d.agent_id,
599                agent_type: d.agent_type,
600                project_root: d.project_root,
601                updated_at: d.updated_at.to_rfc3339(),
602                entries: d
603                    .entries
604                    .iter()
605                    .rev()
606                    .take(25)
607                    .rev()
608                    .map(|e| ExportDiaryEntryV1 {
609                        entry_type: e.entry_type.to_string(),
610                        content: maybe_redact(&e.content, should_redact),
611                        context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
612                        timestamp: e.timestamp.to_rfc3339(),
613                    })
614                    .collect(),
615            });
616
617            let payload = A2ASnapshotV1 {
618                schema_version: crate::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
619                created_at: now.to_rfc3339(),
620                project_root: project_root.to_string(),
621                agent_id: agent_id.to_string(),
622                agents,
623                messages,
624                tasks,
625                diary,
626            };
627
628            let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
629
630            if write {
631                let proofs_dir = std::path::Path::new(project_root)
632                    .join(".lean-ctx")
633                    .join("proofs");
634                if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
635                    return format!("Error: create proofs dir: {e}");
636                }
637
638                let name = if let Some(f) = filename {
639                    let p = std::path::Path::new(f);
640                    if p.components().count() != 1 {
641                        return "Error: filename must be a plain file name (no directories)"
642                            .to_string();
643                    }
644                    f.to_string()
645                } else {
646                    format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
647                };
648
649                let out_path = proofs_dir.join(name);
650                if let Err(e) = std::fs::write(&out_path, &json) {
651                    return format!("Error: write snapshot: {e}");
652                }
653
654                let mut ledger = EvidenceLedgerV1::load();
655                if let Err(e) = ledger.record_artifact_file(
656                    "proof:a2a-snapshot-v1",
657                    &out_path,
658                    chrono::Utc::now(),
659                ) {
660                    return format!("Snapshot written but evidence ledger record failed: {e}");
661                }
662                if let Err(e) = ledger.save() {
663                    return format!("Snapshot written but evidence ledger save failed: {e}");
664                }
665
666                return format!(
667                    "A2A snapshot exported: {}\n  agents: {}\n  messages: {}\n  tasks: {}",
668                    out_path.display(),
669                    payload.agents.len(),
670                    payload.messages.len(),
671                    payload.tasks.len()
672                );
673            }
674
675            match format.unwrap_or("json") {
676                "text" => format!(
677                    "A2A snapshot (v1)\n  agents: {}\n  messages: {}\n  tasks: {}",
678                    payload.agents.len(),
679                    payload.messages.len(),
680                    payload.tasks.len()
681                ),
682                _ => json,
683            }
684        }
685
686        "diary" => {
687            let Some(agent_id) = current_agent_id else {
688                return "Error: agent must be registered first".to_string();
689            };
690            let Some(content) = message else {
691                return "Error: message is required for diary entry".to_string();
692            };
693            let entry_type = match category.unwrap_or("progress") {
694                "discovery" | "found" => DiaryEntryType::Discovery,
695                "decision" | "decided" => DiaryEntryType::Decision,
696                "blocker" | "blocked" => DiaryEntryType::Blocker,
697                "progress" | "done" => DiaryEntryType::Progress,
698                "insight" => DiaryEntryType::Insight,
699                other => {
700                    return format!(
701                        "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
702                    );
703                }
704            };
705            let atype = agent_type.unwrap_or("unknown");
706            let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
707            let context_str = to_agent;
708            diary.add_entry(entry_type.clone(), content, context_str);
709            match diary.save() {
710                Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
711                Err(e) => format!("Diary entry added but save failed: {e}"),
712            }
713        }
714
715        "recall_diary" | "diary_recall" => {
716            let Some(agent_id) = current_agent_id else {
717                let diaries = AgentDiary::list_all();
718                if diaries.is_empty() {
719                    return "No agent diaries found.".to_string();
720                }
721                let mut out = format!("Agent Diaries ({}):\n", diaries.len());
722                for (id, count, updated) in &diaries {
723                    let age = (chrono::Utc::now() - *updated).num_minutes();
724                    out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
725                }
726                return out;
727            };
728            match AgentDiary::load(agent_id) {
729                Some(diary) => diary.format_summary(),
730                None => format!("No diary found for agent '{agent_id}'."),
731            }
732        }
733
734        "diaries" => {
735            let diaries = AgentDiary::list_all();
736            if diaries.is_empty() {
737                return "No agent diaries found.".to_string();
738            }
739            let mut out = format!("Agent Diaries ({}):\n", diaries.len());
740            for (id, count, updated) in &diaries {
741                let age = (chrono::Utc::now() - *updated).num_minutes();
742                out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
743            }
744            out
745        }
746
747        "share_knowledge" => {
748            let cat = category.unwrap_or("general");
749            let Some(msg_text) = message else {
750                return "Error: message required (format: key1=value1;key2=value2)".to_string();
751            };
752            let facts: Vec<(String, String)> = msg_text
753                .split(';')
754                .filter_map(|kv| {
755                    let (k, v) = kv.split_once('=')?;
756                    Some((k.trim().to_string(), v.trim().to_string()))
757                })
758                .collect();
759            if facts.is_empty() {
760                return "Error: no valid key=value pairs found".to_string();
761            }
762            let from = current_agent_id.unwrap_or("anonymous");
763            match AgentRegistry::mutate_locked(|registry| {
764                registry.share_knowledge(from, cat, &facts);
765            }) {
766                Ok(_) => format!("Shared {} facts in category '{}'", facts.len(), cat),
767                Err(e) => format!("Share failed: {e}"),
768            }
769        }
770
771        "receive_knowledge" => {
772            let Some(agent_id) = current_agent_id else {
773                return "Error: agent must be registered first".to_string();
774            };
775            let facts = AgentRegistry::mutate_locked(|registry| {
776                registry.receive_shared_knowledge(agent_id)
777            })
778            .map(|(_, facts)| facts)
779            .unwrap_or_default();
780            if facts.is_empty() {
781                return "No new shared knowledge.".to_string();
782            }
783            let mut out = format!("Received {} facts:\n", facts.len());
784            for f in &facts {
785                let age = (chrono::Utc::now() - f.timestamp).num_minutes();
786                out.push_str(&format!(
787                    "  [{}] {}={} (from {}, {}m ago)\n",
788                    f.category, f.key, f.value, f.from_agent, age
789                ));
790            }
791            out
792        }
793
794        "poll_events" => {
795            let Some(agent_id) = current_agent_id else {
796                return "Error: agent must be registered first".to_string();
797            };
798            let workspace_id = to_agent.unwrap_or(project_root);
799            let channel_id = category.unwrap_or("default");
800            let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
801            let limit: usize = _ttl_hours.unwrap_or(50) as usize;
802
803            let rt = crate::core::context_os::runtime();
804            let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
805
806            let filter = crate::core::context_os::TopicFilter {
807                agent_id: Some(agent_id.to_string()),
808                kinds: privacy.and_then(|s| {
809                    let kinds: Vec<_> = s
810                        .split(',')
811                        .map(|k| crate::core::context_os::ContextEventKindV1::parse(k.trim()))
812                        .collect();
813                    if kinds.is_empty() { None } else { Some(kinds) }
814                }),
815                ..Default::default()
816            };
817
818            let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
819            if filtered.is_empty() {
820                return format!("No new events since id={since_id} for {agent_id}.");
821            }
822
823            let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
824            for ev in &filtered {
825                let actor = ev.actor.as_deref().unwrap_or("-");
826                out.push_str(&format!(
827                    "  #{} [{}] actor={} cl={} ({})\n",
828                    ev.id,
829                    ev.kind,
830                    actor,
831                    ev.consistency_level,
832                    ev.timestamp.format("%H:%M:%S")
833                ));
834            }
835            if let Some(last) = filtered.last() {
836                out.push_str(&format!("cursor={}", last.id));
837            }
838            out
839        }
840
841        // P11: Acquire an opaque path/symbol lease for mutation planning.
842        "lease_acquire" => {
843            let Some(agent_id) = current_agent_id else {
844                return "Error: agent must be registered first".to_string();
845            };
846            let Some(target) = message else {
847                return "Error: message (path or symbol:<name>) is required".to_string();
848            };
849            let (resource_kind, resource_ref) = lease_resource(target);
850            let duration_ms = match _ttl_hours.unwrap_or(0) {
851                0 => 10 * 60 * 1_000,
852                1 => 60 * 60 * 1_000,
853                _ => return "Error: ttl_hours for lease must be 0 (10min) or 1 (1h)".to_string(),
854            };
855            let request = crate::core::agent_lease::AgentLeaseRequestV1 {
856                schema_version: crate::core::agent_lease::AGENT_LEASE_SCHEMA_VERSION,
857                lease_request_ref: format!(
858                    "lease-request:{}",
859                    blake3::hash(format!("{agent_id}:{resource_ref}").as_bytes()).to_hex()
860                ),
861                resource_kind,
862                resource_ref: resource_ref.clone(),
863                owner_agent_id: agent_id.to_string(),
864                duration_ms,
865            };
866            match crate::core::agent_lease::acquire_local(request) {
867                Ok(crate::core::agent_lease::AgentLeaseAcquireV1::Granted(lease)) => format!(
868                    "Lease granted: {} resource={} owner={} expires_at_epoch_ms={}",
869                    lease.lease_ref, resource_ref, agent_id, lease.expires_at_epoch_ms
870                ),
871                Ok(crate::core::agent_lease::AgentLeaseAcquireV1::HeldBy {
872                    owner_agent_id,
873                    lease_ref,
874                    expires_at_epoch_ms,
875                }) => format!(
876                    "Lease HELD: resource={resource_ref} owner={owner_agent_id} lease_ref={lease_ref} expires={expires_at_epoch_ms}"
877                ),
878                Err(e) => format!("Lease rejected: {e}"),
879            }
880        }
881
882        // P11: Release a previously acquired lease.
883        "lease_release" => {
884            let Some(agent_id) = current_agent_id else {
885                return "Error: agent must be registered first".to_string();
886            };
887            let Some(target) = message else {
888                return "Error: message (same path or symbol:<name>) is required".to_string();
889            };
890            let Some(lease_ref) = category else {
891                return "Error: category (lease_ref from lease_acquire) is required".to_string();
892            };
893            let (resource_kind, resource_ref) = lease_resource(target);
894            match crate::core::agent_lease::release_local(
895                resource_kind,
896                &resource_ref,
897                agent_id,
898                lease_ref,
899            ) {
900                Ok(true) => format!("Lease released: {lease_ref} resource={resource_ref}"),
901                Ok(false) => format!("No active lease found for resource={resource_ref}"),
902                Err(e) => format!("Release failed: {e}"),
903            }
904        }
905
906        _ => format!(
907            "Unknown action: {action}. Use: register, list, post, read, status, info, handoff, sync, poll_events, diary, recall_diary, diaries, share_knowledge, receive_knowledge, lease_acquire, lease_release"
908        ),
909    }
910}
911
912/// Convert a raw path/symbol target into an opaque lease resource.
913fn lease_resource(target: &str) -> (crate::core::agent_lease::AgentLeaseResourceKindV1, String) {
914    if let Some(symbol) = target.strip_prefix("symbol:") {
915        (
916            crate::core::agent_lease::AgentLeaseResourceKindV1::Symbol,
917            format!("symbolref:{}", blake3::hash(symbol.as_bytes()).to_hex()),
918        )
919    } else {
920        let normalized = crate::core::pathutil::normalize_tool_path(target);
921        (
922            crate::core::agent_lease::AgentLeaseResourceKindV1::Path,
923            format!("pathref:{}", blake3::hash(normalized.as_bytes()).to_hex()),
924        )
925    }
926}