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}