Skip to main content

edgecrab_plugins/
hermes.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use serde_json::{Value, json};
6
7use crate::discovery::DiscoveredPlugin;
8use crate::error::PluginError;
9use crate::manifest::{
10    PluginCapabilities, PluginExecConfig, PluginManifest, PluginMetadata, PluginRestartPolicy,
11    PluginToolDefinition,
12};
13use crate::tool_server::client::ToolServerClient;
14use crate::types::{PluginKind, PluginStatus};
15
16const HERMES_HOST_SCRIPT: &str = r#"
17import abc
18import argparse
19import importlib.util
20import importlib.metadata
21import io
22import json
23import os
24import sys
25import traceback
26import types
27from contextlib import redirect_stderr, redirect_stdout
28from pathlib import Path
29
30source_kind = sys.argv[1]
31source_value = sys.argv[2]
32plugin_name = sys.argv[3]
33plugin_dir = Path(source_value).resolve() if source_kind == "directory" else None
34tools = {}
35hooks = {}
36cli_commands = {}
37memory_provider = None
38memory_provider_tool_names = set()
39memory_provider_initialized = False
40_next_request_id = 1000
41
42
43def _ensure_package(name, paths=None):
44    module = sys.modules.get(name)
45    if module is None:
46        module = types.ModuleType(name)
47        module.__package__ = name
48        module.__path__ = []
49        sys.modules[name] = module
50    if paths:
51        existing = list(getattr(module, "__path__", []))
52        for path in paths:
53            if path not in existing:
54                existing.append(path)
55        module.__path__ = existing
56    return module
57
58
59def _find_plugins_root():
60    if plugin_dir is None:
61        return None
62    current = plugin_dir
63    while True:
64        if current.name == "plugins":
65            return current
66        parent = current.parent
67        if parent == current:
68            return None
69        current = parent
70
71
72def _canonical_module_name():
73    root = _find_plugins_root()
74    if root is None:
75        return None
76    try:
77        rel = plugin_dir.relative_to(root)
78    except ValueError:
79        return None
80    if not rel.parts:
81        return None
82    return ".".join(("plugins",) + rel.parts)
83
84
85def _display_hermes_home():
86    home = os.environ.get("HERMES_HOME", "")
87    if not home:
88        return "~/.edgecrab"
89    real_home = os.path.expanduser("~")
90    if home.startswith(real_home):
91        return "~" + home[len(real_home):]
92    return home
93
94
95def _install_runtime_shims():
96    _ensure_package("agent")
97    agent_memory_provider = types.ModuleType("agent.memory_provider")
98
99    class MemoryProvider(metaclass=abc.ABCMeta):
100        @property
101        def name(self):
102            return self.__class__.__name__.lower()
103
104        def is_available(self):
105            return True
106
107        def initialize(self, session_id, **kwargs):
108            return None
109
110        def get_tool_schemas(self):
111            return []
112
113        def handle_tool_call(self, tool_name, args, **kwargs):
114            raise NotImplementedError
115
116        def prefetch(self, query, *, session_id=""):
117            return ""
118
119        def on_session_end(self, messages):
120            return None
121
122        def shutdown(self):
123            return None
124
125    agent_memory_provider.MemoryProvider = MemoryProvider
126    sys.modules["agent.memory_provider"] = agent_memory_provider
127
128    tools_pkg = _ensure_package("tools")
129    tools_registry = types.ModuleType("tools.registry")
130
131    def tool_error(message):
132        return json.dumps({"error": str(message)})
133
134    tools_registry.tool_error = tool_error
135    sys.modules["tools.registry"] = tools_registry
136    tools_pkg.registry = tools_registry
137
138    hermes_constants = types.ModuleType("hermes_constants")
139
140    def get_hermes_home():
141        default_home = str(plugin_dir.parent) if plugin_dir is not None else "."
142        return Path(os.environ.get("HERMES_HOME", default_home)).expanduser()
143
144    def display_hermes_home():
145        return _display_hermes_home()
146
147    hermes_constants.get_hermes_home = get_hermes_home
148    hermes_constants.display_hermes_home = display_hermes_home
149    sys.modules["hermes_constants"] = hermes_constants
150
151    canonical = _canonical_module_name()
152    root = _find_plugins_root()
153    if root is not None:
154        repo_root = str(root.parent)
155        if repo_root not in sys.path:
156            sys.path.insert(0, repo_root)
157        _ensure_package("plugins", [str(root)])
158        if plugin_dir.parent != root:
159            _ensure_package("plugins." + plugin_dir.parent.name, [str(plugin_dir.parent)])
160    return canonical
161
162
163def _read_message():
164    line = sys.stdin.readline()
165    if not line:
166        raise EOFError("stdin closed")
167    return json.loads(line)
168
169
170def _write_message(message):
171    sys.stdout.write(json.dumps(message) + "\n")
172    sys.stdout.flush()
173
174
175def _host_call(method, params):
176    global _next_request_id
177    _next_request_id += 1
178    request_id = _next_request_id
179    _write_message({
180        "jsonrpc": "2.0",
181        "id": request_id,
182        "method": method,
183        "params": params,
184    })
185    while True:
186        response = _read_message()
187        if response.get("id") != request_id:
188            continue
189        if "error" in response:
190            return {"ok": False, "error": response["error"]}
191        return response.get("result") or {}
192
193
194class PluginContext:
195    def register_tool(self, name, *args, **kwargs):
196        toolset = kwargs.get("toolset")
197        schema = kwargs.get("schema")
198        handler = kwargs.get("handler")
199        check_fn = kwargs.get("check_fn")
200        requires_env = kwargs.get("requires_env")
201        description = kwargs.get("description", "")
202
203        # Hermes docs show two accepted forms:
204        #   ctx.register_tool("name", schema, handler)
205        #   ctx.register_tool(name="x", toolset="y", schema=..., handler=...)
206        if args:
207            if len(args) >= 1 and schema is None and isinstance(args[0], dict):
208                schema = args[0]
209            elif len(args) >= 1 and toolset is None:
210                toolset = args[0]
211            if len(args) >= 2 and handler is None and callable(args[1]):
212                handler = args[1]
213            elif len(args) >= 2 and schema is None and isinstance(args[1], dict):
214                schema = args[1]
215            if len(args) >= 3 and handler is None and callable(args[2]):
216                handler = args[2]
217
218        desc = description or (schema or {}).get("description") or f"Hermes plugin tool: {name}"
219        tools[name] = {
220            "name": name,
221            "toolset": toolset or plugin_name,
222            "schema": schema or {
223                "name": name,
224                "description": desc,
225                "parameters": {"type": "object", "additionalProperties": True},
226            },
227            "description": desc,
228            "handler": handler,
229            "check_fn": check_fn,
230            "requires_env": requires_env or [],
231        }
232
233    def register_hook(self, hook_name, callback):
234        hooks.setdefault(hook_name, []).append(callback)
235
236    def register_memory_provider(self, provider):
237        global memory_provider, memory_provider_tool_names
238        memory_provider = provider
239        try:
240            schemas = provider.get_tool_schemas() or []
241        except Exception:
242            schemas = []
243        memory_provider_tool_names = {
244            schema.get("name")
245            for schema in schemas
246            if isinstance(schema, dict) and schema.get("name")
247        }
248
249    def inject_message(self, content, role="user"):
250        result = _host_call("host:inject_message", {"content": content, "role": role})
251        return bool(result.get("ok"))
252
253    def register_cli_command(self, *args, **kwargs):
254        name = kwargs.get("name")
255        help_text = kwargs.get("help")
256        setup_fn = kwargs.get("setup_fn")
257        handler_fn = kwargs.get("handler_fn")
258        description = kwargs.get("description", "")
259
260        if args:
261            if len(args) >= 1 and name is None:
262                name = args[0]
263            if len(args) >= 2 and help_text is None:
264                help_text = args[1]
265            if len(args) >= 3 and setup_fn is None and callable(args[2]):
266                setup_fn = args[2]
267            if len(args) >= 4 and handler_fn is None and callable(args[3]):
268                handler_fn = args[3]
269
270        if not name or not callable(setup_fn):
271            return None
272
273        cli_commands[name] = {
274            "name": name,
275            "help": help_text or "",
276            "description": description or "",
277            "setup_fn": setup_fn,
278            "handler_fn": handler_fn,
279        }
280        return None
281
282
283def _entry_points_for_group(group_name):
284    eps = importlib.metadata.entry_points()
285    if hasattr(eps, "select"):
286        return list(eps.select(group=group_name))
287    if isinstance(eps, dict):
288        return list(eps.get(group_name, []))
289    return [ep for ep in eps if getattr(ep, "group", None) == group_name]
290
291
292def _load_entrypoint_module():
293    for ep in _entry_points_for_group("hermes_agent.plugins"):
294        if ep.name == plugin_name:
295            return ep.load()
296    raise ImportError(f"Entry point '{plugin_name}' not found in group 'hermes_agent.plugins'")
297
298
299def _load_plugin():
300    _install_runtime_shims()
301
302    if source_kind == "entrypoint":
303        module = _load_entrypoint_module()
304    else:
305        if plugin_dir is None:
306            raise RuntimeError("directory plugin missing path")
307        init_file = plugin_dir / "__init__.py"
308        if not init_file.exists():
309            raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
310
311        canonical_name = _canonical_module_name()
312        module_name = canonical_name or f"edgecrab_hermes_plugins.{plugin_name.replace('-', '_')}"
313        if "edgecrab_hermes_plugins" not in sys.modules:
314            pkg = types.ModuleType("edgecrab_hermes_plugins")
315            pkg.__path__ = []
316            pkg.__package__ = "edgecrab_hermes_plugins"
317            sys.modules["edgecrab_hermes_plugins"] = pkg
318
319        spec = importlib.util.spec_from_file_location(
320            module_name,
321            init_file,
322            submodule_search_locations=[str(plugin_dir)],
323        )
324        if spec is None or spec.loader is None:
325            raise ImportError(f"Cannot create module spec for {init_file}")
326
327        module = importlib.util.module_from_spec(spec)
328        module.__package__ = module_name
329        module.__path__ = [str(plugin_dir)]
330        sys.modules[module_name] = module
331        spec.loader.exec_module(module)
332
333    register_fn = getattr(module, "register", None)
334    if register_fn is None:
335        raise RuntimeError(f"Plugin '{plugin_name}' has no register(ctx) function")
336    register_fn(PluginContext())
337    _register_memory_provider_package_alias(module)
338    _load_conventional_cli_module()
339
340
341def _memory_provider_module_base():
342    if plugin_dir is None or memory_provider is None:
343        return None
344    return f"plugins.memory.{plugin_name.replace('-', '_')}"
345
346
347def _register_memory_provider_package_alias(module):
348    alias_base = _memory_provider_module_base()
349    if alias_base is None or plugin_dir is None:
350        return
351    _ensure_package("plugins", [str(plugin_dir.parent)])
352    _ensure_package("plugins.memory", [str(plugin_dir.parent)])
353    module.__package__ = alias_base
354    module.__path__ = [str(plugin_dir)]
355    sys.modules[alias_base] = module
356
357
358def _load_conventional_cli_module():
359    if plugin_dir is None:
360        return
361    cli_file = plugin_dir / "cli.py"
362    if not cli_file.exists():
363        return
364
365    alias_base = _memory_provider_module_base()
366    if alias_base is not None:
367        _ensure_package(alias_base, [str(plugin_dir)])
368        module_name = f"{alias_base}.cli"
369    else:
370        canonical_name = _canonical_module_name()
371        module_name = (
372            f"{canonical_name}.cli"
373            if canonical_name
374            else f"edgecrab_hermes_plugins.{plugin_name.replace('-', '_')}.cli"
375        )
376
377    module = sys.modules.get(module_name)
378    if module is None:
379        spec = importlib.util.spec_from_file_location(module_name, cli_file)
380        if spec is None or spec.loader is None:
381            raise ImportError(f"Cannot create module spec for {cli_file}")
382        module = importlib.util.module_from_spec(spec)
383        parent_name = module_name.rsplit(".", 1)[0]
384        module.__package__ = parent_name
385        sys.modules[module_name] = module
386        spec.loader.exec_module(module)
387
388    register_cli = getattr(module, "register_cli", None)
389    if not callable(register_cli):
390        return
391
392    command_name = getattr(memory_provider, "name", None) or plugin_name
393    if command_name in cli_commands:
394        return
395
396    cli_commands[command_name] = {
397        "name": command_name,
398        "help": f"Manage {command_name}",
399        "description": "",
400        "setup_fn": register_cli,
401        "handler_fn": None,
402    }
403
404
405def _tool_list():
406    tool_entries = [
407        {
408            "name": entry["name"],
409            "description": entry["description"],
410            "inputSchema": entry["schema"].get("parameters")
411            or {"type": "object", "additionalProperties": True},
412        }
413        for entry in tools.values()
414        if _tool_is_available(entry)
415    ]
416    if memory_provider is not None:
417        try:
418            for schema in memory_provider.get_tool_schemas() or []:
419                if not isinstance(schema, dict):
420                    continue
421                tool_entries.append({
422                    "name": schema.get("name", ""),
423                    "description": schema.get("description", ""),
424                    "inputSchema": schema.get("parameters") or {"type": "object", "additionalProperties": True},
425                })
426        except Exception:
427            pass
428    return tool_entries
429
430
431def _missing_requires_env(requirements):
432    missing = []
433    for requirement in requirements or []:
434        if isinstance(requirement, str):
435            name = requirement.strip()
436        elif isinstance(requirement, dict):
437            name = str(requirement.get("name") or "").strip()
438        else:
439            name = ""
440        if name and not str(os.environ.get(name, "")).strip():
441            missing.append(name)
442    return missing
443
444
445def _tool_is_available(entry):
446    if _missing_requires_env(entry.get("requires_env")):
447        return False
448    check_fn = entry.get("check_fn")
449    if check_fn is None:
450        return True
451    try:
452        return bool(check_fn())
453    except Exception:
454        return False
455
456
457def _provider_kwargs(params):
458    kwargs = {}
459    for key in ("platform", "agent_context", "user_id", "parent_session_id", "agent_identity", "agent_workspace"):
460        value = params.get(key)
461        if value is not None:
462            kwargs[key] = value
463    kwargs["hermes_home"] = os.environ.get("HERMES_HOME", "")
464    return kwargs
465
466
467def _ensure_memory_provider_initialized(params):
468    global memory_provider_initialized
469    if memory_provider is None or memory_provider_initialized:
470        return
471    session_id = params.get("session_id") or ""
472    memory_provider.initialize(session_id, **_provider_kwargs(params))
473    memory_provider_initialized = True
474
475
476def _call_tool(params):
477    name = params.get("name")
478    entry = tools.get(name)
479    if entry is None:
480        if memory_provider is not None and name in memory_provider_tool_names:
481            _ensure_memory_provider_initialized(params)
482            arguments = params.get("arguments") or {}
483            result = memory_provider.handle_tool_call(
484                name,
485                arguments,
486                task_id=params.get("task_id"),
487                session_id=params.get("session_id"),
488                platform=params.get("platform"),
489            )
490            if result is None:
491                text = ""
492            elif isinstance(result, str):
493                text = result
494            else:
495                text = json.dumps(result)
496            return {"content": [{"type": "text", "text": text}]}
497        raise KeyError(f"Unknown tool: {name}")
498    handler = entry.get("handler")
499    if not _tool_is_available(entry):
500        raise RuntimeError(f"Tool '{name}' is not currently available")
501    if handler is None:
502        raise RuntimeError(f"Tool '{name}' has no handler")
503    arguments = params.get("arguments") or {}
504    result = handler(
505        arguments,
506        task_id=params.get("task_id"),
507        session_id=params.get("session_id"),
508        platform=params.get("platform"),
509    )
510    if result is None:
511        text = ""
512    elif isinstance(result, str):
513        text = result
514    else:
515        text = json.dumps(result)
516    return {"content": [{"type": "text", "text": text}]}
517
518
519def _run_hook(params):
520    hook_name = params.get("hook_name")
521    kwargs = params.get("kwargs") or {}
522    results = []
523    if memory_provider is not None:
524        try:
525            if hook_name == "on_session_start":
526                init_params = dict(kwargs)
527                init_params["session_id"] = kwargs.get("session_id", "")
528                _ensure_memory_provider_initialized(init_params)
529            elif hook_name == "pre_llm_call":
530                _ensure_memory_provider_initialized(kwargs)
531                value = memory_provider.prefetch(
532                    kwargs.get("user_message", ""),
533                    session_id=kwargs.get("session_id", ""),
534                )
535                if value:
536                    results.append({"context": value} if isinstance(value, str) else value)
537            elif hook_name == "on_session_end" and hasattr(memory_provider, "on_session_end"):
538                history = kwargs.get("messages") or kwargs.get("conversation_history") or []
539                memory_provider.on_session_end(history)
540            elif hook_name in ("on_session_finalize", "on_session_reset"):
541                if hasattr(memory_provider, "shutdown"):
542                    memory_provider.shutdown()
543        except Exception as exc:
544            results.append({"error": str(exc)})
545    for callback in hooks.get(hook_name, []):
546        try:
547            value = callback(**kwargs)
548            if value is not None:
549                results.append(value)
550        except Exception as exc:
551            results.append({"error": str(exc)})
552    return {"results": results}
553
554
555def _introspect():
556    hook_names = sorted(hooks.keys())
557    if memory_provider is not None:
558        for hook_name in ("on_session_start", "pre_llm_call", "on_session_end", "on_session_finalize", "on_session_reset"):
559            if hook_name not in hook_names:
560                hook_names.append(hook_name)
561    return {
562        "tools": _tool_list(),
563        "hooks": sorted(hook_names),
564        "cli_commands": [
565            {
566                "name": entry["name"],
567                "help": entry["help"],
568                "description": entry["description"],
569            }
570            for entry in sorted(cli_commands.values(), key=lambda item: item["name"])
571        ],
572        "memory_provider": getattr(memory_provider, "name", None),
573    }
574
575
576def _invoke_cli(params):
577    command_name = params.get("command_name")
578    argv = params.get("argv") or []
579    entry = cli_commands.get(command_name)
580    if entry is None:
581        raise KeyError(f"Unknown CLI command: {command_name}")
582
583    parser = argparse.ArgumentParser(
584        prog=f"edgecrab {command_name}",
585        description=entry.get("description") or entry.get("help") or None,
586    )
587    setup_fn = entry.get("setup_fn")
588    if callable(setup_fn):
589        setup_fn(parser)
590
591    stdout = io.StringIO()
592    stderr = io.StringIO()
593    exit_code = 0
594
595    with redirect_stdout(stdout), redirect_stderr(stderr):
596        try:
597            namespace = parser.parse_args(list(argv))
598            handler = entry.get("handler_fn") or getattr(namespace, "func", None)
599            if callable(handler):
600                result = handler(namespace)
601                if isinstance(result, int):
602                    exit_code = result
603            elif not hasattr(namespace, "func"):
604                parser.print_help()
605        except SystemExit as exc:
606            code = exc.code if isinstance(exc.code, int) else 1
607            exit_code = code
608
609    return {
610        "exit_code": exit_code,
611        "stdout": stdout.getvalue(),
612        "stderr": stderr.getvalue(),
613    }
614
615
616def main():
617    _load_plugin()
618    while True:
619        try:
620            request = _read_message()
621        except EOFError:
622            return
623        method = request.get("method")
624        request_id = request.get("id")
625        if method == "initialize":
626            _write_message({
627                "jsonrpc": "2.0",
628                "id": request_id,
629                "result": {"serverInfo": {"name": plugin_name}},
630            })
631        elif method == "notifications/initialized":
632            continue
633        elif method == "tools/list":
634            _write_message({"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}})
635        elif method == "edgecrab/introspect":
636            try:
637                _write_message({"jsonrpc": "2.0", "id": request_id, "result": _introspect()})
638            except Exception as exc:
639                _write_message({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32012, "message": str(exc)}})
640        elif method == "edgecrab/cli_invoke":
641            try:
642                _write_message({"jsonrpc": "2.0", "id": request_id, "result": _invoke_cli(request.get("params") or {})})
643            except Exception as exc:
644                _write_message({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32013, "message": str(exc)}})
645        elif method == "tools/call":
646            try:
647                result = _call_tool(request.get("params") or {})
648                _write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
649            except Exception as exc:
650                _write_message({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32010, "message": str(exc)}})
651        elif method == "hooks/run":
652            try:
653                result = _run_hook(request.get("params") or {})
654                _write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
655            except Exception as exc:
656                _write_message({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32011, "message": str(exc)}})
657        elif method == "shutdown":
658            _write_message({"jsonrpc": "2.0", "id": request_id, "result": {}})
659            return
660        else:
661            _write_message({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": f"Unknown method: {method}"}})
662
663
664if __name__ == "__main__":
665    try:
666        main()
667    except EOFError:
668        pass
669    except Exception:
670        traceback.print_exc(file=sys.stderr)
671        raise
672"#;
673
674#[derive(Debug, Clone, Deserialize, Default)]
675pub struct HermesPluginManifest {
676    pub name: String,
677    #[serde(default)]
678    pub version: String,
679    #[serde(default)]
680    pub description: String,
681    #[serde(default)]
682    pub author: String,
683    #[serde(default)]
684    pub provides_tools: Vec<String>,
685    #[serde(default, alias = "tools")]
686    pub tools: Vec<String>,
687    #[serde(default)]
688    pub provides_hooks: Vec<String>,
689    #[serde(default, alias = "hooks")]
690    pub hooks: Vec<String>,
691    #[serde(default)]
692    pub requires_env: Vec<HermesEnvRequirement>,
693}
694
695#[derive(Debug, Clone, Default)]
696pub struct HermesCliCommand {
697    pub name: String,
698    pub help: String,
699    pub description: String,
700}
701
702#[derive(Debug, Clone, Default)]
703pub struct HermesEntrypointPlugin {
704    pub name: String,
705    pub value: String,
706    pub module_path: Option<PathBuf>,
707}
708
709#[derive(Debug, Clone, Deserialize)]
710#[serde(untagged)]
711pub enum HermesEnvRequirement {
712    Name(String),
713    Detailed(HermesEnvRequirementDetails),
714}
715
716#[derive(Debug, Clone, Deserialize)]
717pub struct HermesEnvRequirementDetails {
718    pub name: String,
719}
720
721pub fn looks_like_hermes_plugin(path: &Path) -> bool {
722    path.join("__init__.py").is_file()
723        && (path.join("plugin.yaml").is_file() || path.join("plugin.yml").is_file())
724}
725
726pub fn parse_hermes_manifest(path: &Path) -> Result<HermesPluginManifest, PluginError> {
727    let manifest_path = hermes_manifest_path(path).ok_or_else(|| PluginError::MissingManifest {
728        path: path.join("plugin.yaml"),
729    })?;
730    let content = std::fs::read_to_string(&manifest_path)?;
731    let mut manifest: HermesPluginManifest =
732        serde_yml::from_str(&content).map_err(|error| PluginError::InvalidManifest {
733            path: manifest_path.clone(),
734            message: error.to_string(),
735        })?;
736    if manifest.name.trim().is_empty() {
737        manifest.name = path
738            .file_name()
739            .and_then(|value| value.to_str())
740            .unwrap_or("hermes-plugin")
741            .to_string();
742    }
743    Ok(manifest)
744}
745
746pub fn hermes_manifest_path(path: &Path) -> Option<PathBuf> {
747    let yaml = path.join("plugin.yaml");
748    if yaml.is_file() {
749        return Some(yaml);
750    }
751    let yml = path.join("plugin.yml");
752    if yml.is_file() {
753        return Some(yml);
754    }
755    None
756}
757
758pub fn synthesize_manifest(path: &Path, manifest: &HermesPluginManifest) -> PluginManifest {
759    let mut env = HashMap::new();
760    let hermes_home = std::env::var("EDGECRAB_HOME")
761        .map(PathBuf::from)
762        .unwrap_or_else(|_| {
763            dirs::home_dir()
764                .unwrap_or_else(|| PathBuf::from("."))
765                .join(".edgecrab")
766        });
767    env.insert("HERMES_HOME".into(), hermes_home.display().to_string());
768
769    PluginManifest {
770        plugin: PluginMetadata {
771            name: manifest.name.clone(),
772            version: if manifest.version.trim().is_empty() {
773                "0.1.0".into()
774            } else {
775                manifest.version.clone()
776            },
777            description: if manifest.description.trim().is_empty() {
778                format!("Hermes-compatible plugin '{}'", manifest.name)
779            } else {
780                manifest.description.clone()
781            },
782            kind: PluginKind::Hermes,
783            author: manifest.author.clone(),
784            license: String::new(),
785            homepage: None,
786            min_edgecrab_version: None,
787        },
788        exec: Some(PluginExecConfig {
789            command: python_command(),
790            args: vec![
791                "-u".into(),
792                "-c".into(),
793                HERMES_HOST_SCRIPT.into(),
794                "directory".into(),
795                path.to_string_lossy().to_string(),
796                manifest.name.clone(),
797            ],
798            cwd: Some(".".into()),
799            env,
800            startup_timeout_secs: 10,
801            call_timeout_secs: 60,
802            restart_policy: PluginRestartPolicy::Once,
803            restart_max_attempts: 3,
804            idle_timeout_secs: 300,
805        }),
806        script: None,
807        tools: hermes_declared_tools(manifest)
808            .iter()
809            .map(|name| PluginToolDefinition {
810                name: name.clone(),
811                description: format!("Hermes plugin tool: {name}"),
812            })
813            .collect(),
814        capabilities: PluginCapabilities {
815            host: vec!["host:inject_message".into()],
816            ..PluginCapabilities::default()
817        },
818        trust: None,
819        integrity: None,
820    }
821}
822
823pub fn synthesize_entrypoint_manifest(
824    entrypoint: &HermesEntrypointPlugin,
825    home_override: Option<&Path>,
826) -> PluginManifest {
827    let mut env = HashMap::new();
828    let hermes_home = home_override
829        .map(Path::to_path_buf)
830        .or_else(|| std::env::var("EDGECRAB_HOME").ok().map(PathBuf::from))
831        .unwrap_or_else(|| {
832            dirs::home_dir()
833                .unwrap_or_else(|| PathBuf::from("."))
834                .join(".edgecrab")
835        });
836    env.insert("HERMES_HOME".into(), hermes_home.display().to_string());
837
838    PluginManifest {
839        plugin: PluginMetadata {
840            name: entrypoint.name.clone(),
841            version: "0.1.0".into(),
842            description: format!("Hermes entry-point plugin '{}'", entrypoint.name),
843            kind: PluginKind::Hermes,
844            author: String::new(),
845            license: String::new(),
846            homepage: None,
847            min_edgecrab_version: None,
848        },
849        exec: Some(PluginExecConfig {
850            command: python_command(),
851            args: vec![
852                "-u".into(),
853                "-c".into(),
854                HERMES_HOST_SCRIPT.into(),
855                "entrypoint".into(),
856                entrypoint.value.clone(),
857                entrypoint.name.clone(),
858            ],
859            cwd: entrypoint
860                .module_path
861                .as_ref()
862                .map(|path| path.to_string_lossy().to_string()),
863            env,
864            startup_timeout_secs: 10,
865            call_timeout_secs: 60,
866            restart_policy: PluginRestartPolicy::Once,
867            restart_max_attempts: 3,
868            idle_timeout_secs: 300,
869        }),
870        script: None,
871        tools: Vec::new(),
872        capabilities: PluginCapabilities {
873            host: vec!["host:inject_message".into()],
874            ..PluginCapabilities::default()
875        },
876        trust: None,
877        integrity: None,
878    }
879}
880
881fn python_command() -> String {
882    std::env::var("EDGECRAB_PLUGIN_PYTHON")
883        .or_else(|_| std::env::var("PYTHON"))
884        .unwrap_or_else(|_| "python3".into())
885}
886
887pub fn hermes_declared_tools(manifest: &HermesPluginManifest) -> Vec<String> {
888    let mut names = manifest.provides_tools.clone();
889    names.extend(manifest.tools.clone());
890    names.sort();
891    names.dedup();
892    names
893}
894
895pub fn hermes_declared_hooks(manifest: &HermesPluginManifest) -> Vec<String> {
896    let mut names = manifest.provides_hooks.clone();
897    names.extend(manifest.hooks.clone());
898    names.sort();
899    names.dedup();
900    names
901}
902
903#[derive(Debug, Clone, Default)]
904pub struct HermesRuntimeSurface {
905    pub tools: Vec<PluginToolDefinition>,
906    pub hooks: Vec<String>,
907    pub cli_commands: Vec<HermesCliCommand>,
908    pub memory_provider: Option<String>,
909}
910
911pub fn introspect_runtime_surface(
912    path: &Path,
913    manifest: &HermesPluginManifest,
914) -> HermesRuntimeSurface {
915    let synthesized = synthesize_manifest(path, manifest);
916    introspect_runtime_surface_from_manifest(
917        path,
918        &manifest.name,
919        synthesized.exec.clone(),
920        synthesized.capabilities,
921    )
922}
923
924pub fn introspect_runtime_surface_for_entrypoint(
925    entrypoint: &HermesEntrypointPlugin,
926) -> HermesRuntimeSurface {
927    let synthesized = synthesize_entrypoint_manifest(entrypoint, None);
928    introspect_runtime_surface_from_manifest(
929        entrypoint
930            .module_path
931            .as_deref()
932            .unwrap_or_else(|| Path::new(".")),
933        &entrypoint.name,
934        synthesized.exec.clone(),
935        synthesized.capabilities,
936    )
937}
938
939fn introspect_runtime_surface_from_manifest(
940    path: &Path,
941    plugin_name: &str,
942    exec: Option<PluginExecConfig>,
943    capabilities: PluginCapabilities,
944) -> HermesRuntimeSurface {
945    let Some(exec) = exec else {
946        return HermesRuntimeSurface::default();
947    };
948
949    let future = async {
950        let client = ToolServerClient::new(
951            path.to_path_buf(),
952            plugin_name.to_string(),
953            exec,
954            capabilities,
955        );
956        let result = client
957            .call_method("edgecrab/introspect", json!({}), None)
958            .await?;
959        let tools = result
960            .get("tools")
961            .and_then(Value::as_array)
962            .cloned()
963            .unwrap_or_default()
964            .into_iter()
965            .filter_map(|tool| {
966                Some(PluginToolDefinition {
967                    name: tool.get("name")?.as_str()?.to_string(),
968                    description: tool
969                        .get("description")
970                        .and_then(Value::as_str)
971                        .unwrap_or_default()
972                        .to_string(),
973                })
974            })
975            .collect::<Vec<_>>();
976        let hooks = result
977            .get("hooks")
978            .and_then(Value::as_array)
979            .cloned()
980            .unwrap_or_default()
981            .into_iter()
982            .filter_map(|value| value.as_str().map(ToString::to_string))
983            .collect::<Vec<_>>();
984        let cli_commands = result
985            .get("cli_commands")
986            .and_then(Value::as_array)
987            .cloned()
988            .unwrap_or_default()
989            .into_iter()
990            .filter_map(|entry| {
991                Some(HermesCliCommand {
992                    name: entry.get("name")?.as_str()?.to_string(),
993                    help: entry
994                        .get("help")
995                        .and_then(Value::as_str)
996                        .unwrap_or_default()
997                        .to_string(),
998                    description: entry
999                        .get("description")
1000                        .and_then(Value::as_str)
1001                        .unwrap_or_default()
1002                        .to_string(),
1003                })
1004            })
1005            .collect::<Vec<_>>();
1006        let memory_provider = result
1007            .get("memory_provider")
1008            .and_then(Value::as_str)
1009            .map(ToString::to_string);
1010
1011        let _ = client.shutdown().await;
1012        Ok::<HermesRuntimeSurface, PluginError>(HermesRuntimeSurface {
1013            tools,
1014            hooks,
1015            cli_commands,
1016            memory_provider,
1017        })
1018    };
1019
1020    if let Ok(handle) = tokio::runtime::Handle::try_current() {
1021        tokio::task::block_in_place(|| handle.block_on(future)).unwrap_or_default()
1022    } else {
1023        tokio::runtime::Runtime::new()
1024            .ok()
1025            .and_then(|runtime| runtime.block_on(future).ok())
1026            .unwrap_or_default()
1027    }
1028}
1029
1030pub fn discover_entrypoint_plugins() -> Result<Vec<HermesEntrypointPlugin>, PluginError> {
1031    const ENTRYPOINT_SCAN_SCRIPT: &str = r#"
1032import importlib.metadata
1033import json
1034
1035def _entry_points_for_group(group_name):
1036    eps = importlib.metadata.entry_points()
1037    if hasattr(eps, "select"):
1038        return list(eps.select(group=group_name))
1039    if isinstance(eps, dict):
1040        return list(eps.get(group_name, []))
1041    return [ep for ep in eps if getattr(ep, "group", None) == group_name]
1042
1043items = []
1044for ep in _entry_points_for_group("hermes_agent.plugins"):
1045    module_path = None
1046    try:
1047        module = ep.load()
1048        module_path = getattr(module, "__file__", None)
1049    except Exception:
1050        module_path = None
1051    items.append({
1052        "name": ep.name,
1053        "value": ep.value,
1054        "module_path": module_path,
1055    })
1056
1057print(json.dumps(items))
1058"#;
1059
1060    let output = std::process::Command::new(python_command())
1061        .args(["-c", ENTRYPOINT_SCAN_SCRIPT])
1062        .output()?;
1063    if !output.status.success() {
1064        return Err(PluginError::Process(format!(
1065            "failed to scan Hermes entry points: {}",
1066            String::from_utf8_lossy(&output.stderr).trim()
1067        )));
1068    }
1069
1070    let parsed: Vec<Value> = serde_json::from_slice(&output.stdout)?;
1071    Ok(parsed
1072        .into_iter()
1073        .filter_map(|entry| {
1074            Some(HermesEntrypointPlugin {
1075                name: entry.get("name")?.as_str()?.to_string(),
1076                value: entry.get("value")?.as_str()?.to_string(),
1077                module_path: entry
1078                    .get("module_path")
1079                    .and_then(Value::as_str)
1080                    .map(PathBuf::from)
1081                    .and_then(|path| path.parent().map(Path::to_path_buf)),
1082            })
1083        })
1084        .collect())
1085}
1086
1087pub async fn invoke_hook(
1088    plugin: &DiscoveredPlugin,
1089    hook_name: &str,
1090    kwargs: Value,
1091) -> Result<Vec<Value>, PluginError> {
1092    let Some(manifest) = plugin.manifest.clone() else {
1093        return Ok(Vec::new());
1094    };
1095    let Some(exec) = manifest.exec else {
1096        return Ok(Vec::new());
1097    };
1098    let client = ToolServerClient::new(
1099        plugin.path.clone(),
1100        plugin.name.clone(),
1101        exec,
1102        manifest.capabilities,
1103    );
1104    let response = client
1105        .call_method(
1106            "hooks/run",
1107            json!({
1108                "hook_name": hook_name,
1109                "kwargs": kwargs,
1110            }),
1111            None,
1112        )
1113        .await?;
1114    Ok(response
1115        .get("results")
1116        .and_then(Value::as_array)
1117        .cloned()
1118        .unwrap_or_default())
1119}
1120
1121pub async fn invoke_cli_command(
1122    plugin: &DiscoveredPlugin,
1123    command_name: &str,
1124    argv: &[String],
1125) -> Result<(i32, String, String), PluginError> {
1126    let Some(manifest) = plugin.manifest.clone() else {
1127        return Ok((0, String::new(), String::new()));
1128    };
1129    let Some(exec) = manifest.exec else {
1130        return Ok((0, String::new(), String::new()));
1131    };
1132    let client = ToolServerClient::new(
1133        plugin.path.clone(),
1134        plugin.name.clone(),
1135        exec,
1136        manifest.capabilities,
1137    );
1138    let response = client
1139        .call_method(
1140            "edgecrab/cli_invoke",
1141            json!({
1142                "command_name": command_name,
1143                "argv": argv,
1144            }),
1145            None,
1146        )
1147        .await?;
1148    let exit_code = response
1149        .get("exit_code")
1150        .and_then(Value::as_i64)
1151        .unwrap_or_default() as i32;
1152    let stdout = response
1153        .get("stdout")
1154        .and_then(Value::as_str)
1155        .unwrap_or_default()
1156        .to_string();
1157    let stderr = response
1158        .get("stderr")
1159        .and_then(Value::as_str)
1160        .unwrap_or_default()
1161        .to_string();
1162    Ok((exit_code, stdout, stderr))
1163}
1164
1165pub fn extract_pre_llm_context(results: &[Value]) -> Vec<String> {
1166    results
1167        .iter()
1168        .filter_map(|value| {
1169            if let Some(text) = value.as_str() {
1170                let trimmed = text.trim();
1171                if trimmed.is_empty() {
1172                    None
1173                } else {
1174                    Some(trimmed.to_string())
1175                }
1176            } else {
1177                value
1178                    .get("context")
1179                    .and_then(Value::as_str)
1180                    .map(str::trim)
1181                    .filter(|text| !text.is_empty())
1182                    .map(ToString::to_string)
1183            }
1184        })
1185        .collect()
1186}
1187
1188pub fn supports_hook(plugin: &DiscoveredPlugin, hook_name: &str) -> bool {
1189    plugin.hooks.iter().any(|candidate| candidate == hook_name)
1190        && plugin.kind == PluginKind::Hermes
1191        && plugin.enabled
1192        && matches!(
1193            plugin.status,
1194            PluginStatus::Available
1195                | PluginStatus::Disabled
1196                | PluginStatus::SetupNeeded
1197                | PluginStatus::Unsupported
1198        )
1199}
1200
1201pub fn missing_required_env(manifest: &HermesPluginManifest) -> Vec<String> {
1202    manifest
1203        .requires_env
1204        .iter()
1205        .map(|requirement| match requirement {
1206            HermesEnvRequirement::Name(name) => name.as_str(),
1207            HermesEnvRequirement::Detailed(details) => details.name.as_str(),
1208        })
1209        .map(str::trim)
1210        .filter(|name| !name.is_empty())
1211        .filter(|name| {
1212            std::env::var(name)
1213                .ok()
1214                .filter(|value| !value.is_empty())
1215                .is_none()
1216        })
1217        .map(ToString::to_string)
1218        .collect()
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use std::process::Command;
1224    use std::sync::OnceLock;
1225
1226    use super::*;
1227
1228    use crate::types::{SkillSource, TrustLevel};
1229    use tempfile::TempDir;
1230
1231    const REAL_HERMES_REPO_URL: &str = "https://github.com/NousResearch/hermes-agent";
1232    const REAL_HERMES_REF: &str = "268ee6bdce013c74c9a8dfbb13fd850423189322";
1233
1234    fn real_hermes_repo() -> &'static PathBuf {
1235        static REPO: OnceLock<PathBuf> = OnceLock::new();
1236        REPO.get_or_init(|| {
1237            let path = std::env::temp_dir()
1238                .join(format!("edgecrab-hermes-agent-plugins-{REAL_HERMES_REF}"));
1239            if path.exists() {
1240                return path;
1241            }
1242            let status = Command::new("git")
1243                .args([
1244                    "clone",
1245                    "--depth",
1246                    "1",
1247                    REAL_HERMES_REPO_URL,
1248                    path.to_str().expect("utf8 path"),
1249                ])
1250                .status()
1251                .expect("git clone hermes-agent");
1252            if !status.success() {
1253                assert!(
1254                    path.join("plugins").is_dir(),
1255                    "failed to clone hermes-agent fixtures"
1256                );
1257                return path;
1258            }
1259
1260            let status = Command::new("git")
1261                .args([
1262                    "-C",
1263                    path.to_str().expect("utf8 path"),
1264                    "checkout",
1265                    REAL_HERMES_REF,
1266                ])
1267                .status()
1268                .expect("git checkout hermes-agent ref");
1269            if status.success() {
1270                return path;
1271            }
1272
1273            let fetch = Command::new("git")
1274                .args([
1275                    "-C",
1276                    path.to_str().expect("utf8 path"),
1277                    "fetch",
1278                    "--depth",
1279                    "1",
1280                    "origin",
1281                    REAL_HERMES_REF,
1282                ])
1283                .status()
1284                .expect("git fetch hermes-agent ref");
1285            if fetch.success() {
1286                let checkout = Command::new("git")
1287                    .args([
1288                        "-C",
1289                        path.to_str().expect("utf8 path"),
1290                        "checkout",
1291                        "FETCH_HEAD",
1292                    ])
1293                    .status()
1294                    .expect("git checkout fetched hermes-agent ref");
1295                assert!(
1296                    checkout.success(),
1297                    "failed to checkout fetched hermes-agent ref"
1298                );
1299                return path;
1300            }
1301
1302            eprintln!(
1303                "warning: failed to resolve pinned hermes-agent ref {REAL_HERMES_REF}; using cloned default branch HEAD"
1304            );
1305            path
1306        })
1307    }
1308
1309    fn real_plugin_with_home(
1310        plugin_dir: &Path,
1311        home: &Path,
1312    ) -> (HermesPluginManifest, PluginManifest, DiscoveredPlugin) {
1313        let manifest = parse_hermes_manifest(plugin_dir).expect("real plugin manifest");
1314        let mut synthesized = synthesize_manifest(plugin_dir, &manifest);
1315        synthesized
1316            .exec
1317            .as_mut()
1318            .expect("exec config")
1319            .env
1320            .insert("HERMES_HOME".into(), home.display().to_string());
1321
1322        let surface = introspect_runtime_surface(plugin_dir, &manifest);
1323        let plugin = DiscoveredPlugin {
1324            name: manifest.name.clone(),
1325            version: manifest.version.clone(),
1326            description: manifest.description.clone(),
1327            compatibility: None,
1328            kind: PluginKind::Hermes,
1329            status: PluginStatus::Available,
1330            path: plugin_dir.to_path_buf(),
1331            manifest: Some(synthesized.clone()),
1332            skill: None,
1333            tools: surface.tools.iter().map(|tool| tool.name.clone()).collect(),
1334            hooks: surface.hooks.clone(),
1335            trust_level: TrustLevel::Unverified,
1336            enabled: true,
1337            source: SkillSource::User,
1338            install_source: None,
1339            missing_env: Vec::new(),
1340            related_skills: Vec::new(),
1341            cli_commands: surface.cli_commands.clone(),
1342        };
1343
1344        (manifest, synthesized, plugin)
1345    }
1346
1347    fn write_plugin(dir: &Path) {
1348        std::fs::write(
1349            dir.join("plugin.yaml"),
1350            r#"
1351name: hermes-demo
1352version: "1.0.0"
1353description: Demo Hermes plugin
1354provides_tools:
1355  - hello_world
1356provides_hooks:
1357  - pre_llm_call
1358"#,
1359        )
1360        .expect("write manifest");
1361        std::fs::write(
1362            dir.join("__init__.py"),
1363            r#"
1364def register(ctx):
1365    ctx.register_hook("pre_llm_call", lambda **kwargs: {"context": "Remember this context"})
1366"#,
1367        )
1368        .expect("write plugin");
1369    }
1370
1371    fn write_memory_provider_plugin(dir: &Path) {
1372        std::fs::write(
1373            dir.join("plugin.yaml"),
1374            r#"
1375name: sqlite-memory
1376version: "1.0.0"
1377description: SQLite memory provider
1378hooks:
1379  - on_session_end
1380"#,
1381        )
1382        .expect("write manifest");
1383        std::fs::write(
1384            dir.join("__init__.py"),
1385            r###"
1386import json
1387import sqlite3
1388
1389class SQLiteMemoryProvider:
1390    def __init__(self):
1391        self._conn = None
1392        self._session_id = ""
1393
1394    @property
1395    def name(self):
1396        return "sqlite_memory"
1397
1398    def initialize(self, session_id, **kwargs):
1399        self._session_id = session_id
1400        self._conn = sqlite3.connect(":memory:")
1401        self._conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS memories USING fts5(content)")
1402
1403    def get_tool_schemas(self):
1404        return [
1405            {
1406                "name": "sqlite_retain",
1407                "description": "Store a fact.",
1408                "parameters": {
1409                    "type": "object",
1410                    "properties": {"content": {"type": "string"}},
1411                    "required": ["content"],
1412                },
1413            },
1414            {
1415                "name": "sqlite_recall",
1416                "description": "Recall a fact.",
1417                "parameters": {
1418                    "type": "object",
1419                    "properties": {"query": {"type": "string"}},
1420                    "required": ["query"],
1421                },
1422            },
1423        ]
1424
1425    def handle_tool_call(self, tool_name, args, **kwargs):
1426        if tool_name == "sqlite_retain":
1427            self._conn.execute("INSERT INTO memories (content) VALUES (?)", (args.get("content", ""),))
1428            self._conn.commit()
1429            return json.dumps({"result": "stored"})
1430        if tool_name == "sqlite_recall":
1431            rows = self._conn.execute(
1432                "SELECT content FROM memories WHERE memories MATCH ? LIMIT 5",
1433                (args.get("query", ""),),
1434            ).fetchall()
1435            return json.dumps({"results": [row[0] for row in rows]})
1436        return json.dumps({"error": "unknown tool"})
1437
1438    def prefetch(self, query, *, session_id=""):
1439        rows = self._conn.execute(
1440            "SELECT content FROM memories WHERE memories MATCH ? LIMIT 5",
1441            (query,),
1442        ).fetchall()
1443        if not rows:
1444            return ""
1445        return "## SQLite Memory\n" + "\n".join(row[0] for row in rows)
1446
1447    def shutdown(self):
1448        if self._conn is not None:
1449            self._conn.close()
1450            self._conn = None
1451
1452def register(ctx):
1453    ctx.register_memory_provider(SQLiteMemoryProvider())
1454"###,
1455        )
1456        .expect("write plugin");
1457    }
1458
1459    fn write_memory_provider_plugin_with_cli(dir: &Path) {
1460        write_memory_provider_plugin(dir);
1461        std::fs::write(
1462            dir.join("cli.py"),
1463            r#"
1464def register_cli(subparser):
1465    subs = subparser.add_subparsers(dest="sqlite_command")
1466    subs.add_parser("status", help="Show sqlite memory status")
1467"#,
1468        )
1469        .expect("write cli");
1470    }
1471
1472    fn discovered_plugin(dir: &Path) -> DiscoveredPlugin {
1473        let manifest = parse_hermes_manifest(dir).expect("manifest");
1474        DiscoveredPlugin {
1475            name: manifest.name.clone(),
1476            version: manifest.version.clone(),
1477            description: manifest.description.clone(),
1478            compatibility: None,
1479            kind: PluginKind::Hermes,
1480            status: PluginStatus::Available,
1481            path: dir.to_path_buf(),
1482            manifest: Some(synthesize_manifest(dir, &manifest)),
1483            skill: None,
1484            tools: manifest.provides_tools,
1485            hooks: manifest.provides_hooks,
1486            trust_level: TrustLevel::Unverified,
1487            enabled: true,
1488            source: SkillSource::User,
1489            install_source: None,
1490            missing_env: Vec::new(),
1491            related_skills: Vec::new(),
1492            cli_commands: Vec::new(),
1493        }
1494    }
1495
1496    #[test]
1497    fn detects_hermes_plugin_directory() {
1498        let temp = TempDir::new().expect("tempdir");
1499        write_plugin(temp.path());
1500
1501        assert!(looks_like_hermes_plugin(temp.path()));
1502        let manifest = parse_hermes_manifest(temp.path()).expect("manifest");
1503        assert_eq!(manifest.name, "hermes-demo");
1504    }
1505
1506    #[test]
1507    fn collects_missing_required_env_from_both_manifest_forms() {
1508        let manifest: HermesPluginManifest = serde_yml::from_str(
1509            r#"
1510name: hermes-demo
1511requires_env:
1512  - SIMPLE_TOKEN
1513  - name: DETAILED_TOKEN
1514    description: Detailed token
1515"#,
1516        )
1517        .expect("manifest");
1518
1519        let missing = missing_required_env(&manifest);
1520        assert!(missing.contains(&"SIMPLE_TOKEN".to_string()));
1521        assert!(missing.contains(&"DETAILED_TOKEN".to_string()));
1522    }
1523
1524    #[test]
1525    fn parses_real_hermes_hooks_alias() {
1526        let manifest: HermesPluginManifest = serde_yml::from_str(
1527            r#"
1528name: honcho
1529hooks:
1530  - on_session_end
1531"#,
1532        )
1533        .expect("manifest");
1534
1535        assert_eq!(hermes_declared_hooks(&manifest), vec!["on_session_end"]);
1536    }
1537
1538    #[tokio::test]
1539    async fn invokes_pre_llm_hook_via_python_bridge() {
1540        let temp = TempDir::new().expect("tempdir");
1541        write_plugin(temp.path());
1542
1543        let plugin = discovered_plugin(temp.path());
1544        let results = invoke_hook(
1545            &plugin,
1546            "pre_llm_call",
1547            json!({
1548                "session_id": "s1",
1549                "user_message": "hello",
1550                "conversation_history": [],
1551                "is_first_turn": true,
1552                "model": "test-model",
1553                "platform": "cli",
1554            }),
1555        )
1556        .await
1557        .expect("hook invocation");
1558
1559        assert_eq!(
1560            extract_pre_llm_context(&results),
1561            vec!["Remember this context"]
1562        );
1563    }
1564
1565    #[tokio::test(flavor = "multi_thread")]
1566    async fn memory_provider_bridge_exposes_tools_and_prefetch_context() {
1567        let temp = TempDir::new().expect("tempdir");
1568        write_memory_provider_plugin(temp.path());
1569
1570        let manifest = parse_hermes_manifest(temp.path()).expect("manifest");
1571        let surface = introspect_runtime_surface(temp.path(), &manifest);
1572        let tool_names = surface
1573            .tools
1574            .iter()
1575            .map(|tool| tool.name.as_str())
1576            .collect::<Vec<_>>();
1577
1578        assert!(tool_names.contains(&"sqlite_retain"));
1579        assert!(tool_names.contains(&"sqlite_recall"));
1580        assert!(surface.hooks.contains(&"pre_llm_call".to_string()));
1581
1582        let plugin = DiscoveredPlugin {
1583            name: manifest.name.clone(),
1584            version: manifest.version.clone(),
1585            description: manifest.description.clone(),
1586            compatibility: None,
1587            kind: PluginKind::Hermes,
1588            status: PluginStatus::Available,
1589            path: temp.path().to_path_buf(),
1590            manifest: Some(synthesize_manifest(temp.path(), &manifest)),
1591            skill: None,
1592            tools: tool_names.iter().map(|name| (*name).to_string()).collect(),
1593            hooks: surface.hooks,
1594            trust_level: TrustLevel::Unverified,
1595            enabled: true,
1596            source: SkillSource::User,
1597            install_source: None,
1598            missing_env: Vec::new(),
1599            related_skills: Vec::new(),
1600            cli_commands: surface.cli_commands.clone(),
1601        };
1602
1603        invoke_hook(
1604            &plugin,
1605            "on_session_start",
1606            json!({
1607                "session_id": "session-1",
1608                "platform": "cli",
1609            }),
1610        )
1611        .await
1612        .expect("initialization hook");
1613
1614        let client = ToolServerClient::new(
1615            temp.path().to_path_buf(),
1616            plugin.name.clone(),
1617            synthesize_manifest(temp.path(), &manifest)
1618                .exec
1619                .expect("exec"),
1620            PluginCapabilities {
1621                host: vec!["host:inject_message".into()],
1622                ..PluginCapabilities::default()
1623            },
1624        );
1625        client
1626            .call_method(
1627                "tools/call",
1628                json!({
1629                    "name": "sqlite_retain",
1630                    "arguments": { "content": "User prefers rust" },
1631                    "session_id": "session-1",
1632                    "platform": "cli",
1633                }),
1634                None,
1635            )
1636            .await
1637            .expect("store memory");
1638        let recall = client
1639            .call_method(
1640                "tools/call",
1641                json!({
1642                    "name": "sqlite_recall",
1643                    "arguments": { "query": "rust" },
1644                    "session_id": "session-1",
1645                    "platform": "cli",
1646                }),
1647                None,
1648            )
1649            .await
1650            .expect("recall memory");
1651        assert!(recall.to_string().contains("User prefers rust"));
1652        client.shutdown().await.expect("shutdown");
1653
1654        let results = invoke_hook(
1655            &plugin,
1656            "pre_llm_call",
1657            json!({
1658                "session_id": "session-1",
1659                "user_message": "rust",
1660                "conversation_history": [],
1661                "is_first_turn": false,
1662                "model": "test-model",
1663                "platform": "cli",
1664            }),
1665        )
1666        .await
1667        .expect("prefetch hook");
1668
1669        let context = extract_pre_llm_context(&results).join("\n");
1670        if !context.is_empty() {
1671            assert!(context.contains("rust"));
1672        }
1673    }
1674
1675    #[tokio::test(flavor = "multi_thread")]
1676    async fn memory_provider_cli_py_register_cli_is_exposed_and_invocable() {
1677        let temp = TempDir::new().expect("tempdir");
1678        write_memory_provider_plugin_with_cli(temp.path());
1679
1680        let manifest = parse_hermes_manifest(temp.path()).expect("manifest");
1681        let surface = introspect_runtime_surface(temp.path(), &manifest);
1682        assert_eq!(surface.memory_provider.as_deref(), Some("sqlite_memory"));
1683        assert!(
1684            surface
1685                .cli_commands
1686                .iter()
1687                .any(|command| command.name == "sqlite_memory")
1688        );
1689
1690        let plugin = DiscoveredPlugin {
1691            name: manifest.name.clone(),
1692            version: manifest.version.clone(),
1693            description: manifest.description.clone(),
1694            compatibility: None,
1695            kind: PluginKind::Hermes,
1696            status: PluginStatus::Available,
1697            path: temp.path().to_path_buf(),
1698            manifest: Some(synthesize_manifest(temp.path(), &manifest)),
1699            skill: None,
1700            tools: surface.tools.iter().map(|tool| tool.name.clone()).collect(),
1701            hooks: surface.hooks,
1702            trust_level: TrustLevel::Unverified,
1703            enabled: true,
1704            source: SkillSource::User,
1705            install_source: None,
1706            missing_env: Vec::new(),
1707            related_skills: Vec::new(),
1708            cli_commands: surface.cli_commands.clone(),
1709        };
1710
1711        let (exit_code, stdout, stderr) =
1712            invoke_cli_command(&plugin, "sqlite_memory", &["--help".into()])
1713                .await
1714                .expect("invoke cli");
1715        assert_eq!(exit_code, 0);
1716        assert!(stdout.contains("status"), "stdout:\n{stdout}");
1717        assert!(stderr.is_empty(), "stderr:\n{stderr}");
1718    }
1719
1720    #[tokio::test(flavor = "multi_thread")]
1721    async fn real_holographic_plugin_runs_end_to_end() {
1722        let home = TempDir::new().expect("tempdir");
1723        let plugin_dir = real_hermes_repo().join("plugins/memory/holographic");
1724        let (_manifest, synthesized, plugin) = real_plugin_with_home(&plugin_dir, home.path());
1725
1726        assert!(plugin.tools.iter().any(|tool| tool == "fact_store"));
1727        assert!(plugin.tools.iter().any(|tool| tool == "fact_feedback"));
1728        assert!(plugin.hooks.iter().any(|hook| hook == "on_session_start"));
1729        assert!(plugin.hooks.iter().any(|hook| hook == "pre_llm_call"));
1730        assert!(plugin.hooks.iter().any(|hook| hook == "on_session_end"));
1731
1732        invoke_hook(
1733            &plugin,
1734            "on_session_start",
1735            json!({
1736                "session_id": "real-holographic-session",
1737                "platform": "cli",
1738            }),
1739        )
1740        .await
1741        .expect("real holographic initialization");
1742
1743        let client = ToolServerClient::new(
1744            plugin_dir.clone(),
1745            plugin.name.clone(),
1746            synthesized.exec.clone().expect("exec"),
1747            synthesized.capabilities.clone(),
1748        );
1749        client
1750            .call_method(
1751                "tools/call",
1752                json!({
1753                    "name": "fact_store",
1754                    "arguments": {
1755                        "action": "add",
1756                        "content": "User prefers Rust for systems work",
1757                        "category": "user_pref",
1758                    },
1759                    "session_id": "real-holographic-session",
1760                    "platform": "cli",
1761                }),
1762                None,
1763            )
1764            .await
1765            .expect("store fact through real holographic plugin");
1766        let search = client
1767            .call_method(
1768                "tools/call",
1769                json!({
1770                    "name": "fact_store",
1771                    "arguments": {
1772                        "action": "search",
1773                        "query": "Rust systems work",
1774                    },
1775                    "session_id": "real-holographic-session",
1776                    "platform": "cli",
1777                }),
1778                None,
1779            )
1780            .await
1781            .expect("search fact through real holographic plugin");
1782        assert!(
1783            search
1784                .to_string()
1785                .contains("User prefers Rust for systems work")
1786        );
1787        client.shutdown().await.expect("shutdown real holographic");
1788
1789        let results = invoke_hook(
1790            &plugin,
1791            "pre_llm_call",
1792            json!({
1793                "session_id": "real-holographic-session",
1794                "user_message": "rust systems preferences",
1795                "conversation_history": [],
1796                "is_first_turn": false,
1797                "model": "test-model",
1798                "platform": "cli",
1799            }),
1800        )
1801        .await
1802        .expect("prefetch through real holographic plugin");
1803        let context = extract_pre_llm_context(&results).join("\n");
1804        if !context.is_empty() {
1805            assert!(context.contains("Rust"));
1806        }
1807
1808        invoke_hook(
1809            &plugin,
1810            "on_session_end",
1811            json!({
1812                "messages": [
1813                    {"role": "user", "content": "Remember my Rust preference."}
1814                ],
1815                "session_id": "real-holographic-session",
1816                "platform": "cli",
1817            }),
1818        )
1819        .await
1820        .expect("real holographic shutdown hook");
1821    }
1822
1823    #[tokio::test(flavor = "multi_thread")]
1824    async fn real_honcho_plugin_loads_with_hermes_package_shims() {
1825        let home = TempDir::new().expect("tempdir");
1826        let plugin_dir = real_hermes_repo().join("plugins/memory/honcho");
1827        let (_manifest, _synthesized, plugin) = real_plugin_with_home(&plugin_dir, home.path());
1828
1829        assert!(plugin.tools.iter().any(|tool| tool == "honcho_profile"));
1830        assert!(plugin.tools.iter().any(|tool| tool == "honcho_search"));
1831        assert!(plugin.hooks.iter().any(|hook| hook == "on_session_start"));
1832        assert!(plugin.hooks.iter().any(|hook| hook == "pre_llm_call"));
1833        assert!(plugin.hooks.iter().any(|hook| hook == "on_session_end"));
1834
1835        invoke_hook(
1836            &plugin,
1837            "on_session_start",
1838            json!({
1839                "session_id": "real-honcho-session",
1840                "platform": "cli",
1841            }),
1842        )
1843        .await
1844        .expect("real honcho initialization hook");
1845
1846        let results = invoke_hook(
1847            &plugin,
1848            "pre_llm_call",
1849            json!({
1850                "session_id": "real-honcho-session",
1851                "user_message": "hello",
1852                "conversation_history": [],
1853                "is_first_turn": true,
1854                "model": "test-model",
1855                "platform": "cli",
1856            }),
1857        )
1858        .await
1859        .expect("real honcho pre_llm_call");
1860
1861        assert!(extract_pre_llm_context(&results).is_empty());
1862        assert!(
1863            plugin
1864                .cli_commands
1865                .iter()
1866                .any(|command| command.name == "honcho")
1867        );
1868
1869        let (exit_code, stdout, stderr) = invoke_cli_command(&plugin, "honcho", &["--help".into()])
1870            .await
1871            .expect("real honcho cli help");
1872        assert_eq!(exit_code, 0);
1873        assert!(stdout.contains("status"), "stdout:\n{stdout}");
1874        assert!(stdout.contains("sessions"), "stdout:\n{stdout}");
1875        assert!(stderr.is_empty(), "stderr:\n{stderr}");
1876    }
1877}